From 34a8ec26fcea0768a553179cb3a3117e4d40732a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 5 Aug 2026 21:42:00 -0300 Subject: [PATCH 001/376] docs: define EarlyBirds delivery plan --- docs/plans/EARLY_BIRDS.md | 567 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 docs/plans/EARLY_BIRDS.md diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md new file mode 100644 index 00000000..674d0727 --- /dev/null +++ b/docs/plans/EARLY_BIRDS.md @@ -0,0 +1,567 @@ +# EarlyBirds: product and delivery plan + +> **Status:** Draft for agreement with Nico and the Harmonic Beacon team +> **Date:** 2026-08-05 +> **Integration branch:** `early-birds`, based on `main@f520332` +> **Operational rule:** this document authorizes planning only. It does not +> authorize a production deploy, a payment change, or a change to the event +> audio path. + +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 a simple paid listening membership for people who want a private, +continuous relationship with the Beacon outside scheduled events. + +The first useful release lets a Listener: + +1. sign in with Google; +2. obtain a valid EarlyBird membership through the existing commerce authority; +3. open a private, receive-only listening home; +4. hear a continuous 24/7 Beacon stream; +5. optionally start one reviewed guided voice track in Spanish or English and + balance the guide against the Beacon; +6. return later and recover the same access without joining an event room. + +The initial 24/7 source is the long spatialized recording +`luz_de_manana_20260624-155633.wav`, played continuously. It is not yet the live +Costa Rica instrument. The product must say this truthfully. The delivery +service is designed so that a real live source can replace the recorded source +later without replacing the Listener product. + +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 long master recording 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. | +| 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. | + +## 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 6,844.426 seconds (1:54:04.426), stereo, + 48 kHz, 32-bit float PCM, 2,628,259,840 bytes. +- Six candidate guided voice masters exist. They are mono 24 kHz WAV files, + approximately 5.5 minutes each. The final voice/version and permission to use + it have not yet been recorded as product decisions. +- No `beacon-247` service or room exists today. + +## 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 lifetime-price contract. Founder terms + require a versioned offer and durable commercial evidence. +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 source is explicitly a continuous + recorded stream. + +## 5. MVP boundary + +### Included + +- `/early-birds` public explanation and sign-in entry. +- `/early-birds/home` private Listener player. +- Google sign-in only for the first release. +- A separate EarlyBird account/session domain. +- Canonical membership entitlement from the commerce service. +- A continuous, monitored stream from the approved long master. +- Beacon-only playback and one optional ES or EN guide. +- A simple Beacon/Guide balance with an obvious return to Beacon-only. +- Honest source state: recorded continuous source, reconnecting or unavailable. +- 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 + +- Apple and Facebook sign-in. +- MercadoPago as a second recurring provider. +- 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 guide + v + external canary + +Google OIDC ---> EarlyBird account/session ---> EarlyBird web routes + | + v +commerce entitlement API <--- PayPal/webhooks/reconciliation authority +``` + +### 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 preferred preview address is +`earlybirds-staging.harmonicbeacon.com`. A private ZeroTier-only name is an +acceptable first step if public DNS would delay the media proof. + +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 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. +- The six guide masters follow the same provenance 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 delivery codec is deliberately **not chosen in this document**. Browser +support and acoustic quality conflict here, especially on Safari/iOS. The spike +must compare the original standard player, the encoded artifact, the streamed +artifact in a standard player and the actual EarlyBird player. Selecting and +deploying that encoding is an audio-touching decision requiring Nico's explicit +approval. + +To avoid continuous expensive encoding, the expected steady state is: + +1. encode the approved master once; +2. run a small origin process that reads it at real time and loops it; +3. package or relay without another lossy encode; +4. keep a rolling live manifest and bounded segments; +5. expose health, current source, media sequence and last-output timestamp. + +All listeners should hear approximately the same wall-clock position. A process +restart may begin a new epoch; it must not produce overlapping publishers. + +### 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. +- Expiry and refresh do not interrupt healthy playback unnecessarily. +- The UI says "continuous recorded Beacon" (localized wording to be approved), + not "live from Costa Rica". +- Source state comes from the same origin state that drives delivery. + +### 7.4 Reliability acceptance + +- One and only one origin publisher/packager is 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. + +## 8. Listener player contract + +The player starts from the simplest path shown to reproduce clean audio in prior +testing: native media playback. Web Audio is introduced only if a required mix +behavior cannot be achieved cleanly and the alternative passes the audio gate. + +- Playback begins only after an explicit user gesture. +- Beacon-only is the default and remains available if a guide fails. +- Starting a guide does not reconnect or restart the Beacon stream. +- Balance changes are perceptually smooth and never exceed reviewed gain limits. +- Stopping a guide returns to Beacon-only without a jump in the Beacon timeline. +- 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. stream plus ES guide; +6. stream plus EN guide. + +A failure at one level is fixed there before testing the next. + +## 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 is the first provider. The implementation uses Authorization Code with +PKCE, state and nonce, and stores no Google access/refresh token unless a later +feature proves it necessary. Provider subject is the primary external identity; +verified email is contact/linkage evidence, not a mutable authorization key. + +Before choosing an auth library, a short ADR must confirm a maintained stable +option compatible with Next.js 16. The retired NextAuth beta is not the default. + +## 10. Membership and commerce contract + +`proyecciones-mito` remains the canonical commerce authority. The web app does +not infer a membership from a PayPal success page and does not implement a +parallel webhook truth. + +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. + +"Founder price locked for life" is not a boolean. It is a versioned offer grant +recording the acquired amount/currency, acquisition time and continuity policy. +The team must define whether cancellation permanently loses the offer and how a +failed payment during grace differs from voluntary cancellation. + +The first provider should be PayPal because that path already operates in the +project. MercadoPago follows only after the provider-neutral entitlement +contract is demonstrated. No provider is enabled for real EarlyBird charges +until Nico approves the exact offer and a sandbox lifecycle passes end to end. + +## 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 1 — isolated preview and 24/7 media proof + +- add the isolated compose/runtime boundary; +- inventory and checksum the master; +- create a reproducible candidate derivative without replacing the master; +- run the stream origin under resource limits; +- expose health/source state; +- add a bare private test player and canary; +- execute the audio test ladder through streamed standard playback. + +Exit: the 24/7 recorded source survives restart and a 60-minute cross-device +listen, with no event service or current audio file changed. + +### Batch 2 — Listener vertical slice with synthetic 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, guide selection and balance; +- prove that no event connection/capability is created. + +Exit: the team can use the complete listening experience in preview without a +payment provider. + +### Batch 3 — Google identity + +- approve the identity ADR; +- implement Google sign-in, callback, session/revocation and logout; +- add account-linking and duplicate-email protections; +- 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 4 — PayPal membership integration + +- agree the versioned commerce contract with Mariano/Sai; +- extend the commerce sandbox for the EarlyBird offer; +- consume canonical membership state in the app; +- test create, duplicate webhook, out-of-order event, retry, renewal failure, + grace, cancellation, refund and revoke; +- reconcile stale/missing delivery. + +Exit: sandbox purchase-to-listen and revoke-to-deny work without trusting a +browser redirect or duplicate authority. + +### Batch 5 — release candidate + +- approve terms, privacy, offer copy and source wording; +- 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 go/no-go decision. Production remains off until explicitly +approved. + +## 13. Definition of done for the EarlyBirds milestone + +- A Listener can sign in, obtain a canonical sandbox membership and listen. +- The initial recorded source is continuously delivered and truthfully labeled. +- Beacon-only and both approved guide languages 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. +- 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. + +## 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. Decisions required before cards are created + +Recommended defaults are included so the team can approve them as a block or +change only the exceptions. + +| ID | Decision | Recommended default | +|---|---|---| +| D1 | Public name and URL | Product name `EarlyBirds`; preview at `earlybirds-staging.harmonicbeacon.com`; final entry at `/early-birds`. | +| D2 | Founder offer | USD 2/month, amount locked while the same subscription remains active; voluntary cancellation loses the locked offer, provider failure gets a defined grace period. | +| D3 | First identity provider | Google only; Apple/Facebook after launch evidence. | +| D4 | First payment provider | PayPal through the existing commerce authority; MercadoPago second. | +| D5 | Initial source wording | "Continuous recorded Beacon"; never imply the physical source is live. | +| D6 | Guide masters | Amara Sol ES/EN candidates, subject to explicit rights/consent and Nico's content/audio approval. | +| D7 | Stream delivery | Buffered HTTP/HLS spike; codec selected only after A/B and browser support evidence. | +| D8 | EarlyBird offer after cancellation | Locked offer survives only involuntary payment failure during grace, not voluntary cancellation/refund/revoke. | +| D9 | Production topology | Main app after final merge; independent stream-origin service; additive models; feature/kill switch. | +| D10 | Stream timeline | One shared wall-clock stream position for all Listeners, not a private loop beginning at sign-in. | + +## 16. Card map after agreement + +Only after section 15 is agreed, create milestone `EarlyBirds` and non-duplicate +GitHub issues in this dependency order: + +1. EB-00 — freeze product, identity, commerce and media ADRs. +2. EB-01 — isolated preview runtime and Fast Forward CI lane. +3. EB-02 — source provenance and reproducible media artifact pipeline. +4. EB-03 — resource-bounded 24/7 stream origin and health. +5. EB-04 — stream canary, observability and incident stop switch. +6. EB-05 — Listener shell and synthetic-entitlement vertical slice. +7. EB-06 — Google identity and isolated Listener sessions. +8. EB-07 — guide asset approval, delivery and Beacon/Guide player. +9. EB-08 — versioned EarlyBird commerce entitlement contract. +10. EB-09 — PayPal sandbox lifecycle and reconciliation. +11. EB-10 — privacy, terms, accessibility and bilingual acceptance. +12. EB-11 — release/rollback rehearsal and go/no-go. + +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. +- 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. From a01b14399a0664e594ee103f265cc7fc30cf9b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 5 Aug 2026 21:49:07 -0300 Subject: [PATCH 002/376] docs: separate Beacon and drop-in timelines --- docs/plans/EARLY_BIRDS.md | 47 +++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 674d0727..61f7c74d 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -23,8 +23,8 @@ The first useful release lets a Listener: 2. obtain a valid EarlyBird membership through the existing commerce authority; 3. open a private, receive-only listening home; 4. hear a continuous 24/7 Beacon stream; -5. optionally start one reviewed guided voice track in Spanish or English and - balance the guide against the Beacon; +5. optionally play one reviewed drop-in voice track in Spanish or English with + standard private playback controls and balance it against the Beacon; 6. return later and recover the same access without joining an event room. The initial 24/7 source is the long spatialized recording @@ -44,6 +44,7 @@ change has passed its own audio and operational acceptance. | Develop on a long-lived `early-birds` integration branch | Accepted | Weekend work continues independently on `main`. | | Use the long master recording 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 | Drop-ins are private media with independent play, pause, seek and restart controls. | | 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. | @@ -68,7 +69,7 @@ the older Founders proposal alone. create an unrelated payment truth inside the web app. - The selected source master is 6,844.426 seconds (1:54:04.426), stereo, 48 kHz, 32-bit float PCM, 2,628,259,840 bytes. -- Six candidate guided voice masters exist. They are mono 24 kHz WAV files, +- Six candidate drop-in voice masters exist. They are mono 24 kHz WAV files, approximately 5.5 minutes each. The final voice/version and permission to use it have not yet been recorded as product decisions. - No `beacon-247` service or room exists today. @@ -110,8 +111,9 @@ steps are not safe to execute literally. - A separate EarlyBird account/session domain. - Canonical membership entitlement from the commerce service. - A continuous, monitored stream from the approved long master. -- Beacon-only playback and one optional ES or EN guide. -- A simple Beacon/Guide balance with an obvious return to Beacon-only. +- Beacon-only playback and one optional ES or EN drop-in. +- Standard play, pause, seek and restart controls for the private drop-in. +- A simple Beacon/Drop-in balance with an obvious return to Beacon-only. - Honest source state: recorded continuous source, reconnecting or unavailable. - Cancellation/revocation reflected without relying on a front-end redirect. - ES/EN copy, privacy/terms, basic accessibility and mobile-browser acceptance. @@ -140,7 +142,7 @@ offline reviewed derivative ----> 24/7 stream origin ----> cache/CDN boundary | | | v | Listener browser - | + optional guide + | + optional drop-in v external canary @@ -204,7 +206,7 @@ consumer. - 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. -- The six guide masters follow the same provenance process separately. +- The six drop-in masters follow the same provenance process separately. ### 7.2 Delivery shape @@ -228,8 +230,10 @@ To avoid continuous expensive encoding, the expected steady state is: 4. keep a rolling live manifest and bounded segments; 5. expose health, current source, media sequence and last-output timestamp. -All listeners should hear approximately the same wall-clock position. A process -restart may begin a new epoch; it must not produce overlapping publishers. +All listeners should hear approximately the same wall-clock position in the +24/7 Beacon stream. This shared timeline does not apply to drop-ins: each +Listener controls those privately. A stream process restart may begin a new +Beacon epoch; it must not produce overlapping publishers. ### 7.3 Access and truthfulness @@ -260,10 +264,15 @@ testing: native media playback. Web Audio is introduced only if a required mix behavior cannot be achieved cleanly and the alternative passes the audio gate. - Playback begins only after an explicit user gesture. -- Beacon-only is the default and remains available if a guide fails. -- Starting a guide does not reconnect or restart the Beacon stream. +- Beacon-only is the default and remains available if a drop-in fails. +- Starting, pausing, seeking, restarting or finishing a drop-in does not + reconnect, pause, seek or restart the Beacon stream. +- Drop-ins expose familiar play, pause, timeline/seek and restart controls. +- Drop-in position is private to the Listener and is never synchronized with + another Listener. - Balance changes are perceptually smooth and never exceed reviewed gain limits. -- Stopping a guide returns to Beacon-only without a jump in the Beacon timeline. +- Pausing or stopping a drop-in returns to Beacon-only without a jump in the + Beacon timeline. - 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. @@ -275,8 +284,8 @@ The media test ladder is mandatory and intentionally incremental: 2. approved derivative in a standard player; 3. stream in a standard browser player; 4. stream in the EarlyBird player; -5. stream plus ES guide; -6. stream plus EN guide. +5. stream plus independently controlled ES drop-in; +6. stream plus independently controlled EN drop-in. A failure at one level is fixed there before testing the next. @@ -429,7 +438,7 @@ listen, with no event service or current audio file changed. - 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, guide selection and balance; +- add Beacon-only player, drop-in selection, standard controls and balance; - prove that no event connection/capability is created. Exit: the team can use the complete listening experience in preview without a @@ -473,7 +482,7 @@ approved. - A Listener can sign in, obtain a canonical sandbox membership and listen. - The initial recorded source is continuously delivered and truthfully labeled. -- Beacon-only and both approved guide languages pass physical-device listening. +- Beacon-only and both approved drop-in languages 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. @@ -520,11 +529,11 @@ change only the exceptions. | D3 | First identity provider | Google only; Apple/Facebook after launch evidence. | | D4 | First payment provider | PayPal through the existing commerce authority; MercadoPago second. | | D5 | Initial source wording | "Continuous recorded Beacon"; never imply the physical source is live. | -| D6 | Guide masters | Amara Sol ES/EN candidates, subject to explicit rights/consent and Nico's content/audio approval. | +| D6 | Drop-in masters | Amara Sol ES/EN candidates, subject to explicit rights/consent and Nico's content/audio approval. | | D7 | Stream delivery | Buffered HTTP/HLS spike; codec selected only after A/B and browser support evidence. | | D8 | EarlyBird offer after cancellation | Locked offer survives only involuntary payment failure during grace, not voluntary cancellation/refund/revoke. | | D9 | Production topology | Main app after final merge; independent stream-origin service; additive models; feature/kill switch. | -| D10 | Stream timeline | One shared wall-clock stream position for all Listeners, not a private loop beginning at sign-in. | +| D10 | Timelines | Accepted: one shared wall-clock position for the Beacon stream; every drop-in has private standard play/pause/seek/restart controls. | ## 16. Card map after agreement @@ -538,7 +547,7 @@ GitHub issues in this dependency order: 5. EB-04 — stream canary, observability and incident stop switch. 6. EB-05 — Listener shell and synthetic-entitlement vertical slice. 7. EB-06 — Google identity and isolated Listener sessions. -8. EB-07 — guide asset approval, delivery and Beacon/Guide player. +8. EB-07 — drop-in asset approval, delivery and Beacon/Drop-in player. 9. EB-08 — versioned EarlyBird commerce entitlement contract. 10. EB-09 — PayPal sandbox lifecycle and reconciliation. 11. EB-10 — privacy, terms, accessibility and bilingual acceptance. From 35544ac7ed56ae3919e8c3ceda8ea5c6b9cf3577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 00:56:48 -0300 Subject: [PATCH 003/376] chore(early-birds): pin Better Auth dependency --- package-lock.json | 463 ++++++++++++++++++++++++++++++++++++++++++---- package.json | 2 + 2 files changed, 427 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 57ab0795..b3d35143 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", + "better-auth": "1.6.26", "dotenv": "^17.2.4", "livekit-client": "^2.17.0", "livekit-server-sdk": "^2.15.0", @@ -400,6 +401,139 @@ "node": ">=18" } }, + "node_modules/@better-auth/core": { + "version": "1.6.26", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.26.tgz", + "integrity": "sha512-Ud4FqnjIJDvmeo+3bN+OsuVVAiuvFjNERbW7G8/65ictSxCox62gNgTOsJ4YXmbwXzBzyyekStzrupg2qNGEkA==", + "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.3.7", + "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.26", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.26.tgz", + "integrity": "sha512-SMvAeeUqEsz0BLtWVVp+HdStAVOwcxs4vPkL/AVmFJ0e9dDKItXl881xWeB/Ze4NgBW6NYmUId9qI4LrdQu3hA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.26", + "@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.26", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.26.tgz", + "integrity": "sha512-Y0Kdqn8JQMR8dHMtUnbBNqq7Mk8mDf18DwTves2uhjtRfcOWByVTGxsFMDVUbedMuoM+Ab+v04bwCk94sAo8NA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.26", + "@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.26", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.26.tgz", + "integrity": "sha512-kb5ahphEp9jyMleXU4T8I/xgWPa8gjOLFKKsnaVqR/BU+h7xqTOsjJwTY3evY+PFYHvjNFUlrjZ1Km8A/w1p/w==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.26", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.6.26", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.26.tgz", + "integrity": "sha512-jYGhuIQqj48h69ROLPYdc8jgqDaMmRRLLFiRViZFYarNyBuYUV07DywJXvRuw8l9ADx+M1TB+BJZGnLE2nnmwQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.26", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.6.26", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.26.tgz", + "integrity": "sha512-ILQoYmnoYyghDP4iN1h/Gkd9Jt7Xs/vxoNAt4AuuJJbqs73u27LlQM4P1YiP7nJ+bM1iVTBI1dDF73myIAtjzQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.26", + "@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.26", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.26.tgz", + "integrity": "sha512-pBs69RORUSJHRF9r5PdeY7pzLRga09YSWew8igFYrBvT2tLt7DktrbD5WjryLZpAZOuKAMZ+Xo0DlJbnvLWGpQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.26", + "@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", @@ -1765,7 +1899,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": { @@ -1951,6 +2085,30 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "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 +2157,15 @@ "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/@playwright/test": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", @@ -2976,6 +3143,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", @@ -3148,7 +3375,7 @@ "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 +3464,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 +3478,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": { @@ -4045,7 +4272,7 @@ "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -4063,7 +4290,7 @@ "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/spy": "4.0.18", @@ -4090,7 +4317,7 @@ "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, + "devOptional": true, "license": "MIT", "dependencies": { "tinyrainbow": "^3.0.3" @@ -4103,7 +4330,7 @@ "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, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/utils": "4.0.18", @@ -4117,7 +4344,7 @@ "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.0.18", @@ -4132,7 +4359,7 @@ "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, + "devOptional": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" @@ -4142,7 +4369,7 @@ "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.0.18", @@ -4428,7 +4655,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" @@ -4534,6 +4761,131 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.6.26", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.26.tgz", + "integrity": "sha512-nhXWrDDj+EnZsHq1j0z1c6DowOMFZWZHe6LCaXbBfLIgHHZm6dyazBQcbRspM4spUIcUt250Mc1BFOSuP7eniQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.26", + "@better-auth/drizzle-adapter": "1.6.26", + "@better-auth/kysely-adapter": "1.6.26", + "@better-auth/memory-adapter": "1.6.26", + "@better-auth/mongo-adapter": "1.6.26", + "@better-auth/prisma-adapter": "1.6.26", + "@better-auth/telemetry": "1.6.26", + "@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.3.7", + "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-call": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.7.tgz", + "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/better-result": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", @@ -4750,7 +5102,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" @@ -5547,7 +5899,7 @@ "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, + "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -6092,7 +6444,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 +6480,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" @@ -7545,6 +7897,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz", + "integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==", + "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", @@ -8058,7 +8419,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" @@ -8262,6 +8623,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.4.2.tgz", + "integrity": "sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g==", + "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 +8876,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" @@ -8863,7 +9239,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -9350,7 +9726,7 @@ "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -9391,6 +9767,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9557,6 +9939,12 @@ "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", @@ -9770,7 +10158,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/signal-exit": { @@ -9863,7 +10251,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/std-env": { @@ -10159,14 +10547,14 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, + "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==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -10176,7 +10564,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -10193,7 +10581,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -10211,7 +10599,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -10224,7 +10612,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -10638,7 +11026,7 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -10713,7 +11101,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -10731,7 +11119,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -10744,7 +11132,7 @@ "version": "4.0.18", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/expect": "4.0.18", @@ -10822,7 +11210,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -11000,7 +11388,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 +11540,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..c5bbea5c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", + "better-auth": "1.6.26", "dotenv": "^17.2.4", "livekit-client": "^2.17.0", "livekit-server-sdk": "^2.15.0", @@ -72,6 +73,7 @@ "vitest": "^4.0.18" }, "overrides": { + "@sveltejs/vite-plugin-svelte": "6.2.4", "playwright-core": "1.61.0", "next": { "postcss": "8.5.25", From 1d9fcb80486a58e0d0b7239260bdd6b7417da6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:02:09 -0300 Subject: [PATCH 004/376] docs: freeze EarlyBirds implementation contracts --- .../0002-earlybird-identity-boundary.md | 31 ++ ...3-deterministic-hls-and-audio-guardrail.md | 34 +++ ...4-provider-neutral-earlybird-membership.md | 31 ++ .../0005-earlybirds-fast-forward-lane.md | 31 ++ docs/plans/EARLY_BIRDS.md | 271 +++++++++++------- 5 files changed, 288 insertions(+), 110 deletions(-) create mode 100644 docs/decisions/0002-earlybird-identity-boundary.md create mode 100644 docs/decisions/0003-deterministic-hls-and-audio-guardrail.md create mode 100644 docs/decisions/0004-provider-neutral-earlybird-membership.md create mode 100644 docs/decisions/0005-earlybirds-fast-forward-lane.md diff --git a/docs/decisions/0002-earlybird-identity-boundary.md b/docs/decisions/0002-earlybird-identity-boundary.md new file mode 100644 index 00000000..62bd8266 --- /dev/null +++ b/docs/decisions/0002-earlybird-identity-boundary.md @@ -0,0 +1,31 @@ +# 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 Google and Apple only. +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. + +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..f4a4bf30 --- /dev/null +++ b/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md @@ -0,0 +1,34 @@ +# 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 a short-lived manifest whose segment URLs are individually signed. +Signatures cover method, canonical path and expiry, use constant-time comparison +and are never logged. Public health is minimal; metrics bind privately. + +## 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..3ba34941 --- /dev/null +++ b/docs/decisions/0004-provider-neutral-earlybird-membership.md @@ -0,0 +1,31 @@ +# Provider-neutral EarlyBird membership + +*Accepted 2026-08-06 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 2/month offer revision. Involuntary +payment failure receives 14 days of grace. Voluntary cancellation remains active +through paid-through time and then loses founder terms. Refund, dispute and +administrative revoke end access immediately. + +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 2 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/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 61f7c74d..66f8eebd 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -1,11 +1,11 @@ # EarlyBirds: product and delivery plan -> **Status:** Draft for agreement with Nico and the Harmonic Beacon team -> **Date:** 2026-08-05 -> **Integration branch:** `early-birds`, based on `main@f520332` -> **Operational rule:** this document authorizes planning only. It does not -> authorize a production deploy, a payment change, or a change to the event -> audio path. +> **Status:** Accepted implementation baseline +> **Date:** 2026-08-06 +> **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. Reviewed inputs: `.hermes/plans/2026-08-05_beacon-founders-mvp.md` and `docs/BEACON_FOUNDERS.md` from the daimonmatrix checkout. They remain valuable @@ -19,12 +19,13 @@ continuous relationship with the Beacon outside scheduled events. The first useful release lets a Listener: -1. sign in with Google; -2. obtain a valid EarlyBird membership through the existing commerce authority; +1. sign in with Google or Apple; +2. obtain one Free invitation grant or a valid paid EarlyBird membership through + the provider-neutral commerce authority; 3. open a private, receive-only listening home; 4. hear a continuous 24/7 Beacon stream; -5. optionally play one reviewed drop-in voice track in Spanish or English with - standard private playback controls and balance it against the Beacon; +5. optionally play one reviewed drop-in in Spanish or English with standard, + independent private playback controls; 6. return later and recover the same access without joining an event room. The initial 24/7 source is the long spatialized recording @@ -49,6 +50,10 @@ change has passed its own audio and operational acceptance. | 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; event WebRTC is untouched. | +| Keep drop-ins independent | Accepted | Drop-ins have a local timeline; they are not a realtime mix or crossfader. | +| Offer Free and paid access through one contract | Accepted | One-use signed invitations and PayPal/MercadoPago converge on the same revocable membership state machine. | +| 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 @@ -69,9 +74,13 @@ the older Founders proposal alone. create an unrelated payment truth inside the web app. - The selected source master is 6,844.426 seconds (1:54:04.426), stereo, 48 kHz, 32-bit float PCM, 2,628,259,840 bytes. -- Six candidate drop-in voice masters exist. They are mono 24 kHz WAV files, - approximately 5.5 minutes each. The final voice/version and permission to use - it have not yet been recorded as product decisions. +- The selected drop-in masters are + `/home/nicolas/Downloads/BeaconEarlyAdopters/Proyeccion_Caldeamiento_Amara_Sol_ES_VOICE.wav` + and + `/home/nicolas/Downloads/BeaconEarlyAdopters/Proyeccion_Caldeamiento_Amara_Sol_EN_VOICE.wav`. + Delivery candidates are + offline pre-renders with an approved Beacon excerpt ducked by 9 dB; selection, + content and every audio artifact still require Nico's listening approval. - No `beacon-247` service or room exists today. ## 4. Corrections to the initial Founders proposal @@ -107,13 +116,14 @@ steps are not safe to execute literally. - `/early-birds` public explanation and sign-in entry. - `/early-birds/home` private Listener player. -- Google sign-in only for the first release. +- Google and Apple sign-in through an exact, stable Better Auth version. - A separate EarlyBird account/session domain. -- Canonical membership entitlement from the commerce service. +- One-use, signed, auditable, revocable Free invitations and canonical paid + membership entitlements from the commerce service. - A continuous, monitored stream from the approved long master. -- Beacon-only playback and one optional ES or EN drop-in. +- Beacon-only playback and one optional ES or EN drop-in on an independent timeline. - Standard play, pause, seek and restart controls for the private drop-in. -- A simple Beacon/Drop-in balance with an obvious return to Beacon-only. +- Two-device lease enforcement; a third device evicts the oldest lease. - Honest source state: recorded continuous source, reconnecting or unavailable. - Cancellation/revocation reflected without relying on a front-end redirect. - ES/EN copy, privacy/terms, basic accessibility and mobile-browser acceptance. @@ -121,8 +131,7 @@ steps are not safe to execute literally. ### Deferred -- Apple and Facebook sign-in. -- MercadoPago as a second recurring provider. +- 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. @@ -146,10 +155,10 @@ offline reviewed derivative ----> 24/7 stream origin ----> cache/CDN boundary v external canary -Google OIDC ---> EarlyBird account/session ---> EarlyBird web routes +Google/Apple OIDC ---> EarlyBird account/session ---> EarlyBird web routes | v -commerce entitlement API <--- PayPal/webhooks/reconciliation authority +membership authority <--- Free invites / PayPal / MercadoPago / future stores ``` ### 6.1 Code boundary @@ -184,9 +193,9 @@ Development and team acceptance use an isolated preview: - CPU/memory limits so it cannot starve event services; - no automatic production migration or deploy from the `early-birds` branch. -The preferred preview address is -`earlybirds-staging.harmonicbeacon.com`. A private ZeroTier-only name is an -acceptable first step if public DNS would delay the media proof. +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 @@ -200,7 +209,9 @@ consumer. ### 7.1 Source and artifacts -- The WAV master is immutable and identified by a recorded SHA-256. +- The WAV master at + `/home/nicolas/Music/beacon/luz_de_manana_20260624-155633.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 @@ -222,24 +233,29 @@ artifact in a standard player and the actual EarlyBird player. Selecting and deploying that encoding is an audio-touching decision requiring Nico's explicit approval. -To avoid continuous expensive encoding, the expected steady state is: +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. run a small origin process that reads it at real time and loops it; -3. package or relay without another lossy encode; -4. keep a rolling live manifest and bounded segments; +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 should hear approximately the same wall-clock position in the -24/7 Beacon stream. This shared timeline does not apply to drop-ins: each -Listener controls those privately. A stream process restart may begin a new -Beacon epoch; it must not produce overlapping publishers. +All listeners hear approximately the same wall-clock position in the 24/7 +Beacon stream. This shared timeline does not apply to drop-ins. 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 manifest embeds individually signed segment URLs; signatures cover HTTP + method, canonical path and expiry, are compared in constant time and are + never logged. - Expiry and refresh do not interrupt healthy playback unnecessarily. - The UI says "continuous recorded Beacon" (localized wording to be approved), not "live from Costa Rica". @@ -247,7 +263,7 @@ Beacon epoch; it must not produce overlapping publishers. ### 7.4 Reliability acceptance -- One and only one origin publisher/packager is active. +- 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. @@ -256,12 +272,30 @@ Beacon epoch; it must not produce overlapping publishers. 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 media playback. Web Audio is introduced only if a required mix -behavior cannot be achieved cleanly and the alternative passes the audio gate. +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. - Beacon-only is the default and remains available if a drop-in fails. @@ -270,9 +304,11 @@ behavior cannot be achieved cleanly and the alternative passes the audio gate. - Drop-ins expose familiar play, pause, timeline/seek and restart controls. - Drop-in position is private to the Listener and is never synchronized with another Listener. -- Balance changes are perceptually smooth and never exceed reviewed gain limits. -- Pausing or stopping a drop-in returns to Beacon-only without a jump in the - Beacon timeline. +- Each drop-in is an offline reviewed render: the chosen Beacon excerpt is + ducked by 9 dB under the unmodified voice master. Its play, pause, seek and + restart controls never move the 24/7 Beacon timeline. +- Starting or stopping a drop-in 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. @@ -284,8 +320,8 @@ The media test ladder is mandatory and intentionally incremental: 2. approved derivative in a standard player; 3. stream in a standard browser player; 4. stream in the EarlyBird player; -5. stream plus independently controlled ES drop-in; -6. stream plus independently controlled EN drop-in. +5. stream plus independently controlled ES pre-render; +6. stream plus independently controlled EN pre-render. A failure at one level is fixed there before testing the next. @@ -305,19 +341,22 @@ Proposed additive concepts: The browser uses a separate `hb_earlybird_session` cookie. An EarlyBird session cannot grant staff capabilities, event publication or event admission. -Google is the first provider. The implementation uses Authorization Code with -PKCE, state and nonce, and stores no Google access/refresh token unless a later -feature proves it necessary. Provider subject is the primary external identity; -verified email is contact/linkage evidence, not a mutable authorization key. +Google and Apple use Authorization Code with PKCE, state and nonce. 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. -Before choosing an auth library, a short ADR must confirm a maintained stable -option compatible with Next.js 16. The retired NextAuth beta is not the default. +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 commerce authority. The web app does -not infer a membership from a PayPal success page and does not implement a -parallel webhook truth. +`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: @@ -333,15 +372,26 @@ The EarlyBirds contract must provide, at minimum: - cancellation, failed-renewal, refund, dispute and manual-revoke behavior; - a safe test/sandbox mode with synthetic identities. -"Founder price locked for life" is not a boolean. It is a versioned offer grant -recording the acquired amount/currency, acquisition time and continuity policy. -The team must define whether cancellation permanently loses the offer and how a -failed payment during grace differs from voluntary cancellation. +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. -The first provider should be PayPal because that path already operates in the -project. MercadoPago follows only after the provider-neutral entitlement -contract is demonstrated. No provider is enabled for real EarlyBird charges -until Nico approves the exact offer and a sandbox lifecycle passes end to end. +"Founder price locked for life" is not a boolean. It is a versioned USD 2/month +offer grant recording amount/currency, acquisition time and continuity policy. +Voluntary cancellation preserves access through paid-through time and then +loses the founder offer. Involuntary payment failure receives 14 days of grace. +Refund, dispute and administrative revocation remove access immediately. + +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 2 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. + +The product is for all audiences. An adult owns the account and payment; the +service does not request or persist a minor profile or minor-specific data. ## 11. Fast Forward development lane @@ -420,41 +470,45 @@ dependency graph. No application behavior changes. Exit: all decisions in section 15 are accepted or deliberately deferred. -### Batch 1 — isolated preview and 24/7 media proof +### Batch A — contracts, isolated preview and deterministic origin - add the isolated compose/runtime boundary; - inventory and checksum the master; -- create a reproducible candidate derivative without replacing the master; -- run the stream origin under resource limits; -- expose health/source state; +- 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 recorded source survives restart and a 60-minute cross-device listen, with no event service or current audio file changed. -### Batch 2 — Listener vertical slice with synthetic entitlement +### 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, drop-in selection, standard controls and balance; +- add Beacon-only player, drop-in selection and independent standard controls; +- 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 3 — Google identity +### Batch C — identity and provider-neutral membership - approve the identity ADR; -- implement Google sign-in, callback, session/revocation and logout; -- add account-linking and duplicate-email protections; +- implement Google and Apple sign-in, 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 4 — PayPal membership integration +### Batch D — PayPal, MercadoPago and release candidate - agree the versioned commerce contract with Mariano/Sai; - extend the commerce sandbox for the EarlyBird offer; @@ -462,13 +516,8 @@ cross into event/staff privileges. - test create, duplicate webhook, out-of-order event, retry, renewal failure, grace, cancellation, refund and revoke; - reconcile stale/missing delivery. - -Exit: sandbox purchase-to-listen and revoke-to-deny work without trusting a -browser redirect or duplicate authority. - -### Batch 5 — release candidate - -- approve terms, privacy, offer copy and source wording; +- implement MercadoPago/BCRA rate lock and failure semantics through the same contract; +- approve terms, privacy, all-ages offer copy and source wording; - complete accessibility/mobile/audio/security acceptance; - run sustained origin/canary test and failure rehearsal; - verify backups, observability, stop switch and rollback; @@ -488,6 +537,11 @@ approved. 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. @@ -517,41 +571,38 @@ 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. Decisions required before cards are created - -Recommended defaults are included so the team can approve them as a block or -change only the exceptions. - -| ID | Decision | Recommended default | -|---|---|---| -| D1 | Public name and URL | Product name `EarlyBirds`; preview at `earlybirds-staging.harmonicbeacon.com`; final entry at `/early-birds`. | -| D2 | Founder offer | USD 2/month, amount locked while the same subscription remains active; voluntary cancellation loses the locked offer, provider failure gets a defined grace period. | -| D3 | First identity provider | Google only; Apple/Facebook after launch evidence. | -| D4 | First payment provider | PayPal through the existing commerce authority; MercadoPago second. | -| D5 | Initial source wording | "Continuous recorded Beacon"; never imply the physical source is live. | -| D6 | Drop-in masters | Amara Sol ES/EN candidates, subject to explicit rights/consent and Nico's content/audio approval. | -| D7 | Stream delivery | Buffered HTTP/HLS spike; codec selected only after A/B and browser support evidence. | -| D8 | EarlyBird offer after cancellation | Locked offer survives only involuntary payment failure during grace, not voluntary cancellation/refund/revoke. | -| D9 | Production topology | Main app after final merge; independent stream-origin service; additive models; feature/kill switch. | -| D10 | Timelines | Accepted: one shared wall-clock position for the Beacon stream; every drop-in has private standard play/pause/seek/restart controls. | - -## 16. Card map after agreement - -Only after section 15 is agreed, create milestone `EarlyBirds` and non-duplicate -GitHub issues in this dependency order: - -1. EB-00 — freeze product, identity, commerce and media ADRs. -2. EB-01 — isolated preview runtime and Fast Forward CI lane. -3. EB-02 — source provenance and reproducible media artifact pipeline. -4. EB-03 — resource-bounded 24/7 stream origin and health. -5. EB-04 — stream canary, observability and incident stop switch. -6. EB-05 — Listener shell and synthetic-entitlement vertical slice. -7. EB-06 — Google identity and isolated Listener sessions. -8. EB-07 — drop-in asset approval, delivery and Beacon/Drop-in player. -9. EB-08 — versioned EarlyBird commerce entitlement contract. -10. EB-09 — PayPal sandbox lifecycle and reconciliation. -11. EB-10 — privacy, terms, accessibility and bilingual acceptance. -12. EB-11 — release/rollback rehearsal and go/no-go. +## 15. Frozen decisions + +| ID | Accepted decision | +|---|---| +| D1 | `EarlyBirds`; preview `earlybirds-staging.harmonicbeacon.com`; production route `/early-birds`; origin `stream.harmonicbeacon.com`. | +| D2 | USD 2/month founder offer; 14-day involuntary grace; voluntary cancellation loses founder terms after paid-through; refund/dispute/admin revoke immediately. | +| D3 | Google and Apple through exact stable Better Auth; no Facebook and no account linking. | +| D4 | Provider-neutral Free, PayPal and MercadoPago grants; Free is single-use, signed, auditable, revocable and consumed by paid upgrade. | +| D5 | Truthful “continuous recorded Beacon” wording; never imply the physical source is live. | +| D6 | Exact Amara Sol ES/EN voice masters; offline drop-in renders with Beacon ducked 9 dB; every artifact awaits Nico's approval. | +| D7 | Deterministic UTC HLS, immutable six-second segments, signed paths, native Safari and `hls.js`; codec remains unselected. | +| 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 drop-in has private play/pause/seek/restart controls. | +| 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. | + +## 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 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 and independently controlled ES/EN drop-ins. +7. EB-06 — PayPal sandbox lifecycle and reconciliation. +8. EB-07 — MercadoPago/BCRA pricing, lock and failure lifecycle. +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). 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 From b7a6e9efbe764f43c28e3e271dc95019faa6a182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:00:26 -0300 Subject: [PATCH 005/376] feat: add isolated EarlyBird HLS stream origin --- services/beacon-stream/.gitignore | 6 + services/beacon-stream/Dockerfile | 10 ++ services/beacon-stream/README.md | 69 ++++++++ services/beacon-stream/docker-compose.yml | 41 +++++ .../beacon-stream/media/artifacts/README.md | 21 +++ .../media/inventory.example.json | 14 ++ services/beacon-stream/package.json | 19 ++ services/beacon-stream/preview.env.example | 7 + services/beacon-stream/scripts/canary.mjs | 30 ++++ services/beacon-stream/scripts/inventory.mjs | 39 +++++ services/beacon-stream/scripts/load-hls.mjs | 70 ++++++++ .../beacon-stream/scripts/verify-artifact.mjs | 17 ++ services/beacon-stream/src/artifact.mjs | 59 +++++++ services/beacon-stream/src/auth.mjs | 46 +++++ services/beacon-stream/src/manifest.mjs | 37 ++++ services/beacon-stream/src/metrics.mjs | 42 +++++ services/beacon-stream/src/server.mjs | 165 ++++++++++++++++++ services/beacon-stream/test/artifact.test.mjs | 18 ++ services/beacon-stream/test/auth.test.mjs | 19 ++ services/beacon-stream/test/helpers.mjs | 37 ++++ services/beacon-stream/test/manifest.test.mjs | 22 +++ services/beacon-stream/test/server.test.mjs | 49 ++++++ 22 files changed, 837 insertions(+) create mode 100644 services/beacon-stream/.gitignore create mode 100644 services/beacon-stream/Dockerfile create mode 100644 services/beacon-stream/README.md create mode 100644 services/beacon-stream/docker-compose.yml create mode 100644 services/beacon-stream/media/artifacts/README.md create mode 100644 services/beacon-stream/media/inventory.example.json create mode 100644 services/beacon-stream/package.json create mode 100644 services/beacon-stream/preview.env.example create mode 100644 services/beacon-stream/scripts/canary.mjs create mode 100644 services/beacon-stream/scripts/inventory.mjs create mode 100644 services/beacon-stream/scripts/load-hls.mjs create mode 100644 services/beacon-stream/scripts/verify-artifact.mjs create mode 100644 services/beacon-stream/src/artifact.mjs create mode 100644 services/beacon-stream/src/auth.mjs create mode 100644 services/beacon-stream/src/manifest.mjs create mode 100644 services/beacon-stream/src/metrics.mjs create mode 100644 services/beacon-stream/src/server.mjs create mode 100644 services/beacon-stream/test/artifact.test.mjs create mode 100644 services/beacon-stream/test/auth.test.mjs create mode 100644 services/beacon-stream/test/helpers.mjs create mode 100644 services/beacon-stream/test/manifest.test.mjs create mode 100644 services/beacon-stream/test/server.test.mjs 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..2e8fa033 --- /dev/null +++ b/services/beacon-stream/README.md @@ -0,0 +1,69 @@ +# 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` uses schema version 1. Required fields are `approval.status` +(`APPROVED`), approval timestamp and review record, source master SHA-256, derivative SHA-256, +fixed `timing.epochUtc`, `segmentDurationSeconds: 6`, `segmentCount`, and the +complete `{ file, bytes, sha256 }` segment inventory. 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. + +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 canary verifies HLS syntax and retrieves a non-empty signed segment. It is +intentionally codec-neutral; audio decode verification is a release gate run +only after a reviewed delivery format exists. 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..e6023674 --- /dev/null +++ b/services/beacon-stream/docker-compose.yml @@ -0,0 +1,41 @@ +# 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_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 + 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 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..97b87e42 --- /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 scripts/inventory.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..5767a0c2 --- /dev/null +++ b/services/beacon-stream/preview.env.example @@ -0,0 +1,7 @@ +# 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://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/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..69ce7009 --- /dev/null +++ b/services/beacon-stream/scripts/inventory.mjs @@ -0,0 +1,39 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +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 bytes = await fs.readFile(masterPath); +const inventory = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + master: { + immutable: true, + path: masterPath, + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + 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..201b4640 --- /dev/null +++ b/services/beacon-stream/src/artifact.mjs @@ -0,0 +1,59 @@ +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}$/; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +export function validateArtifact(raw) { + assert(raw && raw.schemaVersion === 1, 'artifact schemaVersion must be 1'); + 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' && Number.isFinite(Date.parse(raw.timing.epochUtc)), 'timing.epochUtc is required'); + assert(raw.timing?.segmentDurationSeconds === 6, 'only immutable six-second segments are supported'); + 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(); + for (const segment of raw.segments) { + assert(typeof segment.file === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment.file), 'invalid segment filename'); + assert(!seen.has(segment.file), `duplicate segment filename ${segment.file}`); + seen.add(segment.file); + assert(typeof segment.sha256 === 'string' && SHA256.test(segment.sha256), `segment ${segment.file} SHA-256 is required`); + assert(Number.isSafeInteger(segment.bytes) && segment.bytes > 0, `segment ${segment.file} byte count is required`); + } + + return Object.freeze({ + ...raw, + epochMs: Date.parse(raw.timing.epochUtc), + segmentByFile: new Map(raw.segments.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'); + for (const segment of metadata.segments) { + 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/manifest.mjs b/services/beacon-stream/src/manifest.mjs new file mode 100644 index 00000000..230d12b4 --- /dev/null +++ b/services/beacon-stream/src/manifest.mjs @@ -0,0 +1,37 @@ +import { signedUrl } from './auth.mjs'; + +const WINDOW_SEGMENTS = 6; + +function isoAt(epochMs, sequence, durationSeconds) { + return new Date(epochMs + sequence * durationSeconds * 1000).toISOString(); +} + +export function currentSequence(metadata, nowMs = Date.now()) { + return Math.max(0, Math.floor((nowMs - metadata.epochMs) / (metadata.timing.segmentDurationSeconds * 1000))); +} + +export function renderManifest({ metadata, origin, secret, nowMs = Date.now(), tokenTtlSeconds = 120 }) { + const duration = metadata.timing.segmentDurationSeconds; + const edgeSequence = currentSequence(metadata, nowMs); + const firstSequence = Math.max(0, edgeSequence - (WINDOW_SEGMENTS - 1)); + const expiresAt = Math.floor(nowMs / 1000) + tokenTtlSeconds; + const lines = [ + '#EXTM3U', + '#EXT-X-VERSION:7', + `#EXT-X-TARGETDURATION:${duration}`, + `#EXT-X-MEDIA-SEQUENCE:${firstSequence}`, + '#EXT-X-INDEPENDENT-SEGMENTS', + ]; + + for (let sequence = firstSequence; sequence <= edgeSequence; sequence += 1) { + const index = sequence % metadata.timing.segmentCount; + const segment = metadata.segments[index]; + if (index === 0 && sequence !== 0) lines.push('#EXT-X-DISCONTINUITY'); + lines.push(`#EXT-X-PROGRAM-DATE-TIME:${isoAt(metadata.epochMs, sequence, duration)}`); + lines.push(`#EXTINF:${duration.toFixed(3)},`); + 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(signedUrl({ origin, secret, pathname, expiresAt })); + } + return `${lines.join('\n')}\n`; +} 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..ef8cab05 --- /dev/null +++ b/services/beacon-stream/src/server.mjs @@ -0,0 +1,165 @@ +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 { renderManifest } from './manifest.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') }; +} + +function authorized({ request, url, secret }) { + return verifySignedPath({ + secret, + method: request.method, + pathname: url.pathname, + ...tokenFrom(url), + }); +} + +function routeName(pathname) { + if (pathname === '/healthz') return 'health'; + if (pathname.endsWith('/live.m3u8')) return 'manifest'; + if (pathname.includes('/segments/')) return 'segment'; + return 'unknown'; +} + +export function createPublicHandler({ artifactRoot, metadata, publicOrigin, signingSecret, metrics = new Metrics(), now = () => Date.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; + try { + if (request.method !== 'GET' && request.method !== 'HEAD') { + status = 405; + send(response, status, 'method not allowed\n', { Allow: 'GET, HEAD' }); + return; + } + if (url.pathname === '/healthz') { + status = 200; + send(response, status, 'ok\n', { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }); + return; + } + if (url.pathname === manifestPath) { + if (!authorized({ request, url, secret: signingSecret })) { + status = 403; + send(response, status, 'forbidden\n', { 'Cache-Control': 'no-store' }); + return; + } + const manifest = renderManifest({ metadata, origin: publicOrigin, secret: signingSecret, nowMs: now() }); + status = 200; + bytes = request.method === 'HEAD' ? 0 : Buffer.byteLength(manifest); + send(response, 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 (!authorized({ request, url, secret: signingSecret })) { + status = 403; + send(response, status, 'forbidden\n', { 'Cache-Control': 'no-store' }); + return; + } + const file = decodeURIComponent(url.pathname.slice(segmentPrefix.length)); + if (!metadata.segmentByFile.has(file)) { + status = 404; + send(response, 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; + send(response, status, 'not found\n', { 'Cache-Control': 'no-store' }); + return; + } + const segment = await fs.readFile(segmentPath); + status = 200; + bytes = request.method === 'HEAD' ? 0 : segment.byteLength; + send(response, status, request.method === 'HEAD' ? '' : segment, { + 'Content-Type': 'application/octet-stream', + 'Cache-Control': 'private, no-store', + 'Content-Length': String(segment.byteLength), + }); + return; + } + status = 404; + send(response, status, 'not found\n', { 'Cache-Control': 'no-store' }); + } catch { + // Do not expose filesystem paths, credentials or signed URLs. + status = 500; + if (!response.headersSent) send(response, status, '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; + if (!mediaRoot || !artifactId || !signingSecret || !publicOrigin) { + throw new Error('BEACON_STREAM_MEDIA_ROOT, BEACON_STREAM_ARTIFACT_ID, BEACON_STREAM_SIGNING_SECRET and BEACON_STREAM_PUBLIC_ORIGIN are required'); + } + 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, 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((error) => { + // 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..d63e1f4a --- /dev/null +++ b/services/beacon-stream/test/artifact.test.mjs @@ -0,0 +1,18 @@ +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 } 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/); +}); + +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/helpers.mjs b/services/beacon-stream/test/helpers.mjs new file mode 100644 index 00000000..3bd9b355 --- /dev/null +++ b/services/beacon-stream/test/helpers.mjs @@ -0,0 +1,37 @@ +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 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 }; +} diff --git a/services/beacon-stream/test/manifest.test.mjs b/services/beacon-stream/test/manifest.test.mjs new file mode 100644 index 00000000..aad62f8b --- /dev/null +++ b/services/beacon-stream/test/manifest.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderManifest } from '../src/manifest.mjs'; +import { verifySignedPath } from '../src/auth.mjs'; +import { metadata } 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:2/); + 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, 6); + 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); + } +}); diff --git a/services/beacon-stream/test/server.test.mjs b/services/beacon-stream/test/server.test.mjs new file mode 100644 index 00000000..f8c07d43 --- /dev/null +++ b/services/beacon-stream/test/server.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import test from 'node:test'; +import { createPublicHandler, createInternalHandler } from '../src/server.mjs'; +import { signPath } from '../src/auth.mjs'; +import { Metrics } from '../src/metrics.mjs'; +import { metadata, temporaryArtifact } 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 { server, origin } = await listen(createPublicHandler({ artifactRoot, metadata: item, publicOrigin: 'https://stream.example.test', signingSecret: secret, 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}`); + assert.equal(response.status, 200); + const manifest = await response.text(); + const segmentUrl = manifest.split('\n').find((line) => line.startsWith('https://')); + assert.ok(segmentUrl); + const productionUrl = new URL(segmentUrl); + const localUrl = new URL(`${origin}${productionUrl.pathname}${productionUrl.search}`); + const segment = await fetch(localUrl); + assert.equal(segment.status, 200); + assert.ok(['one', 'two', 'three'].includes(await segment.text())); +}); + +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/); +}); From bb76168096c95fa4f15710fc7e8b49670962741d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:02:49 -0300 Subject: [PATCH 006/376] fix: stream inventory hash master incrementally --- services/beacon-stream/package.json | 2 +- services/beacon-stream/scripts/inventory.mjs | 5 ++--- services/beacon-stream/src/artifact.mjs | 3 ++- services/beacon-stream/src/inventory.mjs | 13 +++++++++++++ services/beacon-stream/test/artifact.test.mjs | 1 + services/beacon-stream/test/inventory.test.mjs | 16 ++++++++++++++++ 6 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 services/beacon-stream/src/inventory.mjs create mode 100644 services/beacon-stream/test/inventory.test.mjs diff --git a/services/beacon-stream/package.json b/services/beacon-stream/package.json index 97b87e42..c8bc69e2 100644 --- a/services/beacon-stream/package.json +++ b/services/beacon-stream/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node src/server.mjs", "test": "node --test test/*.test.mjs", - "check": "node --check src/server.mjs && node --check scripts/inventory.mjs && node --check scripts/verify-artifact.mjs", + "check": "node --check src/server.mjs && node --check src/inventory.mjs && node --check scripts/inventory.mjs && node --check scripts/verify-artifact.mjs", "inventory": "node scripts/inventory.mjs", "verify-artifact": "node scripts/verify-artifact.mjs", "canary": "node scripts/canary.mjs", diff --git a/services/beacon-stream/scripts/inventory.mjs b/services/beacon-stream/scripts/inventory.mjs index 69ce7009..dab57542 100644 --- a/services/beacon-stream/scripts/inventory.mjs +++ b/services/beacon-stream/scripts/inventory.mjs @@ -1,6 +1,6 @@ -import crypto from 'node:crypto'; 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); @@ -21,14 +21,13 @@ if (masterPath === outputPath) { } const stat = await fs.stat(masterPath); if (!stat.isFile()) throw new Error('master must be a regular file'); -const bytes = await fs.readFile(masterPath); const inventory = { schemaVersion: 1, generatedAt: new Date().toISOString(), master: { immutable: true, path: masterPath, - sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + sha256: await sha256File(masterPath), bytes: stat.size, }, }; diff --git a/services/beacon-stream/src/artifact.mjs b/services/beacon-stream/src/artifact.mjs index 201b4640..809877c3 100644 --- a/services/beacon-stream/src/artifact.mjs +++ b/services/beacon-stream/src/artifact.mjs @@ -4,6 +4,7 @@ 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); @@ -17,7 +18,7 @@ export function validateArtifact(raw) { 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' && Number.isFinite(Date.parse(raw.timing.epochUtc)), 'timing.epochUtc is required'); + 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'); assert(raw.timing?.segmentDurationSeconds === 6, 'only immutable six-second segments are supported'); 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'); 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/test/artifact.test.mjs b/services/beacon-stream/test/artifact.test.mjs index d63e1f4a..30340274 100644 --- a/services/beacon-stream/test/artifact.test.mjs +++ b/services/beacon-stream/test/artifact.test.mjs @@ -7,6 +7,7 @@ import { metadata, temporaryArtifact } 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('loads and checksum-verifies every immutable segment', async () => { 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); +}); From d0354adf725dd35ee82448004f4e10d45ce4e8fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:06:48 -0300 Subject: [PATCH 007/376] docs: record EarlyBird source media provenance --- .../EARLY_BIRDS_MEDIA_PROVENANCE.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md diff --git a/docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md b/docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md new file mode 100644 index 00000000..716d2d04 --- /dev/null +++ b/docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md @@ -0,0 +1,41 @@ +# 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. From e4d7b93ae9aada61e580603542d874da60eb64fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:13:42 -0300 Subject: [PATCH 008/376] docs: preserve HLS source across lease refresh --- .../0003-deterministic-hls-and-audio-guardrail.md | 10 +++++++--- docs/plans/EARLY_BIRDS.md | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md b/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md index f4a4bf30..c7eb4206 100644 --- a/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md +++ b/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md @@ -10,9 +10,13 @@ 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 a short-lived manifest whose segment URLs are individually signed. -Signatures cover method, canonical path and expiry, use constant-time comparison -and are never logged. Public health is minimal; metrics bind privately. +authorizes a stable, same-origin lease-manifest route. That route rechecks the +session, current membership and device lease on every manifest refresh, then +proxies a very short-lived origin manifest whose segment URLs are individually +signed. Native players therefore never need an `audio.src` replacement merely +to refresh authorization. Signatures cover method, canonical path and expiry, +use constant-time comparison and are never logged. Public health is minimal; +metrics bind privately. ## Audio boundary diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 66f8eebd..9d27222b 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -253,6 +253,9 @@ promotion, never an accidental restart side effect. - 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 same-origin lease-manifest URL. Each refresh + rechecks session, membership and device lease before proxying a fresh signed + origin manifest, so authorization refresh never replaces the media source. - The manifest embeds individually signed segment URLs; signatures cover HTTP method, canonical path and expiry, are compared in constant time and are never logged. From 524c1827f370a5a80776af733c3885c90292329a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:13:51 -0300 Subject: [PATCH 009/376] feat: add EarlyBirds observability preview stack --- .../workflows/early-birds-fast-forward.yml | 43 +++++++ ops/early-birds/.gitignore | 3 + .../alertmanager/alertmanager.yml.tmpl | 32 +++++ ops/early-birds/alertmanager/telegram.tmpl | 3 + ops/early-birds/canary/Dockerfile | 7 ++ ops/early-birds/canary/canary-exporter.mjs | 82 +++++++++++++ ops/early-birds/docker-compose.yml | 116 ++++++++++++++++++ ops/early-birds/package.json | 11 ++ ops/early-birds/prometheus/alerts.yml | 101 +++++++++++++++ ops/early-birds/prometheus/prometheus.yml | 32 +++++ ops/early-birds/runbook/README.md | 70 +++++++++++ ops/early-birds/scripts/stop-stream.sh | 10 ++ ops/early-birds/scripts/validate-config.mjs | 41 +++++++ ops/early-birds/test/canary-exporter.test.mjs | 19 +++ ops/early-birds/test/config.test.mjs | 34 +++++ 15 files changed, 604 insertions(+) create mode 100644 .github/workflows/early-birds-fast-forward.yml create mode 100644 ops/early-birds/.gitignore create mode 100644 ops/early-birds/alertmanager/alertmanager.yml.tmpl create mode 100644 ops/early-birds/alertmanager/telegram.tmpl create mode 100644 ops/early-birds/canary/Dockerfile create mode 100644 ops/early-birds/canary/canary-exporter.mjs create mode 100644 ops/early-birds/docker-compose.yml create mode 100644 ops/early-birds/package.json create mode 100644 ops/early-birds/prometheus/alerts.yml create mode 100644 ops/early-birds/prometheus/prometheus.yml create mode 100644 ops/early-birds/runbook/README.md create mode 100755 ops/early-birds/scripts/stop-stream.sh create mode 100644 ops/early-birds/scripts/validate-config.mjs create mode 100644 ops/early-birds/test/canary-exporter.test.mjs create mode 100644 ops/early-birds/test/config.test.mjs diff --git a/.github/workflows/early-birds-fast-forward.yml b/.github/workflows/early-birds-fast-forward.yml new file mode 100644 index 00000000..679efa5d --- /dev/null +++ b/.github/workflows/early-birds-fast-forward.yml @@ -0,0 +1,43 @@ +name: EarlyBirds fast-forward checks + +on: + pull_request: + paths: + - services/beacon-stream/** + - ops/early-birds/** + - .github/workflows/early-birds-fast-forward.yml + push: + branches: [early-birds, "feat/early-birds-*"] + paths: + - services/beacon-stream/** + - ops/early-birds/** + - .github/workflows/early-birds-fast-forward.yml + +permissions: + contents: read + +jobs: + 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 } + - run: npm test + - run: npm run check + - run: npm run validate 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..5f98410a --- /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: /run/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: /run/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..d91d6aa0 --- /dev/null +++ b/ops/early-birds/canary/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22.22.0-alpine + +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..7aaf102f --- /dev/null +++ b/ops/early-birds/canary/canary-exporter.mjs @@ -0,0 +1,82 @@ +import http from 'node:http'; +import fs from 'node:fs/promises'; + +const manifestUrlFile = process.env.BEACON_CANARY_MANIFEST_URL_FILE ?? '/run/secrets/canary_manifest_url'; +const intervalMs = Number(process.env.BEACON_CANARY_INTERVAL_MS ?? 30_000); +const timeoutMs = Number(process.env.BEACON_CANARY_TIMEOUT_MS ?? 10_000); +const port = Number(process.env.CANARY_EXPORTER_PORT ?? 8081); + +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) }; +} + +async function readManifestUrl() { + const value = (await fs.readFile(manifestUrlFile, 'utf8')).trim(); + if (!/^https?:\/\//.test(value)) throw new Error('manifest URL must be HTTP(S)'); + return value; +} + +export async function probe({ fetchImpl = fetch, nowMs = () => Date.now() } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const startedAt = nowMs(); + try { + const manifestResponse = await fetchImpl(await readManifestUrl(), { cache: 'no-store', signal: controller.signal }); + if (!manifestResponse.ok) throw new Error(`manifest HTTP ${manifestResponse.status}`); + const { segmentUrl, manifestAgeSeconds } = parseManifest(await manifestResponse.text(), 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'); + 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 HTTP HLS canary fetched a non-empty segment.', + '# 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..5b100ee2 --- /dev/null +++ b/ops/early-birds/docker-compose.yml @@ -0,0 +1,116 @@ +# 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;; esac + sed "s/__TELEGRAM_CHAT_ID__/$$chat_id/g" /template/alertmanager.yml.tmpl > /runtime/alertmanager.yml + networks: [observability] + + 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 + - --web.enable-lifecycle=false + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./prometheus/alerts.yml:/etc/prometheus/rules/alerts.yml:ro + - prometheus-data:/prometheus + extra_hosts: [host.docker.internal:host-gateway] + ports: [127.0.0.1:9090:9090] + networks: [observability] + 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 } + command: + - --config.file=/runtime/alertmanager.yml + - --storage.path=/alertmanager + - --web.listen-address=0.0.0.0:9093 + secrets: [telegram_bot_token] + volumes: + - alertmanager-runtime:/runtime:ro + - ./alertmanager/telegram.tmpl:/etc/alertmanager/templates/telegram.tmpl:ro + - alertmanager-data:/alertmanager + ports: [127.0.0.1:9093:9093] + networks: [observability] + deploy: + resources: + limits: { cpus: "0.25", memory: 256M } + + node-exporter: + image: prom/node-exporter:v1.9.1 + restart: unless-stopped + network_mode: host + pid: host + command: + - --path.rootfs=/host + - --web.listen-address=127.0.0.1:9100 + volumes: + - /:/host:ro,rslave + 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-exporter: + build: ./canary + restart: unless-stopped + secrets: [canary_manifest_url] + environment: + BEACON_CANARY_MANIFEST_URL_FILE: /run/secrets/canary_manifest_url + BEACON_CANARY_INTERVAL_MS: 30000 + networks: [observability] + deploy: + resources: + limits: { cpus: "0.25", memory: 128M } + +networks: + observability: + external: true + name: earlybirds_stream_observability + +volumes: + prometheus-data: + alertmanager-data: + alertmanager-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} + canary_manifest_url: + file: ${BEACON_CANARY_MANIFEST_URL_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..528db7fc --- /dev/null +++ b/ops/early-birds/prometheus/alerts.yml @@ -0,0 +1,101 @@ +groups: + - 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: "HTTP HLS canary cannot fetch a non-empty segment", 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 less than 30% free space", 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 less than 15% free space", 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..56772045 --- /dev/null +++ b/ops/early-birds/prometheus/prometheus.yml @@ -0,0 +1,32 @@ +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] + + - job_name: node + static_configs: + - targets: [host.docker.internal: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..a852c66a --- /dev/null +++ b/ops/early-birds/runbook/README.md @@ -0,0 +1,70 @@ +# 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_CANARY_MANIFEST_URL_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 is HTTP-only: it verifies an HLS manifest and a non-empty +signed segment, then publishes reachability and manifest age. It **does not +decode audio** and is not evidence of decoder/audio quality. After a format is +approved, run the decoder canary from an independent VPS and add its private +target before release. + +## 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, canary failed, 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. + +## 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..3ab8da11 --- /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 manifestUrl = await secret('canary_manifest_url', 'https://example.invalid/not-a-real-manifest'); + 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_CANARY_MANIFEST_URL_FILE=${manifestUrl}`, + '', + ].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/test/canary-exporter.test.mjs b/ops/early-birds/test/canary-exporter.test.mjs new file mode 100644 index 00000000..91049542 --- /dev/null +++ b/ops/early-birds/test/canary-exporter.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { 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/); +}); diff --git a/ops/early-birds/test/config.test.mjs b/ops/early-birds/test/config.test.mjs new file mode 100644 index 00000000..90b9b5b3 --- /dev/null +++ b/ops/early-birds/test/config.test.mjs @@ -0,0 +1,34 @@ +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.match(compose, /--web\.listen-address=127\.0\.0\.1:9100/); + // 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_CANARY_MANIFEST_URL_FILE/); + assert.doesNotMatch(compose, /TELEGRAM_BOT_TOKEN:\s*[^$]/); + assert.match(alertmanager, /bot_token_file: \/run\/secrets\/telegram_bot_token/); + assert.match(alertmanager, /send_resolved: true/g); +}); + +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/); +}); From e37f49a2693f29978b124a069f7296452b0e685d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:17:42 -0300 Subject: [PATCH 010/376] fix: refresh EarlyBirds canary authorization --- ops/early-birds/canary/canary-exporter.mjs | 41 +++++++++++++++---- ops/early-birds/docker-compose.yml | 22 ++++++---- ops/early-birds/prometheus/prometheus.yml | 2 +- ops/early-birds/runbook/README.md | 10 +++-- ops/early-birds/scripts/validate-config.mjs | 4 +- ops/early-birds/test/canary-exporter.test.mjs | 15 ++++++- ops/early-birds/test/config.test.mjs | 17 +++++++- 7 files changed, 85 insertions(+), 26 deletions(-) diff --git a/ops/early-birds/canary/canary-exporter.mjs b/ops/early-birds/canary/canary-exporter.mjs index 7aaf102f..9062f155 100644 --- a/ops/early-birds/canary/canary-exporter.mjs +++ b/ops/early-birds/canary/canary-exporter.mjs @@ -1,7 +1,10 @@ import http from 'node:http'; +import crypto from 'node:crypto'; import fs from 'node:fs/promises'; -const manifestUrlFile = process.env.BEACON_CANARY_MANIFEST_URL_FILE ?? '/run/secrets/canary_manifest_url'; +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 port = Number(process.env.CANARY_EXPORTER_PORT ?? 8081); @@ -18,18 +21,42 @@ export function parseManifest(manifest, nowMs = Date.now()) { return { segmentUrl, manifestAgeSeconds: Math.max(0, (nowMs - programTimes.at(-1)) / 1000) }; } -async function readManifestUrl() { - const value = (await fs.readFile(manifestUrlFile, 'utf8')).trim(); - if (!/^https?:\/\//.test(value)) throw new Error('manifest URL must be HTTP(S)'); - return value; +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 async function probe({ fetchImpl = fetch, nowMs = () => Date.now() } = {}) { +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 probe({ + fetchImpl = fetch, + nowMs = () => Date.now(), + origin = publicOrigin, + id = artifactId, + secretFile = signingSecretFile, +} = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const startedAt = nowMs(); try { - const manifestResponse = await fetchImpl(await readManifestUrl(), { cache: 'no-store', signal: controller.signal }); + // 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 { segmentUrl, manifestAgeSeconds } = parseManifest(await manifestResponse.text(), nowMs()); const segmentResponse = await fetchImpl(segmentUrl, { cache: 'no-store', signal: controller.signal }); diff --git a/ops/early-birds/docker-compose.yml b/ops/early-birds/docker-compose.yml index 5b100ee2..e345261f 100644 --- a/ops/early-birds/docker-compose.yml +++ b/ops/early-birds/docker-compose.yml @@ -12,7 +12,7 @@ services: - -ec - | chat_id="$$(tr -d '\r\n' &2; exit 1;; esac + printf '%s\n' "$$chat_id" | grep -Eq '^-?[0-9]+$$' || { echo 'invalid Telegram chat id' >&2; exit 1; } sed "s/__TELEGRAM_CHAT_ID__/$$chat_id/g" /template/alertmanager.yml.tmpl > /runtime/alertmanager.yml networks: [observability] @@ -29,7 +29,6 @@ services: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./prometheus/alerts.yml:/etc/prometheus/rules/alerts.yml:ro - prometheus-data:/prometheus - extra_hosts: [host.docker.internal:host-gateway] ports: [127.0.0.1:9090:9090] networks: [observability] deploy: @@ -59,13 +58,16 @@ services: node-exporter: image: prom/node-exporter:v1.9.1 restart: unless-stopped - network_mode: host - pid: host command: - --path.rootfs=/host - - --web.listen-address=127.0.0.1:9100 + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --web.listen-address=0.0.0.0:9100 volumes: - /:/host:ro,rslave + - /proc:/host/proc:ro + - /sys:/host/sys:ro + networks: [observability] deploy: resources: limits: { cpus: "0.25", memory: 128M } @@ -88,9 +90,11 @@ services: canary-exporter: build: ./canary restart: unless-stopped - secrets: [canary_manifest_url] + secrets: [beacon_stream_signing_secret] environment: - BEACON_CANARY_MANIFEST_URL_FILE: /run/secrets/canary_manifest_url + BEACON_STREAM_SIGNING_SECRET_FILE: /run/secrets/beacon_stream_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 networks: [observability] deploy: @@ -112,5 +116,5 @@ secrets: 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} - canary_manifest_url: - file: ${BEACON_CANARY_MANIFEST_URL_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/prometheus/prometheus.yml b/ops/early-birds/prometheus/prometheus.yml index 56772045..9f4a13dc 100644 --- a/ops/early-birds/prometheus/prometheus.yml +++ b/ops/early-birds/prometheus/prometheus.yml @@ -25,7 +25,7 @@ scrape_configs: - job_name: node static_configs: - - targets: [host.docker.internal:9100] + - targets: [node-exporter:9100] - job_name: cadvisor static_configs: diff --git a/ops/early-birds/runbook/README.md b/ops/early-birds/runbook/README.md index a852c66a..daa9b722 100644 --- a/ops/early-birds/runbook/README.md +++ b/ops/early-birds/runbook/README.md @@ -10,8 +10,8 @@ ZeroTier/admin tunnel; do not add a public nginx location for metrics or admin. 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_CANARY_MANIFEST_URL_FILE` point to those files at Compose runtime. The -bot token is consumed by Alertmanager as a Docker secret; the chat ID is +`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. @@ -27,8 +27,10 @@ docker compose --project-name earlybirds-observability \ `npm run validate` validates Compose, Prometheus rules/config and Alertmanager config with generated fake secrets; it never contacts Telegram. -The included canary is HTTP-only: it verifies an HLS manifest and a non-empty -signed segment, then publishes reachability and manifest age. It **does not +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 an HLS manifest and a non-empty signed +segment, then publishes reachability and manifest age. It **does not decode audio** and is not evidence of decoder/audio quality. After a format is approved, run the decoder canary from an independent VPS and add its private target before release. diff --git a/ops/early-birds/scripts/validate-config.mjs b/ops/early-birds/scripts/validate-config.mjs index 3ab8da11..c17577d9 100644 --- a/ops/early-birds/scripts/validate-config.mjs +++ b/ops/early-birds/scripts/validate-config.mjs @@ -14,7 +14,7 @@ const secret = async (name, contents) => { try { const botToken = await secret('telegram_bot_token', 'not-a-real-token'); const chatId = await secret('telegram_chat_id', '-1000000000000'); - const manifestUrl = await secret('canary_manifest_url', 'https://example.invalid/not-a-real-manifest'); + 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=.', @@ -24,7 +24,7 @@ try { 'BEACON_STREAM_SIGNING_SECRET=not-a-real-32-character-or-longer-secret', `TELEGRAM_BOT_TOKEN_FILE=${botToken}`, `TELEGRAM_CHAT_ID_FILE=${chatId}`, - `BEACON_CANARY_MANIFEST_URL_FILE=${manifestUrl}`, + `BEACON_STREAM_SIGNING_SECRET_FILE=${signingSecret}`, '', ].join('\n'), { mode: 0o600 }); const generatedAlertmanager = path.join(temporary, 'alertmanager.yml'); diff --git a/ops/early-birds/test/canary-exporter.test.mjs b/ops/early-birds/test/canary-exporter.test.mjs index 91049542..70fcf908 100644 --- a/ops/early-birds/test/canary-exporter.test.mjs +++ b/ops/early-birds/test/canary-exporter.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import test from 'node:test'; -import { parseManifest } from '../canary/canary-exporter.mjs'; +import { mintManifestUrl, parseManifest } from '../canary/canary-exporter.mjs'; test('extracts a signed segment and measures the newest manifest edge age', () => { const result = parseManifest([ @@ -17,3 +18,15 @@ test('extracts a signed segment and measures the newest manifest edge age', () = 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/); +}); diff --git a/ops/early-birds/test/config.test.mjs b/ops/early-birds/test/config.test.mjs index 90b9b5b3..e9678eb8 100644 --- a/ops/early-birds/test/config.test.mjs +++ b/ops/early-birds/test/config.test.mjs @@ -10,7 +10,10 @@ 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.match(compose, /--web\.listen-address=127\.0\.0\.1:9100/); + 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\]/); // 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]/); @@ -21,10 +24,20 @@ test('references Telegram and canary credentials as mounted secret files only', 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_CANARY_MANIFEST_URL_FILE/); + assert.match(compose, /BEACON_STREAM_SIGNING_SECRET_FILE/); + 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: \/run\/secrets\/telegram_bot_token/); 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'); + assert.match(prometheus, /targets: \[node-exporter:9100\]/); + assert.doesNotMatch(prometheus, /host\.docker\.internal/); }); test('routes warnings hourly and critical alerts immediately every fifteen minutes', async () => { From 51933f5ec63b47e858232ff0d1ab1d350bd3d008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:36:01 -0300 Subject: [PATCH 011/376] feat(early-birds): add isolated listener membership and playback --- .env.example | 40 ++ contracts/early-bird-authority/v1/README.md | 42 ++ contracts/early-bird-authority/v1/SHA256SUMS | 5 + .../v1/invitation-redeem.fixture.json | 5 + .../v1/invitation-redeem.schema.json | 18 + .../v1/membership.fixture.json | 19 + .../v1/membership.schema.json | 75 ++++ contracts/early-bird-membership/v1/README.md | 25 ++ contracts/early-bird-membership/v1/SHA256SUMS | 5 + .../v1/command.fixture.json | 20 + .../v1/command.schema.json | 71 +++ .../v1/result.fixture.json | 10 + .../v1/result.schema.json | 37 ++ docs/architecture/EARLY_BIRDS_LISTENER.md | 67 +++ e2e/tests/early-birds.spec.ts | 17 + package-lock.json | 7 + package.json | 2 + .../migration.sql | 129 ++++++ prisma/schema.prisma | 128 ++++++ scripts/verify-early-bird-contracts.py | 29 ++ .../api/early-birds/auth/[...all]/route.ts | 13 + .../free/redeem/__tests__/route.test.ts | 57 +++ src/app/api/early-birds/free/redeem/route.ts | 44 ++ .../api/early-birds/stream/heartbeat/route.ts | 45 ++ .../stream/lease/__tests__/route.test.ts | 52 +++ src/app/api/early-birds/stream/lease/route.ts | 47 ++ .../stream/manifest/__tests__/route.test.ts | 86 ++++ .../api/early-birds/stream/manifest/route.ts | 78 ++++ src/app/api/early-birds/test-login/route.ts | 86 ++++ .../[accountId]/__tests__/route.test.ts | 126 ++++++ .../[accountId]/route.ts | 107 +++++ src/app/early-birds/home/page.tsx | 38 ++ src/app/early-birds/page.tsx | 42 ++ src/app/early-birds/redeem/page.tsx | 23 + src/components/early-birds/EarlyBirdHome.tsx | 53 +++ .../early-birds/EarlyBirdLanding.tsx | 154 +++++++ .../early-birds/FreeInvitationRedeemer.tsx | 70 +++ src/components/early-birds/ListenerPlayer.tsx | 407 ++++++++++++++++++ .../__tests__/EarlyBirdLanding.test.tsx | 66 +++ .../__tests__/ListenerPlayer.test.tsx | 48 +++ src/lib/early-birds/__tests__/auth.test.ts | 67 +++ .../__tests__/membership-gateway.test.ts | 97 +++++ .../early-birds/__tests__/membership.test.ts | 116 +++++ .../__tests__/service-auth.test.ts | 31 ++ .../__tests__/stream-contract.test.ts | 99 +++++ .../__tests__/stream-lease.test.ts | 102 +++++ src/lib/early-birds/auth-client.ts | 9 + src/lib/early-birds/auth-contract.ts | 3 + src/lib/early-birds/auth.ts | 185 ++++++++ src/lib/early-birds/copy.ts | 85 ++++ src/lib/early-birds/membership-contract.ts | 156 +++++++ src/lib/early-birds/membership-gateway.ts | 148 +++++++ src/lib/early-birds/membership.ts | 217 ++++++++++ src/lib/early-birds/service-auth.ts | 38 ++ src/lib/early-birds/stream.ts | 334 ++++++++++++++ 55 files changed, 4080 insertions(+) create mode 100644 contracts/early-bird-authority/v1/README.md create mode 100644 contracts/early-bird-authority/v1/SHA256SUMS create mode 100644 contracts/early-bird-authority/v1/invitation-redeem.fixture.json create mode 100644 contracts/early-bird-authority/v1/invitation-redeem.schema.json create mode 100644 contracts/early-bird-authority/v1/membership.fixture.json create mode 100644 contracts/early-bird-authority/v1/membership.schema.json create mode 100644 contracts/early-bird-membership/v1/README.md create mode 100644 contracts/early-bird-membership/v1/SHA256SUMS create mode 100644 contracts/early-bird-membership/v1/command.fixture.json create mode 100644 contracts/early-bird-membership/v1/command.schema.json create mode 100644 contracts/early-bird-membership/v1/result.fixture.json create mode 100644 contracts/early-bird-membership/v1/result.schema.json create mode 100644 docs/architecture/EARLY_BIRDS_LISTENER.md create mode 100644 e2e/tests/early-birds.spec.ts create mode 100644 prisma/migrations/20260806040000_early_birds_listener/migration.sql create mode 100644 scripts/verify-early-bird-contracts.py create mode 100644 src/app/api/early-birds/auth/[...all]/route.ts create mode 100644 src/app/api/early-birds/free/redeem/__tests__/route.test.ts create mode 100644 src/app/api/early-birds/free/redeem/route.ts create mode 100644 src/app/api/early-birds/stream/heartbeat/route.ts create mode 100644 src/app/api/early-birds/stream/lease/__tests__/route.test.ts create mode 100644 src/app/api/early-birds/stream/lease/route.ts create mode 100644 src/app/api/early-birds/stream/manifest/__tests__/route.test.ts create mode 100644 src/app/api/early-birds/stream/manifest/route.ts create mode 100644 src/app/api/early-birds/test-login/route.ts create mode 100644 src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts create mode 100644 src/app/api/internal/v1/early-bird-memberships/[accountId]/route.ts create mode 100644 src/app/early-birds/home/page.tsx create mode 100644 src/app/early-birds/page.tsx create mode 100644 src/app/early-birds/redeem/page.tsx create mode 100644 src/components/early-birds/EarlyBirdHome.tsx create mode 100644 src/components/early-birds/EarlyBirdLanding.tsx create mode 100644 src/components/early-birds/FreeInvitationRedeemer.tsx create mode 100644 src/components/early-birds/ListenerPlayer.tsx create mode 100644 src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx create mode 100644 src/components/early-birds/__tests__/ListenerPlayer.test.tsx create mode 100644 src/lib/early-birds/__tests__/auth.test.ts create mode 100644 src/lib/early-birds/__tests__/membership-gateway.test.ts create mode 100644 src/lib/early-birds/__tests__/membership.test.ts create mode 100644 src/lib/early-birds/__tests__/service-auth.test.ts create mode 100644 src/lib/early-birds/__tests__/stream-contract.test.ts create mode 100644 src/lib/early-birds/__tests__/stream-lease.test.ts create mode 100644 src/lib/early-birds/auth-client.ts create mode 100644 src/lib/early-birds/auth-contract.ts create mode 100644 src/lib/early-birds/auth.ts create mode 100644 src/lib/early-birds/copy.ts create mode 100644 src/lib/early-birds/membership-contract.ts create mode 100644 src/lib/early-birds/membership-gateway.ts create mode 100644 src/lib/early-birds/membership.ts create mode 100644 src/lib/early-birds/service-auth.ts create mode 100644 src/lib/early-birds/stream.ts diff --git a/.env.example b/.env.example index 01bcdcdf..6f66d451 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,46 @@ 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 +# =================== +# EarlyBird Listener identity, membership and private stream +# =================== +# 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= +EARLY_BIRDS_APPLE_CLIENT_ID= +EARLY_BIRDS_APPLE_CLIENT_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 +EARLY_BIRDS_DROPIN_ES_URL=https://media.example.invalid/early-birds/drop-in-es.mp3 +EARLY_BIRDS_DROPIN_EN_URL=https://media.example.invalid/early-birds/drop-in-en.mp3 + +# Synthetic auth/access is absent unless BOTH values are explicitly present. +# Never set these in production. +EARLY_BIRDS_TEST_ACCESS_ENABLED=0 +# EARLY_BIRDS_TEST_LOGIN_SECRET= + # 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/contracts/early-bird-authority/v1/README.md b/contracts/early-bird-authority/v1/README.md new file mode 100644 index 00000000..106ce5ca --- /dev/null +++ b/contracts/early-bird-authority/v1/README.md @@ -0,0 +1,42 @@ +# 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. +- `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. diff --git a/contracts/early-bird-authority/v1/SHA256SUMS b/contracts/early-bird-authority/v1/SHA256SUMS new file mode 100644 index 00000000..a41bc9ba --- /dev/null +++ b/contracts/early-bird-authority/v1/SHA256SUMS @@ -0,0 +1,5 @@ +be99ca7261e8e4fa84b9cce82f92a554a98088b42fe68dac74a96ba83910d22c README.md +47c937f3f93ed94b9eff6750bd430131af0c18ace5c2a6684ba36b5f5b3d41f2 invitation-redeem.fixture.json +d905ac01c1a8e017832e126a5266ebd506f4889e4111dee91b621e80f926784d invitation-redeem.schema.json +d9c86c455ff006225aaeeaa66787650f690b485185a1f821ed55fdde5e5acbd1 membership.fixture.json +34a40df9816000d5dae8152bb63ea821469aa9a2fda1a1810c167f1b5d3a0261 membership.schema.json 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..51aff943 --- /dev/null +++ b/contracts/early-bird-authority/v1/invitation-redeem.schema.json @@ -0,0 +1,18 @@ +{ + "$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": 255}, + "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..da7af9d1 --- /dev/null +++ b/contracts/early-bird-authority/v1/membership.schema.json @@ -0,0 +1,75 @@ +{ + "$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": 255}, + "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-membership/v1/README.md b/contracts/early-bird-membership/v1/README.md new file mode 100644 index 00000000..3a6f9593 --- /dev/null +++ b/contracts/early-bird-membership/v1/README.md @@ -0,0 +1,25 @@ +# 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. diff --git a/contracts/early-bird-membership/v1/SHA256SUMS b/contracts/early-bird-membership/v1/SHA256SUMS new file mode 100644 index 00000000..e0740479 --- /dev/null +++ b/contracts/early-bird-membership/v1/SHA256SUMS @@ -0,0 +1,5 @@ +719c96403f676e982b5ff43bae610130deb4c5da97e662677053c2afaebaa1d8 README.md +025d36d5aa4c3b455dd197acc4af23669f9cd02cc240e995bfc3cc0e693fd3cb command.fixture.json +393d5058330f33a22d69bb06f86f09b11d75465900e8b52d22fb1e6ea8d0120e command.schema.json +a870cb5590a582ae5408c41cc4414c7bd3804b7efbd11f4e82a3fe769707f682 result.fixture.json +546278e4dd8c8e8baffbfc862004041467a9357545d1a123fb6204744c9bffc4 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..462250ee --- /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": 200 + }, + "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..54d1e1c6 --- /dev/null +++ b/contracts/early-bird-membership/v1/command.schema.json @@ -0,0 +1,71 @@ +{ + "$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": 255}, + "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..25450123 --- /dev/null +++ b/contracts/early-bird-membership/v1/result.schema.json @@ -0,0 +1,37 @@ +{ + "$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": 255}, + "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/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md new file mode 100644 index 00000000..4cfad193 --- /dev/null +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -0,0 +1,67 @@ +# 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 exactly Google and Apple. 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. +The test suite locks this pre-adapter invariant. + +Required OAuth callbacks are: + +- `https:///api/early-birds/auth/callback/google` +- `https:///api/early-birds/auth/callback/apple` + +Production therefore needs the final HTTPS app hostname/DNS record before Google and Apple callback +registration. Provider credentials may remain unset during local testing; the corresponding button +is visibly disabled. + +## Canonical membership boundary + +Byte-exact copies live in `contracts/early-bird-authority/v1` and +`contracts/early-bird-membership/v1`. Verify them with `npm run contract:early-birds:verify`. + +- 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 revisions to + `PUT /api/internal/v1/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 for exactly the twelve required + fields. Higher revisions are `APPLIED`, byte-semantic repeats are `REPLAYED`, lower revisions are + `STALE`, and equal revisions with different payloads conflict. +- `ACTIVE`, time-valid `GRACE`, and time-valid `CANCELLED_PENDING_END` allow access. Every missing, + expired, revoked, refunded or unavailable state fails closed. + +The optional synthetic login creates a clearly marked, source-null local projection only when both +`EARLY_BIRDS_TEST_ACCESS_ENABLED=1` and a separate test secret are configured. It cannot replace a +canonical projection and must never be enabled in production. + +## Stream and device leases + +An entitled account may hold two active device leases. A third device evicts the oldest lease. The +browser plays a stable same-origin URL under `/api/early-birds/stream/manifest`; the route rechecks +the authenticated account, current membership and non-evicted lease before proxying a short-lived +origin manifest with `private, no-store` behavior. + +The origin signature is HMAC-SHA-256 base64url over the exact bytes +`GET\n/v1/hls/{artifactId}/live.m3u8\n{unix_expiry}`. Expiry never exceeds the lease or ten minutes. +The origin manifest must contain individually signed, same-origin segment URLs. Signing material and +signed URLs are never returned in API JSON or logged. + +The approved Spanish/English drop-in renders are configured as immutable media URLs. Listener UI +does not encode or alter them. Progress is local to the browser; pause produces silence, resume seeks +to the current live edge, and no AudioContext, LiveKit, chat or session-event behavior is changed. + +## 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/e2e/tests/early-birds.spec.ts b/e2e/tests/early-birds.spec.ts new file mode 100644 index 00000000..e1a1b5bb --- /dev/null +++ b/e2e/tests/early-birds.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test.describe('EarlyBird Listener boundary', () => { + test('serves the bilingual public landing without exposing test access', async ({ page }) => { + await page.goto('/early-birds'); + await expect(page.getByRole('heading', { name: 'El Beacon, siempre presente.' })).toBeVisible(); + await expect(page.getByRole('button', { name: /Continuar con Google/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /Continuar con Apple/ })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Entrar con acceso de prueba' })).toHaveCount(0); + }); + + test('redirects an unauthenticated listener away from the private home', async ({ page }) => { + await page.goto('/early-birds/home'); + await expect(page).toHaveURL(/\/early-birds$/); + await expect(page.getByRole('heading', { name: 'El Beacon, siempre presente.' })).toBeVisible(); + }); +}); diff --git a/package-lock.json b/package-lock.json index b3d35143..d9247915 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@prisma/client": "^7.9.1", "better-auth": "1.6.26", "dotenv": "^17.2.4", + "hls.js": "1.6.17", "livekit-client": "^2.17.0", "livekit-server-sdk": "^2.15.0", "next": "16.2.12", @@ -7081,6 +7082,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", diff --git a/package.json b/package.json index c5bbea5c..cb817071 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,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", @@ -33,6 +34,7 @@ "@prisma/client": "^7.9.1", "better-auth": "1.6.26", "dotenv": "^17.2.4", + "hls.js": "1.6.17", "livekit-client": "^2.17.0", "livekit-server-sdk": "^2.15.0", "next": "16.2.12", 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/schema.prisma b/prisma/schema.prisma index 71470e94..f91b81cc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -83,6 +83,22 @@ enum ContributionState { WITHDRAWN } +enum EarlyBirdMembershipState { + PENDING + ACTIVE + GRACE + CANCELLED_PENDING_END + EXPIRED + REFUNDED + REVOKED +} + +enum EarlyBirdMembershipSource { + FREE + PAYPAL + MERCADO_PAGO +} + model User { id String @id @default(uuid()) @db.Uuid email String @unique @@ -397,3 +413,115 @@ 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? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + identities EarlyBirdIdentity[] + authSessions EarlyBirdAuthSession[] + membership EarlyBirdMembershipProjection? + streamLeases EarlyBirdStreamLease[] + + @@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]) + @@index([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") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([userId]) + @@index([expiresAt]) + @@map("early_bird_auth_sessions") +} + +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") +} + +// 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) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([state, paidThrough]) + @@map("early_bird_membership_projections") +} + +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) + createdAt DateTime @default(now()) @map("created_at") + lastSeenAt DateTime @map("last_seen_at") + expiresAt DateTime @map("expires_at") + evictedAt DateTime? @map("evicted_at") + + @@unique([accountId, deviceDigest]) + @@index([accountId, evictedAt, expiresAt, lastSeenAt]) + @@map("early_bird_stream_leases") +} diff --git a/scripts/verify-early-bird-contracts.py b/scripts/verify-early-bird-contracts.py new file mode 100644 index 00000000..49a7f202 --- /dev/null +++ b/scripts/verify-early-bird-contracts.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Verify byte-exact copies of both canonical EarlyBird v1 contracts.""" + +import hashlib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CONTRACTS = ( + ROOT / "contracts/early-bird-authority/v1", + ROOT / "contracts/early-bird-membership/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/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..5e398880 --- /dev/null +++ b/src/app/api/early-birds/auth/[...all]/route.ts @@ -0,0 +1,13 @@ +import type { NextRequest } from 'next/server'; + +import { earlyBirdAuth } from '@/lib/early-birds/auth'; + +export const dynamic = 'force-dynamic'; + +export function GET(request: NextRequest): Promise { + return earlyBirdAuth().handler(request); +} + +export function POST(request: NextRequest): Promise { + return earlyBirdAuth().handler(request); +} 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..ccc8d877 --- /dev/null +++ b/src/app/api/early-birds/free/redeem/__tests__/route.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +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'; + +function request(token = 'a'.repeat(43)) { + return new NextRequest('https://live.example.test/api/early-birds/free/redeem', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }); +} + +afterEach(() => vi.clearAllMocks()); + +describe('EarlyBird Free redemption boundary', () => { + 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(); + }); + + 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 = 'opaque_'.padEnd(43, 'x'); + 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/home', + }); + }); + + it('fails closed without leaking whether a token exists', 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.' }); + }); +}); 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..2e81cf70 --- /dev/null +++ b/src/app/api/early-birds/free/redeem/route.ts @@ -0,0 +1,44 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + EarlyBirdMembershipGatewayUnavailableError, + redeemFreeThroughCanonicalGateway, +} from '@/lib/early-birds/membership-gateway'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest): Promise { + const session = await currentEarlyBirdSession(request.headers).catch(() => null); + if (!session) return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); + + let token: string; + try { + const body = await request.json() as { token?: unknown }; + token = typeof body.token === 'string' ? body.token : ''; + } catch { + return NextResponse.json({ error: 'Malformed request.' }, { status: 400 }); + } + if (token.length < 32 || token.length > 512) { + return NextResponse.json({ error: 'Invitation unavailable.' }, { status: 409 }); + } + + let result; + try { + result = await redeemFreeThroughCanonicalGateway(session.user.id, token); + } catch (error) { + if (error instanceof EarlyBirdMembershipGatewayUnavailableError) { + return NextResponse.json({ error: 'Membership service unavailable.' }, { status: 503 }); + } + return NextResponse.json({ error: 'Membership service unavailable.' }, { status: 503 }); + } + if (!result.ok) { + return NextResponse.json({ error: 'Invitation unavailable.' }, { status: 409 }); + } + return NextResponse.json({ + ok: true, + landing: '/early-birds/home', + replayed: result.replayed, + alreadyEntitled: result.alreadyEntitled, + }); +} 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..cd2acf21 --- /dev/null +++ b/src/app/api/early-birds/stream/heartbeat/route.ts @@ -0,0 +1,45 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + EarlyBirdAccessDeniedError, + EarlyBirdLeaseInactiveError, + heartbeatEarlyBirdStreamLease, +} from '@/lib/early-birds/stream'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest): Promise { + const session = await currentEarlyBirdSession(request.headers).catch(() => null); + if (!session) return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); + + let leaseId: string; + try { + const body = await request.json() as { leaseId?: unknown }; + leaseId = typeof body.leaseId === 'string' ? body.leaseId : ''; + } 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 }); + } + + try { + const grant = await heartbeatEarlyBirdStreamLease(session.user.id, leaseId); + return NextResponse.json({ + leaseExpiresAt: grant.leaseExpiresAt.toISOString(), + stream: { + manifestUrl: grant.stream.manifestUrl, + expiresAt: grant.stream.expiresAt.toISOString(), + }, + }); + } catch (error) { + if (error instanceof EarlyBirdLeaseInactiveError) { + return NextResponse.json({ error: 'Device displaced.' }, { status: 410 }); + } + if (error instanceof EarlyBirdAccessDeniedError) { + return NextResponse.json({ error: 'Membership 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..9cf2fe1e --- /dev/null +++ b/src/app/api/early-birds/stream/lease/__tests__/route.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const currentEarlyBirdSession = vi.hoisted(() => vi.fn()); +const acquireEarlyBirdStreamLease = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/stream', () => ({ + acquireEarlyBirdStreamLease, + EarlyBirdAccessDeniedError: class extends Error {}, + EarlyBirdStreamIssuerUnavailableError: class extends Error {}, +})); + +import { POST } from '../route'; + +function request(deviceId = 'device_abcdefghijklmnopqrstuvwxyz') { + return new NextRequest('https://live.example.test/api/early-birds/stream/lease', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ deviceId }), + }); +} + +afterEach(() => vi.clearAllMocks()); + +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('returns only the stable same-origin manifest grant', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + acquireEarlyBirdStreamLease.mockResolvedValue({ + 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', + 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(/^\/api\/early-birds\/stream\/manifest/); + expect(JSON.stringify(body)).not.toContain('sig='); + }); +}); 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..78a30bb7 --- /dev/null +++ b/src/app/api/early-birds/stream/lease/route.ts @@ -0,0 +1,47 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + acquireEarlyBirdStreamLease, + EarlyBirdAccessDeniedError, + EarlyBirdStreamIssuerUnavailableError, +} from '@/lib/early-birds/stream'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest): Promise { + const session = await currentEarlyBirdSession(request.headers).catch(() => null); + if (!session) return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); + + let deviceId: string; + try { + const body = await request.json() as { deviceId?: unknown }; + deviceId = typeof body.deviceId === 'string' ? body.deviceId : ''; + } catch { + return NextResponse.json({ error: 'Malformed request.' }, { status: 400 }); + } + + try { + const grant = await acquireEarlyBirdStreamLease(session.user.id, deviceId); + return NextResponse.json({ + leaseId: grant.leaseId, + 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: 'Membership inactive.' }, { status: 403 }); + } + if (error instanceof EarlyBirdStreamIssuerUnavailableError) { + return NextResponse.json({ error: 'Stream temporarily unavailable.' }, { status: 503 }); + } + 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/stream/manifest/__tests__/route.test.ts b/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts new file mode 100644 index 00000000..8f4f4093 --- /dev/null +++ b/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + currentEarlyBirdSession: vi.fn(), + authorizeEarlyBirdStreamLease: vi.fn(), + earlyBirdOriginConfig: vi.fn(), + signedEarlyBirdOriginManifestUrl: vi.fn(), + validSignedOriginManifest: vi.fn(), + LeaseInactive: class extends Error {}, + AccessDenied: class extends Error {}, +})); + +const { + currentEarlyBirdSession, + authorizeEarlyBirdStreamLease, + earlyBirdOriginConfig, + signedEarlyBirdOriginManifestUrl, + validSignedOriginManifest, + LeaseInactive, +} = mocks; + +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession: mocks.currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/stream', () => ({ + authorizeEarlyBirdStreamLease: mocks.authorizeEarlyBirdStreamLease, + earlyBirdOriginConfig: mocks.earlyBirdOriginConfig, + signedEarlyBirdOriginManifestUrl: mocks.signedEarlyBirdOriginManifestUrl, + validSignedOriginManifest: mocks.validSignedOriginManifest, + EarlyBirdLeaseInactiveError: mocks.LeaseInactive, + EarlyBirdAccessDeniedError: mocks.AccessDenied, +})); + +import { GET } from '../route'; + +const LEASE_ID = '00000000-0000-4000-8000-000000000003'; + +function request() { + return new NextRequest(`https://live.example.test/api/early-birds/stream/manifest?leaseId=${LEASE_ID}`); +} + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('stable EarlyBird lease manifest', () => { + it('does not contact the origin before auth and lease authorization', async () => { + currentEarlyBirdSession.mockResolvedValue(null); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const response = await GET(request()); + expect(response.status).toBe(401); + expect(authorizeEarlyBirdStreamLease).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('proxies only a validated signed-segment manifest with no-store', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + authorizeEarlyBirdStreamLease.mockResolvedValue({ + id: LEASE_ID, + expiresAt: new Date(Date.now() + 120_000), + }); + earlyBirdOriginConfig.mockReturnValue({ origin: 'https://stream.example.test' }); + signedEarlyBirdOriginManifestUrl.mockReturnValue('https://stream.example.test/live.m3u8?exp=1&sig=secret-url'); + validSignedOriginManifest.mockReturnValue(true); + const manifest = '#EXTM3U\nhttps://stream.example.test/segment?exp=2&sig=x\n'; + const fetchMock = vi.fn().mockResolvedValue(new Response(manifest, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const response = await GET(request()); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toContain('no-store'); + expect(response.headers.get('content-type')).toContain('application/vnd.apple.mpegurl'); + await expect(response.text()).resolves.toBe(manifest); + expect(authorizeEarlyBirdStreamLease).toHaveBeenCalledWith('listener-1', LEASE_ID, expect.any(Date)); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('cuts off a displaced device on its next manifest refresh', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + authorizeEarlyBirdStreamLease.mockRejectedValue(new LeaseInactive()); + const response = await GET(request()); + expect(response.status).toBe(410); + expect(response.headers.get('cache-control')).toContain('no-store'); + }); +}); diff --git a/src/app/api/early-birds/stream/manifest/route.ts b/src/app/api/early-birds/stream/manifest/route.ts new file mode 100644 index 00000000..d5f7a298 --- /dev/null +++ b/src/app/api/early-birds/stream/manifest/route.ts @@ -0,0 +1,78 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + authorizeEarlyBirdStreamLease, + earlyBirdOriginConfig, + EarlyBirdAccessDeniedError, + EarlyBirdLeaseInactiveError, + signedEarlyBirdOriginManifestUrl, + validSignedOriginManifest, +} from '@/lib/early-birds/stream'; + +export const dynamic = 'force-dynamic'; + +const MANIFEST_HEADERS = { + 'Cache-Control': 'private, no-store, max-age=0', + 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', +}; + +export async function GET(request: NextRequest): Promise { + const session = await currentEarlyBirdSession(request.headers).catch(() => null); + if (!session) { + return NextResponse.json({ error: 'Sign in required.' }, { + status: 401, + headers: { 'Cache-Control': 'private, no-store' }, + }); + } + const leaseId = request.nextUrl.searchParams.get('leaseId') ?? ''; + if (!/^[0-9a-f-]{36}$/i.test(leaseId)) { + return NextResponse.json({ error: 'Invalid lease.' }, { + status: 400, + headers: { 'Cache-Control': 'private, no-store' }, + }); + } + + try { + const now = new Date(); + const lease = await authorizeEarlyBirdStreamLease(session.user.id, leaseId, now); + const config = earlyBirdOriginConfig(); + const upstreamUrl = signedEarlyBirdOriginManifestUrl({ + config, + leaseExpiresAt: lease.expiresAt, + now, + }); + const upstream = await fetch(upstreamUrl, { + method: 'GET', + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(5_000), + headers: { accept: 'application/vnd.apple.mpegurl' }, + }); + if (!upstream.ok) throw new Error('origin manifest unavailable'); + const manifest = await upstream.text(); + if (!validSignedOriginManifest(manifest, config)) { + throw new Error('origin manifest contract mismatch'); + } + return new NextResponse(manifest, { status: 200, headers: MANIFEST_HEADERS }); + } catch (error) { + if (error instanceof EarlyBirdLeaseInactiveError) { + return NextResponse.json({ error: 'Device displaced.' }, { + status: 410, + headers: { 'Cache-Control': 'private, no-store' }, + }); + } + if (error instanceof EarlyBirdAccessDeniedError) { + return NextResponse.json({ error: 'Membership inactive.' }, { + status: 403, + headers: { 'Cache-Control': 'private, no-store' }, + }); + } + // Never relay or log the signed upstream URL, secret, or response. + return NextResponse.json({ error: 'Stream temporarily unavailable.' }, { + status: 503, + headers: { 'Cache-Control': 'private, no-store' }, + }); + } +} 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..74ecc403 --- /dev/null +++ b/src/app/api/early-birds/test-login/route.ts @@ -0,0 +1,86 @@ +import { createHmac } from 'node:crypto'; + +import { NextResponse, type NextRequest } from 'next/server'; + +import { + EARLY_BIRD_AUTH_BASE_PATH, + earlyBirdAuth, + earlyBirdTestAuthEnabled, +} from '@/lib/early-birds/auth'; +import { issueSyntheticMembership } from '@/lib/early-birds/membership'; + +export const dynamic = 'force-dynamic'; + +function notFound(): NextResponse { + return NextResponse.json({ error: 'Not found.' }, { status: 404 }); +} + +function testPassword(email: string): string { + return createHmac('sha256', process.env.EARLY_BIRDS_TEST_LOGIN_SECRET!) + .update(`early-birds-test-login:v1:${email}`) + .digest('base64url'); +} + +async function authRequest( + request: NextRequest, + operation: 'sign-up' | 'sign-in', + body: Record, +): Promise { + const url = new URL(`${EARLY_BIRD_AUTH_BASE_PATH}/${operation}/email`, request.url); + const headers = new Headers(request.headers); + headers.set('content-type', 'application/json'); + return earlyBirdAuth().handler(new Request(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + })); +} + +export async function POST(request: NextRequest): Promise { + if (!earlyBirdTestAuthEnabled()) return notFound(); + + let email: string; + let name: string; + try { + const body = await request.json() as { email?: unknown; name?: unknown }; + email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''; + name = typeof body.name === 'string' ? body.name.trim() : ''; + } catch { + return NextResponse.json({ error: 'Malformed request.' }, { status: 400 }); + } + 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 password = testPassword(email); + let authResponse = await authRequest(request, 'sign-up', { + email, + name, + password, + rememberMe: true, + }); + if (!authResponse.ok) { + authResponse = await authRequest(request, 'sign-in', { + email, + password, + rememberMe: true, + }); + } + if (!authResponse.ok) { + return NextResponse.json({ error: 'Synthetic login failed.' }, { status: 503 }); + } + + const payload = await authResponse.clone().json() as { user?: { id?: unknown } }; + const accountId = typeof payload.user?.id === 'string' ? payload.user.id : null; + if (!accountId) return NextResponse.json({ error: 'Synthetic login failed.' }, { status: 503 }); + await issueSyntheticMembership(accountId); + + return NextResponse.json( + { ok: true, landing: '/early-birds/home' }, + { headers: new Headers(authResponse.headers) }, + ); +} + +export async function GET(): Promise { + return notFound(); +} diff --git a/src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts b/src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts new file mode 100644 index 00000000..d8d5d236 --- /dev/null +++ b/src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts @@ -0,0 +1,126 @@ +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'; + +const ACCOUNT = 'listener-1'; +const command = { + schema_version: 'early-bird-membership.command.v1', + 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: 200 }, + reason_code: 'PAYMENT_SUCCEEDED', +}; +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: 200, + currency: 'USD', + reasonCode: 'PAYMENT_SUCCEEDED', + synthetic: false, + 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/v1/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 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 enforces exact fields and 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); + const unknown = await PUT(put({ ...command, unexpected: true }), params); + expect(unknown.status).toBe(422); + const idempotency = await PUT(put(command, { 'idempotency-key': 'wrong' }), params); + expect(idempotency.status).toBe(422); + }); + + it('returns the non-secret current projection for reconciliation', async () => { + const request = new NextRequest( + `http://beacon-app:3000/api/internal/v1/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', + }); + }); +}); diff --git a/src/app/api/internal/v1/early-bird-memberships/[accountId]/route.ts b/src/app/api/internal/v1/early-bird-memberships/[accountId]/route.ts new file mode 100644 index 00000000..a692c262 --- /dev/null +++ b/src/app/api/internal/v1/early-bird-memberships/[accountId]/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { prisma } from '@/lib/db'; +import { + EarlyBirdMembershipContractError, + parseMembershipProjectionCommand, +} from '@/lib/early-birds/membership-contract'; +import { + applyMembershipProjection, + 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'), + ); +} + +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); + } + 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/early-birds/home/page.tsx b/src/app/early-birds/home/page.tsx new file mode 100644 index 00000000..b5115a1c --- /dev/null +++ b/src/app/early-birds/home/page.tsx @@ -0,0 +1,38 @@ +import { redirect } from 'next/navigation'; + +import EarlyBirdHome from '@/components/early-birds/EarlyBirdHome'; +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { getEarlyBirdAccess } from '@/lib/early-birds/membership'; + +export const dynamic = 'force-dynamic'; + +function configuredMediaUrl(name: string): string | null { + const value = process.env[name]?.trim(); + if (!value) return null; + try { + const url = new URL(value); + if (!['https:', 'http:'].includes(url.protocol)) return null; + if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:') return null; + return url.toString(); + } catch { + return null; + } +} + +export default async function EarlyBirdHomePage() { + const session = await currentEarlyBirdSession().catch(() => null); + if (!session) redirect('/early-birds'); + const access = await getEarlyBirdAccess(session.user.id).catch(() => null); + if (!access?.allowed || !access.projection) redirect('/early-birds?membership=required'); + + return ( + + ); +} diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx new file mode 100644 index 00000000..488db561 --- /dev/null +++ b/src/app/early-birds/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next'; + +import EarlyBirdLanding from '@/components/early-birds/EarlyBirdLanding'; +import { + currentEarlyBirdSession, + earlyBirdOAuthAvailability, + earlyBirdTestAuthEnabled, +} from '@/lib/early-birds/auth'; +import { getEarlyBirdAccess } from '@/lib/early-birds/membership'; + +export const dynamic = 'force-dynamic'; + +export const metadata: Metadata = { + title: 'EarlyBirds · Harmonic Beacon', + description: 'Beacon 24/7 and private bilingual drop-ins for EarlyBird listeners.', +}; + +export default async function EarlyBirdsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; + const session = await currentEarlyBirdSession().catch(() => null); + const access = session + ? await getEarlyBirdAccess(session.user.id).catch(() => null) + : null; + const invite = typeof params.invite === 'string' && params.invite.length <= 512 + ? params.invite + : null; + + return ( + + ); +} diff --git a/src/app/early-birds/redeem/page.tsx b/src/app/early-birds/redeem/page.tsx new file mode 100644 index 00000000..ce3183f6 --- /dev/null +++ b/src/app/early-birds/redeem/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from 'next/navigation'; + +import FreeInvitationRedeemer from '@/components/early-birds/FreeInvitationRedeemer'; +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; + +export const dynamic = 'force-dynamic'; + +export default async function EarlyBirdRedeemPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; + const token = typeof params.token === 'string' && params.token.length <= 512 + ? params.token + : null; + if (!token) redirect('/early-birds'); + + const session = await currentEarlyBirdSession().catch(() => null); + if (!session) redirect(`/early-birds?invite=${encodeURIComponent(token)}`); + + return ; +} diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx new file mode 100644 index 00000000..c933ad23 --- /dev/null +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -0,0 +1,53 @@ +'use client'; + +import BrandLockup from '@/components/brand/BrandLockup'; +import LanguageControl from '@/components/brand/LanguageControl'; +import { useLocale } from '@/context/LocaleContext'; +import { earlyBirdAuthClient } from '@/lib/early-birds/auth-client'; +import { earlyBirdHomeCopy } from '@/lib/early-birds/copy'; + +import ListenerPlayer from './ListenerPlayer'; + +export default function EarlyBirdHome({ + displayName, + membershipSource, + dropIns, +}: { + displayName: string; + membershipSource: string | null; + dropIns: { es: string | null; en: string | null }; +}) { + const { locale } = useLocale(); + const copy = earlyBirdHomeCopy[locale]; + + async function signOut() { + await earlyBirdAuthClient.signOut(); + window.location.assign('/early-birds'); + } + + return ( +
+
+
+ +
+ + +
+
+
+
+

{copy.eyebrow}

+

{displayName}

+
+ + {copy.active} · {membershipSource ?? 'TEST'} + +
+ +
+
+ ); +} diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx new file mode 100644 index 00000000..5f457177 --- /dev/null +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useState } from 'react'; + +import BrandLockup from '@/components/brand/BrandLockup'; +import LanguageControl from '@/components/brand/LanguageControl'; +import { useLocale } from '@/context/LocaleContext'; +import { earlyBirdAuthClient } from '@/lib/early-birds/auth-client'; +import { earlyBirdCopy } from '@/lib/early-birds/copy'; + +type Props = { + signedIn: boolean; + entitled: boolean; + inviteToken: string | null; + authError: boolean; + testAccessEnabled: boolean; + providers: { google: boolean; apple: boolean }; +}; + +export default function EarlyBirdLanding(props: Props) { + const { locale } = useLocale(); + const copy = earlyBirdCopy[locale]; + const [busy, setBusy] = useState<'google' | 'apple' | 'test' | null>(null); + const [error, setError] = useState(false); + const callbackURL = props.inviteToken + ? `/early-birds/redeem?token=${encodeURIComponent(props.inviteToken)}` + : '/early-birds/home'; + + async function signIn(provider: 'google' | 'apple') { + if (busy || !props.providers[provider]) return; + setBusy(provider); + setError(false); + const result = await earlyBirdAuthClient.signIn.social({ + provider, + callbackURL, + errorCallbackURL: '/early-birds?authError=1', + requestSignUp: true, + }); + if (result.error) { + setBusy(null); + setError(true); + } + } + + async function syntheticLogin() { + if (busy) return; + setBusy('test'); + setError(false); + const response = await fetch('/api/early-birds/test-login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'listener@e2e.invalid', name: 'EarlyBird Test Listener' }), + }); + if (response.ok) { + window.location.assign('/early-birds/home'); + return; + } + setBusy(null); + setError(true); + } + + return ( +
+
+
+ + +
+ +
+
+

{copy.eyebrow}

+

+ {copy.title} +

+

+ {copy.intro} +

+
    + {[copy.live, copy.privateDropIns, copy.membership].map((item) => ( +
  • +
  • + ))} +
+
+ +
+ {(props.authError || error) && ( +

+ {copy.authError} +

+ )} + + {props.signedIn ? ( +
+

{copy.signedIn}

+ {props.entitled ? ( + + {copy.enter} + + ) : props.inviteToken ? ( + + {copy.redeem} + + ) : ( +

{copy.accessNeeded}

+ )} +
+ ) : ( +
+ {(['google', 'apple'] as const).map((provider) => ( + + ))} + {props.testAccessEnabled && ( +
+

{copy.testTitle}

+ +
+ )} +
+ )} +
+
+ +
+ {copy.privacy} +
+
+
+ ); +} diff --git a/src/components/early-birds/FreeInvitationRedeemer.tsx b/src/components/early-birds/FreeInvitationRedeemer.tsx new file mode 100644 index 00000000..9ad37d50 --- /dev/null +++ b/src/components/early-birds/FreeInvitationRedeemer.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useState } from 'react'; + +import BrandLockup from '@/components/brand/BrandLockup'; +import LanguageControl from '@/components/brand/LanguageControl'; +import { useLocale } from '@/context/LocaleContext'; + +export default function FreeInvitationRedeemer({ token }: { token: string }) { + const { locale } = useLocale(); + const copy = locale === 'es' ? { + eyebrow: 'INVITACIÓN PERSONAL', + heading: 'Activa tu acceso EarlyBird.', + body: 'Esta invitación es individual y de un solo uso. Al activarla, tu cuenta recibirá el mismo acceso Listener que una membresía paga.', + action: 'Activar invitación', + activating: 'Activando…', + error: 'Esta invitación no está disponible. Si crees que es un error, contacta a soporte.', + } : { + eyebrow: 'PERSONAL INVITATION', + heading: 'Activate your EarlyBird access.', + body: 'This invitation is individual and can be used once. Activating it gives your account the same Listener access as a paid membership.', + action: 'Activate invitation', + activating: 'Activating…', + error: 'This invitation is unavailable. Contact support if you believe this is a mistake.', + }; + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + + async function redeem() { + if (busy) return; + setBusy(true); + setError(false); + const response = await fetch('/api/early-birds/free/redeem', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (response.ok) { + window.location.assign('/early-birds/home'); + return; + } + setBusy(false); + setError(true); + } + + return ( +
+
+
+ + +
+
+

{copy.eyebrow}

+

{copy.heading}

+

{copy.body}

+ {error &&

{copy.error}

} + +
+
+
+ ); +} diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx new file mode 100644 index 00000000..2d4e0166 --- /dev/null +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -0,0 +1,407 @@ +'use client'; + +import type Hls from 'hls.js'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useLocale } from '@/context/LocaleContext'; +import { earlyBirdHomeCopy } from '@/lib/early-birds/copy'; + +type DropLanguage = 'es' | 'en'; +type LiveState = 'idle' | 'loading' | 'playing' | 'paused' | 'error' | 'displaced'; +type LeasePayload = { + leaseId: string; + leaseExpiresAt: string; + stream: { manifestUrl: string; expiresAt: string }; +}; + +const DEVICE_STORAGE_KEY = 'hb_earlybird_device_id'; +const DROP_PROGRESS_PREFIX = 'hb_earlybird_drop_progress_'; + +export function getOrCreateEarlyBirdDeviceId(storage: Storage): string { + const existing = storage.getItem(DEVICE_STORAGE_KEY); + if (existing && /^[A-Za-z0-9_-]{16,200}$/.test(existing)) return existing; + const generated = typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}_${Math.random().toString(36).slice(2)}`; + storage.setItem(DEVICE_STORAGE_KEY, generated); + return generated; +} + +export function seekNativeAudioToLiveEdge(audio: HTMLAudioElement): boolean { + if (audio.seekable.length < 1) return false; + const edge = audio.seekable.end(audio.seekable.length - 1); + if (!Number.isFinite(edge)) return false; + audio.currentTime = Math.max(0, edge - 0.25); + return true; +} + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; + const rounded = Math.floor(seconds); + return `${Math.floor(rounded / 60)}:${String(rounded % 60).padStart(2, '0')}`; +} + +export default function ListenerPlayer({ + dropIns, +}: { + dropIns: { es: string | null; en: string | null }; +}) { + const { locale } = useLocale(); + const copy = earlyBirdHomeCopy[locale]; + const liveAudio = useRef(null); + const dropAudio = { + es: useRef(null), + en: useRef(null), + }; + const hls = useRef(null); + const manifestUrl = useRef(null); + const manifestExpiresAt = useRef(0); + const leaseId = useRef(null); + const liveStateRef = useRef('idle'); + const [liveState, setLiveState] = useState('idle'); + const [playingDrop, setPlayingDrop] = useState(null); + const [dropProgress, setDropProgress] = useState({ + es: { current: 0, duration: 0 }, + en: { current: 0, duration: 0 }, + }); + const [volume, setVolume] = useState(0.85); + const [volumeSupported, setVolumeSupported] = useState(true); + + const updateLiveState = useCallback((state: LiveState) => { + liveStateRef.current = state; + setLiveState(state); + }, []); + + const stopHls = useCallback(() => { + hls.current?.destroy(); + hls.current = null; + }, []); + + const attachManifest = useCallback(async (url: string) => { + const audio = liveAudio.current; + if (!audio) return; + stopHls(); + manifestUrl.current = url; + + if (audio.canPlayType('application/vnd.apple.mpegurl')) { + audio.src = url; + audio.load(); + return; + } + + const HlsConstructor = (await import('hls.js')).default; + if (!HlsConstructor.isSupported()) throw new Error('HLS is not supported'); + const instance = new HlsConstructor({ + lowLatencyMode: false, + liveDurationInfinity: true, + backBufferLength: 0, + }); + instance.on(HlsConstructor.Events.ERROR, (_event, data) => { + if (!data.fatal) return; + updateLiveState('error'); + liveAudio.current?.pause(); + }); + instance.loadSource(url); + instance.attachMedia(audio); + hls.current = instance; + }, [stopHls, updateLiveState]); + + const requestLease = useCallback(async (): Promise => { + const deviceId = getOrCreateEarlyBirdDeviceId(window.localStorage); + const response = await fetch('/api/early-birds/stream/lease', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ deviceId }), + }); + if (!response.ok) throw new Error(`lease:${response.status}`); + return response.json() as Promise; + }, []); + + const pauseDropIns = useCallback(() => { + dropAudio.es.current?.pause(); + dropAudio.en.current?.pause(); + setPlayingDrop(null); + }, [dropAudio.en, dropAudio.es]); + + const playLive = useCallback(async (forceRefresh = false) => { + const audio = liveAudio.current; + if (!audio || liveStateRef.current === 'loading') return; + pauseDropIns(); + updateLiveState('loading'); + try { + if ( + forceRefresh || + !leaseId.current || + !manifestUrl.current || + manifestExpiresAt.current <= Date.now() + 30_000 + ) { + const grant = await requestLease(); + leaseId.current = grant.leaseId; + manifestExpiresAt.current = Date.parse(grant.stream.expiresAt); + if (grant.stream.manifestUrl !== manifestUrl.current) { + await attachManifest(grant.stream.manifestUrl); + } + } + + const liveSyncPosition = hls.current?.liveSyncPosition; + if (typeof liveSyncPosition === 'number' && Number.isFinite(liveSyncPosition)) { + audio.currentTime = liveSyncPosition; + } else { + seekNativeAudioToLiveEdge(audio); + } + await audio.play(); + updateLiveState('playing'); + } catch { + audio.pause(); + updateLiveState('error'); + } + }, [attachManifest, pauseDropIns, requestLease, updateLiveState]); + + function toggleLive() { + const audio = liveAudio.current; + if (!audio) return; + if (liveState === 'playing') { + audio.pause(); + updateLiveState('paused'); + return; + } + void playLive(liveState === 'error' || liveState === 'displaced'); + } + + useEffect(() => { + const probe = document.createElement('audio'); + probe.volume = 0.37; + setVolumeSupported(Math.abs(probe.volume - 0.37) < 0.01); + }, []); + + useEffect(() => { + const all = [liveAudio.current, dropAudio.es.current, dropAudio.en.current]; + for (const audio of all) if (audio) audio.volume = volume; + }, [dropAudio.en, dropAudio.es, volume]); + + useEffect(() => { + const interval = window.setInterval(async () => { + if (!leaseId.current || liveStateRef.current === 'idle') return; + try { + const response = await fetch('/api/early-birds/stream/heartbeat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ leaseId: leaseId.current }), + }); + if (response.status === 410) { + liveAudio.current?.pause(); + stopHls(); + leaseId.current = null; + updateLiveState('displaced'); + return; + } + if (response.status === 403) { + liveAudio.current?.pause(); + updateLiveState('error'); + return; + } + if (!response.ok) return; + const grant = await response.json() as Omit; + manifestExpiresAt.current = Date.parse(grant.stream.expiresAt); + // Keep the current media pipeline uninterrupted when the issuer + // rotates equivalent signed URLs; a fatal HLS error reacquires. + } catch { + // Transient heartbeat loss does not interrupt already-buffered audio. + } + }, 60_000); + return () => window.clearInterval(interval); + }, [stopHls, updateLiveState]); + + useEffect(() => () => { + liveAudio.current?.pause(); + stopHls(); + }, [stopHls]); + + function restoreProgress(language: DropLanguage) { + const audio = dropAudio[language].current; + if (!audio) return; + let saved = 0; + try { + saved = Number(window.localStorage.getItem(`${DROP_PROGRESS_PREFIX}${language}`) ?? 0); + } catch { + saved = 0; + } + if (Number.isFinite(saved) && saved > 0 && saved < audio.duration - 2) { + audio.currentTime = saved; + } + setDropProgress((current) => ({ + ...current, + [language]: { current: audio.currentTime, duration: audio.duration || 0 }, + })); + } + + function storeProgress(language: DropLanguage) { + const audio = dropAudio[language].current; + if (!audio) return; + setDropProgress((current) => ({ + ...current, + [language]: { current: audio.currentTime, duration: audio.duration || 0 }, + })); + try { + window.localStorage.setItem(`${DROP_PROGRESS_PREFIX}${language}`, String(audio.currentTime)); + } catch { + // Progress is intentionally device-local and best effort. + } + } + + async function toggleDropIn(language: DropLanguage) { + const selected = dropAudio[language].current; + if (!selected || !dropIns[language]) return; + if (playingDrop === language && !selected.paused) { + selected.pause(); + setPlayingDrop(null); + return; + } + liveAudio.current?.pause(); + if (liveStateRef.current === 'playing') updateLiveState('paused'); + const other: DropLanguage = language === 'es' ? 'en' : 'es'; + dropAudio[other].current?.pause(); + try { + await selected.play(); + setPlayingDrop(language); + } catch { + setPlayingDrop(null); + } + } + + function restartDropIn(language: DropLanguage) { + const audio = dropAudio[language].current; + if (!audio || !dropIns[language]) return; + audio.currentTime = 0; + void toggleDropIn(language); + } + + function seekDropIn(language: DropLanguage, value: number) { + const audio = dropAudio[language].current; + if (!audio) return; + audio.currentTime = value; + storeProgress(language); + } + + function finishDropIn(language: DropLanguage) { + try { + window.localStorage.removeItem(`${DROP_PROGRESS_PREFIX}${language}`); + } catch {} + setPlayingDrop(null); + setDropProgress((current) => ({ + ...current, + [language]: { current: 0, duration: current[language].duration }, + })); + void playLive(true); + } + + const liveButton = liveState === 'loading' + ? copy.loading + : liveState === 'playing' + ? copy.pause + : liveState === 'paused' + ? copy.resume + : copy.play; + + return ( +
+
+ ); +} diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx new file mode 100644 index 00000000..b65b871f --- /dev/null +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { LocaleProvider } from '@/context/LocaleContext'; + +const signInSocial = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/early-birds/auth-client', () => ({ + earlyBirdAuthClient: { signIn: { social: signInSocial } }, +})); +vi.mock('@/components/brand/LanguageControl', () => ({ default: () =>
})); +vi.mock('@/components/brand/BrandLockup', () => ({ default: () => Harmonic Beacon })); + +import EarlyBirdLanding from '../EarlyBirdLanding'; + +function renderLanding(overrides: Partial> = {}) { + return render( + + + , + ); +} + +describe('EarlyBird public landing', () => { + beforeEach(() => { + signInSocial.mockReset(); + signInSocial.mockResolvedValue({ error: null }); + window.localStorage.clear(); + }); + afterEach(() => cleanup()); + + it('offers exactly Google and Apple and preserves an invitation through OAuth', async () => { + renderLanding({ inviteToken: 'opaque_'.padEnd(43, 'x') }); + expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Continue with Apple' })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Continue with Google' })); + expect(signInSocial).toHaveBeenCalledWith({ + provider: 'google', + callbackURL: expect.stringMatching(/^\/early-birds\/redeem\?token=/), + errorCallbackURL: '/early-birds?authError=1', + requestSignUp: true, + }); + }); + + it('makes an unconfigured provider visibly unavailable', () => { + renderLanding({ providers: { google: true, apple: false } }); + expect(screen.getByRole('button', { name: /Continue with Apple/ })).toBeDisabled(); + expect(screen.getByText('Configuration pending')).toBeInTheDocument(); + }); + + it('takes an entitled signed-in listener directly to the private home', () => { + renderLanding({ signedIn: true, entitled: true }); + expect(screen.getByRole('link', { name: 'Enter the Beacon' })).toHaveAttribute('href', '/early-birds/home'); + expect(screen.queryByRole('button', { name: 'Continue with Google' })).toBeNull(); + }); +}); diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx new file mode 100644 index 00000000..e2f89d5b --- /dev/null +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +import { LocaleProvider } from '@/context/LocaleContext'; +import ListenerPlayer, { + getOrCreateEarlyBirdDeviceId, + seekNativeAudioToLiveEdge, +} from '../ListenerPlayer'; + +afterEach(() => { + cleanup(); + window.localStorage.clear(); +}); + +describe('EarlyBird Listener player', () => { + it('keeps the device identifier stable and device-local', () => { + const first = getOrCreateEarlyBirdDeviceId(window.localStorage); + const second = getOrCreateEarlyBirdDeviceId(window.localStorage); + expect(second).toBe(first); + expect(first.length).toBeGreaterThanOrEqual(16); + }); + + it('resumes a native HLS element at the current live edge', () => { + const audio = { + currentTime: 12, + seekable: { + length: 1, + end: () => 123.5, + }, + } as unknown as HTMLAudioElement; + expect(seekNativeAudioToLiveEdge(audio)).toBe(true); + expect(audio.currentTime).toBe(123.25); + }); + + it('renders both language controls and fails closed when renders are absent', () => { + render( + + + , + ); + expect(screen.getByRole('button', { name: 'Listen live' })).toBeInTheDocument(); + expect(screen.getByText('Warm-up · Spanish')).toBeInTheDocument(); + expect(screen.getByText('Warm-up · English')).toBeInTheDocument(); + expect(screen.getAllByText('The approved render has not been published yet.')).toHaveLength(2); + expect(screen.getByText('Master volume')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/early-birds/__tests__/auth.test.ts b/src/lib/early-birds/__tests__/auth.test.ts new file mode 100644 index 00000000..afff3812 --- /dev/null +++ b/src/lib/early-birds/__tests__/auth.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { + EARLY_BIRD_SESSION_COOKIE, + earlyBirdAuth, +} from '../auth'; + +describe('EarlyBird Better Auth isolation', () => { + it('uses only EarlyBird models/cookies and disables every linking path', () => { + const options = earlyBirdAuth().options; + + expect(options.user?.modelName).toBe('earlyBirdUser'); + expect(options.session?.modelName).toBe('earlyBirdAuthSession'); + expect(options.account?.modelName).toBe('earlyBirdIdentity'); + expect(options.verification?.modelName).toBe('earlyBirdVerification'); + expect(options.advanced?.cookiePrefix).toBe('hb_earlybird'); + expect(options.advanced?.cookies?.session_token?.name).toBe(EARLY_BIRD_SESSION_COOKIE); + expect(Object.keys(options.socialProviders ?? {}).sort()).toEqual(['apple', 'google']); + expect(options.account?.accountLinking).toMatchObject({ + enabled: false, + disableImplicitLinking: true, + trustedProviders: [], + allowDifferentEmails: false, + allowUnlinkingAll: false, + }); + expect(options.account?.storeAccountCookie).toBe(false); + }); + + it('scrubs provider token material before create and update reach Prisma', async () => { + const hooks = earlyBirdAuth().options.databaseHooks?.account; + const providerPayload = { + id: 'identity-1', + providerId: 'google', + accountId: 'google-account', + userId: 'listener-1', + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + idToken: 'identity-secret', + accessTokenExpiresAt: new Date('2026-08-07T00:00:00.000Z'), + refreshTokenExpiresAt: new Date('2026-08-08T00:00:00.000Z'), + scope: 'openid email profile', + password: null, + createdAt: new Date('2026-08-06T00:00:00.000Z'), + updatedAt: new Date('2026-08-06T00:00:00.000Z'), + }; + + const created = await hooks?.create?.before?.(providerPayload); + const updated = await hooks?.update?.before?.(providerPayload); + + for (const outcome of [created, updated]) { + expect(outcome).not.toBe(false); + expect(outcome && 'data' in outcome ? outcome.data : null).toMatchObject({ + providerId: 'google', + accountId: 'google-account', + accessToken: null, + refreshToken: null, + idToken: null, + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + scope: null, + }); + expect(JSON.stringify(outcome)).not.toContain('access-secret'); + expect(JSON.stringify(outcome)).not.toContain('refresh-secret'); + expect(JSON.stringify(outcome)).not.toContain('identity-secret'); + } + }); +}); diff --git a/src/lib/early-birds/__tests__/membership-gateway.test.ts b/src/lib/early-birds/__tests__/membership-gateway.test.ts new file mode 100644 index 00000000..171d4887 --- /dev/null +++ b/src/lib/early-birds/__tests__/membership-gateway.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const applyMembershipProjection = vi.hoisted(() => vi.fn()); + +vi.mock('../membership', async (importOriginal) => ({ + ...await importOriginal(), + applyMembershipProjection, +})); + +import { + HttpEarlyBirdMembershipGateway, + redeemFreeThroughCanonicalGateway, +} from '../membership-gateway'; + +const TOKEN = 'ebi_v1.AAAAAAAAAAAAAAAAAAAAAA.synthetic_nonce_00000000000000000000.synthetic_signature_0000000000000000000000000000000'; +const authorityMembership = { + schema_version: 'early-bird-authority.membership.v1', + account_id: 'listener-1', + 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', +}; + +afterEach(() => vi.restoreAllMocks()); +beforeEach(() => vi.clearAllMocks()); + +describe('canonical EarlyBird membership HTTP gateway', () => { + it('sends the exact authenticated contract and applies only the returned projection', async () => { + const request = vi.fn().mockResolvedValue(new Response(JSON.stringify(authorityMembership), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + const gateway = new HttpEarlyBirdMembershipGateway({ + baseUrl: 'http://pmp-myth-bot:3000', + keyId: '2026-08-current', + token: 's'.repeat(43), + }, request); + applyMembershipProjection.mockResolvedValue({ outcome: 'APPLIED' }); + + await expect(redeemFreeThroughCanonicalGateway('listener-1', TOKEN, gateway)) + .resolves.toMatchObject({ ok: true, replayed: false }); + + const [url, init] = request.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('http://pmp-myth-bot:3000/api/internal/v1/early-bird-invitations/redeem'); + expect(init).toMatchObject({ method: 'POST', redirect: 'error', cache: 'no-store' }); + expect(init.headers).toMatchObject({ + authorization: `Bearer ${'s'.repeat(43)}`, + 'x-hb-service-key-id': '2026-08-current', + 'content-type': 'application/json', + }); + expect((init.headers as Record)['idempotency-key']).toMatch( + /^early-bird-invitation-redeem:[a-f0-9]{64}$/, + ); + expect((init.headers as Record)['idempotency-key']).not.toContain(TOKEN); + expect(JSON.parse(String(init.body))).toEqual({ + schema_version: 'early-bird-authority.invitation-redeem.v1', + account_id: 'listener-1', + invitation_token: TOKEN, + }); + expect(applyMembershipProjection).toHaveBeenCalledWith(expect.objectContaining({ + schema_version: 'early-bird-membership.command.v1', + account_id: 'listener-1', + membership_revision: 1, + })); + }); + + it('rejects malformed invitations locally and never calls the authority', async () => { + const gateway = { redeemFree: vi.fn() }; + await expect(redeemFreeThroughCanonicalGateway('listener-1', 'x'.repeat(43), gateway)) + .resolves.toEqual({ ok: false, reason: 'unavailable' }); + expect(gateway.redeemFree).not.toHaveBeenCalled(); + }); + + it('fails closed on mismatched or structurally invalid authority responses', async () => { + const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + ...authorityMembership, + account_id: 'someone-else', + }), { status: 200 })); + const gateway = new HttpEarlyBirdMembershipGateway({ + baseUrl: 'https://authority.example.test', + keyId: 'current', + token: 's'.repeat(43), + }, request); + await expect(gateway.redeemFree({ accountId: 'listener-1', opaqueInvitation: TOKEN })) + .rejects.toThrow('unavailable'); + expect(applyMembershipProjection).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/early-birds/__tests__/membership.test.ts b/src/lib/early-birds/__tests__/membership.test.ts new file mode 100644 index 00000000..b56b2b48 --- /dev/null +++ b/src/lib/early-birds/__tests__/membership.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const tx = vi.hoisted(() => ({ + $queryRaw: vi.fn(), + earlyBirdMembershipProjection: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, +})); + +const prisma = vi.hoisted(() => ({ + $transaction: vi.fn((callback: (client: typeof tx) => unknown) => callback(tx)), + earlyBirdMembershipProjection: { findUnique: vi.fn() }, +})); + +vi.mock('@/lib/db', () => ({ prisma })); + +import { + applyMembershipProjection, + EarlyBirdProjectionConflictError, + membershipAccessDecision, + membershipCommandHash, + type EarlyBirdMembershipProjectionCommand, +} from '../membership'; + +const NOW = new Date('2026-08-06T12:00:00.000Z'); + +function projection(overrides: Record = {}) { + return { + id: '00000000-0000-4000-8000-000000000001', + accountId: 'listener-1', + revision: 1, + commandHash: 'a'.repeat(64), + state: 'ACTIVE', + source: 'FREE', + offerCode: 'EARLY_BIRDS_FOUNDERS_V1', + offerRevision: 1, + effectiveAt: NOW, + paidThrough: null, + graceUntil: null, + provider: null, + amountMinor: null, + currency: null, + reasonCode: 'INVITATION_REDEEMED', + synthetic: false, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + } as never; +} + +function command(overrides: Partial = {}) { + return { + schema_version: 'early-bird-membership.command.v1', + account_id: 'listener-1', + membership_revision: 1, + state: 'ACTIVE', + source: 'FREE', + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, + effective_at: NOW.toISOString(), + paid_through: null, + grace_until: null, + provider: null, + current_price: null, + reason_code: 'INVITATION_REDEEMED', + ...overrides, + } satisfies EarlyBirdMembershipProjectionCommand; +} + +describe('EarlyBird membership read model', () => { + beforeEach(() => { + vi.clearAllMocks(); + tx.$queryRaw.mockResolvedValue([{ id: 'listener-1' }]); + }); + + it('fails closed for missing/ended access and respects paid/grace horizons', () => { + expect(membershipAccessDecision(null, NOW)).toMatchObject({ allowed: false, reason: 'missing' }); + expect(membershipAccessDecision(projection(), NOW)).toMatchObject({ allowed: true, reason: 'active' }); + expect(membershipAccessDecision(projection({ state: 'GRACE', graceUntil: new Date(NOW.getTime() + 1_000) }), NOW)) + .toMatchObject({ allowed: true, reason: 'grace' }); + expect(membershipAccessDecision(projection({ state: 'GRACE', graceUntil: NOW }), NOW)) + .toMatchObject({ allowed: false, reason: 'ended' }); + expect(membershipAccessDecision(projection({ state: 'CANCELLED_PENDING_END', paidThrough: new Date(NOW.getTime() + 1_000) }), NOW)) + .toMatchObject({ allowed: true, reason: 'paid-through' }); + expect(membershipAccessDecision(projection({ state: 'REFUNDED' }), NOW)).toMatchObject({ allowed: false }); + }); + + it('hashes the canonical command independently from object identity', () => { + expect(membershipCommandHash(command())).toBe(membershipCommandHash({ ...command() })); + expect(membershipCommandHash(command({ membership_revision: 2 }))).not.toBe(membershipCommandHash(command())); + expect(membershipCommandHash(command({ effective_at: '2026-08-06T12:00:00Z' }))) + .not.toBe(membershipCommandHash(command())); + }); + + it('applies a new projection and replays the exact same revision', async () => { + const expected = projection({ commandHash: membershipCommandHash(command()) }); + tx.earlyBirdMembershipProjection.findUnique.mockResolvedValueOnce(null); + tx.earlyBirdMembershipProjection.create.mockResolvedValueOnce(expected); + + await expect(applyMembershipProjection(command())).resolves.toEqual({ + projection: expected, + outcome: 'APPLIED', + }); + + tx.earlyBirdMembershipProjection.findUnique.mockResolvedValueOnce(expected); + await expect(applyMembershipProjection(command())).resolves.toMatchObject({ + outcome: 'REPLAYED', + }); + }); + + it('rejects two payloads claiming the same canonical revision', async () => { + tx.earlyBirdMembershipProjection.findUnique.mockResolvedValueOnce(projection({ commandHash: 'b'.repeat(64) })); + await expect(applyMembershipProjection(command())).rejects.toBeInstanceOf(EarlyBirdProjectionConflictError); + }); +}); diff --git a/src/lib/early-birds/__tests__/service-auth.test.ts b/src/lib/early-birds/__tests__/service-auth.test.ts new file mode 100644 index 00000000..e411a959 --- /dev/null +++ b/src/lib/early-birds/__tests__/service-auth.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { authorizeEarlyBirdMembershipService } from '../service-auth'; + +const current = 'a'.repeat(43); +const previous = 'b'.repeat(43); +const env = { + NODE_ENV: 'test', + EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID: 'current', + EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT: current, + EARLY_BIRDS_BEACON_SERVICE_KEY_PREVIOUS_ID: 'previous', + EARLY_BIRDS_BEACON_SERVICE_KEY_PREVIOUS: previous, +} as NodeJS.ProcessEnv; + +describe('EarlyBird membership service authentication', () => { + it('accepts the current and previous key while binding token to key id', () => { + expect(authorizeEarlyBirdMembershipService(`Bearer ${current}`, 'current', env)).toBe(true); + expect(authorizeEarlyBirdMembershipService(`Bearer ${previous}`, 'previous', env)).toBe(true); + expect(authorizeEarlyBirdMembershipService(`Bearer ${current}`, 'previous', env)).toBe(false); + }); + + it('fails closed for malformed or short configuration', () => { + expect(authorizeEarlyBirdMembershipService(null, null, env)).toBe(false); + expect(authorizeEarlyBirdMembershipService('Basic nope', 'current', env)).toBe(false); + expect(authorizeEarlyBirdMembershipService('Bearer short', 'current', { + NODE_ENV: 'test', + EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID: 'current', + EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT: 'short', + } as NodeJS.ProcessEnv)).toBe(false); + }); +}); diff --git a/src/lib/early-birds/__tests__/stream-contract.test.ts b/src/lib/early-birds/__tests__/stream-contract.test.ts new file mode 100644 index 00000000..04e59b1c --- /dev/null +++ b/src/lib/early-birds/__tests__/stream-contract.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + EARLY_BIRD_LEASE_MANIFEST_PATH, + earlyBirdOriginConfig, + earlyBirdOriginManifestPath, + earlyBirdStreamUrlIssuer, + EarlyBirdStreamIssuerUnavailableError, + setEarlyBirdStreamUrlIssuerForTests, + signEarlyBirdOriginPath, + signedEarlyBirdOriginManifestUrl, + validSignedOriginManifest, +} from '../stream'; + +const SECRET = 'x'.repeat(32); + +afterEach(() => { + setEarlyBirdStreamUrlIssuerForTests(null); + vi.unstubAllEnvs(); +}); + +describe('Beacon origin signing contract', () => { + it('matches the byte-exact services/beacon-stream signer and its fixture', async () => { + const { signPath } = await import('../../../../services/beacon-stream/src/auth.mjs'); + const input = { + secret: SECRET, + pathname: '/v1/hls/approved-v1/live.m3u8', + expiresAt: 1_100, + }; + const beaconSignature = signEarlyBirdOriginPath(input); + expect(beaconSignature).toBe('tb9hrzcc1Q7Ji_LOxvAlbmmBCDyTOpvnptAOSVMW1nA'); + expect(beaconSignature).toBe(signPath(input)); + }); + + it('caps origin authorization at the lease horizon and emits exp/sig only server-side', () => { + const config = { + origin: 'https://stream.example.test', + artifactId: 'approved-v1', + signingSecret: SECRET, + }; + const url = new URL(signedEarlyBirdOriginManifestUrl({ + config, + now: new Date('1970-01-01T00:16:40.000Z'), + leaseExpiresAt: new Date('1970-01-01T00:16:45.000Z'), + })); + expect(url.pathname).toBe(earlyBirdOriginManifestPath('approved-v1')); + expect(url.searchParams.get('exp')).toBe('1005'); + expect(url.searchParams.get('sig')).toBeTruthy(); + }); + + it('fails closed for missing, short-secret, malformed artifact, and HTTP production config', () => { + expect(() => earlyBirdOriginConfig({} as NodeJS.ProcessEnv)).toThrow(EarlyBirdStreamIssuerUnavailableError); + expect(() => earlyBirdOriginConfig({ + NODE_ENV: 'test', + EARLY_BIRDS_STREAM_ORIGIN: 'https://stream.example.test', + EARLY_BIRDS_STREAM_ARTIFACT_ID: '../escape', + EARLY_BIRDS_STREAM_SIGNING_SECRET: SECRET, + } as NodeJS.ProcessEnv)).toThrow(EarlyBirdStreamIssuerUnavailableError); + expect(() => earlyBirdOriginConfig({ + NODE_ENV: 'production', + EARLY_BIRDS_STREAM_ORIGIN: 'http://stream.example.test', + EARLY_BIRDS_STREAM_ARTIFACT_ID: 'approved-v1', + EARLY_BIRDS_STREAM_SIGNING_SECRET: SECRET, + } as NodeJS.ProcessEnv)).toThrow(EarlyBirdStreamIssuerUnavailableError); + }); + + it('accepts only manifests whose every media URI is an individually signed origin segment', () => { + const config = { + origin: 'https://stream.example.test', + artifactId: 'approved-v1', + signingSecret: SECRET, + }; + const manifest = [ + '#EXTM3U', + '#EXT-X-VERSION:7', + '#EXTINF:6.000,', + 'https://stream.example.test/v1/hls/approved-v1/segments/000001.m4s?exp=1100&sig=abc', + '', + ].join('\n'); + expect(validSignedOriginManifest(manifest, config)).toBe(true); + expect(validSignedOriginManifest(manifest.replace('stream.example.test', 'evil.example'), config)).toBe(false); + expect(validSignedOriginManifest(manifest.replace('&sig=abc', ''), config)).toBe(false); + }); + + it('gives browsers only the stable same-origin lease manifest URL', async () => { + 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', SECRET); + const grant = await earlyBirdStreamUrlIssuer().issue({ + accountId: 'listener-1', + leaseId: '00000000-0000-4000-8000-000000000111', + issuedAt: new Date(), + leaseExpiresAt: new Date(Date.now() + 60_000), + }); + expect(grant.manifestUrl).toBe(`${EARLY_BIRD_LEASE_MANIFEST_PATH}?leaseId=00000000-0000-4000-8000-000000000111`); + expect(grant.manifestUrl).not.toContain('sig='); + expect(grant.manifestUrl).not.toContain('stream.example.test'); + }); +}); diff --git a/src/lib/early-birds/__tests__/stream-lease.test.ts b/src/lib/early-birds/__tests__/stream-lease.test.ts new file mode 100644 index 00000000..4cdfc5a7 --- /dev/null +++ b/src/lib/early-birds/__tests__/stream-lease.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const tx = vi.hoisted(() => ({ + $queryRaw: vi.fn(), + earlyBirdMembershipProjection: { findUnique: vi.fn() }, + earlyBirdStreamLease: { + findUnique: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + findFirst: vi.fn(), + }, +})); +const prisma = vi.hoisted(() => ({ + $transaction: vi.fn((callback: (client: typeof tx) => unknown) => callback(tx)), + earlyBirdStreamLease: { updateMany: vi.fn(), findFirst: vi.fn() }, + earlyBirdMembershipProjection: { findUnique: vi.fn() }, +})); +vi.mock('@/lib/db', () => ({ prisma })); + +import { + acquireEarlyBirdStreamLease, + earlyBirdDeviceDigest, + EARLY_BIRD_LEASE_TTL_MS, + type EarlyBirdStreamUrlIssuer, +} from '../stream'; + +const NOW = new Date('2026-08-06T12:00:00.000Z'); + +describe('EarlyBird two-device leases', () => { + beforeEach(() => { + vi.clearAllMocks(); + tx.$queryRaw.mockResolvedValue([{ id: 'listener-1' }]); + tx.earlyBirdMembershipProjection.findUnique.mockResolvedValue({ + state: 'ACTIVE', + paidThrough: null, + }); + tx.earlyBirdStreamLease.findUnique.mockResolvedValue(null); + tx.earlyBirdStreamLease.updateMany.mockResolvedValue({ count: 1 }); + prisma.earlyBirdStreamLease.updateMany.mockResolvedValue({ count: 1 }); + }); + + it('never persists a raw browser device identifier', () => { + const raw = 'device_abcdefghijklmnopqrstuvwxyz'; + const digest = earlyBirdDeviceDigest(raw, 'p'.repeat(32)); + expect(digest).toMatch(/^[0-9a-f]{64}$/); + expect(digest).not.toContain(raw); + }); + + it('evicts the oldest lease when a third distinct device enters', async () => { + tx.earlyBirdStreamLease.findMany.mockResolvedValue([ + { id: '00000000-0000-4000-8000-000000000001' }, + { id: '00000000-0000-4000-8000-000000000002' }, + ]); + tx.earlyBirdStreamLease.create.mockResolvedValue({ + id: '00000000-0000-4000-8000-000000000003', + }); + const issuer: EarlyBirdStreamUrlIssuer = { + issue: vi.fn().mockResolvedValue({ + manifestUrl: '/api/early-birds/stream/manifest?leaseId=3', + expiresAt: new Date(NOW.getTime() + EARLY_BIRD_LEASE_TTL_MS), + }), + }; + + const result = await acquireEarlyBirdStreamLease( + 'listener-1', + 'device_abcdefghijklmnopqrstuvwxyz', + NOW, + issuer, + ); + + expect(tx.earlyBirdStreamLease.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['00000000-0000-4000-8000-000000000001'] } }, + data: { evictedAt: NOW }, + }); + expect(result.evictedLeaseId).toBe('00000000-0000-4000-8000-000000000001'); + expect(result.leaseId).toBe('00000000-0000-4000-8000-000000000003'); + expect(issuer.issue).toHaveBeenCalledOnce(); + }); + + it('marks a just-created lease inactive if URL issuance fails closed', async () => { + tx.earlyBirdStreamLease.findMany.mockResolvedValue([]); + tx.earlyBirdStreamLease.create.mockResolvedValue({ + id: '00000000-0000-4000-8000-000000000004', + }); + const issuer: EarlyBirdStreamUrlIssuer = { + issue: vi.fn().mockRejectedValue(new Error('origin unavailable')), + }; + + await expect(acquireEarlyBirdStreamLease( + 'listener-1', + 'device_abcdefghijklmnopqrstuvwxyz', + NOW, + issuer, + )).rejects.toThrow('origin unavailable'); + expect(prisma.earlyBirdStreamLease.updateMany).toHaveBeenCalledWith({ + where: { id: '00000000-0000-4000-8000-000000000004', accountId: 'listener-1' }, + data: { evictedAt: NOW }, + }); + }); +}); diff --git a/src/lib/early-birds/auth-client.ts b/src/lib/early-birds/auth-client.ts new file mode 100644 index 00000000..af83d321 --- /dev/null +++ b/src/lib/early-birds/auth-client.ts @@ -0,0 +1,9 @@ +'use client'; + +import { createAuthClient } from 'better-auth/react'; + +import { EARLY_BIRD_AUTH_BASE_PATH } from './auth-contract'; + +export const earlyBirdAuthClient = createAuthClient({ + basePath: EARLY_BIRD_AUTH_BASE_PATH, +}); diff --git a/src/lib/early-birds/auth-contract.ts b/src/lib/early-birds/auth-contract.ts new file mode 100644 index 00000000..78dc1634 --- /dev/null +++ b/src/lib/early-birds/auth-contract.ts @@ -0,0 +1,3 @@ +// Browser-safe values only. Server auth configuration lives in auth.ts and is +// intentionally never imported by Client Components. +export const EARLY_BIRD_AUTH_BASE_PATH = '/api/early-birds/auth'; diff --git a/src/lib/early-birds/auth.ts b/src/lib/early-birds/auth.ts new file mode 100644 index 00000000..d7636264 --- /dev/null +++ b/src/lib/early-birds/auth.ts @@ -0,0 +1,185 @@ +import { headers as requestHeaders } from 'next/headers'; +import { betterAuth } from 'better-auth/minimal'; +import { prismaAdapter } from 'better-auth/adapters/prisma'; + +import { prisma } from '@/lib/db'; + +export const EARLY_BIRD_AUTH_BASE_PATH = '/api/early-birds/auth'; +export const EARLY_BIRD_COOKIE_PREFIX = 'hb_earlybird'; +export const EARLY_BIRD_SESSION_COOKIE = 'hb_earlybird_session'; + +function nonEmpty(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +export function earlyBirdTestAuthEnabled(): boolean { + return ( + process.env.EARLY_BIRDS_TEST_ACCESS_ENABLED === '1' && + Boolean(nonEmpty(process.env.EARLY_BIRDS_TEST_LOGIN_SECRET)) + ); +} + +export function earlyBirdOAuthAvailability() { + return { + google: Boolean( + nonEmpty(process.env.EARLY_BIRDS_GOOGLE_CLIENT_ID) && + nonEmpty(process.env.EARLY_BIRDS_GOOGLE_CLIENT_SECRET), + ), + apple: Boolean( + nonEmpty(process.env.EARLY_BIRDS_APPLE_CLIENT_ID) && + nonEmpty(process.env.EARLY_BIRDS_APPLE_CLIENT_SECRET), + ), + } as const; +} + +function authSecret(): string { + const configured = nonEmpty(process.env.EARLY_BIRDS_AUTH_SECRET); + if (configured) return configured; + + if (process.env.NODE_ENV === 'production') { + throw new Error('EARLY_BIRDS_AUTH_SECRET is required at runtime'); + } + + // Local/test fallback only. Production never reaches this value. + return 'early-birds-local-only-secret-change-before-deploy'; +} + +function trustedOrigins(): string[] { + const configured = (process.env.EARLY_BIRDS_TRUSTED_ORIGINS ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + const baseURL = nonEmpty(process.env.EARLY_BIRDS_AUTH_BASE_URL); + return baseURL ? [...new Set([baseURL, ...configured])] : configured; +} + +function scrubOAuthTokens>(account: T): T { + return { + ...account, + accessToken: null, + refreshToken: null, + idToken: null, + accessTokenExpiresAt: null, + refreshTokenExpiresAt: null, + scope: null, + }; +} + +function buildEarlyBirdAuth() { + const testAuth = earlyBirdTestAuthEnabled(); + const baseURL = nonEmpty(process.env.EARLY_BIRDS_AUTH_BASE_URL); + + return betterAuth({ + appName: 'Harmonic Beacon EarlyBirds', + ...(baseURL ? { baseURL } : {}), + basePath: EARLY_BIRD_AUTH_BASE_PATH, + secret: authSecret(), + trustedOrigins: trustedOrigins(), + database: prismaAdapter(prisma, { provider: 'postgresql' }), + socialProviders: { + google: { + clientId: nonEmpty(process.env.EARLY_BIRDS_GOOGLE_CLIENT_ID) ?? 'not-configured', + clientSecret: nonEmpty(process.env.EARLY_BIRDS_GOOGLE_CLIENT_SECRET) ?? 'not-configured', + accessType: 'online', + }, + apple: { + clientId: nonEmpty(process.env.EARLY_BIRDS_APPLE_CLIENT_ID) ?? 'not-configured', + clientSecret: nonEmpty(process.env.EARLY_BIRDS_APPLE_CLIENT_SECRET) ?? 'not-configured', + }, + }, + // Email/password is a supervised synthetic-login seam only. The public + // product exposes exactly Google and Apple, and the seam is absent + // unless both an explicit gate and a separate secret are present. + emailAndPassword: { enabled: testAuth }, + user: { + modelName: 'earlyBirdUser', + changeEmail: { enabled: false }, + deleteUser: { enabled: false }, + }, + session: { + modelName: 'earlyBirdAuthSession', + expiresIn: 60 * 60 * 24 * 30, + updateAge: 60 * 60 * 24, + cookieCache: { enabled: false }, + }, + account: { + modelName: 'earlyBirdIdentity', + updateAccountOnSignIn: false, + storeStateStrategy: 'database', + storeAccountCookie: false, + accountLinking: { + enabled: false, + disableImplicitLinking: true, + trustedProviders: [], + allowDifferentEmails: false, + allowUnlinkingAll: false, + updateUserInfoOnLink: false, + }, + }, + verification: { modelName: 'earlyBirdVerification' }, + databaseHooks: { + account: { + create: { + async before(account) { + return { data: scrubOAuthTokens(account) }; + }, + }, + update: { + async before(account) { + return { data: scrubOAuthTokens(account) }; + }, + }, + }, + }, + advanced: { + cookiePrefix: EARLY_BIRD_COOKIE_PREFIX, + cookies: { + session_token: { name: EARLY_BIRD_SESSION_COOKIE }, + }, + useSecureCookies: baseURL?.startsWith('https://') ?? process.env.NODE_ENV === 'production', + }, + }); +} + +let singleton: ReturnType | undefined; + +export function earlyBirdAuth() { + singleton ??= buildEarlyBirdAuth(); + return singleton; +} + +export type EarlyBirdSession = { + user: { + id: string; + name: string; + email: string; + image?: string | null; + }; + session: { + id: string; + expiresAt: Date; + }; +}; + +/** Resolve the EarlyBird account authoritatively from its own cookie/table. */ +export async function currentEarlyBirdSession( + suppliedHeaders?: Headers, +): Promise { + const resolvedHeaders = suppliedHeaders ?? new Headers(await requestHeaders()); + const result = await earlyBirdAuth().api.getSession({ headers: resolvedHeaders }); + if (!result) return null; + + return { + user: { + id: result.user.id, + name: result.user.name, + email: result.user.email, + image: result.user.image, + }, + session: { + id: result.session.id, + expiresAt: result.session.expiresAt, + }, + }; +} diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts new file mode 100644 index 00000000..9cd14245 --- /dev/null +++ b/src/lib/early-birds/copy.ts @@ -0,0 +1,85 @@ +import type { UiLocale } from '@/lib/i18n'; + +export const earlyBirdCopy = { + es: { + eyebrow: 'EARLYBIRDS · ACCESO FUNDADOR', + title: 'El Beacon, siempre presente.', + intro: 'Una línea temporal compartida, disponible las 24 horas para volver a escuchar, respirar y entrar.', + live: 'Beacon 24/7', + privateDropIns: 'Drop-ins privados completos en español e inglés', + membership: 'Membresía fundadora · US$2 por mes', + signInGoogle: 'Continuar con Google', + signInApple: 'Continuar con Apple', + signingIn: 'Abriendo acceso…', + providerSoon: 'Configuración pendiente', + signedIn: 'Tu cuenta EarlyBird está lista.', + enter: 'Entrar al Beacon', + redeem: 'Activar mi invitación', + accessNeeded: 'Tu cuenta todavía no tiene una membresía activa.', + authError: 'No pudimos completar el acceso. Usa el mismo proveedor con el que creaste tu cuenta o contacta a soporte.', + privacy: 'La cuenta, la membresía y el pago pertenecen a una persona adulta. No creamos perfiles de menores ni historiales personales de escucha.', + testTitle: 'Acceso sintético de prueba', + testAction: 'Entrar con acceso de prueba', + }, + en: { + eyebrow: 'EARLYBIRDS · FOUNDING ACCESS', + title: 'The Beacon, always present.', + intro: 'One shared timeline, available around the clock whenever you want to listen, breathe, and enter.', + live: 'Beacon 24/7', + privateDropIns: 'Complete private drop-ins in Spanish and English', + membership: 'Founding membership · US$2 per month', + signInGoogle: 'Continue with Google', + signInApple: 'Continue with Apple', + signingIn: 'Opening access…', + providerSoon: 'Configuration pending', + signedIn: 'Your EarlyBird account is ready.', + enter: 'Enter the Beacon', + redeem: 'Activate my invitation', + accessNeeded: 'Your account does not have an active membership yet.', + authError: 'We could not complete sign-in. Use the provider that created your account, or contact support.', + privacy: 'An adult owns the account, membership, consent, and payment. We do not create child profiles or personal listening histories.', + testTitle: 'Synthetic test access', + testAction: 'Enter with test access', + }, +} satisfies Record>; + +export const earlyBirdHomeCopy = { + es: { + eyebrow: 'EARLYBIRDS · LISTENER', + heading: 'Beacon 24/7', + subheading: 'Estás entrando al mismo borde vivo que todas las personas EarlyBird.', + play: 'Escuchar en vivo', + pause: 'Pausar', + resume: 'Volver al borde vivo', + loading: 'Conectando…', + unavailable: 'El Beacon no está disponible en este momento.', + displaced: 'Este dispositivo fue desplazado porque la cuenta ya está escuchando en otros dos dispositivos.', + dropIns: 'Drop-ins privados', + spanish: 'Caldeamiento · Español', + english: 'Warm-up · English', + dropUnavailable: 'El render aprobado todavía no fue publicado.', + restart: 'Reiniciar', + master: 'Volumen master', + signOut: 'Cerrar sesión', + active: 'Membresía activa', + }, + en: { + eyebrow: 'EARLYBIRDS · LISTENER', + heading: 'Beacon 24/7', + subheading: 'You are joining the same live edge as every other EarlyBird listener.', + play: 'Listen live', + pause: 'Pause', + resume: 'Return to live edge', + loading: 'Connecting…', + unavailable: 'The Beacon is unavailable right now.', + displaced: 'This device was displaced because the account is already listening on two other devices.', + dropIns: 'Private drop-ins', + spanish: 'Warm-up · Spanish', + english: 'Warm-up · English', + dropUnavailable: 'The approved render has not been published yet.', + restart: 'Restart', + master: 'Master volume', + signOut: 'Sign out', + active: 'Active membership', + }, +} satisfies Record>; diff --git a/src/lib/early-birds/membership-contract.ts b/src/lib/early-birds/membership-contract.ts new file mode 100644 index 00000000..147453b9 --- /dev/null +++ b/src/lib/early-birds/membership-contract.ts @@ -0,0 +1,156 @@ +import type { EarlyBirdMembershipProjectionCommand } from './membership'; + +const COMMAND_KEYS = [ + 'account_id', 'current_price', 'effective_at', 'grace_until', 'membership_revision', 'offer', + 'paid_through', 'provider', 'reason_code', 'schema_version', 'source', 'state', +] as const; +const AUTHORITY_KEYS = [ + 'access_allowed', 'account_id', 'current_price', 'effective_at', 'free_entitlement_consumed', + 'grace_until', 'membership_revision', 'offer', 'paid_through', 'provider', 'reason_code', + 'schema_version', 'source', 'state', +] as const; +const STATES = [ + 'PENDING', 'ACTIVE', 'GRACE', 'CANCELLED_PENDING_END', 'EXPIRED', 'REFUNDED', 'REVOKED', +] as const; +const SOURCES = ['FREE', 'PAYPAL', 'MERCADO_PAGO'] as const; +const PROVIDERS = ['paypal', 'mercado_pago'] as const; + +export class EarlyBirdMembershipContractError extends Error { + constructor(message: string) { + super(message); + this.name = 'EarlyBirdMembershipContractError'; + } +} + +type CanonicalAuthorityMembership = Omit & { + schema_version: 'early-bird-authority.membership.v1'; + access_allowed: boolean; + free_entitlement_consumed: boolean; +}; + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new EarlyBirdMembershipContractError(`${label} must be an object`); + } + return value as Record; +} + +function exactKeys(value: Record, keys: readonly string[]): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new EarlyBirdMembershipContractError('Membership payload fields do not match the contract'); + } +} + +function nullableEnum(value: unknown, allowed: readonly T[], field: string): T | null { + if (value === null) return null; + if (typeof value === 'string' && allowed.includes(value as T)) return value as T; + throw new EarlyBirdMembershipContractError(`${field} is invalid`); +} + +function instant(value: unknown, field: string, nullable = false): string | null { + if (nullable && value === null) return null; + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(value) || !Number.isFinite(Date.parse(value))) { + throw new EarlyBirdMembershipContractError(`${field} must be a date-time`); + } + return value; +} + +function offer(value: unknown): EarlyBirdMembershipProjectionCommand['offer'] { + if (value === null) return null; + const input = record(value, 'offer'); + exactKeys(input, ['code', 'revision']); + if (input.code !== 'EARLY_BIRDS_FOUNDERS_V1' || + !Number.isSafeInteger(input.revision) || (input.revision as number) < 1) { + throw new EarlyBirdMembershipContractError('offer is invalid'); + } + return { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: input.revision as number }; +} + +function price(value: unknown): EarlyBirdMembershipProjectionCommand['current_price'] { + if (value === null) return null; + const input = record(value, 'current_price'); + exactKeys(input, ['amount_minor', 'currency']); + if (!['USD', 'ARS'].includes(String(input.currency)) || + !Number.isSafeInteger(input.amount_minor) || (input.amount_minor as number) < 1) { + throw new EarlyBirdMembershipContractError('current_price is invalid'); + } + return { + currency: input.currency as 'USD' | 'ARS', + amount_minor: input.amount_minor as number, + }; +} + +function common(input: Record) { + if (typeof input.account_id !== 'string' || input.account_id.length < 1 || input.account_id.length > 255) { + throw new EarlyBirdMembershipContractError('account_id is invalid'); + } + if (!Number.isSafeInteger(input.membership_revision) || (input.membership_revision as number) < 1) { + throw new EarlyBirdMembershipContractError('membership_revision is invalid'); + } + if (!STATES.includes(input.state as typeof STATES[number])) { + throw new EarlyBirdMembershipContractError('state is invalid'); + } + if (typeof input.reason_code !== 'string' || input.reason_code.length < 1 || input.reason_code.length > 64) { + throw new EarlyBirdMembershipContractError('reason_code is invalid'); + } + return { + account_id: input.account_id, + membership_revision: input.membership_revision as number, + state: input.state as typeof STATES[number], + source: nullableEnum(input.source, SOURCES, 'source'), + offer: offer(input.offer), + effective_at: instant(input.effective_at, 'effective_at')!, + paid_through: instant(input.paid_through, 'paid_through', true), + grace_until: instant(input.grace_until, 'grace_until', true), + provider: nullableEnum(input.provider, PROVIDERS, 'provider'), + current_price: price(input.current_price), + reason_code: input.reason_code, + }; +} + +export function parseMembershipProjectionCommand(value: unknown): EarlyBirdMembershipProjectionCommand { + const input = record(value, 'membership command'); + exactKeys(input, COMMAND_KEYS); + if (input.schema_version !== 'early-bird-membership.command.v1') { + throw new EarlyBirdMembershipContractError('Unsupported membership command schema'); + } + return { schema_version: input.schema_version, ...common(input) }; +} + +export function parseCanonicalAuthorityMembership(value: unknown): CanonicalAuthorityMembership { + const input = record(value, 'authority membership'); + exactKeys(input, AUTHORITY_KEYS); + if (input.schema_version !== 'early-bird-authority.membership.v1') { + throw new EarlyBirdMembershipContractError('Unsupported authority membership schema'); + } + if (typeof input.access_allowed !== 'boolean' || typeof input.free_entitlement_consumed !== 'boolean') { + throw new EarlyBirdMembershipContractError('Authority membership booleans are invalid'); + } + return { + schema_version: input.schema_version, + ...common(input), + access_allowed: input.access_allowed, + free_entitlement_consumed: input.free_entitlement_consumed, + }; +} + +export function authorityMembershipCommand( + membership: CanonicalAuthorityMembership, +): EarlyBirdMembershipProjectionCommand { + return { + schema_version: 'early-bird-membership.command.v1', + account_id: membership.account_id, + membership_revision: membership.membership_revision, + state: membership.state, + source: membership.source, + offer: membership.offer, + effective_at: membership.effective_at, + paid_through: membership.paid_through, + grace_until: membership.grace_until, + provider: membership.provider, + current_price: membership.current_price, + reason_code: membership.reason_code, + }; +} diff --git a/src/lib/early-birds/membership-gateway.ts b/src/lib/early-birds/membership-gateway.ts new file mode 100644 index 00000000..0d1cbbc0 --- /dev/null +++ b/src/lib/early-birds/membership-gateway.ts @@ -0,0 +1,148 @@ +import { createHash } from 'node:crypto'; + +import { + authorityMembershipCommand, + parseCanonicalAuthorityMembership, +} from './membership-contract'; +import { + applyMembershipProjection, + type EarlyBirdMembershipProjectionCommand, +} from './membership'; + +const INVITATION_TOKEN = /^ebi_v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; +const REQUEST_TIMEOUT_MS = 5_000; + +export type CanonicalFreeRedemptionResult = + | { + ok: true; + replayed: boolean; + alreadyEntitled: boolean; + projection: EarlyBirdMembershipProjectionCommand; + } + | { ok: false; reason: 'unavailable' }; + +export interface EarlyBirdMembershipGateway { + redeemFree(input: { + accountId: string; + opaqueInvitation: string; + }): Promise; +} + +export class EarlyBirdMembershipGatewayUnavailableError extends Error { + constructor() { + super('Canonical EarlyBird membership gateway is unavailable'); + this.name = 'EarlyBirdMembershipGatewayUnavailableError'; + } +} + +type GatewayConfig = { baseUrl: string; keyId: string; token: string }; + +function gatewayConfig(env: NodeJS.ProcessEnv = process.env): GatewayConfig { + const baseUrl = env.EARLY_BIRDS_AUTHORITY_BASE_URL?.trim(); + const keyId = env.EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID?.trim(); + const token = env.EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN?.trim(); + if (!baseUrl || !keyId || !token || token.length < 43) { + throw new EarlyBirdMembershipGatewayUnavailableError(); + } + let parsed: URL; + try { + parsed = new URL(baseUrl); + } catch { + throw new EarlyBirdMembershipGatewayUnavailableError(); + } + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) { + throw new EarlyBirdMembershipGatewayUnavailableError(); + } + return { baseUrl: parsed.toString().replace(/\/$/, ''), keyId, token }; +} + +function redemptionIdempotencyKey(accountId: string, invitation: string): string { + const digest = createHash('sha256').update(`${accountId}\n${invitation}`).digest('hex'); + return `early-bird-invitation-redeem:${digest}`; +} + +export class HttpEarlyBirdMembershipGateway implements EarlyBirdMembershipGateway { + constructor( + private readonly config: GatewayConfig = gatewayConfig(), + private readonly request: typeof fetch = fetch, + ) {} + + async redeemFree({ accountId, opaqueInvitation }: { + accountId: string; + opaqueInvitation: string; + }): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await this.request( + `${this.config.baseUrl}/api/internal/v1/early-bird-invitations/redeem`, + { + method: 'POST', + redirect: 'error', + cache: 'no-store', + signal: controller.signal, + headers: { + accept: 'application/json', + authorization: `Bearer ${this.config.token}`, + 'content-type': 'application/json', + 'idempotency-key': redemptionIdempotencyKey(accountId, opaqueInvitation), + 'x-hb-service-key-id': this.config.keyId, + }, + body: JSON.stringify({ + schema_version: 'early-bird-authority.invitation-redeem.v1', + account_id: accountId, + invitation_token: opaqueInvitation, + }), + }, + ); + if (!response.ok) { + if (response.status === 409) return { ok: false, reason: 'unavailable' }; + throw new EarlyBirdMembershipGatewayUnavailableError(); + } + const membership = parseCanonicalAuthorityMembership(await response.json()); + if (membership.account_id !== accountId) throw new EarlyBirdMembershipGatewayUnavailableError(); + const projection = authorityMembershipCommand(membership); + return { + ok: true, + replayed: false, + alreadyEntitled: membership.access_allowed && membership.reason_code !== 'INVITATION_REDEEMED', + projection, + }; + } catch (error) { + if (error instanceof EarlyBirdMembershipGatewayUnavailableError) throw error; + throw new EarlyBirdMembershipGatewayUnavailableError(); + } finally { + clearTimeout(timeout); + } + } +} + +let gatewayOverride: EarlyBirdMembershipGateway | null = null; + +export function setEarlyBirdMembershipGatewayForTests(gateway: EarlyBirdMembershipGateway | null): void { + gatewayOverride = gateway; +} + +export function earlyBirdMembershipGateway(): EarlyBirdMembershipGateway { + return gatewayOverride ?? new HttpEarlyBirdMembershipGateway(); +} + +/** The browser's opaque invitation is consumed only by the canonical authority. */ +export async function redeemFreeThroughCanonicalGateway( + accountId: string, + opaqueInvitation: string, + gateway = earlyBirdMembershipGateway(), +): Promise { + if (opaqueInvitation.length < 32 || opaqueInvitation.length > 512 || !INVITATION_TOKEN.test(opaqueInvitation)) { + return { ok: false, reason: 'unavailable' }; + } + const result = await gateway.redeemFree({ accountId, opaqueInvitation }); + if (!result.ok) return result; + if (result.projection.account_id !== accountId) throw new EarlyBirdMembershipGatewayUnavailableError(); + const applied = await applyMembershipProjection(result.projection); + return { + ...result, + replayed: applied.outcome === 'REPLAYED', + alreadyEntitled: result.alreadyEntitled || applied.outcome === 'STALE', + }; +} diff --git a/src/lib/early-birds/membership.ts b/src/lib/early-birds/membership.ts new file mode 100644 index 00000000..dd8bbf27 --- /dev/null +++ b/src/lib/early-birds/membership.ts @@ -0,0 +1,217 @@ +import { createHash } from 'node:crypto'; + +import type { + EarlyBirdMembershipProjection, + EarlyBirdMembershipSource, + EarlyBirdMembershipState, +} from '@prisma/client'; +import { Prisma } from '@prisma/client'; + +import { prisma } from '@/lib/db'; + +export const EARLY_BIRDS_FOUNDERS_OFFER = 'EARLY_BIRDS_FOUNDERS_V1' as const; + +export type EarlyBirdMembershipProjectionCommand = { + schema_version: 'early-bird-membership.command.v1'; + account_id: string; + membership_revision: number; + state: EarlyBirdMembershipState; + source: EarlyBirdMembershipSource | null; + offer: { code: typeof EARLY_BIRDS_FOUNDERS_OFFER; revision: number } | null; + effective_at: string; + paid_through: string | null; + grace_until: string | null; + provider: 'paypal' | 'mercado_pago' | null; + current_price: { currency: 'USD' | 'ARS'; amount_minor: number } | null; + reason_code: string; +}; + +export type EarlyBirdProjectionOutcome = 'APPLIED' | 'REPLAYED' | 'STALE'; + +export type EarlyBirdAccessDecision = { + allowed: boolean; + reason: 'active' | 'grace' | 'paid-through' | 'pending' | 'ended' | 'missing'; + projection: EarlyBirdMembershipProjection | null; +}; + +export class EarlyBirdProjectionConflictError extends Error { + constructor() { + super('Membership revision already exists with a different payload'); + this.name = 'EarlyBirdProjectionConflictError'; + } +} + +function normalizedInstant(value: string, field: string): string { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(value)) { + throw new Error(`${field} must be an RFC 3339 date-time`); + } + const instant = new Date(value); + if (!Number.isFinite(instant.getTime())) throw new Error(`${field} must be an RFC 3339 date-time`); + // Keep the validated wire string: JCS hashes exact field values, so + // semantically equal but lexically different date strings must conflict. + return value; +} + +function normalizedCommand(command: EarlyBirdMembershipProjectionCommand): EarlyBirdMembershipProjectionCommand { + if (command.schema_version !== 'early-bird-membership.command.v1') { + throw new Error('Unsupported membership command schema'); + } + if (!command.account_id || command.account_id.length > 255) throw new Error('account_id is invalid'); + if (!Number.isSafeInteger(command.membership_revision) || command.membership_revision < 1) { + throw new Error('membership_revision must be a positive integer'); + } + if (command.offer && ( + command.offer.code !== EARLY_BIRDS_FOUNDERS_OFFER || + !Number.isSafeInteger(command.offer.revision) || command.offer.revision < 1 + )) throw new Error('offer is invalid'); + if (command.current_price && ( + !['USD', 'ARS'].includes(command.current_price.currency) || + !Number.isSafeInteger(command.current_price.amount_minor) || + command.current_price.amount_minor < 1 + )) throw new Error('current_price is invalid'); + if (!command.reason_code || command.reason_code.length > 64) throw new Error('reason_code is invalid'); + + return { + ...command, + effective_at: normalizedInstant(command.effective_at, 'effective_at'), + paid_through: command.paid_through === null + ? null + : normalizedInstant(command.paid_through, 'paid_through'), + grace_until: command.grace_until === null + ? null + : normalizedInstant(command.grace_until, 'grace_until'), + }; +} + +/** RFC 8785 is intentionally small here: the contract contains only objects, strings, nulls and integers. */ +export function jcsCanonicalize(value: unknown): string { + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return JSON.stringify(value); + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('JCS cannot encode a non-finite number'); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(jcsCanonicalize).join(',')}]`; + if (typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => ( + `${JSON.stringify(key)}:${jcsCanonicalize(record[key])}` + )).join(',')}}`; + } + throw new Error('JCS cannot encode this value'); +} + +export function membershipCommandHash(command: EarlyBirdMembershipProjectionCommand): string { + return createHash('sha256').update(jcsCanonicalize(normalizedCommand(command))).digest('hex'); +} + +export function membershipAccessDecision( + projection: EarlyBirdMembershipProjection | null, + now = new Date(), +): EarlyBirdAccessDecision { + if (!projection) return { allowed: false, reason: 'missing', projection }; + + if (projection.state === 'ACTIVE') { + const allowed = projection.paidThrough === null || projection.paidThrough > now; + return { allowed, reason: allowed ? 'active' : 'ended', projection }; + } + if (projection.state === 'GRACE') { + const allowed = projection.graceUntil !== null && projection.graceUntil > now; + return { allowed, reason: allowed ? 'grace' : 'ended', projection }; + } + if (projection.state === 'CANCELLED_PENDING_END') { + const allowed = projection.paidThrough !== null && projection.paidThrough > now; + return { allowed, reason: allowed ? 'paid-through' : 'ended', projection }; + } + if (projection.state === 'PENDING') return { allowed: false, reason: 'pending', projection }; + return { allowed: false, reason: 'ended', projection }; +} + +export async function getEarlyBirdAccess( + accountId: string, + now = new Date(), +): Promise { + const projection = await prisma.earlyBirdMembershipProjection.findUnique({ where: { accountId } }); + return membershipAccessDecision(projection, now); +} + +export async function applyMembershipProjection( + rawCommand: EarlyBirdMembershipProjectionCommand, + options: { synthetic?: boolean } = {}, +): Promise<{ projection: EarlyBirdMembershipProjection; outcome: EarlyBirdProjectionOutcome }> { + const command = normalizedCommand(rawCommand); + const commandHash = membershipCommandHash(command); + const synthetic = options.synthetic === true; + + return prisma.$transaction(async (tx) => { + const accountRows = await tx.$queryRaw>( + Prisma.sql`SELECT "id" FROM "early_bird_users" WHERE "id" = ${command.account_id} FOR UPDATE`, + ); + if (accountRows.length !== 1) throw new Error('EarlyBird account does not exist'); + + const existing = await tx.earlyBirdMembershipProjection.findUnique({ + where: { accountId: command.account_id }, + }); + if (synthetic && existing && !existing.synthetic) { + throw new Error('Synthetic access cannot replace a canonical membership'); + } + // A synthetic test row is outside the canonical revision sequence and may be replaced. + if (existing && !existing.synthetic && existing.revision > command.membership_revision) { + return { projection: existing, outcome: 'STALE' }; + } + if (existing && !existing.synthetic && existing.revision === command.membership_revision) { + if (existing.commandHash !== commandHash) throw new EarlyBirdProjectionConflictError(); + return { projection: existing, outcome: 'REPLAYED' }; + } + + const data = { + revision: command.membership_revision, + commandHash, + state: command.state, + source: command.source, + offerCode: command.offer?.code ?? null, + offerRevision: command.offer?.revision ?? null, + effectiveAt: new Date(command.effective_at), + paidThrough: command.paid_through ? new Date(command.paid_through) : null, + graceUntil: command.grace_until ? new Date(command.grace_until) : null, + provider: command.provider, + amountMinor: command.current_price?.amount_minor ?? null, + currency: command.current_price?.currency ?? null, + reasonCode: command.reason_code, + synthetic, + }; + const projection = existing + ? await tx.earlyBirdMembershipProjection.update({ + where: { accountId: command.account_id }, + data, + }) + : await tx.earlyBirdMembershipProjection.create({ + data: { accountId: command.account_id, ...data }, + }); + return { projection, outcome: 'APPLIED' }; + }); +} + +/** Test-only entitlement. It carries no canonical source and never replaces a real projection. */ +export async function issueSyntheticMembership(accountId: string, now = new Date()) { + const existing = await prisma.earlyBirdMembershipProjection.findUnique({ + where: { accountId }, + select: { revision: true, synthetic: true }, + }); + if (existing && !existing.synthetic) throw new Error('Synthetic access cannot replace a canonical membership'); + return applyMembershipProjection({ + schema_version: 'early-bird-membership.command.v1', + account_id: accountId, + membership_revision: (existing?.revision ?? 0) + 1, + state: 'ACTIVE', + source: null, + offer: { code: EARLY_BIRDS_FOUNDERS_OFFER, revision: 1 }, + effective_at: now.toISOString(), + paid_through: null, + grace_until: null, + provider: null, + current_price: null, + reason_code: 'SYNTHETIC_TEST_ACCESS', + }, { synthetic: true }); +} diff --git a/src/lib/early-birds/service-auth.ts b/src/lib/early-birds/service-auth.ts new file mode 100644 index 00000000..2dd62b49 --- /dev/null +++ b/src/lib/early-birds/service-auth.ts @@ -0,0 +1,38 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +type ServiceKey = { id: string; token: string }; + +function configuredKeys(env: NodeJS.ProcessEnv): ServiceKey[] { + return [ + { + id: env.EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT_ID, + token: env.EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT, + }, + { + id: env.EARLY_BIRDS_BEACON_SERVICE_KEY_PREVIOUS_ID, + token: env.EARLY_BIRDS_BEACON_SERVICE_KEY_PREVIOUS, + }, + ].flatMap(({ id, token }) => id && token && token.length >= 43 ? [{ id, token }] : []); +} + +function digest(value: string): Buffer { + return createHash('sha256').update(value, 'utf8').digest(); +} + +export function authorizeEarlyBirdMembershipService( + authorization: string | null, + keyId: string | null, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (!keyId || !authorization?.startsWith('Bearer ')) return false; + const presented = authorization.slice('Bearer '.length); + if (!presented) return false; + const presentedDigest = digest(presented); + let authorized = false; + for (const candidate of configuredKeys(env)) { + const sameId = candidate.id === keyId; + const sameToken = timingSafeEqual(presentedDigest, digest(candidate.token)); + authorized = authorized || (sameId && sameToken); + } + return authorized; +} diff --git a/src/lib/early-birds/stream.ts b/src/lib/early-birds/stream.ts new file mode 100644 index 00000000..962ad03b --- /dev/null +++ b/src/lib/early-birds/stream.ts @@ -0,0 +1,334 @@ +import { createHmac } from 'node:crypto'; + +import { Prisma } from '@prisma/client'; + +import { prisma } from '@/lib/db'; +import { membershipAccessDecision } from './membership'; + +export const EARLY_BIRD_MAX_STREAM_DEVICES = 2; +export const EARLY_BIRD_LEASE_TTL_MS = 3 * 60 * 1000; +export const EARLY_BIRD_ORIGIN_MAX_SIGNATURE_TTL_SECONDS = 10 * 60; +export const EARLY_BIRD_ORIGIN_MANIFEST_TTL_SECONDS = 60; +export const EARLY_BIRD_LEASE_MANIFEST_PATH = '/api/early-birds/stream/manifest'; + +export type StreamUrlIssueRequest = { + accountId: string; + leaseId: string; + issuedAt: Date; + leaseExpiresAt: Date; +}; + +export type StreamUrlGrant = { + manifestUrl: string; + expiresAt: Date; +}; + +/** Integration seam implemented by the deterministic HLS origin slice. */ +export interface EarlyBirdStreamUrlIssuer { + issue(request: StreamUrlIssueRequest): Promise; +} + +export class EarlyBirdStreamIssuerUnavailableError extends Error { + constructor() { + super('EarlyBird stream URL issuer is not configured'); + this.name = 'EarlyBirdStreamIssuerUnavailableError'; + } +} + +class EnvironmentManifestIssuer implements EarlyBirdStreamUrlIssuer { + async issue(request: StreamUrlIssueRequest): Promise { + // Validate the origin integration at lease issuance, but expose only a + // stable same-origin URL to the browser. The route signs and refreshes + // the upstream manifest on every HLS poll. + earlyBirdOriginConfig(); + const query = new URLSearchParams({ leaseId: request.leaseId }); + return { + manifestUrl: `${EARLY_BIRD_LEASE_MANIFEST_PATH}?${query}`, + expiresAt: request.leaseExpiresAt, + }; + } +} + +let issuerOverride: EarlyBirdStreamUrlIssuer | null = null; + +export function setEarlyBirdStreamUrlIssuerForTests( + issuer: EarlyBirdStreamUrlIssuer | null, +): void { + issuerOverride = issuer; +} + +export function earlyBirdStreamUrlIssuer(): EarlyBirdStreamUrlIssuer { + return issuerOverride ?? new EnvironmentManifestIssuer(); +} + +function devicePepper(): string { + const configured = process.env.EARLY_BIRDS_DEVICE_PEPPER?.trim(); + if (configured) return configured; + if (process.env.NODE_ENV === 'production') { + throw new Error('EARLY_BIRDS_DEVICE_PEPPER is required'); + } + return 'early-birds-local-device-pepper-change-before-deploy'; +} + +export function earlyBirdDeviceDigest(deviceId: string, pepper = devicePepper()): string { + const normalized = deviceId.trim(); + if (!/^[A-Za-z0-9_-]{16,200}$/.test(normalized)) throw new Error('invalid device id'); + return createHmac('sha256', pepper) + .update(`early-birds-device:v1:${normalized}`, 'utf8') + .digest('hex'); +} + +export type LeaseAcquisition = { + leaseId: string; + leaseExpiresAt: Date; + evictedLeaseId: string | null; + stream: StreamUrlGrant; +}; + +export class EarlyBirdAccessDeniedError extends Error { + constructor() { + super('An active EarlyBird membership is required'); + this.name = 'EarlyBirdAccessDeniedError'; + } +} + +export class EarlyBirdLeaseInactiveError extends Error { + constructor() { + super('The stream lease is no longer active'); + this.name = 'EarlyBirdLeaseInactiveError'; + } +} + +export type EarlyBirdOriginConfig = { + origin: string; + artifactId: string; + signingSecret: string; +}; + +export function earlyBirdOriginConfig( + environment: NodeJS.ProcessEnv = process.env, +): EarlyBirdOriginConfig { + const origin = environment.EARLY_BIRDS_STREAM_ORIGIN?.trim(); + const artifactId = environment.EARLY_BIRDS_STREAM_ARTIFACT_ID?.trim(); + const signingSecret = environment.EARLY_BIRDS_STREAM_SIGNING_SECRET?.trim(); + if (!origin || !artifactId || !signingSecret || signingSecret.length < 32) { + throw new EarlyBirdStreamIssuerUnavailableError(); + } + if (!/^[A-Za-z0-9._-]{1,160}$/.test(artifactId)) { + throw new EarlyBirdStreamIssuerUnavailableError(); + } + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + throw new EarlyBirdStreamIssuerUnavailableError(); + } + if (!['https:', 'http:'].includes(parsed.protocol)) { + throw new EarlyBirdStreamIssuerUnavailableError(); + } + if (environment.NODE_ENV === 'production' && parsed.protocol !== 'https:') { + throw new EarlyBirdStreamIssuerUnavailableError(); + } + return { + origin: parsed.origin, + artifactId, + signingSecret, + }; +} + +export function earlyBirdOriginManifestPath(artifactId: string): string { + return `/v1/hls/${artifactId}/live.m3u8`; +} + +/** Byte-exact peer of services/beacon-stream/src/auth.mjs#signPath. */ +export function signEarlyBirdOriginPath(input: { + secret: string; + pathname: string; + expiresAt: number; + method?: 'GET'; +}): string { + if (input.secret.length < 32) throw new EarlyBirdStreamIssuerUnavailableError(); + if (!Number.isSafeInteger(input.expiresAt)) throw new Error('expiresAt must be whole seconds'); + const canonical = `${input.method ?? 'GET'}\n${input.pathname}\n${input.expiresAt}`; + return createHmac('sha256', input.secret).update(canonical).digest('base64url'); +} + +export function signedEarlyBirdOriginManifestUrl(input: { + config: EarlyBirdOriginConfig; + leaseExpiresAt: Date; + now?: Date; +}): string { + const nowSeconds = Math.floor((input.now ?? new Date()).getTime() / 1000); + const leaseExpirySeconds = Math.floor(input.leaseExpiresAt.getTime() / 1000); + const expiresAt = Math.min( + leaseExpirySeconds, + nowSeconds + Math.min( + EARLY_BIRD_ORIGIN_MANIFEST_TTL_SECONDS, + EARLY_BIRD_ORIGIN_MAX_SIGNATURE_TTL_SECONDS, + ), + ); + if (expiresAt <= nowSeconds) throw new EarlyBirdLeaseInactiveError(); + const pathname = earlyBirdOriginManifestPath(input.config.artifactId); + const url = new URL(pathname, input.config.origin); + url.searchParams.set('exp', String(expiresAt)); + url.searchParams.set('sig', signEarlyBirdOriginPath({ + secret: input.config.signingSecret, + pathname, + expiresAt, + })); + return url.toString(); +} + +export function validSignedOriginManifest( + body: string, + config: EarlyBirdOriginConfig, +): boolean { + if (body.length < 8 || body.length > 256_000 || !body.startsWith('#EXTM3U\n')) return false; + const mediaLines = body.split('\n').filter((line) => line && !line.startsWith('#')); + if (mediaLines.length < 1) return false; + const segmentPrefix = `/v1/hls/${config.artifactId}/segments/`; + return mediaLines.every((line) => { + try { + const url = new URL(line); + return ( + url.origin === config.origin && + url.pathname.startsWith(segmentPrefix) && + /^\d+$/.test(url.searchParams.get('exp') ?? '') && + Boolean(url.searchParams.get('sig')) + ); + } catch { + return false; + } + }); +} + +export async function authorizeEarlyBirdStreamLease( + accountId: string, + leaseId: string, + now = new Date(), +) { + const [projection, lease] = await Promise.all([ + prisma.earlyBirdMembershipProjection.findUnique({ where: { accountId } }), + prisma.earlyBirdStreamLease.findFirst({ where: { id: leaseId, accountId } }), + ]); + if (!membershipAccessDecision(projection, now).allowed) { + throw new EarlyBirdAccessDeniedError(); + } + if (!lease || lease.evictedAt !== null || lease.expiresAt <= now) { + throw new EarlyBirdLeaseInactiveError(); + } + return lease; +} + +export async function acquireEarlyBirdStreamLease( + accountId: string, + deviceId: string, + now = new Date(), + issuer = earlyBirdStreamUrlIssuer(), +): Promise { + const deviceDigest = earlyBirdDeviceDigest(deviceId); + const leaseExpiresAt = new Date(now.getTime() + EARLY_BIRD_LEASE_TTL_MS); + + const lease = await prisma.$transaction(async (tx) => { + const accountRows = await tx.$queryRaw>( + Prisma.sql`SELECT "id" FROM "early_bird_users" WHERE "id" = ${accountId} FOR UPDATE`, + ); + if (accountRows.length !== 1) throw new EarlyBirdAccessDeniedError(); + + const projection = await tx.earlyBirdMembershipProjection.findUnique({ + where: { accountId }, + }); + if (!membershipAccessDecision(projection, now).allowed) { + throw new EarlyBirdAccessDeniedError(); + } + + const previous = await tx.earlyBirdStreamLease.findUnique({ + where: { accountId_deviceDigest: { accountId, deviceDigest } }, + }); + const active = await tx.earlyBirdStreamLease.findMany({ + where: { + accountId, + evictedAt: null, + expiresAt: { gt: now }, + ...(previous ? { id: { not: previous.id } } : {}), + }, + orderBy: [{ lastSeenAt: 'asc' }, { createdAt: 'asc' }], + select: { id: true }, + }); + + const overflow = Math.max(0, active.length - (EARLY_BIRD_MAX_STREAM_DEVICES - 1)); + const evicted = active.slice(0, overflow); + if (evicted.length > 0) { + await tx.earlyBirdStreamLease.updateMany({ + where: { id: { in: evicted.map(({ id }) => id) } }, + data: { evictedAt: now }, + }); + } + + const current = previous + ? await tx.earlyBirdStreamLease.update({ + where: { id: previous.id }, + data: { createdAt: now, lastSeenAt: now, expiresAt: leaseExpiresAt, evictedAt: null }, + }) + : await tx.earlyBirdStreamLease.create({ + data: { accountId, deviceDigest, lastSeenAt: now, expiresAt: leaseExpiresAt }, + }); + return { current, evictedLeaseId: evicted[0]?.id ?? null }; + }); + + try { + const stream = await issuer.issue({ + accountId, + leaseId: lease.current.id, + issuedAt: now, + leaseExpiresAt, + }); + return { + leaseId: lease.current.id, + leaseExpiresAt, + evictedLeaseId: lease.evictedLeaseId, + stream, + }; + } catch (error) { + await prisma.earlyBirdStreamLease.updateMany({ + where: { id: lease.current.id, accountId }, + data: { evictedAt: now }, + }); + throw error; + } +} + +export async function heartbeatEarlyBirdStreamLease( + accountId: string, + leaseId: string, + now = new Date(), + issuer = earlyBirdStreamUrlIssuer(), +): Promise<{ leaseExpiresAt: Date; stream: StreamUrlGrant }> { + const leaseExpiresAt = new Date(now.getTime() + EARLY_BIRD_LEASE_TTL_MS); + const lease = await prisma.$transaction(async (tx) => { + const projection = await tx.earlyBirdMembershipProjection.findUnique({ + where: { accountId }, + }); + if (!membershipAccessDecision(projection, now).allowed) { + throw new EarlyBirdAccessDeniedError(); + } + const current = await tx.earlyBirdStreamLease.findFirst({ + where: { id: leaseId, accountId }, + }); + if (!current || current.evictedAt !== null || current.expiresAt <= now) { + throw new EarlyBirdLeaseInactiveError(); + } + return tx.earlyBirdStreamLease.update({ + where: { id: current.id }, + data: { lastSeenAt: now, expiresAt: leaseExpiresAt }, + }); + }); + + const stream = await issuer.issue({ + accountId, + leaseId: lease.id, + issuedAt: now, + leaseExpiresAt, + }); + return { leaseExpiresAt, stream }; +} From eb31e1edee8a1e8c0118f8204c40bae53fd0edd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:39:01 -0300 Subject: [PATCH 012/376] fix(early-birds): preserve live timeline during drop-ins --- docs/architecture/EARLY_BIRDS_LISTENER.md | 6 +- src/components/early-birds/ListenerPlayer.tsx | 28 ++++-- .../__tests__/ListenerPlayer.test.tsx | 85 ++++++++++++++++++- 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 4cfad193..9a8808a1 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -56,8 +56,10 @@ The origin manifest must contain individually signed, same-origin segment URLs. signed URLs are never returned in API JSON or logged. The approved Spanish/English drop-in renders are configured as immutable media URLs. Listener UI -does not encode or alter them. Progress is local to the browser; pause produces silence, resume seeks -to the current live edge, and no AudioContext, LiveKit, chat or session-event behavior is changed. +does not encode or alter them. Progress is local to the browser. A drop-in mutes only the live output +while the HLS element, source and lease continue untouched, then restores the still-running Beacon. +The live pause control produces silence and its resume seeks to the current live edge. No AudioContext, +LiveKit, chat or session-event behavior is changed, and the initial gain remains the native 1.0. ## Dependency note diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 2d4e0166..b6667cbb 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -54,6 +54,7 @@ export default function ListenerPlayer({ en: useRef(null), }; const hls = useRef(null); + const liveSuppressedForDrop = useRef(false); const manifestUrl = useRef(null); const manifestExpiresAt = useRef(0); const leaseId = useRef(null); @@ -64,7 +65,7 @@ export default function ListenerPlayer({ es: { current: 0, duration: 0 }, en: { current: 0, duration: 0 }, }); - const [volume, setVolume] = useState(0.85); + const [volume, setVolume] = useState(1); const [volumeSupported, setVolumeSupported] = useState(true); const updateLiveState = useCallback((state: LiveState) => { @@ -117,11 +118,18 @@ export default function ListenerPlayer({ return response.json() as Promise; }, []); + const restoreLiveOutput = useCallback(() => { + if (!liveSuppressedForDrop.current) return; + if (liveAudio.current) liveAudio.current.muted = false; + liveSuppressedForDrop.current = false; + }, []); + const pauseDropIns = useCallback(() => { dropAudio.es.current?.pause(); dropAudio.en.current?.pause(); setPlayingDrop(null); - }, [dropAudio.en, dropAudio.es]); + restoreLiveOutput(); + }, [dropAudio.en, dropAudio.es, restoreLiveOutput]); const playLive = useCallback(async (forceRefresh = false) => { const audio = liveAudio.current; @@ -162,6 +170,8 @@ export default function ListenerPlayer({ if (!audio) return; if (liveState === 'playing') { audio.pause(); + audio.muted = false; + liveSuppressedForDrop.current = false; updateLiveState('paused'); return; } @@ -252,13 +262,18 @@ export default function ListenerPlayer({ async function toggleDropIn(language: DropLanguage) { const selected = dropAudio[language].current; if (!selected || !dropIns[language]) return; - if (playingDrop === language && !selected.paused) { + if (playingDrop === language) { selected.pause(); setPlayingDrop(null); + restoreLiveOutput(); return; } - liveAudio.current?.pause(); - if (liveStateRef.current === 'playing') updateLiveState('paused'); + // A drop-in overlays the still-running shared Beacon. Muting preserves + // its timeline, HLS source and lease; ending the drop only restores output. + if (liveStateRef.current === 'playing' && liveAudio.current) { + liveAudio.current.muted = true; + liveSuppressedForDrop.current = true; + } const other: DropLanguage = language === 'es' ? 'en' : 'es'; dropAudio[other].current?.pause(); try { @@ -266,6 +281,7 @@ export default function ListenerPlayer({ setPlayingDrop(language); } catch { setPlayingDrop(null); + restoreLiveOutput(); } } @@ -292,7 +308,7 @@ export default function ListenerPlayer({ ...current, [language]: { current: 0, duration: current[language].duration }, })); - void playLive(true); + restoreLiveOutput(); } const liveButton = liveState === 'loading' diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index e2f89d5b..a649fbaf 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it } from 'vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { LocaleProvider } from '@/context/LocaleContext'; import ListenerPlayer, { @@ -11,6 +11,8 @@ import ListenerPlayer, { afterEach(() => { cleanup(); window.localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe('EarlyBird Listener player', () => { @@ -44,5 +46,84 @@ describe('EarlyBird Listener player', () => { expect(screen.getByText('Warm-up · English')).toBeInTheDocument(); expect(screen.getAllByText('The approved render has not been published yet.')).toHaveLength(2); expect(screen.getByText('Master volume')).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'Master volume' })).toHaveValue('1'); + }); + + it('keeps the Beacon timeline, source and lease untouched across drop-in pause and end', async () => { + const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); + const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); + vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); + vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + leaseId: '00000000-0000-4000-8000-000000000003', + leaseExpiresAt: '2026-08-06T12:03:00.000Z', + stream: { + manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', + expiresAt: '2099-08-06T12:03:00.000Z', + }, + }), { status: 200, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', fetchMock); + + render( + + + , + ); + const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; + const spanishCard = spanish.closest('article')!; + + fireEvent.click(screen.getByRole('button', { name: 'Listen live' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()); + const liveSource = live.src; + const leaseRequests = fetchMock.mock.calls.length; + const livePlayCalls = play.mock.instances.filter((instance) => instance === live).length; + pause.mockClear(); + + fireEvent.click(within(spanishCard).getByRole('button', { name: 'Play' })); + await waitFor(() => expect(within(spanishCard).getByRole('button', { name: 'Pause' })).toBeInTheDocument()); + expect(live.muted).toBe(true); + expect(pause.mock.instances).not.toContain(live); + expect(fetchMock).toHaveBeenCalledTimes(leaseRequests); + expect(live.src).toBe(liveSource); + + fireEvent.click(within(spanishCard).getByRole('button', { name: 'Pause' })); + expect(live.muted).toBe(false); + expect(pause.mock.instances).not.toContain(live); + expect(fetchMock).toHaveBeenCalledTimes(leaseRequests); + + fireEvent.click(within(spanishCard).getByRole('button', { name: 'Play' })); + await waitFor(() => expect(live.muted).toBe(true)); + fireEvent.ended(spanish); + expect(live.muted).toBe(false); + expect(live.src).toBe(liveSource); + expect(fetchMock).toHaveBeenCalledTimes(leaseRequests); + expect(play.mock.instances.filter((instance) => instance === live)).toHaveLength(livePlayCalls); + expect(pause.mock.instances).not.toContain(live); + }); + + it('keeps a drop-in independent when the Beacon has never started', async () => { + const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); + vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + render( + + + , + ); + const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; + const card = spanish.closest('article')!; + fireEvent.click(within(card).getByRole('button', { name: 'Play' })); + await waitFor(() => expect(play).toHaveBeenCalled()); + fireEvent.ended(spanish); + expect(fetchMock).not.toHaveBeenCalled(); + expect(live.src).toBe(''); + expect(live.muted).toBe(false); + expect(play.mock.instances).not.toContain(live); }); }); From 09a50f9acc26b716e378605aee287ed8de44072d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:26:13 -0300 Subject: [PATCH 013/376] ops(early-birds): add isolated staging preview rehearsal --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 96 +++++++++++++++++++ ops/early-birds-preview/.gitignore | 1 + ops/early-birds-preview/compose.yml | 54 +++++++++++ ops/early-birds-preview/package.json | 9 ++ .../preview.env.synthetic.example | 18 ++++ .../test/preview-contract.test.mjs | 23 +++++ scripts/early-birds-preview/health-smoke.sh | 11 +++ scripts/early-birds-preview/lib.sh | 25 +++++ .../early-birds-preview/rehearse-migration.sh | 8 ++ scripts/early-birds-preview/rollback.sh | 8 ++ scripts/early-birds-preview/start.sh | 7 ++ scripts/early-birds-preview/stop.sh | 7 ++ scripts/early-birds-preview/validate.mjs | 28 ++++++ 13 files changed, 295 insertions(+) create mode 100644 docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md create mode 100644 ops/early-birds-preview/.gitignore create mode 100644 ops/early-birds-preview/compose.yml create mode 100644 ops/early-birds-preview/package.json create mode 100644 ops/early-birds-preview/preview.env.synthetic.example create mode 100644 ops/early-birds-preview/test/preview-contract.test.mjs create mode 100755 scripts/early-birds-preview/health-smoke.sh create mode 100755 scripts/early-birds-preview/lib.sh create mode 100755 scripts/early-birds-preview/rehearse-migration.sh create mode 100755 scripts/early-birds-preview/rollback.sh create mode 100755 scripts/early-birds-preview/start.sh create mode 100755 scripts/early-birds-preview/stop.sh create mode 100644 scripts/early-birds-preview/validate.mjs diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md new file mode 100644 index 00000000..4378de87 --- /dev/null +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -0,0 +1,96 @@ +# EarlyBirds staging preview and rollback + +This is the EB-08 preview/rehearsal surface for +`earlybirds-staging.harmonicbeacon.com`. It is isolated from the weekend event +stack: project name `earlybirds-preview`, volume `earlybirds-preview-postgres`, +loopback-only ports, an internal preview network, and the separately bounded +`services/beacon-stream` origin. It never runs a production deploy, creates a +real account, calls OAuth, or calls a payment provider. + +The URL is a future TLS/DNS boundary. Local validation deliberately uses +`earlybirds-staging.localhost` and `127.0.0.1`; DNS is not required. + +## Prepare synthetic inputs + +Copy `ops/early-birds-preview/preview.env.synthetic.example` to a root-owned +`0600` path outside Git. Keep `EARLYBIRDS_PREVIEW_ENV=synthetic`. The lifecycle +scripts reject production domains and provider names in this file. + +Point `BEACON_STREAM_ARTIFACTS_HOST_PATH` only at a generated synthetic HLS +fixture that satisfies the existing `services/beacon-stream` artifact contract. +Do not mount an approved master, a drop-in, real Listener data, or an event +volume. Audio creation, artifact selection, and listening approval remain +outside EB-08. + +## Start and validate + +From the repository root, with Docker available: + +```bash +npm --prefix ops/early-birds-preview run test +npm --prefix ops/early-birds-preview run validate +scripts/early-birds-preview/start.sh /secure/earlybirds-preview.env +scripts/early-birds-preview/health-smoke.sh /secure/earlybirds-preview.env +``` + +`start.sh` starts only PostgreSQL and the existing bounded stream origin. +`health-smoke.sh` proves PostgreSQL readiness and the stream `/readyz` endpoint. +It intentionally does not assert playback or decoded audio: that needs an +approved artifact and EB-08 cross-device/audio acceptance. + +No Listener service is defined in this slice. When the Listener lane is ready, +add its explicit image, isolated cookie/OAuth config, and health endpoint as a +small overlay; do not infer an app health contract here. + +## Migration rehearsal + +Run this after additive EarlyBird Prisma migrations land: + +```bash +scripts/early-birds-preview/rehearse-migration.sh /secure/earlybirds-preview.env +``` + +The command runs `prisma migrate deploy` only against the `postgres` service in +this compose project. It is a forward migration rehearsal, not a destructive +down-migration. The rollback strategy is additive data retention plus disabling +the Listener entry and origin; do not delete the preview volume during an +incident unless its exact target has been reviewed. + +## Stop and rollback rehearsal + +For a normal stop (retains preview data): + +```bash +scripts/early-birds-preview/stop.sh /secure/earlybirds-preview.env +``` + +For an EarlyBird stream incident, stop only that origin: + +```bash +scripts/early-birds-preview/rollback.sh /secure/earlybirds-preview.env +``` + +Then disable the EarlyBird public entry using the Listener lane's feature flag +and serve its truthful unavailable state. This slice cannot and does not stop +the event compose project, LiveKit, playlist-bot, or production database. + +## Staging release gate + +Before external staging acceptance, record all of the following: + +1. Synthetic compose validation, unit contract checks, startup, health smoke, + stop, origin rollback, restart, and migration rehearsal evidence. +2. The existing stream origin/canary and observability validation in + `ops/early-birds/runbook/README.md`; use fake alert secrets locally. +3. Listener auth/authorization negative tests, synthetic entitlement checks, + and separate-cookie verification once its lane is integrated. +4. An approved artifact, external decoded-audio canary, 60-minute physical + desktop/iOS/Android listening, and isolated load/soak evidence. These are + release prerequisites, not assertions made by this scaffold. +5. DNS/TLS verification for `earlybirds-staging.harmonicbeacon.com` only after + the local/ZeroTier path is green. Never use DNS success as a replacement for + the local health and rollback checks. + +Do not promote this staging runtime to production without the explicit +EarlyBirds release, audio, identity, commerce, and rollback gates in +`docs/plans/EARLY_BIRDS.md`. 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/compose.yml b/ops/early-birds-preview/compose.yml new file mode 100644 index 00000000..a99d3e37 --- /dev/null +++ b/ops/early-birds-preview/compose.yml @@ -0,0 +1,54 @@ +# EarlyBirds staging foundation. This file is intentionally separate from the +# event compose project and is safe to combine with services/beacon-stream. +# It does not define a Listener application until that lane supplies its image +# and health contract. +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} + ports: + - "127.0.0.1:${EARLYBIRDS_PREVIEW_DB_PORT:-15432}:5432" + volumes: + - earlybirds-preview-postgres:/var/lib/postgresql/data + networks: [preview] + 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 } + + # Run only on demand. It uses the current repository migrations against the + # preview-only database and never points at a production host or volume. + migration-rehearsal: + build: + context: ../.. + target: deps + profiles: [migration] + restart: "no" + working_dir: /app + command: ["npx", "prisma", "migrate", "deploy"] + environment: + DATABASE_URL: postgresql://${EARLYBIRDS_PREVIEW_DB_USER}:${EARLYBIRDS_PREVIEW_DB_PASSWORD}@postgres:5432/${EARLYBIRDS_PREVIEW_DB_NAME}?schema=public + depends_on: + postgres: { condition: service_healthy } + networks: [preview] + deploy: + resources: + limits: { cpus: "0.5", memory: 512M } + +networks: + preview: + name: earlybirds_preview_internal + internal: true + +volumes: + earlybirds-preview-postgres: + name: earlybirds-preview-postgres diff --git a/ops/early-birds-preview/package.json b/ops/early-birds-preview/package.json new file mode 100644 index 00000000..ec386806 --- /dev/null +++ b/ops/early-birds-preview/package.json @@ -0,0 +1,9 @@ +{ + "name": "harmonic-beacon-earlybirds-preview", + "private": true, + "type": "module", + "scripts": { + "validate": "node ../../scripts/early-birds-preview/validate.mjs", + "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..6c63e2aa --- /dev/null +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -0,0 +1,18 @@ +# Synthetic-only local/staging rehearsal values. Copy outside Git with mode 0600. +# These values are rejected by the lifecycle scripts if changed to a production +# hostname, a real provider endpoint, or the event database identity. +EARLYBIRDS_PREVIEW_ENV=synthetic +EARLYBIRDS_PREVIEW_DB_USER=earlybirds_preview +EARLYBIRDS_PREVIEW_DB_PASSWORD=synthetic-local-only-not-a-secret +EARLYBIRDS_PREVIEW_DB_NAME=earlybirds_preview +EARLYBIRDS_PREVIEW_DB_PORT=15432 + +# Existing isolated beacon-stream service inputs. Use only a synthetic artifact +# fixture; no approved master, drop-in, account, OAuth credential, or payment +# provider data belongs in this preview env file. +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=http://earlybirds-staging.localhost +BEACON_STREAM_SIGNING_SECRET=synthetic-local-only-32-character-minimum-secret +BEACON_STREAM_HOST_PORT=18080 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..76d87e7d --- /dev/null +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -0,0 +1,23 @@ +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 = (name) => fs.readFile(path.join(root, name), 'utf8'); + +test('synthetic env cannot accidentally name production or payment providers', async () => { + const source = await read('preview.env.synthetic.example'); + assert.match(source, /^EARLYBIRDS_PREVIEW_ENV=synthetic$/m); + assert.doesNotMatch(source, /harmonicbeacon\.com|paypal|mercadopago/i); +}); + +test('preview compose has an isolated database and an on-demand migration rehearsal', async () => { + const source = await read('compose.yml'); + assert.match(source, /postgres:/); + assert.match(source, /earlybirds-preview-postgres/); + assert.match(source, /migration-rehearsal:/); + assert.match(source, /profiles: \[migration\]/); + assert.match(source, /prisma", "migrate", "deploy/); + assert.doesNotMatch(source, /livekit|playlist-bot|tapestry/i); +}); diff --git a/scripts/early-birds-preview/health-smoke.sh b/scripts/early-birds-preview/health-smoke.sh new file mode 100755 index 00000000..d33f861a --- /dev/null +++ b/scripts/early-birds-preview/health-smoke.sh @@ -0,0 +1,11 @@ +#!/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" +port=$(sed -n 's/^BEACON_STREAM_HOST_PORT=//p' "$env_file" | tail -n 1) +port=${port:-18080} +preview_compose_command "$env_file" ps --status running postgres beacon-stream +preview_compose_command "$env_file" exec -T postgres sh -ec 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' +curl --fail --silent --show-error "http://127.0.0.1:${port}/readyz" >/dev/null +echo 'EarlyBirds synthetic preview health smoke passed (PostgreSQL and isolated stream ready).' diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh new file mode 100755 index 00000000..48aeea2a --- /dev/null +++ b/scripts/early-birds-preview/lib.sh @@ -0,0 +1,25 @@ +#!/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_project=earlybirds-preview + +require_synthetic_env() { + env_file=${1:?usage: provide a synthetic preview env file} + test -f "$env_file" || { echo "preview env file not found: $env_file" >&2; exit 2; } + grep -q '^EARLYBIRDS_PREVIEW_ENV=synthetic$' "$env_file" || { + echo 'refusing to run: EARLYBIRDS_PREVIEW_ENV must be synthetic' >&2; exit 2; + } + if grep -Eiq '(harmonicbeacon\.com|paypal|mercadopago|production)' "$env_file"; then + echo 'refusing to run: synthetic preview env contains a production/provider value' >&2; exit 2 + fi +} + +preview_compose_command() { + env_file=${1:?usage: provide a synthetic preview env file} + shift + docker compose --project-name "$preview_project" --env-file "$env_file" \ + -f "$preview_compose" -f "$stream_compose" "$@" +} diff --git a/scripts/early-birds-preview/rehearse-migration.sh b/scripts/early-birds-preview/rehearse-migration.sh new file mode 100755 index 00000000..584e2ec1 --- /dev/null +++ b/scripts/early-birds-preview/rehearse-migration.sh @@ -0,0 +1,8 @@ +#!/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 +preview_compose_command "$env_file" --profile migration run --rm migration-rehearsal +echo 'Preview-only Prisma migration rehearsal passed. Prisma migrations are forward-only; rollback is route/origin disable plus additive data retention.' diff --git a/scripts/early-birds-preview/rollback.sh b/scripts/early-birds-preview/rollback.sh new file mode 100755 index 00000000..28c7650c --- /dev/null +++ b/scripts/early-birds-preview/rollback.sh @@ -0,0 +1,8 @@ +#!/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" +preview_compose_command "$env_file" stop beacon-stream +echo 'EarlyBirds stream origin stopped. No event service, event database, or production container was targeted.' +echo 'Disable the Listener entry feature flag in its owning application lane before announcing unavailability.' diff --git a/scripts/early-birds-preview/start.sh b/scripts/early-birds-preview/start.sh new file mode 100755 index 00000000..9278157c --- /dev/null +++ b/scripts/early-birds-preview/start.sh @@ -0,0 +1,7 @@ +#!/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" +preview_compose_command "$env_file" up -d --build postgres beacon-stream +echo 'EarlyBirds synthetic preview started. Run health-smoke.sh after a synthetic artifact is available.' diff --git a/scripts/early-birds-preview/stop.sh b/scripts/early-birds-preview/stop.sh new file mode 100755 index 00000000..bbd33307 --- /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 postgres beacon-stream +echo 'EarlyBirds preview services stopped; preview volume retained for a migration rollback rehearsal.' diff --git a/scripts/early-birds-preview/validate.mjs b/scripts/early-birds-preview/validate.mjs new file mode 100644 index 00000000..f6823323 --- /dev/null +++ b/scripts/early-birds-preview/validate.mjs @@ -0,0 +1,28 @@ +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'); +await fs.writeFile(envFile, [ + 'EARLYBIRDS_PREVIEW_ENV=synthetic', + 'EARLYBIRDS_PREVIEW_DB_USER=earlybirds_preview', + 'EARLYBIRDS_PREVIEW_DB_PASSWORD=synthetic-local-only-not-a-secret', + 'EARLYBIRDS_PREVIEW_DB_NAME=earlybirds_preview', + 'BEACON_STREAM_ARTIFACTS_HOST_PATH=.', + 'BEACON_STREAM_MEDIA_ROOT=/media/artifacts', + 'BEACON_STREAM_ARTIFACT_ID=synthetic-preview-artifact', + 'BEACON_STREAM_PUBLIC_ORIGIN=http://earlybirds-staging.localhost', + 'BEACON_STREAM_SIGNING_SECRET=synthetic-local-only-32-character-minimum-secret', + '', +].join('\n'), { mode: 0o600 }); +try { + execFileSync('docker', ['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'), 'config', '--quiet'], { stdio: 'inherit' }); + console.log('EarlyBirds preview compose configuration is valid.'); +} finally { + await fs.rm(temporary, { recursive: true, force: true }); +} From e1213534ea06ad789936c78d4eb4825e0f2fdc1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:28:20 -0300 Subject: [PATCH 014/376] fix(early-birds): harden preview guard and stream build --- .../stream-build.override.yml | 8 +++++++ .../test/preview-contract.test.mjs | 23 +++++++++++++++++-- scripts/early-birds-preview/lib.sh | 10 +++++--- scripts/early-birds-preview/validate.mjs | 11 +++++++-- 4 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 ops/early-birds-preview/stream-build.override.yml 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..69dca14e --- /dev/null +++ b/ops/early-birds-preview/stream-build.override.yml @@ -0,0 +1,8 @@ +# 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 diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 76d87e7d..c7f43bb0 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -1,15 +1,28 @@ 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 root = path.resolve(import.meta.dirname, '..'); const read = (name) => fs.readFile(path.join(root, name), 'utf8'); -test('synthetic env cannot accidentally name production or payment providers', async () => { +const runGuard = (envFile) => spawnSync('sh', ['-c', '. "$1"; require_synthetic_env "$2"', 'sh', + path.resolve(root, '../../scripts/early-birds-preview/lib.sh'), envFile], { encoding: 'utf8' }); + +test('synthetic env guard accepts comments and rejects effective dangerous assignments', async () => { const source = await read('preview.env.synthetic.example'); assert.match(source, /^EARLYBIRDS_PREVIEW_ENV=synthetic$/m); - assert.doesNotMatch(source, /harmonicbeacon\.com|paypal|mercadopago/i); + const example = path.join(root, 'preview.env.synthetic.example'); + assert.equal(runGuard(example).status, 0); + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'earlybirds-preview-guard-')); + const dangerous = path.join(temporary, 'dangerous.env'); + await fs.writeFile(dangerous, `${source}\nBEACON_STREAM_PUBLIC_ORIGIN=https://harmonicbeacon.com\n`); + const result = runGuard(dangerous); + assert.equal(result.status, 2); + assert.match(result.stderr, /production\/provider value/); + await fs.rm(temporary, { recursive: true, force: true }); }); test('preview compose has an isolated database and an on-demand migration rehearsal', async () => { @@ -21,3 +34,9 @@ test('preview compose has an isolated database and an on-demand migration rehear assert.match(source, /prisma", "migrate", "deploy/); assert.doesNotMatch(source, /livekit|playlist-bot|tapestry/i); }); + +test('stream build overlay pins the service directory after compose merge', async () => { + const source = await read('stream-build.override.yml'); + assert.match(source, /context: \.\.\/\.\.\/services\/beacon-stream/); + assert.match(source, /dockerfile: Dockerfile/); +}); diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index 48aeea2a..0a02f67b 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -9,10 +9,13 @@ preview_project=earlybirds-preview require_synthetic_env() { env_file=${1:?usage: provide a synthetic preview env file} test -f "$env_file" || { echo "preview env file not found: $env_file" >&2; exit 2; } - grep -q '^EARLYBIRDS_PREVIEW_ENV=synthetic$' "$env_file" || { + # Only assignments affect Compose. Comments intentionally describe production + # boundaries, so never reject an env file merely for documenting them. + effective_assignments=$(grep -E '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=' "$env_file" || true) + printf '%s\n' "$effective_assignments" | grep -q '^EARLYBIRDS_PREVIEW_ENV=synthetic$' || { echo 'refusing to run: EARLYBIRDS_PREVIEW_ENV must be synthetic' >&2; exit 2; } - if grep -Eiq '(harmonicbeacon\.com|paypal|mercadopago|production)' "$env_file"; then + if printf '%s\n' "$effective_assignments" | grep -Eiq '(harmonicbeacon\.com|paypal|mercadopago|production)'; then echo 'refusing to run: synthetic preview env contains a production/provider value' >&2; exit 2 fi } @@ -21,5 +24,6 @@ preview_compose_command() { env_file=${1:?usage: provide a synthetic preview env file} shift docker compose --project-name "$preview_project" --env-file "$env_file" \ - -f "$preview_compose" -f "$stream_compose" "$@" + -f "$preview_compose" -f "$stream_compose" \ + -f "$preview_root/ops/early-birds-preview/stream-build.override.yml" "$@" } diff --git a/scripts/early-birds-preview/validate.mjs b/scripts/early-birds-preview/validate.mjs index f6823323..4d7dfd84 100644 --- a/scripts/early-birds-preview/validate.mjs +++ b/scripts/early-birds-preview/validate.mjs @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process'; +import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -19,9 +20,15 @@ await fs.writeFile(envFile, [ '', ].join('\n'), { mode: 0o600 }); try { - execFileSync('docker', ['compose', '--project-name', 'earlybirds-preview-validation', '--env-file', envFile, + 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'), 'config', '--quiet'], { stdio: 'inherit' }); + '-f', path.join(root, 'services/beacon-stream/docker-compose.yml'), + '-f', path.join(root, 'ops/early-birds-preview/stream-build.override.yml')]; + execFileSync('docker', [...composeArgs, 'config', '--quiet'], { stdio: 'inherit' }); + const resolved = JSON.parse(execFileSync('docker', [...composeArgs, 'config', '--format', 'json'], { encoding: 'utf8' })); + const build = resolved.services?.['beacon-stream']?.build; + assert.equal(build?.context, path.join(root, 'services/beacon-stream')); + assert.equal(build?.dockerfile, 'Dockerfile'); console.log('EarlyBirds preview compose configuration is valid.'); } finally { await fs.rm(temporary, { recursive: true, force: true }); From 6e4a7e7ecfca17de85f2a8dab3ed4181d926f64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:31:29 -0300 Subject: [PATCH 015/376] ci(early-birds): validate staging preview lane --- .github/workflows/early-birds-fast-forward.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/early-birds-fast-forward.yml b/.github/workflows/early-birds-fast-forward.yml index 679efa5d..d9ac083d 100644 --- a/.github/workflows/early-birds-fast-forward.yml +++ b/.github/workflows/early-birds-fast-forward.yml @@ -5,12 +5,16 @@ on: paths: - services/beacon-stream/** - ops/early-birds/** + - ops/early-birds-preview/** + - scripts/early-birds-preview/** - .github/workflows/early-birds-fast-forward.yml push: branches: [early-birds, "feat/early-birds-*"] paths: - services/beacon-stream/** - ops/early-birds/** + - ops/early-birds-preview/** + - scripts/early-birds-preview/** - .github/workflows/early-birds-fast-forward.yml permissions: @@ -41,3 +45,12 @@ jobs: - run: npm test - run: npm run check - run: npm run validate + + 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 test + - run: npm --prefix ops/early-birds-preview run validate From 71692c214597028f57fe80bb691641219836827b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:38:19 -0300 Subject: [PATCH 016/376] fix(early-birds): allow signed HLS segments in browsers --- .../preview.env.synthetic.example | 1 + scripts/early-birds-preview/validate.mjs | 1 + services/beacon-stream/README.md | 5 ++ services/beacon-stream/docker-compose.yml | 1 + services/beacon-stream/preview.env.example | 3 +- services/beacon-stream/src/server.mjs | 65 +++++++++++++++---- services/beacon-stream/test/server.test.mjs | 37 +++++++++-- 7 files changed, 94 insertions(+), 19 deletions(-) diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example index 6c63e2aa..80674ba2 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -14,5 +14,6 @@ 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=http://earlybirds-staging.localhost +BEACON_STREAM_ALLOWED_ORIGINS=http://earlybirds-staging.localhost BEACON_STREAM_SIGNING_SECRET=synthetic-local-only-32-character-minimum-secret BEACON_STREAM_HOST_PORT=18080 diff --git a/scripts/early-birds-preview/validate.mjs b/scripts/early-birds-preview/validate.mjs index 4d7dfd84..39178f1c 100644 --- a/scripts/early-birds-preview/validate.mjs +++ b/scripts/early-birds-preview/validate.mjs @@ -16,6 +16,7 @@ await fs.writeFile(envFile, [ 'BEACON_STREAM_MEDIA_ROOT=/media/artifacts', 'BEACON_STREAM_ARTIFACT_ID=synthetic-preview-artifact', 'BEACON_STREAM_PUBLIC_ORIGIN=http://earlybirds-staging.localhost', + 'BEACON_STREAM_ALLOWED_ORIGINS=http://earlybirds-staging.localhost', 'BEACON_STREAM_SIGNING_SECRET=synthetic-local-only-32-character-minimum-secret', '', ].join('\n'), { mode: 0o600 }); diff --git a/services/beacon-stream/README.md b/services/beacon-stream/README.md index 2e8fa033..ec2ea54c 100644 --- a/services/beacon-stream/README.md +++ b/services/beacon-stream/README.md @@ -42,6 +42,11 @@ 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 diff --git a/services/beacon-stream/docker-compose.yml b/services/beacon-stream/docker-compose.yml index e6023674..ca382440 100644 --- a/services/beacon-stream/docker-compose.yml +++ b/services/beacon-stream/docker-compose.yml @@ -8,6 +8,7 @@ services: 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 diff --git a/services/beacon-stream/preview.env.example b/services/beacon-stream/preview.env.example index 5767a0c2..f8be866d 100644 --- a/services/beacon-stream/preview.env.example +++ b/services/beacon-stream/preview.env.example @@ -2,6 +2,7 @@ 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://earlybirds-staging.harmonicbeacon.com +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/src/server.mjs b/services/beacon-stream/src/server.mjs index ef8cab05..7842babb 100644 --- a/services/beacon-stream/src/server.mjs +++ b/services/beacon-stream/src/server.mjs @@ -19,6 +19,37 @@ function tokenFrom(url) { 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, @@ -35,7 +66,7 @@ function routeName(pathname) { return 'unknown'; } -export function createPublicHandler({ artifactRoot, metadata, publicOrigin, signingSecret, metrics = new Metrics(), now = () => Date.now() }) { +export function createPublicHandler({ artifactRoot, metadata, publicOrigin, signingSecret, allowedOrigins = new Set(), metrics = new Metrics(), now = () => Date.now() }) { const manifestPath = `/v1/hls/${metadata.artifactId}/live.m3u8`; const segmentPrefix = `/v1/hls/${metadata.artifactId}/segments/`; @@ -45,27 +76,31 @@ export function createPublicHandler({ artifactRoot, metadata, publicOrigin, sign 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 { if (request.method !== 'GET' && request.method !== 'HEAD') { status = 405; - send(response, status, 'method not allowed\n', { Allow: 'GET, HEAD' }); + respond(status, 'method not allowed\n', { Allow: 'GET, HEAD' }); return; } if (url.pathname === '/healthz') { status = 200; - send(response, status, 'ok\n', { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }); + respond(status, 'ok\n', { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }); return; } if (url.pathname === manifestPath) { if (!authorized({ request, url, secret: signingSecret })) { status = 403; - send(response, status, 'forbidden\n', { 'Cache-Control': 'no-store' }); + respond(status, 'forbidden\n', { 'Cache-Control': 'no-store' }); return; } const manifest = renderManifest({ metadata, origin: publicOrigin, secret: signingSecret, nowMs: now() }); status = 200; bytes = request.method === 'HEAD' ? 0 : Buffer.byteLength(manifest); - send(response, status, request.method === 'HEAD' ? '' : manifest, { + respond(status, request.method === 'HEAD' ? '' : manifest, { 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', 'Cache-Control': 'private, no-store', }); @@ -74,26 +109,26 @@ export function createPublicHandler({ artifactRoot, metadata, publicOrigin, sign if (url.pathname.startsWith(segmentPrefix)) { if (!authorized({ request, url, secret: signingSecret })) { status = 403; - send(response, status, 'forbidden\n', { 'Cache-Control': 'no-store' }); + respond(status, 'forbidden\n', { 'Cache-Control': 'no-store' }); return; } const file = decodeURIComponent(url.pathname.slice(segmentPrefix.length)); if (!metadata.segmentByFile.has(file)) { status = 404; - send(response, status, 'not found\n', { 'Cache-Control': 'no-store' }); + 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; - send(response, status, 'not found\n', { 'Cache-Control': 'no-store' }); + 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; - send(response, status, request.method === 'HEAD' ? '' : segment, { + respond(status, request.method === 'HEAD' ? '' : segment, { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'private, no-store', 'Content-Length': String(segment.byteLength), @@ -101,11 +136,11 @@ export function createPublicHandler({ artifactRoot, metadata, publicOrigin, sign return; } status = 404; - send(response, status, 'not found\n', { 'Cache-Control': 'no-store' }); + respond(status, 'not found\n', { 'Cache-Control': 'no-store' }); } catch { // Do not expose filesystem paths, credentials or signed URLs. status = 500; - if (!response.headersSent) send(response, status, 'internal server error\n', { 'Cache-Control': 'no-store' }); + if (!response.headersSent) respond(status, 'internal server error\n', { 'Cache-Control': 'no-store' }); } finally { metrics.observe({ route, status, bytes, durationMs: Math.max(0, now() - startedAt) }); } @@ -135,13 +170,15 @@ export async function startFromEnvironment(environment = process.env) { const artifactId = environment.BEACON_STREAM_ARTIFACT_ID; const signingSecret = environment.BEACON_STREAM_SIGNING_SECRET; const publicOrigin = environment.BEACON_STREAM_PUBLIC_ORIGIN; - if (!mediaRoot || !artifactId || !signingSecret || !publicOrigin) { - throw new Error('BEACON_STREAM_MEDIA_ROOT, BEACON_STREAM_ARTIFACT_ID, BEACON_STREAM_SIGNING_SECRET and BEACON_STREAM_PUBLIC_ORIGIN are required'); + 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, 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); diff --git a/services/beacon-stream/test/server.test.mjs b/services/beacon-stream/test/server.test.mjs index f8c07d43..74d04a33 100644 --- a/services/beacon-stream/test/server.test.mjs +++ b/services/beacon-stream/test/server.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import http from 'node:http'; import test from 'node:test'; -import { createPublicHandler, createInternalHandler } from '../src/server.mjs'; +import { createPublicHandler, createInternalHandler, parseAllowedOrigins } from '../src/server.mjs'; import { signPath } from '../src/auth.mjs'; import { Metrics } from '../src/metrics.mjs'; import { metadata, temporaryArtifact } from './helpers.mjs'; @@ -19,7 +19,15 @@ test('only exposes minimal health publicly and protects manifest and every segme const { artifactRoot } = await temporaryArtifact(); const item = metadata(); const metrics = new Metrics(); - const { server, origin } = await listen(createPublicHandler({ artifactRoot, metadata: item, publicOrigin: 'https://stream.example.test', signingSecret: secret, 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); @@ -27,16 +35,37 @@ test('only exposes minimal health publicly and protects manifest and every segme 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}`); + 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 manifest = await response.text(); const segmentUrl = manifest.split('\n').find((line) => line.startsWith('https://')); assert.ok(segmentUrl); const productionUrl = new URL(segmentUrl); const localUrl = new URL(`${origin}${productionUrl.pathname}${productionUrl.search}`); - const segment = await fetch(localUrl); + 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('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) => { From eb92f2fbf8c5154320edf35df51329d8e78cca1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:38:39 -0300 Subject: [PATCH 017/376] fix(early-birds): authorize browser HLS probes --- services/beacon-stream/src/server.mjs | 5 ++++- services/beacon-stream/test/server.test.mjs | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/services/beacon-stream/src/server.mjs b/services/beacon-stream/src/server.mjs index 7842babb..1d5108c6 100644 --- a/services/beacon-stream/src/server.mjs +++ b/services/beacon-stream/src/server.mjs @@ -53,7 +53,10 @@ function crossOriginHeaders(request, allowedOrigins) { function authorized({ request, url, secret }) { return verifySignedPath({ secret, - method: request.method, + // 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), }); diff --git a/services/beacon-stream/test/server.test.mjs b/services/beacon-stream/test/server.test.mjs index 74d04a33..cc38d1d5 100644 --- a/services/beacon-stream/test/server.test.mjs +++ b/services/beacon-stream/test/server.test.mjs @@ -41,6 +41,12 @@ test('only exposes minimal health publicly and protects manifest and every segme 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); From d1638f0af79b1be86c65c769acabd4f2088b642a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:41:31 -0300 Subject: [PATCH 018/376] fix(early-birds): authenticate synthetic login seam --- .env.example | 5 ++- docs/architecture/EARLY_BIRDS_LISTENER.md | 8 ++-- .../test-login/__tests__/route.test.ts | 44 +++++++++++++++++++ src/app/api/early-birds/test-login/route.ts | 21 ++++++++- src/app/early-birds/page.tsx | 2 - .../early-birds/EarlyBirdLanding.tsx | 33 +------------- .../__tests__/EarlyBirdLanding.test.tsx | 1 - src/lib/early-birds/auth.ts | 3 +- src/lib/early-birds/copy.ts | 4 -- 9 files changed, 74 insertions(+), 47 deletions(-) create mode 100644 src/app/api/early-birds/test-login/__tests__/route.test.ts diff --git a/.env.example b/.env.example index 6f66d451..84b40ba7 100644 --- a/.env.example +++ b/.env.example @@ -80,9 +80,10 @@ EARLY_BIRDS_DROPIN_ES_URL=https://media.example.invalid/early-birds/drop-in-es.m EARLY_BIRDS_DROPIN_EN_URL=https://media.example.invalid/early-birds/drop-in-en.mp3 # Synthetic auth/access is absent unless BOTH values are explicitly present. -# Never set these in production. +# 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= +# EARLY_BIRDS_TEST_LOGIN_SECRET=replace-with-at-least-32-random-characters # 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. diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 9a8808a1..b7b1a44c 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -39,9 +39,11 @@ Byte-exact copies live in `contracts/early-bird-authority/v1` and - `ACTIVE`, time-valid `GRACE`, and time-valid `CANCELLED_PENDING_END` allow access. Every missing, expired, revoked, refunded or unavailable state fails closed. -The optional synthetic login creates a clearly marked, source-null local projection only when both -`EARLY_BIRDS_TEST_ACCESS_ENABLED=1` and a separate test secret are configured. It cannot replace a -canonical projection and must never be enabled in production. +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 in production. ## Stream and device leases 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..b7215c45 --- /dev/null +++ b/src/app/api/early-birds/test-login/__tests__/route.test.ts @@ -0,0 +1,44 @@ +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { POST } from '../route'; + +const URL = 'https://app.example.test/api/early-birds/test-login'; + +function request(authorization?: string): NextRequest { + return new NextRequest(URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(authorization ? { authorization } : {}), + }, + body: JSON.stringify({ + email: 'listener@e2e.invalid', + name: 'Synthetic Listener', + }), + }); +} + +describe('EarlyBird synthetic login seam', () => { + beforeEach(() => { + vi.stubEnv('EARLY_BIRDS_TEST_ACCESS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_TEST_LOGIN_SECRET', 's'.repeat(32)); + }); + afterEach(() => vi.unstubAllEnvs()); + + it('hides the route when the caller omits the test-only bearer', async () => { + const response = await POST(request()); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ error: 'Not found.' }); + }); + + it('hides the route for a wrong or malformed bearer', async () => { + await expect(POST(request(`Bearer ${'x'.repeat(32)}`))).resolves.toMatchObject({ status: 404 }); + await expect(POST(request(`Basic ${'s'.repeat(32)}`))).resolves.toMatchObject({ status: 404 }); + }); + + it('stays disabled with a short configured secret', async () => { + vi.stubEnv('EARLY_BIRDS_TEST_LOGIN_SECRET', 'short'); + await expect(POST(request('Bearer short'))).resolves.toMatchObject({ status: 404 }); + }); +}); diff --git a/src/app/api/early-birds/test-login/route.ts b/src/app/api/early-birds/test-login/route.ts index 74ecc403..2236789f 100644 --- a/src/app/api/early-birds/test-login/route.ts +++ b/src/app/api/early-birds/test-login/route.ts @@ -1,4 +1,4 @@ -import { createHmac } from 'node:crypto'; +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; import { NextResponse, type NextRequest } from 'next/server'; @@ -15,6 +15,20 @@ 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 { + if (!earlyBirdTestAuthEnabled()) return false; + const authorization = request.headers.get('authorization'); + const presented = authorization?.startsWith('Bearer ') + ? authorization.slice('Bearer '.length) + : ''; + const expected = process.env.EARLY_BIRDS_TEST_LOGIN_SECRET ?? ''; + return timingSafeEqual(digest(presented), digest(expected)); +} + function testPassword(email: string): string { return createHmac('sha256', process.env.EARLY_BIRDS_TEST_LOGIN_SECRET!) .update(`early-birds-test-login:v1:${email}`) @@ -28,6 +42,9 @@ async function authRequest( ): Promise { const url = new URL(`${EARLY_BIRD_AUTH_BASE_PATH}/${operation}/email`, request.url); const headers = new Headers(request.headers); + // The test harness credential authorizes this route only. It is not part + // of Better Auth's request and never enters a cookie or client response. + headers.delete('authorization'); headers.set('content-type', 'application/json'); return earlyBirdAuth().handler(new Request(url, { method: 'POST', @@ -37,7 +54,7 @@ async function authRequest( } export async function POST(request: NextRequest): Promise { - if (!earlyBirdTestAuthEnabled()) return notFound(); + if (!authorizedSyntheticLogin(request)) return notFound(); let email: string; let name: string; diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index 488db561..ec3cd3c5 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -4,7 +4,6 @@ import EarlyBirdLanding from '@/components/early-birds/EarlyBirdLanding'; import { currentEarlyBirdSession, earlyBirdOAuthAvailability, - earlyBirdTestAuthEnabled, } from '@/lib/early-birds/auth'; import { getEarlyBirdAccess } from '@/lib/early-birds/membership'; @@ -35,7 +34,6 @@ export default async function EarlyBirdsPage({ entitled={access?.allowed === true} inviteToken={invite} authError={params.authError === '1'} - testAccessEnabled={earlyBirdTestAuthEnabled()} providers={earlyBirdOAuthAvailability()} /> ); diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index 5f457177..c5094051 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -13,14 +13,13 @@ type Props = { entitled: boolean; inviteToken: string | null; authError: boolean; - testAccessEnabled: boolean; providers: { google: boolean; apple: boolean }; }; export default function EarlyBirdLanding(props: Props) { const { locale } = useLocale(); const copy = earlyBirdCopy[locale]; - const [busy, setBusy] = useState<'google' | 'apple' | 'test' | null>(null); + const [busy, setBusy] = useState<'google' | 'apple' | null>(null); const [error, setError] = useState(false); const callbackURL = props.inviteToken ? `/early-birds/redeem?token=${encodeURIComponent(props.inviteToken)}` @@ -42,23 +41,6 @@ export default function EarlyBirdLanding(props: Props) { } } - async function syntheticLogin() { - if (busy) return; - setBusy('test'); - setError(false); - const response = await fetch('/api/early-birds/test-login', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ email: 'listener@e2e.invalid', name: 'EarlyBird Test Listener' }), - }); - if (response.ok) { - window.location.assign('/early-birds/home'); - return; - } - setBusy(null); - setError(true); - } - return (
@@ -127,19 +109,6 @@ export default function EarlyBirdLanding(props: Props) { )} ))} - {props.testAccessEnabled && ( -
-

{copy.testTitle}

- -
- )}
)} diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index b65b871f..42608dd6 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -22,7 +22,6 @@ function renderLanding(overrides: Partial diff --git a/src/lib/early-birds/auth.ts b/src/lib/early-birds/auth.ts index d7636264..cbbd7d43 100644 --- a/src/lib/early-birds/auth.ts +++ b/src/lib/early-birds/auth.ts @@ -14,9 +14,10 @@ function nonEmpty(value: string | undefined): string | undefined { } export function earlyBirdTestAuthEnabled(): boolean { + const secret = nonEmpty(process.env.EARLY_BIRDS_TEST_LOGIN_SECRET); return ( process.env.EARLY_BIRDS_TEST_ACCESS_ENABLED === '1' && - Boolean(nonEmpty(process.env.EARLY_BIRDS_TEST_LOGIN_SECRET)) + Boolean(secret && secret.length >= 32) ); } diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 9cd14245..ed64e894 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -18,8 +18,6 @@ export const earlyBirdCopy = { accessNeeded: 'Tu cuenta todavía no tiene una membresía activa.', authError: 'No pudimos completar el acceso. Usa el mismo proveedor con el que creaste tu cuenta o contacta a soporte.', privacy: 'La cuenta, la membresía y el pago pertenecen a una persona adulta. No creamos perfiles de menores ni historiales personales de escucha.', - testTitle: 'Acceso sintético de prueba', - testAction: 'Entrar con acceso de prueba', }, en: { eyebrow: 'EARLYBIRDS · FOUNDING ACCESS', @@ -38,8 +36,6 @@ export const earlyBirdCopy = { accessNeeded: 'Your account does not have an active membership yet.', authError: 'We could not complete sign-in. Use the provider that created your account, or contact support.', privacy: 'An adult owns the account, membership, consent, and payment. We do not create child profiles or personal listening histories.', - testTitle: 'Synthetic test access', - testAction: 'Enter with test access', }, } satisfies Record>; From bac185e5c99184fa3dd83acb98d46cf9b217118d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:43:52 -0300 Subject: [PATCH 019/376] docs(early-birds): keep drop-in artifacts format neutral --- .env.example | 4 ++-- docs/architecture/EARLY_BIRDS_LISTENER.md | 2 +- .../early-birds/__tests__/ListenerPlayer.test.tsx | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 84b40ba7..34af97d4 100644 --- a/.env.example +++ b/.env.example @@ -76,8 +76,8 @@ 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 -EARLY_BIRDS_DROPIN_ES_URL=https://media.example.invalid/early-birds/drop-in-es.mp3 -EARLY_BIRDS_DROPIN_EN_URL=https://media.example.invalid/early-birds/drop-in-en.mp3 +EARLY_BIRDS_DROPIN_ES_URL=https://media.example.invalid/early-birds/reviewed-drop-in-es +EARLY_BIRDS_DROPIN_EN_URL=https://media.example.invalid/early-birds/reviewed-drop-in-en # Synthetic auth/access is absent unless BOTH values are explicitly present. # The harness must call POST /api/early-birds/test-login with diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index b7b1a44c..4193c124 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -57,7 +57,7 @@ The origin signature is HMAC-SHA-256 base64url over the exact bytes The origin manifest must contain individually signed, same-origin segment URLs. Signing material and signed URLs are never returned in API JSON or logged. -The approved Spanish/English drop-in renders are configured as immutable media URLs. Listener UI +The reviewed Spanish/English drop-in artifacts are configured as immutable, format-neutral URLs. Listener UI does not encode or alter them. Progress is local to the browser. A drop-in mutes only the live output while the HLS element, source and lease continue untouched, then restores the still-running Beacon. The live pause control produces silence and its resume seeks to the current live edge. No AudioContext, diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index a649fbaf..c66e368a 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -67,8 +67,8 @@ describe('EarlyBird Listener player', () => { render( , ); @@ -112,7 +112,7 @@ describe('EarlyBird Listener player', () => { vi.stubGlobal('fetch', fetchMock); render( - + , ); const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; From 49324be449dea3e7cf2e4c5abec58cc8e386a0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:49:54 -0300 Subject: [PATCH 020/376] test(early-birds): prove synthetic Listener vertical slice --- e2e/tests/early-birds.spec.ts | 23 +++++++++++++++++++ playwright.config.ts | 5 ++++ src/components/early-birds/ListenerPlayer.tsx | 2 +- .../__tests__/ListenerPlayer.test.tsx | 4 ++-- src/lib/early-birds/copy.ts | 14 ++++++----- 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/e2e/tests/early-birds.spec.ts b/e2e/tests/early-birds.spec.ts index e1a1b5bb..fb867c95 100644 --- a/e2e/tests/early-birds.spec.ts +++ b/e2e/tests/early-birds.spec.ts @@ -14,4 +14,27 @@ test.describe('EarlyBird Listener boundary', () => { await expect(page).toHaveURL(/\/early-birds$/); await expect(page.getByRole('heading', { name: 'El Beacon, siempre presente.' })).toBeVisible(); }); + + test('creates an isolated synthetic session and reaches the private Listener home', async ({ page }) => { + const response = await page.request.post('/api/early-birds/test-login', { + headers: { + authorization: 'Bearer early-birds-e2e-login-secret-not-for-production', + }, + data: { + email: 'listener@e2e.invalid', + name: 'Synthetic Listener', + }, + }); + expect(response.status()).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + landing: '/early-birds/home', + }); + + await page.goto('/early-birds/home'); + await expect(page.getByText('Synthetic Listener')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Beacon 24/7' })).toBeVisible(); + await expect(page.getByText(/Membresía activa · TEST/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Escuchar ahora' })).toBeVisible(); + }); }); diff --git a/playwright.config.ts b/playwright.config.ts index 192d02e7..64dafd4b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -166,6 +166,11 @@ 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_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', }, }, }); diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index b6667cbb..08fe253a 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -327,7 +327,7 @@ export default function ListenerPlayer({

{copy.heading}

{copy.subheading}

diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index c66e368a..de480a0e 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -41,7 +41,7 @@ describe('EarlyBird Listener player', () => { , ); - expect(screen.getByRole('button', { name: 'Listen live' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Listen now' })).toBeInTheDocument(); expect(screen.getByText('Warm-up · Spanish')).toBeInTheDocument(); expect(screen.getByText('Warm-up · English')).toBeInTheDocument(); expect(screen.getAllByText('The approved render has not been published yet.')).toHaveLength(2); @@ -76,7 +76,7 @@ describe('EarlyBird Listener player', () => { const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; const spanishCard = spanish.closest('article')!; - fireEvent.click(screen.getByRole('button', { name: 'Listen live' })); + fireEvent.click(screen.getByRole('button', { name: 'Listen now' })); await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()); const liveSource = live.src; const leaseRequests = fetchMock.mock.calls.length; diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index ed64e894..3b8df0b1 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -43,10 +43,11 @@ export const earlyBirdHomeCopy = { es: { eyebrow: 'EARLYBIRDS · LISTENER', heading: 'Beacon 24/7', - subheading: 'Estás entrando al mismo borde vivo que todas las personas EarlyBird.', - play: 'Escuchar en vivo', + sharedPoint: 'Punto compartido', + subheading: 'Estás entrando al mismo punto del Beacon continuo grabado que todas las personas EarlyBird.', + play: 'Escuchar ahora', pause: 'Pausar', - resume: 'Volver al borde vivo', + resume: 'Volver al presente', loading: 'Conectando…', unavailable: 'El Beacon no está disponible en este momento.', displaced: 'Este dispositivo fue desplazado porque la cuenta ya está escuchando en otros dos dispositivos.', @@ -62,10 +63,11 @@ export const earlyBirdHomeCopy = { en: { eyebrow: 'EARLYBIRDS · LISTENER', heading: 'Beacon 24/7', - subheading: 'You are joining the same live edge as every other EarlyBird listener.', - play: 'Listen live', + sharedPoint: 'Shared point', + subheading: 'You are joining the same point in the continuous recorded Beacon as every other EarlyBird listener.', + play: 'Listen now', pause: 'Pause', - resume: 'Return to live edge', + resume: 'Return to now', loading: 'Connecting…', unavailable: 'The Beacon is unavailable right now.', displaced: 'This device was displaced because the account is already listening on two other devices.', From eee6bab3854b58f8fc4ca6ee0a7e4270e70ee93e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 01:56:30 -0300 Subject: [PATCH 021/376] feat(early-birds): add fail-closed public kill switch --- .env.example | 5 ++ playwright.config.ts | 1 + .../api/early-birds/auth/[...all]/route.ts | 10 +++- .../free/redeem/__tests__/route.test.ts | 18 ++++++- src/app/api/early-birds/free/redeem/route.ts | 3 ++ .../api/early-birds/stream/heartbeat/route.ts | 3 ++ .../stream/lease/__tests__/route.test.ts | 8 +++- src/app/api/early-birds/stream/lease/route.ts | 3 ++ .../stream/manifest/__tests__/route.test.ts | 4 +- .../api/early-birds/stream/manifest/route.ts | 3 ++ .../test-login/__tests__/route.test.ts | 1 + src/app/api/early-birds/test-login/route.ts | 2 + src/app/early-birds/home/page.tsx | 3 ++ src/app/early-birds/page.tsx | 4 ++ src/app/early-birds/redeem/page.tsx | 3 ++ .../early-birds/EarlyBirdUnavailable.tsx | 47 +++++++++++++++++++ src/lib/early-birds/__tests__/enabled.test.ts | 36 ++++++++++++++ src/lib/early-birds/enabled.ts | 23 +++++++++ 18 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 src/components/early-birds/EarlyBirdUnavailable.tsx create mode 100644 src/lib/early-birds/__tests__/enabled.test.ts create mode 100644 src/lib/early-birds/enabled.ts diff --git a/.env.example b/.env.example index 34af97d4..8d939dad 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,11 @@ SESSION_COOKIE_TTL_SECONDS=604800 # =================== # EarlyBird Listener identity, membership and private stream # =================== +# 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 + # Public app origin. Register these exact OAuth callbacks: # https:///api/early-birds/auth/callback/google # https:///api/early-birds/auth/callback/apple diff --git a/playwright.config.ts b/playwright.config.ts index 64dafd4b..c89a4f8a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -166,6 +166,7 @@ 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_AUTH_SECRET: 'early-birds-e2e-auth-secret-not-for-production', EARLY_BIRDS_AUTH_BASE_URL: BASE_URL, EARLY_BIRDS_TRUSTED_ORIGINS: BASE_URL, diff --git a/src/app/api/early-birds/auth/[...all]/route.ts b/src/app/api/early-birds/auth/[...all]/route.ts index 5e398880..975a800f 100644 --- a/src/app/api/early-birds/auth/[...all]/route.ts +++ b/src/app/api/early-birds/auth/[...all]/route.ts @@ -1,13 +1,19 @@ import type { NextRequest } from 'next/server'; import { earlyBirdAuth } from '@/lib/early-birds/auth'; +import { + earlyBirdsEnabled, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; export const dynamic = 'force-dynamic'; -export function GET(request: NextRequest): Promise { +export function GET(request: NextRequest): Promise | Response { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); return earlyBirdAuth().handler(request); } -export function POST(request: NextRequest): Promise { +export function POST(request: NextRequest): Promise | Response { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); return earlyBirdAuth().handler(request); } 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 index ccc8d877..bf0fc7dd 100644 --- a/src/app/api/early-birds/free/redeem/__tests__/route.test.ts +++ b/src/app/api/early-birds/free/redeem/__tests__/route.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; const currentEarlyBirdSession = vi.hoisted(() => vi.fn()); @@ -20,9 +20,23 @@ function request(token = 'a'.repeat(43)) { }); } -afterEach(() => vi.clearAllMocks()); +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()); diff --git a/src/app/api/early-birds/free/redeem/route.ts b/src/app/api/early-birds/free/redeem/route.ts index 2e81cf70..9579bb4e 100644 --- a/src/app/api/early-birds/free/redeem/route.ts +++ b/src/app/api/early-birds/free/redeem/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; import { EarlyBirdMembershipGatewayUnavailableError, redeemFreeThroughCanonicalGateway, @@ -9,6 +10,8 @@ import { export const dynamic = 'force-dynamic'; export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + const session = await currentEarlyBirdSession(request.headers).catch(() => null); if (!session) return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); diff --git a/src/app/api/early-birds/stream/heartbeat/route.ts b/src/app/api/early-birds/stream/heartbeat/route.ts index cd2acf21..6fb5a1f8 100644 --- a/src/app/api/early-birds/stream/heartbeat/route.ts +++ b/src/app/api/early-birds/stream/heartbeat/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; import { EarlyBirdAccessDeniedError, EarlyBirdLeaseInactiveError, @@ -10,6 +11,8 @@ import { export const dynamic = 'force-dynamic'; export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + const session = await currentEarlyBirdSession(request.headers).catch(() => null); if (!session) return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); 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 index 9cf2fe1e..8f4ebe82 100644 --- a/src/app/api/early-birds/stream/lease/__tests__/route.test.ts +++ b/src/app/api/early-birds/stream/lease/__tests__/route.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; const currentEarlyBirdSession = vi.hoisted(() => vi.fn()); @@ -21,7 +21,11 @@ function request(deviceId = 'device_abcdefghijklmnopqrstuvwxyz') { }); } -afterEach(() => vi.clearAllMocks()); +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 () => { diff --git a/src/app/api/early-birds/stream/lease/route.ts b/src/app/api/early-birds/stream/lease/route.ts index 78a30bb7..9963ab47 100644 --- a/src/app/api/early-birds/stream/lease/route.ts +++ b/src/app/api/early-birds/stream/lease/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; import { acquireEarlyBirdStreamLease, EarlyBirdAccessDeniedError, @@ -10,6 +11,8 @@ import { export const dynamic = 'force-dynamic'; export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + const session = await currentEarlyBirdSession(request.headers).catch(() => null); if (!session) return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); diff --git a/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts b/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts index 8f4f4093..e51c106c 100644 --- a/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts +++ b/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; const mocks = vi.hoisted(() => ({ @@ -38,9 +38,11 @@ function request() { return new NextRequest(`https://live.example.test/api/early-birds/stream/manifest?leaseId=${LEASE_ID}`); } +beforeEach(() => vi.stubEnv('EARLY_BIRDS_ENABLED', '1')); afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); describe('stable EarlyBird lease manifest', () => { diff --git a/src/app/api/early-birds/stream/manifest/route.ts b/src/app/api/early-birds/stream/manifest/route.ts index d5f7a298..4fcfd331 100644 --- a/src/app/api/early-birds/stream/manifest/route.ts +++ b/src/app/api/early-birds/stream/manifest/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; import { authorizeEarlyBirdStreamLease, earlyBirdOriginConfig, @@ -19,6 +20,8 @@ const MANIFEST_HEADERS = { }; export async function GET(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + const session = await currentEarlyBirdSession(request.headers).catch(() => null); if (!session) { return NextResponse.json({ error: 'Sign in required.' }, { 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 index b7215c45..c35523ea 100644 --- a/src/app/api/early-birds/test-login/__tests__/route.test.ts +++ b/src/app/api/early-birds/test-login/__tests__/route.test.ts @@ -21,6 +21,7 @@ function request(authorization?: string): NextRequest { describe('EarlyBird synthetic login seam', () => { beforeEach(() => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); vi.stubEnv('EARLY_BIRDS_TEST_ACCESS_ENABLED', '1'); vi.stubEnv('EARLY_BIRDS_TEST_LOGIN_SECRET', 's'.repeat(32)); }); diff --git a/src/app/api/early-birds/test-login/route.ts b/src/app/api/early-birds/test-login/route.ts index 2236789f..bdf33d7c 100644 --- a/src/app/api/early-birds/test-login/route.ts +++ b/src/app/api/early-birds/test-login/route.ts @@ -8,6 +8,7 @@ import { earlyBirdTestAuthEnabled, } from '@/lib/early-birds/auth'; import { issueSyntheticMembership } from '@/lib/early-birds/membership'; +import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; export const dynamic = 'force-dynamic'; @@ -54,6 +55,7 @@ async function authRequest( } export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return notFound(); if (!authorizedSyntheticLogin(request)) return notFound(); let email: string; diff --git a/src/app/early-birds/home/page.tsx b/src/app/early-birds/home/page.tsx index b5115a1c..e285daef 100644 --- a/src/app/early-birds/home/page.tsx +++ b/src/app/early-birds/home/page.tsx @@ -3,6 +3,7 @@ import { redirect } from 'next/navigation'; import EarlyBirdHome from '@/components/early-birds/EarlyBirdHome'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; import { getEarlyBirdAccess } from '@/lib/early-birds/membership'; +import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; export const dynamic = 'force-dynamic'; @@ -20,6 +21,8 @@ function configuredMediaUrl(name: string): string | null { } export default async function EarlyBirdHomePage() { + if (!earlyBirdsEnabled()) redirect('/early-birds'); + const session = await currentEarlyBirdSession().catch(() => null); if (!session) redirect('/early-birds'); const access = await getEarlyBirdAccess(session.user.id).catch(() => null); diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index ec3cd3c5..ec708b03 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -1,11 +1,13 @@ import type { Metadata } from 'next'; import EarlyBirdLanding from '@/components/early-birds/EarlyBirdLanding'; +import EarlyBirdUnavailable from '@/components/early-birds/EarlyBirdUnavailable'; import { currentEarlyBirdSession, earlyBirdOAuthAvailability, } from '@/lib/early-birds/auth'; import { getEarlyBirdAccess } from '@/lib/early-birds/membership'; +import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; export const dynamic = 'force-dynamic'; @@ -19,6 +21,8 @@ export default async function EarlyBirdsPage({ }: { searchParams: Promise>; }) { + if (!earlyBirdsEnabled()) return ; + const params = await searchParams; const session = await currentEarlyBirdSession().catch(() => null); const access = session diff --git a/src/app/early-birds/redeem/page.tsx b/src/app/early-birds/redeem/page.tsx index ce3183f6..dfef885e 100644 --- a/src/app/early-birds/redeem/page.tsx +++ b/src/app/early-birds/redeem/page.tsx @@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'; import FreeInvitationRedeemer from '@/components/early-birds/FreeInvitationRedeemer'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; export const dynamic = 'force-dynamic'; @@ -10,6 +11,8 @@ export default async function EarlyBirdRedeemPage({ }: { searchParams: Promise>; }) { + if (!earlyBirdsEnabled()) redirect('/early-birds'); + const params = await searchParams; const token = typeof params.token === 'string' && params.token.length <= 512 ? params.token diff --git a/src/components/early-birds/EarlyBirdUnavailable.tsx b/src/components/early-birds/EarlyBirdUnavailable.tsx new file mode 100644 index 00000000..37461b66 --- /dev/null +++ b/src/components/early-birds/EarlyBirdUnavailable.tsx @@ -0,0 +1,47 @@ +'use client'; + +import BrandLockup from '@/components/brand/BrandLockup'; +import LanguageControl from '@/components/brand/LanguageControl'; +import { useLocale } from '@/context/LocaleContext'; + +const copy = { + es: { + eyebrow: 'EARLYBIRDS', + title: 'Estamos preparando el Beacon.', + body: 'El acceso fundador todavía no está disponible. Volvé a intentarlo más tarde.', + }, + en: { + eyebrow: 'EARLYBIRDS', + title: 'We are preparing the Beacon.', + body: 'Founding access is not available yet. Please try again later.', + }, +} as const; + +export default function EarlyBirdUnavailable() { + const { locale } = useLocale(); + const text = copy[locale]; + + return ( +
+
+
+ + +
+
+
+

+ {text.eyebrow} +

+

+ {text.title} +

+

+ {text.body} +

+
+
+
+
+ ); +} diff --git a/src/lib/early-birds/__tests__/enabled.test.ts b/src/lib/early-birds/__tests__/enabled.test.ts new file mode 100644 index 00000000..cc8852d1 --- /dev/null +++ b/src/lib/early-birds/__tests__/enabled.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + earlyBirdsEnabled, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; + +describe('EarlyBird public kill switch', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('fails closed when the setting is absent or ambiguous', () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', ''); + expect(earlyBirdsEnabled()).toBe(false); + + for (const value of ['true', 'yes', '0', ' 1 ']) { + vi.stubEnv('EARLY_BIRDS_ENABLED', value); + expect(earlyBirdsEnabled()).toBe(false); + } + }); + + it('enables public entry only for the explicit value 1', () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + expect(earlyBirdsEnabled()).toBe(true); + }); + + it('returns a non-cacheable, retryable unavailable response', async () => { + const response = earlyBirdsUnavailableResponse(); + + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('retry-after')).toBe('300'); + await expect(response.json()).resolves.toEqual({ + error: 'EarlyBirds is temporarily unavailable.', + }); + }); +}); diff --git a/src/lib/early-birds/enabled.ts b/src/lib/early-birds/enabled.ts new file mode 100644 index 00000000..ca404634 --- /dev/null +++ b/src/lib/early-birds/enabled.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; + +/** + * Public EarlyBird entry is fail-closed. Internal membership projection routes + * deliberately do not use this switch so reconciliation can continue while + * the customer-facing experience is paused. + */ +export function earlyBirdsEnabled(): boolean { + return process.env.EARLY_BIRDS_ENABLED === '1'; +} + +export function earlyBirdsUnavailableResponse(): NextResponse { + return NextResponse.json( + { error: 'EarlyBirds is temporarily unavailable.' }, + { + status: 503, + headers: { + 'Cache-Control': 'private, no-store', + 'Retry-After': '300', + }, + }, + ); +} From f88a7b83c365ff2288cc62bc927049b4f448d500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 02:47:03 -0300 Subject: [PATCH 022/376] chore(deps): patch EarlyBird test toolchain advisories --- package-lock.json | 4406 +++++++++++++++++++++++---------------------- package.json | 9 +- 2 files changed, 2258 insertions(+), 2157 deletions(-) diff --git a/package-lock.json b/package-lock.json index d9247915..14df1b0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "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", @@ -36,7 +36,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", @@ -44,7 +44,7 @@ "lint-staged": "^16.2.7", "tailwindcss": "^4", "typescript": "^5", - "vitest": "^4.0.18" + "vitest": "^4.1.10" } }, "node_modules/@acemir/cssom": { @@ -733,657 +733,552 @@ "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" - ], + "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, - "os": [ - "linux" - ], "engines": { "node": ">=18" } }, - "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==", + "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": [ - "riscv64" + "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/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "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": [ - "s390x" + "x64" ], - "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-x64": "1.3.2" } }, - "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==", - "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": [ - "linux" + "freebsd" ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "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-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": [ - "netbsd" + "darwin" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "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==", + "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": [ - "netbsd" + "darwin" ], - "engines": { - "node": ">=18" + "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/@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-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": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "openbsd" + "linux" ], - "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-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": [ - "x64" + "ppc64" ], - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "openbsd" + "linux" ], - "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-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": [ - "arm64" + "riscv64" ], - "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-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ - "x64" + "s390x" ], - "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-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ - "arm64" + "x64" ], - "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-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ - "ia32" + "arm64" ], - "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-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==", + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "win32" + "linux" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "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" - }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://opencollective.com/libvips" }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" } }, - "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, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=20.9.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" + "url": "https://opencollective.com/libvips" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, - "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, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], "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", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=20.9.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" + "url": "https://opencollective.com/libvips" }, - "engines": { - "node": ">=18.18.0" + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, - "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, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.22" + "node": ">=20.9.0" }, "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" + "url": "https://opencollective.com/libvips" }, - "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" + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, - "node_modules/@img/sharp-darwin-arm64": { + "node_modules/@img/sharp-linux-s390x": { "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ - "arm64" + "s390x" ], "license": "Apache-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { "node": ">=20.9.0" @@ -1392,20 +1287,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, - "node_modules/@img/sharp-darwin-x64": { + "node_modules/@img/sharp-linux-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==", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], "license": "Apache-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { "node": ">=20.9.0" @@ -1414,448 +1309,137 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, - "node_modules/@img/sharp-freebsd-wasm32": { + "node_modules/@img/sharp-linuxmusl-arm64": { "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" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], - "license": "LGPL-3.0-or-later", + "license": "Apache-2.0", "optional": true, "os": [ "linux" ], + "engines": { + "node": ">=20.9.0" + }, "funding": { "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", + "license": "Apache-2.0", "optional": true, "os": [ "linux" ], + "engines": { + "node": ">=20.9.0" + }, "funding": { "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, - "node_modules/@img/sharp-linux-arm": { + "node_modules/@img/sharp-wasm32": { "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, "engines": { "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" } }, - "node_modules/@img/sharp-linux-arm64": { + "node_modules/@img/sharp-webcontainers-wasm32": { "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ - "arm64" + "wasm32" ], "license": "Apache-2.0", "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, "engines": { "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" } }, - "node_modules/@img/sharp-linux-ppc64": { + "node_modules/@img/sharp-win32-arm64": { "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ - "ppc64" + "arm64" ], - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, - "node_modules/@img/sharp-linux-riscv64": { + "node_modules/@img/sharp-win32-ia32": { "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ - "riscv64" + "ia32" ], - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, - "node_modules/@img/sharp-linux-s390x": { + "node_modules/@img/sharp-win32-x64": { "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ - "s390x" + "x64" ], - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.1" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" + "win32" ], "engines": { "node": ">=20.9.0" @@ -2167,6 +1751,16 @@ "node": ">=14" } }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "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", @@ -2549,24 +2143,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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", "cpu": [ "arm64" ], @@ -2575,12 +2155,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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", "cpu": [ "arm64" ], @@ -2589,12 +2172,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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", "cpu": [ "x64" ], @@ -2603,26 +2189,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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", "cpu": [ "x64" ], @@ -2631,26 +2206,15 @@ "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" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", "cpu": [ "arm" ], @@ -2659,12 +2223,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", "cpu": [ "arm64" ], @@ -2673,12 +2240,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", "cpu": [ "arm64" ], @@ -2687,152 +2257,83 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", "cpu": [ - "loong64" + "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", "cpu": [ - "loong64" + "s390x" ], "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-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", "cpu": [ - "ppc64" + "x64" ], "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-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", "cpu": [ - "ppc64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "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==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "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==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "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==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", "cpu": [ "arm64" ], @@ -2841,12 +2342,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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", "cpu": [ "arm64" ], @@ -2855,26 +2359,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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", "cpu": [ "x64" ], @@ -2883,21 +2376,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", @@ -4227,29 +3716,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": { @@ -4269,32 +3758,39 @@ "source-map-js": "^1.2.1" } }, + "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.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "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==", + "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" }, @@ -4303,7 +3799,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": { @@ -4315,26 +3811,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==", + "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==", + "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": { @@ -4342,13 +3838,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==", + "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" }, @@ -4357,9 +3854,9 @@ } }, "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==", + "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": { @@ -4367,14 +3864,15 @@ } }, "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==", + "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" @@ -4670,9 +4168,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": { @@ -5240,7 +4738,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": { @@ -5897,9 +5395,9 @@ } }, "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==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "devOptional": true, "license": "MIT" }, @@ -5963,47 +5461,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", @@ -6880,6 +6337,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" @@ -8721,34 +8179,6 @@ } } }, - "node_modules/next/node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -9149,13 +8579,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" @@ -9243,10 +8673,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "devOptional": true, + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -9263,7 +8692,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9496,93 +8925,596 @@ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", "license": "MIT", - "engines": { - "node": ">=12" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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==", + "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.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.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.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "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": ">= 0.4" + }, + "funding": { + "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" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "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": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "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", + "bin": { + "sdp-verify": "checker.js" + } + }, + "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/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" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 0.4" } }, - "node_modules/rc9": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", - "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "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", "dependencies": { - "defu": "^6.1.6", - "destr": "^2.0.5" + "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/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "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": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "license": "MIT", + "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": { - "scheduler": "^0.27.0" + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" }, - "peerDependencies": { - "react": "^19.2.3" + "engines": { + "node": ">=20.9.0" + }, + "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 + } } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" + "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/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "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", - "engines": { - "node": ">= 20.19.0" + "dependencies": { + "shebang-regex": "^3.0.0" }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "engines": { + "node": ">=8" } }, - "node_modules/redent": { + "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, "engines": { "node": ">=8" } }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "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", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" + "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" @@ -9591,19 +9523,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "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, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" + "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" @@ -9612,37 +9540,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/remeda": { - "version": "2.33.4", - "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", - "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/remeda" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "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": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" @@ -9651,489 +9559,380 @@ "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/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": { + "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": ">=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==", - "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/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/privatenumber/resolve-pkg-maps?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "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/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": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "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/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": ">=10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "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", - "engines": { - "node": ">= 4" + "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/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", + "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": { - "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/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/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "devOptional": true, + "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", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "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" + "node": ">= 0.6" } }, - "node_modules/rou3": { - "version": "0.7.12", - "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", - "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "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/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/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/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/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/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/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", "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", + "internal-slot": "^1.1.0" }, "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.4" } }, - "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==", + "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", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.6.19" } }, - "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/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": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=20" }, "funding": { - "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" - } - ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "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", + "license": "MIT", "dependencies": { - "xmlchars": "^2.2.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" }, - "engines": { - "node": ">=v12.22.7" - } - }, - "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" + "engines": { + "node": ">= 0.4" + } }, - "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/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", - "bin": { - "sdp-verify": "checker.js" + "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" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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.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": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" } }, - "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==", + "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", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", + "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-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.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": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" + "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/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.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": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", + "call-bind": "^1.0.7", + "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/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": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=20.9.0" + "node": ">=12" }, "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/chalk/strip-ansi?sponsor=1" } }, - "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-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", "engines": { - "node": ">=10" + "node": ">=4" } }, - "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-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, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "min-indent": "^1.0.0" }, "engines": { "node": ">=8" } }, - "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==", + "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", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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, + "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", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" + "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-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==", + "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": { - "es-errors": "^1.3.0", - "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-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-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" - }, "engines": { "node": ">= 0.4" }, @@ -10141,617 +9940,679 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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/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/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/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": { - "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" + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "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/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": "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", + "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", "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" } }, - "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, + "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": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=18" + "node": ">=12.0.0" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "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, + "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": ">=12" + "node": ">=12.0.0" }, - "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" + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "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/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": ">= 10.x" + "node": ">=14.0.0" } }, - "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/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", - "engines": { - "node": ">= 0.6" + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" } }, - "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-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/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/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.8", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.8.tgz", + "integrity": "sha512-8W675THjbzfFmLOQzjDBIBna+WjqMGIxmSZ1mMc1+o9qoVsEuAgQu5j5ueLhau8inOkDu9OslVg0FmfBs1RIHw==", "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.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "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.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "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.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "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.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "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.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "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/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-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "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-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "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==", - "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, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], "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==", - "devOptional": true, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], "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==", - "devOptional": true, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], "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==", - "devOptional": true, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], "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==", - "devOptional": true, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], "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/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.23" - }, - "bin": { - "tldts": "bin/cli.js" + "optional": true, + "os": [ + "linux" + ], + "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-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "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/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, + "optional": true, + "os": [ + "netbsd" + ], "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-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "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/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, + "optional": true, + "os": [ + "openbsd" + ], "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/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "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/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "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-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], "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-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], "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/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "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.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "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.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/type-check": { @@ -11030,18 +10891,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==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "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.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -11057,9 +10917,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", @@ -11072,13 +10933,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -11104,63 +10968,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": "MIT", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=12.0.0" + "node": ">= 12.0.0" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "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": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "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==", - "devOptional": true, - "license": "MIT", + "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" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "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": { @@ -11176,12 +11270,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": { @@ -11202,6 +11299,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -11210,21 +11313,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==", + "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", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "license": "MIT" }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", diff --git a/package.json b/package.json index cb817071..ea08a8e7 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,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}": [ @@ -64,7 +64,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", @@ -72,7 +72,7 @@ "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", @@ -80,6 +80,7 @@ "next": { "postcss": "8.5.25", "sharp": "0.35.3" - } + }, + "picomatch": "4.0.5" } } From 231f525f5e312607bf670eea670b6405156b5b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 02:58:53 -0300 Subject: [PATCH 023/376] feat(early-birds): add gated staging team entry --- .env.example | 8 ++ docs/architecture/EARLY_BIRDS_LISTENER.md | 28 +++++- e2e/tests/early-birds.spec.ts | 30 ++++++ playwright.config.ts | 2 + .../auth/[...all]/__tests__/route.test.ts | 26 +++++ .../api/early-birds/auth/[...all]/route.ts | 11 +++ .../test-login/__tests__/route.test.ts | 44 +++++++++ src/app/api/early-birds/test-login/route.ts | 6 +- src/app/early-birds/page.tsx | 4 + .../early-birds/EarlyBirdLanding.tsx | 4 + .../early-birds/SyntheticTeamEntryForm.tsx | 97 +++++++++++++++++++ .../__tests__/EarlyBirdLanding.test.tsx | 1 + .../__tests__/SyntheticTeamEntryForm.test.tsx | 56 +++++++++++ .../__tests__/synthetic-team-entry.test.ts | 50 ++++++++++ src/lib/early-birds/auth.ts | 6 +- src/lib/early-birds/copy.ts | 23 +++++ src/lib/early-birds/enabled.ts | 4 +- src/lib/early-birds/synthetic-team-entry.ts | 43 ++++++++ 18 files changed, 436 insertions(+), 7 deletions(-) create mode 100644 src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts create mode 100644 src/components/early-birds/SyntheticTeamEntryForm.tsx create mode 100644 src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx create mode 100644 src/lib/early-birds/__tests__/synthetic-team-entry.test.ts create mode 100644 src/lib/early-birds/synthetic-team-entry.ts diff --git a/.env.example b/.env.example index 8d939dad..24f02d19 100644 --- a/.env.example +++ b/.env.example @@ -90,6 +90,14 @@ EARLY_BIRDS_DROPIN_EN_URL=https://media.example.invalid/early-birds/reviewed-dro 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/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 4193c124..f24ea000 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -43,7 +43,33 @@ The optional synthetic-login API creates a clearly marked, source-null local pro 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 in production. +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 diff --git a/e2e/tests/early-birds.spec.ts b/e2e/tests/early-birds.spec.ts index fb867c95..4e850716 100644 --- a/e2e/tests/early-birds.spec.ts +++ b/e2e/tests/early-birds.spec.ts @@ -15,10 +15,40 @@ test.describe('EarlyBird Listener boundary', () => { await expect(page.getByRole('heading', { name: 'El Beacon, siempre presente.' })).toBeVisible(); }); + test('shows the team form only on the exact HTTPS staging host and never persists its code', async ({ page }) => { + const accessCode = 'browser-entered-staging-code-000000000001'; + await page.setExtraHTTPHeaders({ 'x-forwarded-proto': 'https' }); + 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('/early-birds'); + await expect(page.getByText('Acceso de equipo · staging')).toBeVisible(); + 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 private Listener home', 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', diff --git a/playwright.config.ts b/playwright.config.ts index c89a4f8a..90bcf200 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -172,6 +172,8 @@ export default defineConfig({ 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}`, }, }, }); 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..f27f6721 --- /dev/null +++ b/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts @@ -0,0 +1,26 @@ +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const handler = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/early-birds/auth', () => ({ earlyBirdAuth: () => ({ handler }) })); + +import { POST } from '../route'; + +describe('EarlyBird public auth route', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + }); + afterEach(() => vi.unstubAllEnvs()); + + it.each(['sign-up', 'sign-in'])('does not publicly expose synthetic email %s', async (operation) => { + const request = new NextRequest( + `https://earlybirds-staging.example.test/api/early-birds/auth/${operation}/email`, + { method: 'POST', body: '{}' }, + ); + const response = await POST(request); + expect(response.status).toBe(404); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/early-birds/auth/[...all]/route.ts b/src/app/api/early-birds/auth/[...all]/route.ts index 975a800f..127bb6ed 100644 --- a/src/app/api/early-birds/auth/[...all]/route.ts +++ b/src/app/api/early-birds/auth/[...all]/route.ts @@ -8,6 +8,15 @@ import { export const dynamic = 'force-dynamic'; +function hiddenSyntheticEmailEndpoint(request: NextRequest): Response | null { + if (!request.nextUrl.pathname.endsWith('/sign-up/email') && + !request.nextUrl.pathname.endsWith('/sign-in/email')) return null; + return Response.json({ error: 'Not found.' }, { + status: 404, + headers: { 'Cache-Control': 'private, no-store' }, + }); +} + export function GET(request: NextRequest): Promise | Response { if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); return earlyBirdAuth().handler(request); @@ -15,5 +24,7 @@ export function GET(request: NextRequest): Promise | Response { export function POST(request: NextRequest): Promise | Response { if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + const hidden = hiddenSyntheticEmailEndpoint(request); + if (hidden) return hidden; return earlyBirdAuth().handler(request); } 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 index c35523ea..6415432a 100644 --- a/src/app/api/early-birds/test-login/__tests__/route.test.ts +++ b/src/app/api/early-birds/test-login/__tests__/route.test.ts @@ -1,6 +1,15 @@ import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ handler: vi.fn(), issueMembership: vi.fn() })); +vi.mock('@/lib/early-birds/auth', async (importOriginal) => ({ + ...await importOriginal(), + earlyBirdAuth: () => ({ handler: mocks.handler }), +})); +vi.mock('@/lib/early-birds/membership', () => ({ + issueSyntheticMembership: mocks.issueMembership, +})); + import { POST } from '../route'; const URL = 'https://app.example.test/api/early-birds/test-login'; @@ -9,6 +18,8 @@ function request(authorization?: string): NextRequest { return new NextRequest(URL, { method: 'POST', headers: { + host: 'app.example.test', + 'x-forwarded-proto': 'https', 'content-type': 'application/json', ...(authorization ? { authorization } : {}), }, @@ -21,9 +32,20 @@ function request(authorization?: string): NextRequest { describe('EarlyBird synthetic login seam', () => { 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', 'app.example.test'); + mocks.handler.mockResolvedValue(new Response(JSON.stringify({ + user: { id: 'listener-synthetic-1' }, + }), { + status: 200, + headers: { 'set-cookie': 'hb_earlybird_session=synthetic; HttpOnly; Secure' }, + })); + mocks.issueMembership.mockResolvedValue({}); }); afterEach(() => vi.unstubAllEnvs()); @@ -42,4 +64,26 @@ describe('EarlyBird synthetic login seam', () => { vi.stubEnv('EARLY_BIRDS_TEST_LOGIN_SECRET', 'short'); await expect(POST(request('Bearer short'))).resolves.toMatchObject({ status: 404 }); }); + + it('hides the route on a non-allowlisted host or non-HTTPS request', async () => { + const wrongHost = request(`Bearer ${'s'.repeat(32)}`); + wrongHost.headers.set('host', 'other.example.test'); + await expect(POST(wrongHost)).resolves.toMatchObject({ status: 404 }); + + const insecure = request(`Bearer ${'s'.repeat(32)}`); + insecure.headers.set('x-forwarded-proto', 'http'); + await expect(POST(insecure)).resolves.toMatchObject({ status: 404 }); + }); + + it('creates isolated synthetic access on the exact staging host without forwarding the bearer', async () => { + const secret = 's'.repeat(32); + const response = await POST(request(`Bearer ${secret}`)); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true, landing: '/early-birds/home' }); + expect(mocks.handler).toHaveBeenCalledOnce(); + const internalRequest = mocks.handler.mock.calls[0][0] as Request; + expect(internalRequest.headers.get('authorization')).toBeNull(); + expect(JSON.stringify(await internalRequest.clone().json())).not.toContain(secret); + expect(mocks.issueMembership).toHaveBeenCalledWith('listener-synthetic-1'); + }); }); diff --git a/src/app/api/early-birds/test-login/route.ts b/src/app/api/early-birds/test-login/route.ts index bdf33d7c..4b2747c4 100644 --- a/src/app/api/early-birds/test-login/route.ts +++ b/src/app/api/early-birds/test-login/route.ts @@ -9,6 +9,7 @@ import { } 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'; export const dynamic = 'force-dynamic'; @@ -55,7 +56,10 @@ async function authRequest( } export async function POST(request: NextRequest): Promise { - if (!earlyBirdsEnabled()) return notFound(); + if (!earlyBirdsEnabled() || !syntheticTeamEntryAllowed({ + headers: request.headers, + requestProtocol: request.nextUrl.protocol, + })) return notFound(); if (!authorizedSyntheticLogin(request)) return notFound(); let email: string; diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index ec708b03..08b92752 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { headers as requestHeaders } from 'next/headers'; import EarlyBirdLanding from '@/components/early-birds/EarlyBirdLanding'; import EarlyBirdUnavailable from '@/components/early-birds/EarlyBirdUnavailable'; @@ -8,6 +9,7 @@ import { } from '@/lib/early-birds/auth'; import { getEarlyBirdAccess } from '@/lib/early-birds/membership'; import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; +import { syntheticTeamEntryAllowed } from '@/lib/early-birds/synthetic-team-entry'; export const dynamic = 'force-dynamic'; @@ -24,6 +26,7 @@ export default async function EarlyBirdsPage({ if (!earlyBirdsEnabled()) return ; const params = await searchParams; + const incomingHeaders = new Headers(await requestHeaders()); const session = await currentEarlyBirdSession().catch(() => null); const access = session ? await getEarlyBirdAccess(session.user.id).catch(() => null) @@ -39,6 +42,7 @@ export default async function EarlyBirdsPage({ inviteToken={invite} authError={params.authError === '1'} providers={earlyBirdOAuthAvailability()} + syntheticTeamEntryAvailable={syntheticTeamEntryAllowed({ headers: incomingHeaders })} /> ); } diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index c5094051..c8de702b 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -8,12 +8,15 @@ import { useLocale } from '@/context/LocaleContext'; import { earlyBirdAuthClient } from '@/lib/early-birds/auth-client'; import { earlyBirdCopy } from '@/lib/early-birds/copy'; +import SyntheticTeamEntryForm from './SyntheticTeamEntryForm'; + type Props = { signedIn: boolean; entitled: boolean; inviteToken: string | null; authError: boolean; providers: { google: boolean; apple: boolean }; + syntheticTeamEntryAvailable: boolean; }; export default function EarlyBirdLanding(props: Props) { @@ -109,6 +112,7 @@ export default function EarlyBirdLanding(props: Props) { )} ))} + {props.syntheticTeamEntryAvailable && }
)} diff --git a/src/components/early-birds/SyntheticTeamEntryForm.tsx b/src/components/early-birds/SyntheticTeamEntryForm.tsx new file mode 100644 index 00000000..d5d2f13b --- /dev/null +++ b/src/components/early-birds/SyntheticTeamEntryForm.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; + +import { useLocale } from '@/context/LocaleContext'; +import { earlyBirdSyntheticEntryCopy } from '@/lib/early-birds/copy'; + +export default function SyntheticTeamEntryForm() { + const { locale } = useLocale(); + const copy = earlyBirdSyntheticEntryCopy[locale]; + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [accessCode, setAccessCode] = useState(''); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (busy || !accessCode) return; + setBusy(true); + setFailed(false); + + // Keep the staging credential in component memory only, and clear it + // immediately after constructing the one authenticated request. + const bearer = accessCode; + setAccessCode(''); + try { + const response = await fetch('/api/early-birds/test-login', { + method: 'POST', + cache: 'no-store', + headers: { + authorization: `Bearer ${bearer}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ name, email }), + }); + if (!response.ok) throw new Error('synthetic entry unavailable'); + window.location.assign('/early-birds/home'); + } catch { + setBusy(false); + setFailed(true); + } + } + + return ( +
+
+

{copy.title}

+

{copy.description}

+
+ + + + {failed &&

{copy.failed}

} + +
+ ); +} diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index 42608dd6..ec589f7b 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -23,6 +23,7 @@ function renderLanding(overrides: Partial , diff --git a/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx b/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx new file mode 100644 index 00000000..20d003d3 --- /dev/null +++ b/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { LocaleProvider } from '@/context/LocaleContext'; + +import SyntheticTeamEntryForm from '../SyntheticTeamEntryForm'; + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + window.sessionStorage.clear(); + vi.unstubAllGlobals(); +}); + +describe('EarlyBird staging team entry form', () => { + it('renders the small synthetic-account form in both supported languages', () => { + const { unmount } = render( + , + ); + expect(screen.getByText('Acceso de equipo · staging')).toBeInTheDocument(); + expect(screen.getByLabelText('Cuenta sintética')).toHaveAttribute('placeholder', 'name@e2e.invalid'); + unmount(); + + render(); + expect(screen.getByText('Team access · staging')).toBeInTheDocument(); + expect(screen.getByLabelText('Temporary access code')).toHaveAttribute('type', 'password'); + }); + + it('sends the tester-entered bearer once and clears it without persistence', async () => { + const accessCode = 'team-staging-access-code-0000000000000001'; + const request = vi.fn().mockResolvedValue(new Response('{}', { status: 404 })); + vi.stubGlobal('fetch', request); + render(); + + await userEvent.type(screen.getByLabelText('Test name'), 'Team Listener'); + await userEvent.type(screen.getByLabelText('Synthetic account'), 'team.listener@e2e.invalid'); + await userEvent.type(screen.getByLabelText('Temporary access code'), accessCode); + await userEvent.click(screen.getByRole('button', { name: 'Enter staging' })); + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + expect(request).toHaveBeenCalledOnce(); + const [url, init] = request.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/api/early-birds/test-login'); + expect(init).toMatchObject({ method: 'POST', cache: 'no-store' }); + expect(init.headers).toMatchObject({ authorization: `Bearer ${accessCode}` }); + expect(JSON.parse(String(init.body))).toEqual({ + name: 'Team Listener', + email: 'team.listener@e2e.invalid', + }); + expect(screen.getByLabelText('Temporary access code')).toHaveValue(''); + expect(JSON.stringify(window.localStorage)).not.toContain(accessCode); + expect(JSON.stringify(window.sessionStorage)).not.toContain(accessCode); + }); +}); diff --git a/src/lib/early-birds/__tests__/synthetic-team-entry.test.ts b/src/lib/early-birds/__tests__/synthetic-team-entry.test.ts new file mode 100644 index 00000000..8b6c93d4 --- /dev/null +++ b/src/lib/early-birds/__tests__/synthetic-team-entry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { syntheticTeamEntryAllowed } from '../synthetic-team-entry'; + +const environment = { + NODE_ENV: 'production', + EARLY_BIRDS_ENABLED: '1', + EARLY_BIRDS_TEST_ACCESS_ENABLED: '1', + EARLY_BIRDS_TEST_LOGIN_SECRET: 's'.repeat(32), + EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED: '1', + EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS: 'earlybirds-staging.example.test', +} as NodeJS.ProcessEnv; + +function allowed(overrides: Record = {}, headers: HeadersInit = {}) { + return syntheticTeamEntryAllowed({ + headers: new Headers({ + host: 'earlybirds-staging.example.test', + 'x-forwarded-proto': 'https', + ...headers, + }), + }, { ...environment, ...overrides } as NodeJS.ProcessEnv); +} + +describe('EarlyBird synthetic team entry staging gate', () => { + it('opens only with every explicit production staging condition', () => { + expect(allowed()).toBe(true); + expect(allowed({ EARLY_BIRDS_ENABLED: '0' })).toBe(false); + expect(allowed({ EARLY_BIRDS_TEST_ACCESS_ENABLED: '0' })).toBe(false); + expect(allowed({ EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED: '0' })).toBe(false); + expect(allowed({ EARLY_BIRDS_TEST_LOGIN_SECRET: 'short' })).toBe(false); + expect(allowed({ NODE_ENV: 'test' })).toBe(false); + }); + + it('requires HTTPS and an exact host match', () => { + expect(allowed({}, { 'x-forwarded-proto': 'http' })).toBe(false); + expect(allowed({}, { 'x-forwarded-proto': 'https,http' })).toBe(false); + expect(allowed({}, { host: 'sub.earlybirds-staging.example.test' })).toBe(false); + expect(allowed({}, { host: 'earlybirds-staging.example.test.evil.test' })).toBe(false); + }); + + it('fails closed for wildcard, origin-shaped or partially invalid allowlists', () => { + expect(allowed({ EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS: '*.example.test' })).toBe(false); + expect(allowed({ + EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS: 'https://earlybirds-staging.example.test', + })).toBe(false); + expect(allowed({ + EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS: 'earlybirds-staging.example.test,bad/value', + })).toBe(false); + }); +}); diff --git a/src/lib/early-birds/auth.ts b/src/lib/early-birds/auth.ts index cbbd7d43..783952b0 100644 --- a/src/lib/early-birds/auth.ts +++ b/src/lib/early-birds/auth.ts @@ -13,10 +13,10 @@ function nonEmpty(value: string | undefined): string | undefined { return normalized ? normalized : undefined; } -export function earlyBirdTestAuthEnabled(): boolean { - const secret = nonEmpty(process.env.EARLY_BIRDS_TEST_LOGIN_SECRET); +export function earlyBirdTestAuthEnabled(environment: NodeJS.ProcessEnv = process.env): boolean { + const secret = nonEmpty(environment.EARLY_BIRDS_TEST_LOGIN_SECRET); return ( - process.env.EARLY_BIRDS_TEST_ACCESS_ENABLED === '1' && + environment.EARLY_BIRDS_TEST_ACCESS_ENABLED === '1' && Boolean(secret && secret.length >= 32) ); } diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 3b8df0b1..cacb511d 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -39,6 +39,29 @@ export const earlyBirdCopy = { }, } satisfies Record>; +export const earlyBirdSyntheticEntryCopy = { + es: { + title: 'Acceso de equipo · staging', + description: 'Sólo para pruebas acordadas. Usa una cuenta sintética @e2e.invalid y el código temporal del equipo.', + name: 'Nombre de prueba', + email: 'Cuenta sintética', + accessCode: 'Código de acceso temporal', + enter: 'Entrar a staging', + entering: 'Abriendo staging…', + failed: 'El acceso de prueba no está disponible o los datos no son válidos.', + }, + en: { + title: 'Team access · staging', + description: 'For agreed testing only. Use a synthetic @e2e.invalid account and the team’s temporary access code.', + name: 'Test name', + email: 'Synthetic account', + accessCode: 'Temporary access code', + enter: 'Enter staging', + entering: 'Opening staging…', + failed: 'Test access is unavailable or the supplied details are invalid.', + }, +} satisfies Record>; + export const earlyBirdHomeCopy = { es: { eyebrow: 'EARLYBIRDS · LISTENER', diff --git a/src/lib/early-birds/enabled.ts b/src/lib/early-birds/enabled.ts index ca404634..c2681969 100644 --- a/src/lib/early-birds/enabled.ts +++ b/src/lib/early-birds/enabled.ts @@ -5,8 +5,8 @@ import { NextResponse } from 'next/server'; * deliberately do not use this switch so reconciliation can continue while * the customer-facing experience is paused. */ -export function earlyBirdsEnabled(): boolean { - return process.env.EARLY_BIRDS_ENABLED === '1'; +export function earlyBirdsEnabled(environment: NodeJS.ProcessEnv = process.env): boolean { + return environment.EARLY_BIRDS_ENABLED === '1'; } export function earlyBirdsUnavailableResponse(): NextResponse { diff --git a/src/lib/early-birds/synthetic-team-entry.ts b/src/lib/early-birds/synthetic-team-entry.ts new file mode 100644 index 00000000..dd6ff8cd --- /dev/null +++ b/src/lib/early-birds/synthetic-team-entry.ts @@ -0,0 +1,43 @@ +import { earlyBirdTestAuthEnabled } from './auth'; +import { earlyBirdsEnabled } from './enabled'; + +const HOSTNAME = /^(?:localhost|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)(?::[1-9][0-9]{0,4})?$/; + +function canonicalHost(value: string): string | null { + const host = value.trim().toLowerCase(); + if (!HOSTNAME.test(host)) return null; + const separator = host.lastIndexOf(':'); + if (separator !== -1) { + const port = Number(host.slice(separator + 1)); + if (!Number.isSafeInteger(port) || port > 65_535) return null; + } + return host; +} + +function allowedHosts(environment: NodeJS.ProcessEnv): string[] | null { + const raw = environment.EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS ?? ''; + const entries = raw.split(',').map((entry) => canonicalHost(entry)).filter(Boolean); + if (entries.length === 0 || entries.length !== raw.split(',').length) return null; + return [...new Set(entries)] as string[]; +} + +/** + * Server-only staging gate. No secret or allowlist value is returned to a + * client; callers receive only an availability boolean or a hidden 404. + */ +export function syntheticTeamEntryAllowed( + input: { headers: Headers; requestProtocol?: string }, + environment: NodeJS.ProcessEnv = process.env, +): boolean { + if (environment.NODE_ENV !== 'production') return false; + if (!earlyBirdsEnabled(environment) || !earlyBirdTestAuthEnabled(environment)) return false; + if (environment.EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED !== '1') return false; + + const host = canonicalHost(input.headers.get('host') ?? ''); + const hosts = allowedHosts(environment); + if (!host || !hosts?.includes(host)) return false; + + const forwardedProtocol = input.headers.get('x-forwarded-proto'); + const protocol = forwardedProtocol ?? input.requestProtocol ?? ''; + return protocol.trim().toLowerCase().replace(/:$/, '') === 'https'; +} From 37ce4abce6812cc05e7cfdcf0444a9236018290a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 03:00:55 -0300 Subject: [PATCH 024/376] docs(early-birds): gate paid rollout behind Free acceptance --- docs/plans/EARLY_BIRDS.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 9d27222b..9601d275 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -53,6 +53,8 @@ change has passed its own audio and operational acceptance. | Use deterministic HLS over HTTP | Accepted | Every listener follows one UTC-derived live edge through immutable six-second segments; event WebRTC is untouched. | | Keep drop-ins independent | Accepted | Drop-ins have a local timeline; they are not a realtime mix or crossfader. | | Offer Free and paid access through one contract | Accepted | One-use signed invitations and PayPal/MercadoPago converge on the same revocable membership state machine. | +| 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 @@ -393,6 +395,14 @@ 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. An adult owns the account and payment; the service does not request or persist a minor profile or minor-specific data. @@ -511,11 +521,12 @@ payment provider. Exit: a returning test Listener reaches the same isolated account and cannot cross into event/staff privileges. -### Batch D — PayPal, MercadoPago and release candidate +### 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. @@ -527,8 +538,9 @@ cross into event/staff privileges. - merge current `main` into `early-birds` and resolve conflicts; - run the release checkpoint once. -Exit: a documented go/no-go decision. Production remains off until explicitly -approved. +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 @@ -590,6 +602,7 @@ event sound and reliability are at least as good as the current path. | D10 | One shared wall-clock Beacon timeline; every drop-in has private play/pause/seek/restart controls. | | 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. | ## 16. Card map @@ -601,12 +614,15 @@ Create milestone `EarlyBirds` and use these non-overlapping delivery cards: 4. EB-03 — Google/Apple 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 and independently controlled ES/EN drop-ins. -7. EB-06 — PayPal sandbox lifecycle and reconciliation. -8. EB-07 — MercadoPago/BCRA pricing, lock and failure lifecycle. +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. From f63db74650ab336928cd2bd0b4382c4e581cf663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 03:09:19 -0300 Subject: [PATCH 025/376] fix(early-birds): align canonical membership contracts --- contracts/early-bird-authority/v1/README.md | 10 ++++++ contracts/early-bird-authority/v1/SHA256SUMS | 10 ++++-- .../v1/checkout-create.fixture.json | 7 ++++ .../v1/checkout-create.schema.json | 20 ++++++++++++ .../v1/checkout.fixture.json | 10 ++++++ .../v1/checkout.schema.json | 32 +++++++++++++++++++ .../v1/invitation-redeem.schema.json | 7 +++- .../v1/membership.schema.json | 7 +++- contracts/early-bird-membership/v1/README.md | 4 +++ contracts/early-bird-membership/v1/SHA256SUMS | 6 ++-- .../v1/command.schema.json | 7 +++- .../v1/result.schema.json | 7 +++- .../__tests__/membership-gateway.test.ts | 11 +++++++ .../early-birds/__tests__/membership.test.ts | 9 ++++++ src/lib/early-birds/account-id.ts | 6 ++++ src/lib/early-birds/membership-contract.ts | 3 +- src/lib/early-birds/membership-gateway.ts | 5 ++- src/lib/early-birds/membership.ts | 4 ++- 18 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 contracts/early-bird-authority/v1/checkout-create.fixture.json create mode 100644 contracts/early-bird-authority/v1/checkout-create.schema.json create mode 100644 contracts/early-bird-authority/v1/checkout.fixture.json create mode 100644 contracts/early-bird-authority/v1/checkout.schema.json create mode 100644 src/lib/early-birds/account-id.ts diff --git a/contracts/early-bird-authority/v1/README.md b/contracts/early-bird-authority/v1/README.md index 106ce5ca..35f65a07 100644 --- a/contracts/early-bird-authority/v1/README.md +++ b/contracts/early-bird-authority/v1/README.md @@ -14,6 +14,12 @@ Las llamadas son server-to-server por red privada. Exigen `Authorization: Bearer - 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; + - persiste la unión cuenta↔suscripción externa antes de devolver la URL sandbox; + - 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. @@ -40,3 +46,7 @@ Beacon aplica una revisión mayor, reproduce la misma y rechaza como stale una m 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 index a41bc9ba..19720fa4 100644 --- a/contracts/early-bird-authority/v1/SHA256SUMS +++ b/contracts/early-bird-authority/v1/SHA256SUMS @@ -1,5 +1,9 @@ -be99ca7261e8e4fa84b9cce82f92a554a98088b42fe68dac74a96ba83910d22c README.md +f452841e487b02f233ab8f8633e17c5625eeab5dc3620fbe2fa993cf87a8daaf README.md +46ebfa406c3e17e7913122f5cb6fe16084fccef2baada373017bf411fe1908bf checkout-create.fixture.json +2d464d210c61e98489059a5828c83bbaf31370eb0ebd980edf1271e785d1d97a checkout-create.schema.json +6e73b4b0099afdaf4ce6d5894d845220b0828b9125ea9b574d8e8c7535120e4d checkout.fixture.json +b9595f3238a7750c2dc442b1d988290c62787afb03e84bfb662360453673274a checkout.schema.json 47c937f3f93ed94b9eff6750bd430131af0c18ace5c2a6684ba36b5f5b3d41f2 invitation-redeem.fixture.json -d905ac01c1a8e017832e126a5266ebd506f4889e4111dee91b621e80f926784d invitation-redeem.schema.json +085d6b6bbf0e88e974ed63e0585d2e52601e859cd283d13dbaf4191a3ae30fd2 invitation-redeem.schema.json d9c86c455ff006225aaeeaa66787650f690b485185a1f821ed55fdde5e5acbd1 membership.fixture.json -34a40df9816000d5dae8152bb63ea821469aa9a2fda1a1810c167f1b5d3a0261 membership.schema.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..01ce5aab --- /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": 200, + "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..629d5d01 --- /dev/null +++ b/contracts/early-bird-authority/v1/checkout.schema.json @@ -0,0 +1,32 @@ +{ + "$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", "minLength": 1, "maxLength": 2048}, + "currency": {"enum": ["USD", "ARS"]}, + "amount_minor": {"type": "integer", "minimum": 1}, + "sandbox": {"type": "boolean"} + } +} diff --git a/contracts/early-bird-authority/v1/invitation-redeem.schema.json b/contracts/early-bird-authority/v1/invitation-redeem.schema.json index 51aff943..0cdf0545 100644 --- a/contracts/early-bird-authority/v1/invitation-redeem.schema.json +++ b/contracts/early-bird-authority/v1/invitation-redeem.schema.json @@ -7,7 +7,12 @@ "required": ["schema_version", "account_id", "invitation_token"], "properties": { "schema_version": {"const": "early-bird-authority.invitation-redeem.v1"}, - "account_id": {"type": "string", "minLength": 1, "maxLength": 255}, + "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, diff --git a/contracts/early-bird-authority/v1/membership.schema.json b/contracts/early-bird-authority/v1/membership.schema.json index da7af9d1..ff094b30 100644 --- a/contracts/early-bird-authority/v1/membership.schema.json +++ b/contracts/early-bird-authority/v1/membership.schema.json @@ -22,7 +22,12 @@ ], "properties": { "schema_version": {"const": "early-bird-authority.membership.v1"}, - "account_id": {"type": "string", "minLength": 1, "maxLength": 255}, + "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": [ diff --git a/contracts/early-bird-membership/v1/README.md b/contracts/early-bird-membership/v1/README.md index 3a6f9593..510ad852 100644 --- a/contracts/early-bird-membership/v1/README.md +++ b/contracts/early-bird-membership/v1/README.md @@ -23,3 +23,7 @@ cobro. El comando no contiene email, nombre, tokens OAuth, URLs firmadas ni dato 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 index e0740479..4faa9bc9 100644 --- a/contracts/early-bird-membership/v1/SHA256SUMS +++ b/contracts/early-bird-membership/v1/SHA256SUMS @@ -1,5 +1,5 @@ -719c96403f676e982b5ff43bae610130deb4c5da97e662677053c2afaebaa1d8 README.md +d3cd62086dc661acb4532fd725e592e70ddac565a490a4732d7d32321edc8d17 README.md 025d36d5aa4c3b455dd197acc4af23669f9cd02cc240e995bfc3cc0e693fd3cb command.fixture.json -393d5058330f33a22d69bb06f86f09b11d75465900e8b52d22fb1e6ea8d0120e command.schema.json +32aa0ee5222ba85d56e6f0baca822e0072d8db1059cdc3d73193a9ab4a9b89c0 command.schema.json a870cb5590a582ae5408c41cc4414c7bd3804b7efbd11f4e82a3fe769707f682 result.fixture.json -546278e4dd8c8e8baffbfc862004041467a9357545d1a123fb6204744c9bffc4 result.schema.json +7e00175cb5a48391f1c4823e161a30a7cbab902b3c63c99748f839263f428454 result.schema.json diff --git a/contracts/early-bird-membership/v1/command.schema.json b/contracts/early-bird-membership/v1/command.schema.json index 54d1e1c6..e7597e27 100644 --- a/contracts/early-bird-membership/v1/command.schema.json +++ b/contracts/early-bird-membership/v1/command.schema.json @@ -20,7 +20,12 @@ ], "properties": { "schema_version": {"const": "early-bird-membership.command.v1"}, - "account_id": {"type": "string", "minLength": 1, "maxLength": 255}, + "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": [ diff --git a/contracts/early-bird-membership/v1/result.schema.json b/contracts/early-bird-membership/v1/result.schema.json index 25450123..66c81068 100644 --- a/contracts/early-bird-membership/v1/result.schema.json +++ b/contracts/early-bird-membership/v1/result.schema.json @@ -17,7 +17,12 @@ "properties": { "schema_version": {"const": "early-bird-membership.result.v1"}, "membership_id": {"type": "string", "format": "uuid"}, - "account_id": {"type": "string", "minLength": 1, "maxLength": 255}, + "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": { diff --git a/src/lib/early-birds/__tests__/membership-gateway.test.ts b/src/lib/early-birds/__tests__/membership-gateway.test.ts index 171d4887..9da7542a 100644 --- a/src/lib/early-birds/__tests__/membership-gateway.test.ts +++ b/src/lib/early-birds/__tests__/membership-gateway.test.ts @@ -80,6 +80,17 @@ describe('canonical EarlyBird membership HTTP gateway', () => { expect(gateway.redeemFree).not.toHaveBeenCalled(); }); + it.each([ + 'listener/1', + '-listener', + `a${'b'.repeat(128)}`, + ])('rejects account IDs outside the canonical authority contract: %s', async (accountId) => { + const gateway = { redeemFree: vi.fn() }; + await expect(redeemFreeThroughCanonicalGateway(accountId, TOKEN, gateway)) + .resolves.toEqual({ ok: false, reason: 'unavailable' }); + expect(gateway.redeemFree).not.toHaveBeenCalled(); + }); + it('fails closed on mismatched or structurally invalid authority responses', async () => { const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...authorityMembership, diff --git a/src/lib/early-birds/__tests__/membership.test.ts b/src/lib/early-birds/__tests__/membership.test.ts index b56b2b48..61913917 100644 --- a/src/lib/early-birds/__tests__/membership.test.ts +++ b/src/lib/early-birds/__tests__/membership.test.ts @@ -93,6 +93,15 @@ describe('EarlyBird membership read model', () => { .not.toBe(membershipCommandHash(command())); }); + it.each([ + 'listener/1', + '-listener', + `a${'b'.repeat(128)}`, + ])('rejects account IDs outside the canonical authority contract: %s', (accountId) => { + expect(() => membershipCommandHash(command({ account_id: accountId }))) + .toThrow('account_id is invalid'); + }); + it('applies a new projection and replays the exact same revision', async () => { const expected = projection({ commandHash: membershipCommandHash(command()) }); tx.earlyBirdMembershipProjection.findUnique.mockResolvedValueOnce(null); diff --git a/src/lib/early-birds/account-id.ts b/src/lib/early-birds/account-id.ts new file mode 100644 index 00000000..eedcbe5f --- /dev/null +++ b/src/lib/early-birds/account-id.ts @@ -0,0 +1,6 @@ +const EARLY_BIRD_ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/; + +/** Byte-equivalent to the canonical authority's RFC 3986 unreserved account key. */ +export function isEarlyBirdAccountId(value: unknown): value is string { + return typeof value === 'string' && EARLY_BIRD_ACCOUNT_ID.test(value); +} diff --git a/src/lib/early-birds/membership-contract.ts b/src/lib/early-birds/membership-contract.ts index 147453b9..cc47362a 100644 --- a/src/lib/early-birds/membership-contract.ts +++ b/src/lib/early-birds/membership-contract.ts @@ -1,4 +1,5 @@ import type { EarlyBirdMembershipProjectionCommand } from './membership'; +import { isEarlyBirdAccountId } from './account-id'; const COMMAND_KEYS = [ 'account_id', 'current_price', 'effective_at', 'grace_until', 'membership_revision', 'offer', @@ -83,7 +84,7 @@ function price(value: unknown): EarlyBirdMembershipProjectionCommand['current_pr } function common(input: Record) { - if (typeof input.account_id !== 'string' || input.account_id.length < 1 || input.account_id.length > 255) { + if (!isEarlyBirdAccountId(input.account_id)) { throw new EarlyBirdMembershipContractError('account_id is invalid'); } if (!Number.isSafeInteger(input.membership_revision) || (input.membership_revision as number) < 1) { diff --git a/src/lib/early-birds/membership-gateway.ts b/src/lib/early-birds/membership-gateway.ts index 0d1cbbc0..8a9ba2c9 100644 --- a/src/lib/early-birds/membership-gateway.ts +++ b/src/lib/early-birds/membership-gateway.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; +import { isEarlyBirdAccountId } from './account-id'; import { authorityMembershipCommand, parseCanonicalAuthorityMembership, @@ -71,6 +72,7 @@ export class HttpEarlyBirdMembershipGateway implements EarlyBirdMembershipGatewa accountId: string; opaqueInvitation: string; }): Promise { + if (!isEarlyBirdAccountId(accountId)) throw new EarlyBirdMembershipGatewayUnavailableError(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { @@ -133,7 +135,8 @@ export async function redeemFreeThroughCanonicalGateway( opaqueInvitation: string, gateway = earlyBirdMembershipGateway(), ): Promise { - if (opaqueInvitation.length < 32 || opaqueInvitation.length > 512 || !INVITATION_TOKEN.test(opaqueInvitation)) { + if (!isEarlyBirdAccountId(accountId) || opaqueInvitation.length < 32 || + opaqueInvitation.length > 512 || !INVITATION_TOKEN.test(opaqueInvitation)) { return { ok: false, reason: 'unavailable' }; } const result = await gateway.redeemFree({ accountId, opaqueInvitation }); diff --git a/src/lib/early-birds/membership.ts b/src/lib/early-birds/membership.ts index dd8bbf27..75c4d900 100644 --- a/src/lib/early-birds/membership.ts +++ b/src/lib/early-birds/membership.ts @@ -9,6 +9,8 @@ import { Prisma } from '@prisma/client'; import { prisma } from '@/lib/db'; +import { isEarlyBirdAccountId } from './account-id'; + export const EARLY_BIRDS_FOUNDERS_OFFER = 'EARLY_BIRDS_FOUNDERS_V1' as const; export type EarlyBirdMembershipProjectionCommand = { @@ -56,7 +58,7 @@ function normalizedCommand(command: EarlyBirdMembershipProjectionCommand): Early if (command.schema_version !== 'early-bird-membership.command.v1') { throw new Error('Unsupported membership command schema'); } - if (!command.account_id || command.account_id.length > 255) throw new Error('account_id is invalid'); + if (!isEarlyBirdAccountId(command.account_id)) throw new Error('account_id is invalid'); if (!Number.isSafeInteger(command.membership_revision) || command.membership_revision < 1) { throw new Error('membership_revision must be a positive integer'); } From 378951f679bf1484ee1856c979f01e983d3d1d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 03:19:20 -0300 Subject: [PATCH 026/376] feat(early-birds): add isolated staging runtime --- .../workflows/early-birds-fast-forward.yml | 23 +- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 204 +++++++++++++----- .../authority-network.override.yml | 14 ++ ops/early-birds-preview/compose.yml | 95 ++++++-- ...s-staging.harmonicbeacon.com.conf.template | 113 ++++++++++ .../stream.harmonicbeacon.com.conf.template | 54 +++++ ops/early-birds-preview/package.json | 2 + .../preview.env.synthetic.example | 62 +++++- .../stream-build.override.yml | 6 + .../test/preview-contract.test.mjs | 156 +++++++++++--- scripts/early-birds-preview/health-smoke.sh | 29 ++- scripts/early-birds-preview/lib.sh | 126 +++++++++-- .../early-birds-preview/rehearse-migration.sh | 4 +- scripts/early-birds-preview/rollback.sh | 10 +- scripts/early-birds-preview/start.sh | 10 +- scripts/early-birds-preview/stop.sh | 4 +- scripts/early-birds-preview/validate.mjs | 149 +++++++++++-- 17 files changed, 909 insertions(+), 152 deletions(-) create mode 100644 ops/early-birds-preview/authority-network.override.yml create mode 100644 ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template create mode 100644 ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template diff --git a/.github/workflows/early-birds-fast-forward.yml b/.github/workflows/early-birds-fast-forward.yml index d9ac083d..7d8a176f 100644 --- a/.github/workflows/early-birds-fast-forward.yml +++ b/.github/workflows/early-birds-fast-forward.yml @@ -3,18 +3,38 @@ 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/early-birds/** + - src/components/early-birds/** + - src/lib/early-birds/** - services/beacon-stream/** - ops/early-birds/** - ops/early-birds-preview/** - scripts/early-birds-preview/** + - docs/operations/EARLY_BIRDS_STAGING_PREVIEW.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/early-birds/** + - src/components/early-birds/** + - src/lib/early-birds/** - services/beacon-stream/** - ops/early-birds/** - ops/early-birds-preview/** - scripts/early-birds-preview/** + - docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md - .github/workflows/early-birds-fast-forward.yml permissions: @@ -52,5 +72,6 @@ jobs: - 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 + - run: npm --prefix ops/early-birds-preview run validate:build diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 4378de87..eb299e94 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,96 +1,186 @@ -# EarlyBirds staging preview and rollback +# EarlyBirds isolated staging runtime -This is the EB-08 preview/rehearsal surface for -`earlybirds-staging.harmonicbeacon.com`. It is isolated from the weekend event -stack: project name `earlybirds-preview`, volume `earlybirds-preview-postgres`, -loopback-only ports, an internal preview network, and the separately bounded -`services/beacon-stream` origin. It never runs a production deploy, creates a -real account, calls OAuth, or calls a payment provider. +This is the non-deploying EB-08 staging lane for exactly: -The URL is a future TLS/DNS boundary. Local validation deliberately uses -`earlybirds-staging.localhost` and `127.0.0.1`; DNS is not required. +- `https://earlybirds-staging.harmonicbeacon.com` — Next Listener on host loopback `127.0.0.1:13000`. +- `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. + +Free acceptance is the only membership flow in this staging milestone. The +runtime defines no checkout service and supplies no PayPal, Mercado Pago, or +other paid-provider configuration. Paid acceptance remains disabled even when +the public Listener kill switch is opened. ## Prepare synthetic inputs -Copy `ops/early-birds-preview/preview.env.synthetic.example` to a root-owned -`0600` path outside Git. Keep `EARLYBIRDS_PREVIEW_ENV=synthetic`. The lifecycle -scripts reject production domains and provider names in this file. +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; +- exactly the two HTTPS staging 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- +``` -Point `BEACON_STREAM_ARTIFACTS_HOST_PATH` only at a generated synthetic HLS -fixture that satisfies the existing `services/beacon-stream` artifact contract. -Do not mount an approved master, a drop-in, real Listener data, or an event -volume. Audio creation, artifact selection, and listening approval remain -outside EB-08. +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. -## Start and validate +## Validate without starting -From the repository root, with Docker available: +From the repository root: ```bash -npm --prefix ops/early-birds-preview run test +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` starts only PostgreSQL and the existing bounded stream origin. -`health-smoke.sh` proves PostgreSQL readiness and the stream `/readyz` endpoint. -It intentionally does not assert playback or decoded audio: that needs an -approved artifact and EB-08 cross-device/audio acceptance. +Startup is fail closed: -No Listener service is defined in this slice. When the Listener lane is ready, -add its explicit image, isolated cookie/OAuth config, and health endpoint as a -small overlay; do not infer an app health contract here. +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. -## Migration rehearsal +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. -Run this after additive EarlyBird Prisma migrations land: +To rerun the idempotent forward migration separately: ```bash scripts/early-birds-preview/rehearse-migration.sh /secure/earlybirds-preview.env ``` -The command runs `prisma migrate deploy` only against the `postgres` service in -this compose project. It is a forward migration rehearsal, not a destructive -down-migration. The rollback strategy is additive data retention plus disabling -the Listener entry and origin; do not delete the preview volume during an -incident unless its exact target has been reviewed. +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 (not executed here) + +The two 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 only `/early-birds`, +`/api/early-birds/`, Next static assets and health; it blocks `/api/internal/` +and returns 404 for the image's weekend, staff, event and checkout surfaces. + +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 + +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 +``` -## Stop and rollback rehearsal +Recreate the Listener through `start.sh`, rerun the smoke, and exercise only +`@e2e.invalid` synthetic identities with the separate test-login bearer. +Provider buttons remain disabled because OAuth credentials are blank. +Return both switches to `0` after the supervised team window. -For a normal stop (retains preview data): +Normal stop retains all preview data: ```bash scripts/early-birds-preview/stop.sh /secure/earlybirds-preview.env ``` -For an EarlyBird stream incident, stop only that origin: +Incident rollback stops the two public-serving components while retaining +PostgreSQL for diagnosis and a forward fix: ```bash scripts/early-birds-preview/rollback.sh /secure/earlybirds-preview.env ``` -Then disable the EarlyBird public entry using the Listener lane's feature flag -and serve its truthful unavailable state. This slice cannot and does not stop -the event compose project, LiveKit, playlist-bot, or production database. +Set `EARLY_BIRDS_ENABLED=0` before the next start. None of these scripts uses +`docker compose down`, deletes a volume, or targets the event/live project. ## Staging release gate -Before external staging acceptance, record all of the following: - -1. Synthetic compose validation, unit contract checks, startup, health smoke, - stop, origin rollback, restart, and migration rehearsal evidence. -2. The existing stream origin/canary and observability validation in - `ops/early-birds/runbook/README.md`; use fake alert secrets locally. -3. Listener auth/authorization negative tests, synthetic entitlement checks, - and separate-cookie verification once its lane is integrated. -4. An approved artifact, external decoded-audio canary, 60-minute physical - desktop/iOS/Android listening, and isolated load/soak evidence. These are - release prerequisites, not assertions made by this scaffold. -5. DNS/TLS verification for `earlybirds-staging.harmonicbeacon.com` only after - the local/ZeroTier path is green. Never use DNS success as a replacement for - the local health and rollback checks. - -Do not promote this staging runtime to production without the explicit -EarlyBirds release, audio, identity, commerce, and rollback gates in +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/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 index a99d3e37..1596c779 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -1,20 +1,17 @@ -# EarlyBirds staging foundation. This file is intentionally separate from the -# event compose project and is safe to combine with services/beacon-stream. -# It does not define a Listener application until that lane supplies its image -# and health contract. +# 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} - ports: - - "127.0.0.1:${EARLYBIRDS_PREVIEW_DB_PORT:-15432}:5432" + 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] + networks: [preview_db] healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 5s @@ -25,13 +22,12 @@ services: limits: { cpus: "1.0", memory: 1G } reservations: { cpus: "0.25", memory: 256M } - # Run only on demand. It uses the current repository migrations against the - # preview-only database and never points at a production host or volume. - migration-rehearsal: + # 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 - profiles: [migration] restart: "no" working_dir: /app command: ["npx", "prisma", "migrate", "deploy"] @@ -39,15 +35,80 @@ services: DATABASE_URL: postgresql://${EARLYBIRDS_PREVIEW_DB_USER}:${EARLYBIRDS_PREVIEW_DB_PASSWORD}@postgres:5432/${EARLYBIRDS_PREVIEW_DB_NAME}?schema=public depends_on: postgres: { condition: service_healthy } - networks: [preview] + 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:-preview-forward-only} + restart: unless-stopped + init: true + environment: + NODE_ENV: production + DATABASE_URL: postgresql://${EARLYBIRDS_PREVIEW_DB_USER}:${EARLYBIRDS_PREVIEW_DB_PASSWORD}@postgres:5432/${EARLYBIRDS_PREVIEW_DB_NAME}?schema=public + EARLY_BIRDS_ENABLED: ${EARLY_BIRDS_ENABLED:-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:-} + EARLY_BIRDS_APPLE_CLIENT_ID: ${EARLY_BIRDS_APPLE_CLIENT_ID:-} + EARLY_BIRDS_APPLE_CLIENT_SECRET: ${EARLY_BIRDS_APPLE_CLIENT_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_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_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} + TRUSTED_PROXY_HOPS: "1" + ports: + - "127.0.0.1:${EARLYBIRDS_PREVIEW_APP_PORT:-13000}:3000" + networks: + - preview_db + - listener_egress + 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" + networks: - preview: - name: earlybirds_preview_internal + 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 volumes: earlybirds-preview-postgres: 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..450f2148 --- /dev/null +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -0,0 +1,113 @@ +# Isolated EarlyBirds staging vhost. Review and install separately from every +# live/event vhost only after the named certificate exists. +server { + listen 80; + listen [::]:80; + server_name earlybirds-staging.harmonicbeacon.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + 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; + + # 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; + } + + 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"; + } + + 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; + } + + location = /early-birds { + 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 ^~ /early-birds/ { + 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/ { + 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 = / { + return 302 /early-birds; + } + + # 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/stream.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template new file mode 100644 index 00000000..b77602ad --- /dev/null +++ b/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template @@ -0,0 +1,54 @@ +# 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; + + 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/ { + 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 index ec386806..f6ce0e76 100644 --- a/ops/early-birds-preview/package.json +++ b/ops/early-birds-preview/package.json @@ -3,7 +3,9 @@ "private": true, "type": "module", "scripts": { + "check": "node --check ../../scripts/early-birds-preview/validate.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/rehearse-migration.sh ../../scripts/early-birds-preview/health-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 index 80674ba2..66ed5504 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -1,19 +1,59 @@ -# Synthetic-only local/staging rehearsal values. Copy outside Git with mode 0600. -# These values are rejected by the lifecycle scripts if changed to a production -# hostname, a real provider endpoint, or the event database identity. +# 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-local-only-not-a-secret +EARLYBIRDS_PREVIEW_DB_PASSWORD=synthetic-preview-database-password EARLYBIRDS_PREVIEW_DB_NAME=earlybirds_preview -EARLYBIRDS_PREVIEW_DB_PORT=15432 +EARLYBIRDS_PREVIEW_APP_PORT=13000 +EARLYBIRDS_PREVIEW_IMAGE_TAG=synthetic +EARLYBIRDS_PREVIEW_GIT_SHA=synthetic-preview +EARLYBIRDS_PREVIEW_BUILD_TIME=synthetic-preview +EARLYBIRDS_PREVIEW_SCHEMA_VERSION=preview-forward-only +# 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= -# Existing isolated beacon-stream service inputs. Use only a synthetic artifact -# fixture; no approved master, drop-in, account, OAuth credential, or payment -# provider data belongs in this preview env file. +# 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_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= +EARLY_BIRDS_APPLE_CLIENT_ID= +EARLY_BIRDS_APPLE_CLIENT_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 +EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com + +# 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_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 + +# 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=http://earlybirds-staging.localhost -BEACON_STREAM_ALLOWED_ORIGINS=http://earlybirds-staging.localhost -BEACON_STREAM_SIGNING_SECRET=synthetic-local-only-32-character-minimum-secret +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 index 69dca14e..816682d3 100644 --- a/ops/early-birds-preview/stream-build.override.yml +++ b/ops/early-birds-preview/stream-build.override.yml @@ -6,3 +6,9 @@ services: 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/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index c7f43bb0..e52859e2 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -5,38 +5,144 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -const root = path.resolve(import.meta.dirname, '..'); -const read = (name) => fs.readFile(path.join(root, name), 'utf8'); +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(root, '../../scripts/early-birds-preview/lib.sh'), envFile], { encoding: '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 env guard accepts comments and rejects effective dangerous assignments', async () => { - const source = await read('preview.env.synthetic.example'); - assert.match(source, /^EARLYBIRDS_PREVIEW_ENV=synthetic$/m); - const example = path.join(root, 'preview.env.synthetic.example'); - assert.equal(runGuard(example).status, 0); +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-')); - const dangerous = path.join(temporary, 'dangerous.env'); - await fs.writeFile(dangerous, `${source}\nBEACON_STREAM_PUBLIC_ORIGIN=https://harmonicbeacon.com\n`); - const result = runGuard(dangerous); - assert.equal(result.status, 2); - assert.match(result.stderr, /production\/provider value/); - await fs.rm(temporary, { recursive: true, force: true }); + 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/], + ['real OAuth seam', 'EARLY_BIRDS_GOOGLE_CLIENT_ID=real-client-id', /must stay empty/], + ['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 team-entry switch', 'EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=true', /must be 0 or 1/], + ['wrong team-entry host', 'EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=staging.example.invalid', /must be earlybirds-staging/], + ['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); + }); }); -test('preview compose has an isolated database and an on-demand migration rehearsal', async () => { - const source = await read('compose.yml'); - assert.match(source, /postgres:/); - assert.match(source, /earlybirds-preview-postgres/); - assert.match(source, /migration-rehearsal:/); - assert.match(source, /profiles: \[migration\]/); - assert.match(source, /prisma", "migrate", "deploy/); - assert.doesNotMatch(source, /livekit|playlist-bot|tapestry/i); +test('compose gates the loopback Listener on a forward-only isolated database migration', async () => { + const source = await readPreview('compose.yml'); + 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_STAGING_TEAM_ENTRY_ENABLED: \$\{EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED:-0\}/); + 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|mercadopago|checkout/i); + + const postgresBlock = source.slice(source.indexOf(' postgres:'), source.indexOf('\n # Forward-only')); + assert.doesNotMatch(postgresBlock, /ports:/, 'preview PostgreSQL must stay container-private'); }); -test('stream build overlay pins the service directory after compose merge', async () => { - const source = await read('stream-build.override.yml'); +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('nginx templates name only the two staging hosts and proxy only fixed loopback ports', async () => { + const app = await readPreview('nginx/earlybirds-staging.harmonicbeacon.com.conf.template'); + const stream = await readPreview('nginx/stream.harmonicbeacon.com.conf.template'); + const combined = `${app}\n${stream}`; + const serverNames = [...combined.matchAll(/server_name\s+([^;]+);/g)].map((match) => match[1]); + assert.deepEqual([...new Set(serverNames)].sort(), [ + 'earlybirds-staging.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|18080)$/.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(app, /location \^~ \/api\/internal\//); + assert.match(app, /location \^~ \/api\/early-birds\//); + assert.match(app, /location = \/ \{\s*return 302 \/early-birds;/); + 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)/); +}); + +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 and rollback contracts cover both probes without deleting state', async () => { + const smoke = await readRepository('scripts/early-birds-preview/health-smoke.sh'); + assert.match(smoke, /api\/health"/); + 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 beacon-stream/); + assert.doesNotMatch(rollback, /preview_compose_command[^\n]*stop[^\n]*postgres|\bdown\b|volume rm/); + const stop = await readRepository('scripts/early-birds-preview/stop.sh'); + assert.match(stop, /stop listener beacon-stream postgres/); + assert.doesNotMatch(stop, /\bdown\b|-v\b|volume rm/); }); diff --git a/scripts/early-birds-preview/health-smoke.sh b/scripts/early-birds-preview/health-smoke.sh index d33f861a..4c13bb95 100755 --- a/scripts/early-birds-preview/health-smoke.sh +++ b/scripts/early-birds-preview/health-smoke.sh @@ -3,9 +3,28 @@ 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" -port=$(sed -n 's/^BEACON_STREAM_HOST_PORT=//p' "$env_file" | tail -n 1) -port=${port:-18080} -preview_compose_command "$env_file" ps --status running postgres beacon-stream +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"' -curl --fail --silent --show-error "http://127.0.0.1:${port}/readyz" >/dev/null -echo 'EarlyBirds synthetic preview health smoke passed (PostgreSQL and isolated stream ready).' +curl --fail --silent --show-error --max-time 5 "http://127.0.0.1:${app_port}/api/health" >/dev/null +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/lib.sh b/scripts/early-birds-preview/lib.sh index 0a02f67b..06196e77 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -4,26 +4,128 @@ 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_synthetic_env() { env_file=${1:?usage: provide a synthetic preview env file} - test -f "$env_file" || { echo "preview env file not found: $env_file" >&2; exit 2; } - # Only assignments affect Compose. Comments intentionally describe production - # boundaries, so never reject an env file merely for documenting them. - effective_assignments=$(grep -E '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=' "$env_file" || true) - printf '%s\n' "$effective_assignments" | grep -q '^EARLYBIRDS_PREVIEW_ENV=synthetic$' || { - echo 'refusing to run: EARLYBIRDS_PREVIEW_ENV must be synthetic' >&2; exit 2; - } - if printf '%s\n' "$effective_assignments" | grep -Eiq '(harmonicbeacon\.com|paypal|mercadopago|production)'; then - echo 'refusing to run: synthetic preview env contains a production/provider value' >&2; exit 2 + 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" + 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" + require_exact_preview_value EARLY_BIRDS_STREAM_ORIGIN https://stream.harmonicbeacon.com "$env_file" + require_exact_preview_value BEACON_STREAM_PUBLIC_ORIGIN https://stream.harmonicbeacon.com "$env_file" + require_exact_preview_value BEACON_STREAM_ALLOWED_ORIGINS https://earlybirds-staging.harmonicbeacon.com "$env_file" + require_exact_preview_value EARLY_BIRDS_STREAM_ARTIFACT_ID synthetic-preview-artifact "$env_file" + require_exact_preview_value BEACON_STREAM_ARTIFACT_ID synthetic-preview-artifact "$env_file" + require_exact_preview_value EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS earlybirds-staging.harmonicbeacon.com "$env_file" + + 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 + 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 + 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 + + for oauth_key in \ + EARLY_BIRDS_GOOGLE_CLIENT_ID EARLY_BIRDS_GOOGLE_CLIENT_SECRET \ + EARLY_BIRDS_APPLE_CLIENT_ID EARLY_BIRDS_APPLE_CLIENT_SECRET + do + test -z "$(preview_env_value "$oauth_key" "$env_file")" || preview_fail "$oauth_key must stay empty in synthetic staging" + done + + 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_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com|\ + EARLY_BIRDS_STREAM_ORIGIN=https://stream.harmonicbeacon.com|\ + BEACON_STREAM_PUBLIC_ORIGIN=https://stream.harmonicbeacon.com|\ + BEACON_STREAM_ALLOWED_ORIGINS=https://earlybirds-staging.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/rehearse-migration.sh b/scripts/early-birds-preview/rehearse-migration.sh index 584e2ec1..890b572d 100755 --- a/scripts/early-birds-preview/rehearse-migration.sh +++ b/scripts/early-birds-preview/rehearse-migration.sh @@ -4,5 +4,5 @@ set -eu 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 -preview_compose_command "$env_file" --profile migration run --rm migration-rehearsal -echo 'Preview-only Prisma migration rehearsal passed. Prisma migrations are forward-only; rollback is route/origin disable plus additive data retention.' +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 index 28c7650c..827b7793 100755 --- a/scripts/early-birds-preview/rollback.sh +++ b/scripts/early-birds-preview/rollback.sh @@ -3,6 +3,10 @@ set -eu . "$(dirname -- "$0")/lib.sh" env_file=${1:?usage: scripts/early-birds-preview/rollback.sh /secure/preview.env} require_synthetic_env "$env_file" -preview_compose_command "$env_file" stop beacon-stream -echo 'EarlyBirds stream origin stopped. No event service, event database, or production container was targeted.' -echo 'Disable the Listener entry feature flag in its owning application lane before announcing unavailability.' + +# Stop public-serving components only. PostgreSQL and its named volume remain +# intact for inspection and an additive forward fix. +preview_compose_command "$env_file" stop listener beacon-stream +echo 'EarlyBirds Listener and stream origin stopped; preview PostgreSQL was retained.' +echo 'Set EARLY_BIRDS_ENABLED=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.sh b/scripts/early-birds-preview/start.sh index 9278157c..882f1f66 100755 --- a/scripts/early-birds-preview/start.sh +++ b/scripts/early-birds-preview/start.sh @@ -3,5 +3,11 @@ set -eu . "$(dirname -- "$0")/lib.sh" env_file=${1:?usage: scripts/early-birds-preview/start.sh /secure/preview.env} require_synthetic_env "$env_file" -preview_compose_command "$env_file" up -d --build postgres beacon-stream -echo 'EarlyBirds synthetic preview started. Run health-smoke.sh after a synthetic artifact is available.' + +# Compose's completed-successfully dependency makes this order fail closed: +# PostgreSQL health -> forward-only migration -> Listener readiness. +preview_compose_command "$env_file" up -d --build listener beacon-stream +kill_switch=$(preview_env_value EARLY_BIRDS_ENABLED "$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 and EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=$team_entry_switch." +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 index bbd33307..0051994e 100755 --- a/scripts/early-birds-preview/stop.sh +++ b/scripts/early-birds-preview/stop.sh @@ -3,5 +3,5 @@ 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 postgres beacon-stream -echo 'EarlyBirds preview services stopped; preview volume retained for a migration rollback rehearsal.' +preview_compose_command "$env_file" stop listener 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 index 39178f1c..1a836717 100644 --- a/scripts/early-birds-preview/validate.mjs +++ b/scripts/early-birds-preview/validate.mjs @@ -1,5 +1,5 @@ -import { execFileSync } from 'node:child_process'; 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'; @@ -7,29 +7,148 @@ 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'); -await fs.writeFile(envFile, [ +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-local-only-not-a-secret', + '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_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_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=', + 'EARLY_BIRDS_APPLE_CLIENT_ID=', + 'EARLY_BIRDS_APPLE_CLIENT_SECRET=', + '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', + 'EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com', + '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_ARTIFACT_ID=synthetic-preview-artifact', + `EARLY_BIRDS_STREAM_SIGNING_SECRET=${syntheticSecret}`, + 'EARLY_BIRDS_DEVICE_PEPPER=synthetic-preview-device-pepper-at-least-32-characters', 'BEACON_STREAM_ARTIFACTS_HOST_PATH=.', 'BEACON_STREAM_MEDIA_ROOT=/media/artifacts', 'BEACON_STREAM_ARTIFACT_ID=synthetic-preview-artifact', - 'BEACON_STREAM_PUBLIC_ORIGIN=http://earlybirds-staging.localhost', - 'BEACON_STREAM_ALLOWED_ORIGINS=http://earlybirds-staging.localhost', - 'BEACON_STREAM_SIGNING_SECRET=synthetic-local-only-32-character-minimum-secret', + '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'), { mode: 0o600 }); +].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 { - 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')]; execFileSync('docker', [...composeArgs, 'config', '--quiet'], { stdio: 'inherit' }); - const resolved = JSON.parse(execFileSync('docker', [...composeArgs, 'config', '--format', 'json'], { encoding: 'utf8' })); - const build = resolved.services?.['beacon-stream']?.build; - assert.equal(build?.context, path.join(root, 'services/beacon-stream')); - assert.equal(build?.dockerfile, 'Dockerfile'); + const rendered = execFileSync('docker', [...composeArgs, 'config', '--format', 'json'], { + encoding: 'utf8', + }); + const resolved = JSON.parse(rendered); + const { postgres, migration, listener, 'beacon-stream': stream } = resolved.services; + + assert.deepEqual(Object.keys(resolved.services).sort(), [ + 'beacon-stream', 'listener', 'migration', 'postgres', + ]); + assert.equal(postgres.ports, undefined, 'PostgreSQL must not publish a host port'); + assert.deepEqual(Object.keys(postgres.networks), ['preview_db']); + 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.EARLY_BIRDS_ENABLED, '0'); + assert.equal(listener.environment.EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED, '0'); + 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.EARLY_BIRDS_APPLE_CLIENT_ID, ''); + 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']); + const appPort = publishedPort(listener, 3000); + assert.equal(appPort.host_ip, '127.0.0.1'); + assert.equal(Number(appPort.published), 13000); + + assert.equal(stream.build.context, path.join(root, 'services/beacon-stream')); + assert.equal(stream.build.dockerfile, 'Dockerfile'); + assert.deepEqual(Object.keys(stream.networks), ['stream_observability']); + assert.equal(resolved.networks.stream_observability.internal, true); + 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', + ]); + 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 }); From 2fafad31c915b3a3ef3e439c2d529e07286dbed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 03:38:34 -0300 Subject: [PATCH 027/376] fix(early-birds): isolate preview network identities --- ops/early-birds-preview/compose.yml | 12 +++++++++--- .../test/preview-contract.test.mjs | 10 ++++++++++ services/beacon-stream/docker-compose.yml | 6 ++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index 1596c779..46d1042f 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -11,7 +11,13 @@ services: POSTGRES_DB: ${EARLYBIRDS_PREVIEW_DB_NAME:?set_in_preview.env} volumes: - earlybirds-preview-postgres:/var/lib/postgresql/data - networks: [preview_db] + 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 @@ -32,7 +38,7 @@ services: working_dir: /app command: ["npx", "prisma", "migrate", "deploy"] environment: - DATABASE_URL: postgresql://${EARLYBIRDS_PREVIEW_DB_USER}:${EARLYBIRDS_PREVIEW_DB_PASSWORD}@postgres:5432/${EARLYBIRDS_PREVIEW_DB_NAME}?schema=public + 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] @@ -54,7 +60,7 @@ services: init: true environment: NODE_ENV: production - DATABASE_URL: postgresql://${EARLYBIRDS_PREVIEW_DB_USER}:${EARLYBIRDS_PREVIEW_DB_PASSWORD}@postgres:5432/${EARLYBIRDS_PREVIEW_DB_NAME}?schema=public + 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_AUTH_BASE_URL: ${EARLY_BIRDS_AUTH_BASE_URL:?set_in_preview.env} EARLY_BIRDS_TRUSTED_ORIGINS: ${EARLY_BIRDS_TRUSTED_ORIGINS:?set_in_preview.env} diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index e52859e2..32364b1e 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -74,6 +74,8 @@ test('compose gates the loopback Listener on a forward-only isolated database mi 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'); }); test('optional authority overlay joins only the dedicated external private network', async () => { @@ -96,6 +98,14 @@ test('stream overlay preserves its isolated build and adds a public liveness pro 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/); +}); + test('nginx templates name only the two staging hosts and proxy only fixed loopback ports', async () => { const app = await readPreview('nginx/earlybirds-staging.harmonicbeacon.com.conf.template'); const stream = await readPreview('nginx/stream.harmonicbeacon.com.conf.template'); diff --git a/services/beacon-stream/docker-compose.yml b/services/beacon-stream/docker-compose.yml index ca382440..581be7af 100644 --- a/services/beacon-stream/docker-compose.yml +++ b/services/beacon-stream/docker-compose.yml @@ -22,6 +22,10 @@ services: - "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 deploy: resources: limits: @@ -40,3 +44,5 @@ networks: stream_observability: name: earlybirds_stream_observability internal: true + stream_edge: + name: earlybirds_stream_edge From 2ac97944d3ff07c48d018c23a1fe635c8df900a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 03:44:11 -0300 Subject: [PATCH 028/376] chore(early-birds): add fail-closed ACME bootstrap --- .../nginx/acme-bootstrap.conf.template | 16 ++++++++++++++++ .../test/preview-contract.test.mjs | 9 +++++++++ 2 files changed, 25 insertions(+) create mode 100644 ops/early-birds-preview/nginx/acme-bootstrap.conf.template 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/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 32364b1e..05c010f0 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -129,6 +129,15 @@ test('nginx templates name only the two staging hosts and proxy only fixed loopb assert.doesNotMatch(stream, /proxy_pass[^\n]*(9090|readyz|metrics)/); }); +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('production Listener HTTPS validation remains fail closed', async () => { const streamContract = await readRepository('src/lib/early-birds/stream.ts'); assert.match( From 5daae176e97a1f0832ac3b0ae447c4a963a24aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 03:50:42 -0300 Subject: [PATCH 029/376] test(early-birds): add canonical Free staging smoke --- .../test/preview-contract.test.mjs | 10 +++++ .../canonical-free-smoke.sh | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100755 scripts/early-birds-preview/canonical-free-smoke.sh diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 05c010f0..3d60842f 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -165,3 +165,13 @@ test('smoke and rollback contracts cover both probes without deleting state', as assert.match(stop, /stop listener beacon-stream postgres/); assert.doesNotMatch(stop, /\bdown\b|-v\b|volume rm/); }); + +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, /early-birds\/home/); + assert.match(source, /trap 'rm -rf "\$temporary"'/); + assert.doesNotMatch(source, /echo[^\n]*(login_secret|invitation_token)/); +}); 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..279c07f0 --- /dev/null +++ b/scripts/early-birds-preview/canonical-free-smoke.sh @@ -0,0 +1,42 @@ +#!/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"}' "$synthetic_email" >"$temporary/login.json" +printf '{"token":"%s"}' "$invitation_token" >"$temporary/redeem.json" +printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' \ + "$login_secret" >"$temporary/login.curl" + +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 --header 'Content-Type: application/json' \ + --cookie "$temporary/cookies" --cookie-jar "$temporary/cookies" \ + --data-binary @"$temporary/redeem.json" "$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/early-birds/home") +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." From 63f943eda963d3cc6537fe50c333fc36775c165c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 03:57:43 -0300 Subject: [PATCH 030/376] fix(early-birds): align staging validation with edge network --- scripts/early-birds-preview/validate.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/early-birds-preview/validate.mjs b/scripts/early-birds-preview/validate.mjs index 1a836717..ad2bf430 100644 --- a/scripts/early-birds-preview/validate.mjs +++ b/scripts/early-birds-preview/validate.mjs @@ -78,6 +78,7 @@ try { ]); 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); @@ -109,8 +110,11 @@ try { assert.equal(stream.build.context, path.join(root, 'services/beacon-stream')); assert.equal(stream.build.dockerfile, 'Dockerfile'); - assert.deepEqual(Object.keys(stream.networks), ['stream_observability']); + assert.deepEqual(Object.keys(stream.networks).sort(), ['stream_edge', 'stream_observability']); 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); From 2f720eae7d02fbda50d6c952c91d9c5bdca4d5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 03:59:36 -0300 Subject: [PATCH 031/376] feat(early-birds): exercise canonical Free invitation entry --- .../canonical-free-smoke.sh | 2 +- .../test-login/__tests__/route.test.ts | 11 ++++++++- src/app/api/early-birds/test-login/route.ts | 8 +++++-- .../early-birds/EarlyBirdLanding.tsx | 7 +++++- .../early-birds/SyntheticTeamEntryForm.tsx | 12 +++++++--- .../__tests__/SyntheticTeamEntryForm.test.tsx | 23 +++++++++++++++++++ 6 files changed, 55 insertions(+), 8 deletions(-) diff --git a/scripts/early-birds-preview/canonical-free-smoke.sh b/scripts/early-birds-preview/canonical-free-smoke.sh index 279c07f0..1db701ef 100755 --- a/scripts/early-birds-preview/canonical-free-smoke.sh +++ b/scripts/early-birds-preview/canonical-free-smoke.sh @@ -16,7 +16,7 @@ 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"}' "$synthetic_email" >"$temporary/login.json" +printf '{"name":"Canonical Free smoke","email":"%s","authOnly":true}' "$synthetic_email" >"$temporary/login.json" printf '{"token":"%s"}' "$invitation_token" >"$temporary/redeem.json" printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' \ "$login_secret" >"$temporary/login.curl" 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 index 6415432a..1403c680 100644 --- a/src/app/api/early-birds/test-login/__tests__/route.test.ts +++ b/src/app/api/early-birds/test-login/__tests__/route.test.ts @@ -14,7 +14,7 @@ import { POST } from '../route'; const URL = 'https://app.example.test/api/early-birds/test-login'; -function request(authorization?: string): NextRequest { +function request(authorization?: string, authOnly = false): NextRequest { return new NextRequest(URL, { method: 'POST', headers: { @@ -26,6 +26,7 @@ function request(authorization?: string): NextRequest { body: JSON.stringify({ email: 'listener@e2e.invalid', name: 'Synthetic Listener', + authOnly, }), }); } @@ -86,4 +87,12 @@ describe('EarlyBird synthetic login seam', () => { expect(JSON.stringify(await internalRequest.clone().json())).not.toContain(secret); expect(mocks.issueMembership).toHaveBeenCalledWith('listener-synthetic-1'); }); + + it('creates only the staging identity when a canonical Free invitation will issue access', async () => { + const secret = 's'.repeat(32); + const response = await POST(request(`Bearer ${secret}`, true)); + expect(response.status).toBe(200); + expect(mocks.handler).toHaveBeenCalledOnce(); + expect(mocks.issueMembership).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/early-birds/test-login/route.ts b/src/app/api/early-birds/test-login/route.ts index 4b2747c4..7cf2313d 100644 --- a/src/app/api/early-birds/test-login/route.ts +++ b/src/app/api/early-birds/test-login/route.ts @@ -64,10 +64,12 @@ export async function POST(request: NextRequest): Promise { let email: string; let name: string; + let authOnly: boolean; try { - const body = await request.json() as { email?: unknown; name?: unknown }; + const body = await request.json() as { email?: unknown; name?: unknown; authOnly?: unknown }; email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''; name = typeof body.name === 'string' ? body.name.trim() : ''; + authOnly = body.authOnly === true; } catch { return NextResponse.json({ error: 'Malformed request.' }, { status: 400 }); } @@ -96,7 +98,9 @@ export async function POST(request: NextRequest): Promise { const payload = await authResponse.clone().json() as { user?: { id?: unknown } }; const accountId = typeof payload.user?.id === 'string' ? payload.user.id : null; if (!accountId) return NextResponse.json({ error: 'Synthetic login failed.' }, { status: 503 }); - await issueSyntheticMembership(accountId); + // Invitation acceptance uses this seam only for its staging identity. The + // canonical authority remains the sole issuer of the Free membership. + if (!authOnly) await issueSyntheticMembership(accountId); return NextResponse.json( { ok: true, landing: '/early-birds/home' }, diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index c8de702b..c0966543 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -112,7 +112,12 @@ export default function EarlyBirdLanding(props: Props) { )} ))} - {props.syntheticTeamEntryAvailable && } + {props.syntheticTeamEntryAvailable && ( + + )}
)} diff --git a/src/components/early-birds/SyntheticTeamEntryForm.tsx b/src/components/early-birds/SyntheticTeamEntryForm.tsx index d5d2f13b..b0578f7d 100644 --- a/src/components/early-birds/SyntheticTeamEntryForm.tsx +++ b/src/components/early-birds/SyntheticTeamEntryForm.tsx @@ -5,7 +5,13 @@ import { useState, type FormEvent } from 'react'; import { useLocale } from '@/context/LocaleContext'; import { earlyBirdSyntheticEntryCopy } from '@/lib/early-birds/copy'; -export default function SyntheticTeamEntryForm() { +export default function SyntheticTeamEntryForm({ + authOnly = false, + postLoginPath = '/early-birds/home', +}: { + authOnly?: boolean; + postLoginPath?: string; +}) { const { locale } = useLocale(); const copy = earlyBirdSyntheticEntryCopy[locale]; const [name, setName] = useState(''); @@ -32,10 +38,10 @@ export default function SyntheticTeamEntryForm() { authorization: `Bearer ${bearer}`, 'content-type': 'application/json', }, - body: JSON.stringify({ name, email }), + body: JSON.stringify({ name, email, authOnly }), }); if (!response.ok) throw new Error('synthetic entry unavailable'); - window.location.assign('/early-birds/home'); + window.location.assign(postLoginPath); } catch { setBusy(false); setFailed(true); diff --git a/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx b/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx index 20d003d3..99aaac64 100644 --- a/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx +++ b/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx @@ -48,9 +48,32 @@ describe('EarlyBird staging team entry form', () => { expect(JSON.parse(String(init.body))).toEqual({ name: 'Team Listener', email: 'team.listener@e2e.invalid', + authOnly: false, }); expect(screen.getByLabelText('Temporary access code')).toHaveValue(''); expect(JSON.stringify(window.localStorage)).not.toContain(accessCode); expect(JSON.stringify(window.sessionStorage)).not.toContain(accessCode); }); + + it('requests identity only when entering through a canonical Free invitation', async () => { + const request = vi.fn().mockResolvedValue(new Response('{}', { status: 404 })); + vi.stubGlobal('fetch', request); + render( + + + , + ); + + await userEvent.type(screen.getByLabelText('Test name'), 'Free Listener'); + await userEvent.type(screen.getByLabelText('Synthetic account'), 'free.listener@e2e.invalid'); + await userEvent.type(screen.getByLabelText('Temporary access code'), 'team-staging-access-code-0000000000000001'); + await userEvent.click(screen.getByRole('button', { name: 'Enter staging' })); + + await waitFor(() => expect(request).toHaveBeenCalledOnce()); + const [, init] = request.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toMatchObject({ authOnly: true }); + }); }); From b90429acd6826dafe4278cdd5ed00449282cfafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 04:03:02 -0300 Subject: [PATCH 032/376] test(early-birds): add external HLS load harness --- .gitignore | 1 + docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md | 192 ++++ tools/early-birds-hls-load/README.md | 104 ++ tools/early-birds-hls-load/aggregate.mjs | 51 + tools/early-birds-hls-load/package.json | 14 + tools/early-birds-hls-load/profiles.json | 85 ++ tools/early-birds-hls-load/run.mjs | 220 +++++ tools/early-birds-hls-load/src/contracts.mjs | 930 ++++++++++++++++++ tools/early-birds-hls-load/src/runner.mjs | 734 ++++++++++++++ .../target-policy.example.json | 31 + .../test/contracts.test.mjs | 356 +++++++ .../early-birds-hls-load/test/runner.test.mjs | 447 +++++++++ 12 files changed, 3165 insertions(+) create mode 100644 docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md create mode 100644 tools/early-birds-hls-load/README.md create mode 100644 tools/early-birds-hls-load/aggregate.mjs create mode 100644 tools/early-birds-hls-load/package.json create mode 100644 tools/early-birds-hls-load/profiles.json create mode 100644 tools/early-birds-hls-load/run.mjs create mode 100644 tools/early-birds-hls-load/src/contracts.mjs create mode 100644 tools/early-birds-hls-load/src/runner.mjs create mode 100644 tools/early-birds-hls-load/target-policy.example.json create mode 100644 tools/early-birds-hls-load/test/contracts.test.mjs create mode 100644 tools/early-birds-hls-load/test/runner.test.mjs diff --git a/.gitignore b/.gitignore index f5713255..dee5ef8a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ /playwright-report/ /e2e/.auth/ /artifacts/load-test/ +/artifacts/early-birds-hls-load/ # next.js /.next/ 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/tools/early-birds-hls-load/README.md b/tools/early-birds-hls-load/README.md new file mode 100644 index 00000000..ffacacf2 --- /dev/null +++ b/tools/early-birds-hls-load/README.md @@ -0,0 +1,104 @@ +# External EarlyBird HLS load harness + +This dependency-free Node 22 tool models the origin/CDN media-plane HTTP work +of deterministic HLS clients from one or more **external** load generators. It reads a media +playlist on a shared UTC schedule, follows newly visible segment sequences and +records request latency, status/errors, transferred bytes and +rebuffer-equivalent fetch misses. + +It deliberately does not exercise the Listener application manifest proxy, +Better Auth, PostgreSQL membership/lease checks, per-device cookies or one +connection pool per real browser. Therefore its 3,000/4,000/5,000 profiles are +media-plane evidence only, never end-to-end Listener capacity. A separate +synthetic-session/lease/browser stage is required before setting a customer +limit. + +It does not decode media or inspect codec, bitrate, sample rate, +channel count, loudness or gain. It cannot provide audio acceptance evidence. +It also does not use application sessions, event auth, LiveKit or production +runtime code. + +## Safety contract + +- Dry-run is the default rehearsal path and performs zero HTTP requests. +- A network run requires a target from an explicit JSON allowlist with + `production: false`, target-specific client/ramp/soak/shard limits, an exact + generated confirmation phrase and a shared UTC start. +- Staging origins must be HTTPS and every response must carry the exact + `X-Harmonic-Beacon-Environment: early-birds-staging[-suffix]` attestation + configured in the target policy. Redirects and every manifest/segment URL whose + exact origin is not allowlisted are refused. +- `harmonicbeacon.com`, `www.harmonicbeacon.com`, + `live.harmonicbeacon.com`, `app.harmonicbeacon.com` and live/prod-like host + labels are blocked. + A policy cannot declare a `production` or `live` environment. +- Staging network runs require + `EARLY_BIRDS_GENERATOR_ROLE=external-load-generator` and + `--external-generator`. Hostnames `mona`, `mona-*` and `mona.*` are refused. Never + execute a staging network run from the event/production VPS. +- Global hard bounds are 5,000 clients, 200 client starts/second, 60 minutes, + 64 shards and 1,000 clients per shard. Each target policy can only lower + these bounds and must also cap manifest cadence, request starts/second, + per-shard concurrency, segments/poll and manifest/segment body bytes. An + enforced per-shard rate limiter prevents phase bunching from exceeding the + target RPS budget. More than 250 clients requires at least two shards; more + than 1,000 requires at least four. +- Whole-run request-error and fetch-miss tolerances cannot exceed 10%; derived + rolling-window gates are capped at 20% and in-run circuit breakers at 25%, + regardless of a custom profile. +- The signed manifest URL lives only in a group/world-inaccessible file. That + file is refreshed from disk at least once per second so a separate approved control + plane can rotate short-lived URLs without giving the harness a signing key. +- Every staging shard records an externally measured UTC clock offset within + the target-policy bound. Obtain it from a trusted NTP/chrony source immediately + before the run. +- Evidence is created mode `0600` with no overwrite. It stores only origins, + path/input/host hashes and aggregate measurements—never a query string, + signed URL, authorization value, cookie, response body or raw error. + +## Files + +- `profiles.json`: reproducible bounded profiles. Only `tiny-synthetic` is + exercised by automated tests. +- `target-policy.example.json`: non-runnable placeholder. Copy it outside Git, + replace the `.invalid` origins with exact approved staging origins and set + limits no larger than the reviewed run. +- `run.mjs`: one dry-run or network shard. +- `aggregate.mjs`: verifies and combines one evidence file from every shard. + +## Tiny dry-run + +```bash +node tools/early-birds-hls-load/run.mjs \ + --policy /secure/early-birds-hls-targets.json \ + --target early-birds-staging \ + --profile staging-smoke \ + --run-id eb-staging-smoke-001 \ + --start-at 2026-08-07T15:00:00.000Z \ + --shard-index 0 \ + --shard-count 1 \ + --evidence artifacts/early-birds-hls-load/smoke-plan.json \ + --dry-run +``` + +Dry-run validates the complete plan, writes `PLANNED` evidence and prints the +exact confirmation required by a network run. It does not read a signed URL or +contact the target. + +See [`docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md`](../../docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md) +for the distributed procedure, stop conditions and evidence interpretation. + +## Development verification + +```bash +npm --prefix tools/early-birds-hls-load run check +npm --prefix tools/early-birds-hls-load test +``` + +The parser follows ordinary media playlists, opaque initialization maps, byte +ranges and declared gaps. It rejects master playlists, encrypted media and +LL-HLS parts instead of silently undercounting them; this says nothing about the +opaque media encoding. + +The test suite uses only a tiny loopback synthetic origin. It never contacts +staging, production, DNS or external media. diff --git a/tools/early-birds-hls-load/aggregate.mjs b/tools/early-birds-hls-load/aggregate.mjs new file mode 100644 index 00000000..941cbc65 --- /dev/null +++ b/tools/early-birds-hls-load/aggregate.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import { aggregateEvidence } from './src/contracts.mjs'; + +function printHelp() { + process.stdout.write( + 'Usage: node tools/early-birds-hls-load/aggregate.mjs --output PATH SHARD_EVIDENCE...\n', + ); +} + +async function main() { + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h')) return printHelp(); + const outputIndex = args.indexOf('--output'); + if (outputIndex < 0 || !args[outputIndex + 1]) throw new Error('--output is required'); + const output = resolve(args[outputIndex + 1]); + const sources = args.filter((_, index) => index !== outputIndex && index !== outputIndex + 1); + if (sources.length === 0 || sources.some((value) => value.startsWith('--'))) { + throw new Error('one exact shard evidence path per shard is required'); + } + try { + await access(output, constants.F_OK); + throw new Error('refusing to overwrite an existing aggregate evidence file'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + const entries = await Promise.all(sources.map(async (source) => { + const bytes = await readFile(resolve(source)); + return { + sha256: createHash('sha256').update(bytes).digest('hex'), + evidence: JSON.parse(bytes.toString('utf8')), + }; + })); + const aggregate = aggregateEvidence(entries); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, `${JSON.stringify(aggregate, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); + process.stdout.write(`Aggregate evidence: ${output}\nStatus: ${aggregate.status}\n`); + if (aggregate.status !== 'PASS') process.exitCode = 1; +} + +main().catch((error) => { + process.stderr.write( + `EarlyBird HLS evidence aggregation refused: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/tools/early-birds-hls-load/package.json b/tools/early-birds-hls-load/package.json new file mode 100644 index 00000000..da79d764 --- /dev/null +++ b/tools/early-birds-hls-load/package.json @@ -0,0 +1,14 @@ +{ + "name": "harmonic-beacon-early-birds-hls-load", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "External, bounded, format-neutral HLS load and soak evidence harness", + "scripts": { + "check": "node --check run.mjs && node --check aggregate.mjs && node --check src/contracts.mjs && node --check src/runner.mjs", + "test": "node --test test/*.test.mjs" + }, + "engines": { + "node": ">=22" + } +} diff --git a/tools/early-birds-hls-load/profiles.json b/tools/early-birds-hls-load/profiles.json new file mode 100644 index 00000000..07601293 --- /dev/null +++ b/tools/early-birds-hls-load/profiles.json @@ -0,0 +1,85 @@ +{ + "schemaVersion": 1, + "profiles": { + "tiny-synthetic": { + "clients": 2, + "rampPerSecond": 2, + "soakSeconds": 1, + "manifestIntervalMs": 100, + "requestTimeoutMs": 100, + "startupSegments": 1, + "maxSegmentsPerPoll": 2, + "maxInflightPerShard": 4, + "minShards": 1, + "maxErrorRate": 0, + "maxFetchMissRate": 0, + "maxManifestP95Ms": 100, + "maxSegmentP95Ms": 100, + "syntheticOnly": true + }, + "staging-smoke": { + "clients": 10, + "rampPerSecond": 2, + "soakSeconds": 60, + "manifestIntervalMs": 3000, + "requestTimeoutMs": 5000, + "startupSegments": 2, + "maxSegmentsPerPoll": 6, + "maxInflightPerShard": 16, + "minShards": 1, + "maxErrorRate": 0.01, + "maxFetchMissRate": 0.01, + "maxManifestP95Ms": 1000, + "maxSegmentP95Ms": 2000, + "syntheticOnly": false + }, + "origin-media-3000": { + "clients": 3000, + "rampPerSecond": 50, + "soakSeconds": 1200, + "manifestIntervalMs": 3000, + "requestTimeoutMs": 5000, + "startupSegments": 2, + "maxSegmentsPerPoll": 6, + "maxInflightPerShard": 128, + "minShards": 4, + "maxErrorRate": 0.005, + "maxFetchMissRate": 0.005, + "maxManifestP95Ms": 1000, + "maxSegmentP95Ms": 2000, + "syntheticOnly": false + }, + "origin-media-4000-expansion": { + "clients": 4000, + "rampPerSecond": 50, + "soakSeconds": 1200, + "manifestIntervalMs": 3000, + "requestTimeoutMs": 5000, + "startupSegments": 2, + "maxSegmentsPerPoll": 6, + "maxInflightPerShard": 128, + "minShards": 6, + "maxErrorRate": 0.005, + "maxFetchMissRate": 0.005, + "maxManifestP95Ms": 1000, + "maxSegmentP95Ms": 2000, + "syntheticOnly": false + }, + "origin-media-5000-critical": { + "clients": 5000, + "rampPerSecond": 50, + "soakSeconds": 1200, + "manifestIntervalMs": 3000, + "requestTimeoutMs": 5000, + "startupSegments": 2, + "maxSegmentsPerPoll": 6, + "maxInflightPerShard": 128, + "minShards": 8, + "maxErrorRate": 0.005, + "maxFetchMissRate": 0.005, + "maxManifestP95Ms": 1000, + "maxSegmentP95Ms": 2000, + "syntheticOnly": false + } + } +} diff --git a/tools/early-birds-hls-load/run.mjs b/tools/early-birds-hls-load/run.mjs new file mode 100644 index 00000000..4642e155 --- /dev/null +++ b/tools/early-birds-hls-load/run.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node + +import { constants } from 'node:fs'; +import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildPlan, selectTarget, sha256 } from './src/contracts.mjs'; +import { plannedEvidence, runShard } from './src/runner.mjs'; + +const toolRoot = dirname(fileURLToPath(import.meta.url)); + +function option(args, name, fallback = null) { + const index = args.indexOf(name); + if (index < 0) return fallback; + if (!args[index + 1] || args[index + 1].startsWith('--')) throw new Error(`${name} requires a value`); + return args[index + 1]; +} + +function integerOption(args, name, fallback) { + const value = Number(option(args, name, String(fallback))); + if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`); + return value; +} + +function numberOption(args, name, fallback = null) { + const raw = option(args, name, fallback === null ? null : String(fallback)); + if (raw === null) return null; + const value = Number(raw); + if (!Number.isFinite(value)) throw new Error(`${name} must be a finite number`); + return value; +} + +function printHelp() { + process.stdout.write(`Usage: + node tools/early-birds-hls-load/run.mjs \\ + --policy /secure/target-policy.json --target TARGET_ID \\ + --profile PROFILE --run-id RUN_ID --start-at UTC \\ + --shard-index N --shard-count N --evidence PATH --dry-run + +For a network run, remove --dry-run and also provide: + --manifest-url-file /secure/current-manifest-url + --confirm "" + --external-generator + --clock-offset-ms NUMBER Measured system-clock offset from trusted UTC + +The staging generator must set: + EARLY_BIRDS_GENERATOR_ROLE=external-load-generator + +Options: + --profiles PATH Profile document (default: bundled profiles.json) + --policy PATH Explicit non-production target allowlist and limits + --target ID Target entry in the policy + --profile NAME Bounded load profile + --run-id ID Safe, shared run identifier + --start-at UTC Shared deterministic UTC start, ending in Z + --shard-index N Zero-based shard index (default: 0) + --shard-count N Total distributed shards (default: 1) + --evidence PATH New redacted evidence file; overwrite is refused + --manifest-url-file PATH 0600 file; refreshed from disk at least once/second + --confirm TEXT Exact target/profile/start confirmation + --external-generator Assert execution is external and not mona + --dry-run Validate and write PLANNED evidence; zero HTTP requests +`); +} + +async function readJson(path, label) { + let bytes; + try { + bytes = await readFile(path); + } catch { + throw new Error(`cannot read ${label}`); + } + try { + return { bytes, value: JSON.parse(bytes.toString('utf8')) }; + } catch { + throw new Error(`${label} is not valid JSON`); + } +} + +async function assertNewFile(path) { + try { + await access(path, constants.F_OK); + throw new Error('refusing to overwrite an existing evidence file'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +} + +async function readManifestUrl(path) { + const details = await stat(path); + if (!details.isFile()) throw new Error('manifest URL source must be a regular file'); + if ((details.mode & 0o077) !== 0) throw new Error('manifest URL source must not be group/world accessible'); + const value = (await readFile(path, 'utf8')).trim(); + if (!value || value.includes('\n') || value.includes('\r')) { + throw new Error('manifest URL source must contain exactly one URL'); + } + return value; +} + +function createManifestUrlProvider(path) { + let cached = null; + let loadedAtMs = 0; + let pending = null; + return async () => { + if (cached !== null && Date.now() - loadedAtMs < 1_000) return cached; + if (!pending) { + pending = readManifestUrl(path).then((value) => { + cached = value; + loadedAtMs = Date.now(); + return value; + }).finally(() => { pending = null; }); + } + return pending; + }; +} + +async function main() { + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h')) { + printHelp(); + return; + } + const known = new Set([ + '--profiles', '--policy', '--target', '--profile', '--run-id', '--start-at', + '--shard-index', '--shard-count', '--evidence', '--manifest-url-file', '--confirm', + '--external-generator', '--clock-offset-ms', '--dry-run', + ]); + for (const argument of args.filter((value) => value.startsWith('--'))) { + if (!known.has(argument)) throw new Error(`unknown option ${argument}`); + } + const dryRun = args.includes('--dry-run'); + const profilesPath = resolve(option(args, '--profiles', resolve(toolRoot, 'profiles.json'))); + const policyOption = option(args, '--policy'); + if (!policyOption) throw new Error('--policy is required'); + const policyPath = resolve(policyOption); + const profileName = option(args, '--profile'); + const targetId = option(args, '--target'); + const runId = option(args, '--run-id'); + const startAt = option(args, '--start-at'); + const evidenceOption = option(args, '--evidence'); + if (!profileName || !targetId || !runId || !startAt || !evidenceOption) { + throw new Error('--target, --profile, --run-id, --start-at and --evidence are required'); + } + const evidencePath = resolve(evidenceOption); + await assertNewFile(evidencePath); + const [profilesDocument, policyDocument] = await Promise.all([ + readJson(profilesPath, 'profile document'), + readJson(policyPath, 'target policy'), + ]); + if (profilesDocument.value?.schemaVersion !== 1) { + throw new Error('profile document schemaVersion must be 1'); + } + const profile = profilesDocument.value.profiles?.[profileName]; + if (!profile) throw new Error(`unknown profile ${profileName}`); + const target = selectTarget(policyDocument.value, targetId); + const plan = buildPlan({ + runId, + profileName, + profile, + target, + shardIndex: integerOption(args, '--shard-index', 0), + shardCount: integerOption(args, '--shard-count', 1), + startAt, + networkRun: !dryRun, + }); + const shared = { + plan, + target, + policySha256: sha256(policyDocument.bytes), + profileSha256: sha256(profilesDocument.bytes), + }; + let evidence; + if (dryRun) { + evidence = plannedEvidence(shared); + process.stdout.write(`Network requests: 0\nExact confirmation for this plan:\n${plan.confirmation}\n`); + } else { + const manifestPathOption = option(args, '--manifest-url-file'); + if (!manifestPathOption) throw new Error('--manifest-url-file is required for a network run'); + const manifestPath = resolve(manifestPathOption); + const provideManifestUrl = createManifestUrlProvider(manifestPath); + const clockOffsetMs = numberOption(args, '--clock-offset-ms'); + if (clockOffsetMs === null) throw new Error('--clock-offset-ms is required for a network run'); + const controller = new AbortController(); + const abort = () => controller.abort(); + process.once('SIGINT', abort); + process.once('SIGTERM', abort); + evidence = await runShard({ + ...shared, + confirmation: option(args, '--confirm'), + manifestUrlProvider: provideManifestUrl, + externalGenerator: args.includes('--external-generator'), + declaredGeneratorRole: process.env.EARLY_BIRDS_GENERATOR_ROLE, + clockOffsetMs, + onProgress: (progress) => { + process.stdout.write( + `Progress requests=${progress.requests} failed=${progress.failed} ` + + `bytes=${progress.bytes} fetch_misses=${progress.fetchMisses} ` + + `clients=${progress.clientsCompleted}/${progress.clientsStarted}\n`, + ); + }, + signal: controller.signal, + }); + } + await mkdir(dirname(evidencePath), { recursive: true }); + await writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, { + mode: 0o600, + flag: 'wx', + }); + process.stdout.write(`Evidence: ${evidencePath}\nStatus: ${evidence.status}\n`); + if (evidence.status === 'FAIL') process.exitCode = 1; + if (evidence.status === 'ABORTED') process.exitCode = 130; +} + +main().catch((error) => { + process.stderr.write( + `EarlyBird HLS load harness refused or failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/tools/early-birds-hls-load/src/contracts.mjs b/tools/early-birds-hls-load/src/contracts.mjs new file mode 100644 index 00000000..2680658a --- /dev/null +++ b/tools/early-birds-hls-load/src/contracts.mjs @@ -0,0 +1,930 @@ +import { createHash } from 'node:crypto'; + +export const EVIDENCE_KIND = 'harmonic-beacon-early-birds-hls-origin-media-load'; +export const EVIDENCE_SCHEMA_VERSION = 1; +export const GENERATOR_ROLE = 'external-load-generator'; + +export const LATENCY_BUCKETS_MS = Object.freeze([ + 25, + 50, + 100, + 250, + 500, + 1_000, + 2_000, + 5_000, + 10_000, + 30_000, +]); + +const ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,63}$/; +const UTC_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/; +const HARD_LIMITS = Object.freeze({ + clients: 5_000, + rampPerSecond: 200, + soakSeconds: 3_600, + shardCount: 64, + clientsPerShard: 1_000, + manifestIntervalMs: 30_000, + requestTimeoutMs: 30_000, + maxSegmentsPerPoll: 12, + maxRequestsPerSecond: 100_000, + maxManifestBytes: 1024 * 1024, + maxSegmentBytes: 64 * 1024 * 1024, + qualityFailureRate: 0.1, +}); +const BLOCKED_TARGET_HOSTS = new Set([ + 'harmonicbeacon.com', + 'www.harmonicbeacon.com', + 'live.harmonicbeacon.com', + 'app.harmonicbeacon.com', +]); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function positiveInteger(value, field, maximum) { + assert(Number.isSafeInteger(value) && value > 0, `${field} must be a positive integer`); + assert(value <= maximum, `${field} exceeds the hard limit of ${maximum}`); + return value; +} + +function rate(value, field, maximum = 1) { + assert(typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= maximum, + `${field} must be between zero and ${maximum}`); + return value; +} + +export function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => ( + `${JSON.stringify(key)}:${canonicalJson(value[key])}` + )).join(',')}}`; + } + return JSON.stringify(value); +} + +export function canonicalOrigin(value) { + const url = new URL(String(value)); + assert(['http:', 'https:'].includes(url.protocol), 'target origins must use HTTP or HTTPS'); + assert(!url.username && !url.password, 'target origins cannot contain credentials'); + assert(url.pathname === '/' && !url.search && !url.hash, + 'target origins must not contain a path, query, or fragment'); + return url.origin; +} + +function validateTarget(target) { + assert(target && typeof target === 'object', 'target policy entry must be an object'); + assert(typeof target.id === 'string' && ID_PATTERN.test(target.id), 'target id is invalid'); + assert(['staging', 'synthetic'].includes(target.environment), + 'target environment must be staging or synthetic; live/production is forbidden'); + assert(target.production === false, 'target policy must explicitly set production to false'); + let attestation = null; + if (target.environment === 'staging') { + assert(target.attestation && typeof target.attestation === 'object', + 'staging target requires an exact response-header attestation'); + const header = String(target.attestation.header ?? '').toLowerCase(); + const value = String(target.attestation.value ?? ''); + assert(header === 'x-harmonic-beacon-environment', + 'staging attestation header must be x-harmonic-beacon-environment'); + assert(/^early-birds-staging(?:-[a-z0-9-]+)?$/.test(value), + 'staging attestation value must identify early-birds-staging'); + attestation = Object.freeze({ header, value }); + } + assert(Array.isArray(target.origins) && target.origins.length > 0 && target.origins.length <= 8, + 'target must allowlist between one and eight exact origins'); + const origins = target.origins.map(canonicalOrigin); + assert(new Set(origins).size === origins.length, 'target origins must be unique'); + for (const origin of origins) { + const url = new URL(origin); + const hostname = url.hostname.toLowerCase().replace(/\.+$/, ''); + const labels = hostname.split('.'); + assert(!BLOCKED_TARGET_HOSTS.has(hostname) + && !hostname.endsWith('.live.harmonicbeacon.com'), + `production target hostname ${url.hostname} is forbidden`); + assert(!labels.some((label) => ['live', 'prod', 'production'].includes(label)), + `production-like target hostname ${url.hostname} is forbidden`); + if (target.environment === 'staging') { + assert(url.protocol === 'https:', 'staging target origins must use HTTPS'); + } else { + assert(['localhost', '127.0.0.1', '::1'].includes(url.hostname), + 'synthetic targets must be loopback-only'); + } + } + const limits = target.limits; + assert(limits && typeof limits === 'object', 'target limits are required'); + const normalizedLimits = Object.freeze({ + maxClients: positiveInteger(limits.maxClients, 'limits.maxClients', HARD_LIMITS.clients), + maxRampPerSecond: positiveInteger( + limits.maxRampPerSecond, + 'limits.maxRampPerSecond', + HARD_LIMITS.rampPerSecond, + ), + maxSoakSeconds: positiveInteger( + limits.maxSoakSeconds, + 'limits.maxSoakSeconds', + HARD_LIMITS.soakSeconds, + ), + maxShardCount: positiveInteger( + limits.maxShardCount, + 'limits.maxShardCount', + HARD_LIMITS.shardCount, + ), + minManifestIntervalMs: positiveInteger( + limits.minManifestIntervalMs, + 'limits.minManifestIntervalMs', + HARD_LIMITS.manifestIntervalMs, + ), + maxInflightPerShard: positiveInteger( + limits.maxInflightPerShard, + 'limits.maxInflightPerShard', + 512, + ), + maxSegmentsPerPoll: positiveInteger( + limits.maxSegmentsPerPoll, + 'limits.maxSegmentsPerPoll', + HARD_LIMITS.maxSegmentsPerPoll, + ), + maxRequestsPerSecond: positiveInteger( + limits.maxRequestsPerSecond, + 'limits.maxRequestsPerSecond', + HARD_LIMITS.maxRequestsPerSecond, + ), + maxManifestBytes: positiveInteger( + limits.maxManifestBytes, + 'limits.maxManifestBytes', + HARD_LIMITS.maxManifestBytes, + ), + maxSegmentBytes: positiveInteger( + limits.maxSegmentBytes, + 'limits.maxSegmentBytes', + HARD_LIMITS.maxSegmentBytes, + ), + maxClockOffsetMs: positiveInteger( + limits.maxClockOffsetMs, + 'limits.maxClockOffsetMs', + 1_000, + ), + }); + assert(normalizedLimits.maxInflightPerShard * normalizedLimits.maxSegmentBytes + <= 512 * 1024 * 1024, + 'target maxInflightPerShard × maxSegmentBytes exceeds the 512 MiB safety product'); + return Object.freeze({ + id: target.id, + environment: target.environment, + production: false, + attestation, + origins: Object.freeze(origins), + limits: normalizedLimits, + }); +} + +export function selectTarget(policy, targetId) { + assert(policy?.schemaVersion === 1, 'target policy schemaVersion must be 1'); + assert(Array.isArray(policy.targets) && policy.targets.length > 0, + 'target policy must contain targets'); + const matches = policy.targets.filter((target) => target?.id === targetId); + assert(matches.length === 1, `target policy must contain exactly one ${targetId} entry`); + return validateTarget(matches[0]); +} + +export function validateProfile(profile, profileName = 'profile') { + assert(profile && typeof profile === 'object', `${profileName} must be an object`); + const normalized = { + clients: positiveInteger(profile.clients, 'clients', HARD_LIMITS.clients), + rampPerSecond: positiveInteger( + profile.rampPerSecond, + 'rampPerSecond', + HARD_LIMITS.rampPerSecond, + ), + soakSeconds: positiveInteger(profile.soakSeconds, 'soakSeconds', HARD_LIMITS.soakSeconds), + manifestIntervalMs: positiveInteger( + profile.manifestIntervalMs, + 'manifestIntervalMs', + HARD_LIMITS.manifestIntervalMs, + ), + requestTimeoutMs: positiveInteger( + profile.requestTimeoutMs, + 'requestTimeoutMs', + HARD_LIMITS.requestTimeoutMs, + ), + startupSegments: positiveInteger( + profile.startupSegments, + 'startupSegments', + HARD_LIMITS.maxSegmentsPerPoll, + ), + maxSegmentsPerPoll: positiveInteger( + profile.maxSegmentsPerPoll, + 'maxSegmentsPerPoll', + HARD_LIMITS.maxSegmentsPerPoll, + ), + maxInflightPerShard: positiveInteger( + profile.maxInflightPerShard, + 'maxInflightPerShard', + 512, + ), + minShards: positiveInteger(profile.minShards, 'minShards', HARD_LIMITS.shardCount), + maxErrorRate: rate( + profile.maxErrorRate, + 'maxErrorRate', + HARD_LIMITS.qualityFailureRate, + ), + maxFetchMissRate: rate( + profile.maxFetchMissRate, + 'maxFetchMissRate', + HARD_LIMITS.qualityFailureRate, + ), + maxManifestP95Ms: positiveInteger( + profile.maxManifestP95Ms, + 'maxManifestP95Ms', + HARD_LIMITS.requestTimeoutMs, + ), + maxSegmentP95Ms: positiveInteger( + profile.maxSegmentP95Ms, + 'maxSegmentP95Ms', + HARD_LIMITS.requestTimeoutMs, + ), + syntheticOnly: profile.syntheticOnly === true, + }; + assert(normalized.startupSegments <= normalized.maxSegmentsPerPoll, + 'startupSegments cannot exceed maxSegmentsPerPoll'); + assert(normalized.requestTimeoutMs <= normalized.manifestIntervalMs * 2, + 'requestTimeoutMs cannot exceed twice manifestIntervalMs'); + return Object.freeze(normalized); +} + +export function deriveThresholds(profile) { + return Object.freeze({ + maxErrorRate: profile.maxErrorRate, + maxFetchMissRate: profile.maxFetchMissRate, + maxManifestP95Ms: profile.maxManifestP95Ms, + maxSegmentP95Ms: profile.maxSegmentP95Ms, + rollingWindowSeconds: 10, + rollingMinRequests: 20, + rollingMinFetchOpportunities: 10, + maxRollingErrorRate: Math.min(0.2, Math.max(profile.maxErrorRate * 2, 0.02)), + maxRollingFetchMissRate: Math.min( + 0.2, + Math.max(profile.maxFetchMissRate * 2, 0.02), + ), + circuitBreakerMinRequests: 50, + circuitBreakerMinFetchOpportunities: 25, + circuitBreakerErrorRate: Math.min(0.25, Math.max(profile.maxErrorRate * 3, 0.05)), + circuitBreakerFetchMissRate: Math.min( + 0.25, + Math.max(profile.maxFetchMissRate * 3, 0.05), + ), + }); +} + +export function parseUtc(value, field = 'startAt') { + assert(typeof value === 'string' && UTC_PATTERN.test(value), + `${field} must be an explicit UTC timestamp ending in Z`); + const milliseconds = Date.parse(value); + assert(Number.isFinite(milliseconds), `${field} is not a valid timestamp`); + const [datePart, timePart] = value.split('T'); + const [year, month, day] = datePart.split('-').map(Number); + const [hour, minute, rawSecond] = timePart.slice(0, -1).split(':'); + const second = Number(rawSecond.split('.')[0]); + const parsed = new Date(milliseconds); + assert(parsed.getUTCFullYear() === year + && parsed.getUTCMonth() + 1 === month + && parsed.getUTCDate() === day + && parsed.getUTCHours() === Number(hour) + && parsed.getUTCMinutes() === Number(minute) + && parsed.getUTCSeconds() === second, + `${field} contains an invalid UTC civil date`); + return parsed.toISOString(); +} + +export function clientOrdinals(totalClients, shardIndex, shardCount) { + const ordinals = []; + for (let ordinal = shardIndex; ordinal < totalClients; ordinal += shardCount) { + ordinals.push(ordinal); + } + return ordinals; +} + +export function expectedConfirmation({ targetId, runId, profile, shardCount, startAt }) { + return [ + 'EARLYBIRDS-HLS-LOAD', + `target=${targetId}`, + `run=${runId}`, + `clients=${profile.clients}`, + `ramp=${profile.rampPerSecond}/s`, + `soak=${profile.soakSeconds}s`, + `shards=${shardCount}`, + `start=${startAt}`, + 'generator=external-not-mona', + ].join(' '); +} + +export function buildPlan({ + runId, + profileName, + profile: rawProfile, + target, + shardIndex, + shardCount, + startAt: rawStartAt, + networkRun, + nowMs = Date.now(), +}) { + assert(typeof runId === 'string' && ID_PATTERN.test(runId), 'run id is invalid'); + assert(typeof profileName === 'string' && ID_PATTERN.test(profileName), 'profile name is invalid'); + const profile = validateProfile(rawProfile, profileName); + assert(Number.isInteger(shardCount) && shardCount >= 1 && shardCount <= HARD_LIMITS.shardCount, + `shardCount must be between 1 and ${HARD_LIMITS.shardCount}`); + assert(Number.isInteger(shardIndex) && shardIndex >= 0 && shardIndex < shardCount, + 'shardIndex must be between zero and shardCount - 1'); + assert(shardCount >= profile.minShards, + `profile ${profileName} requires at least ${profile.minShards} shards`); + assert(shardCount <= target.limits.maxShardCount, 'shardCount exceeds the target policy'); + assert(profile.clients <= target.limits.maxClients, 'clients exceed the target policy'); + assert(profile.rampPerSecond <= target.limits.maxRampPerSecond, + 'rampPerSecond exceeds the target policy'); + assert(profile.soakSeconds <= target.limits.maxSoakSeconds, + 'soakSeconds exceeds the target policy'); + assert(!(profile.syntheticOnly && target.environment !== 'synthetic'), + 'synthetic-only profiles cannot target staging'); + const minimumManifestIntervalMs = Math.max( + target.environment === 'staging' ? 1_000 : 25, + target.limits.minManifestIntervalMs, + ); + assert(profile.manifestIntervalMs >= minimumManifestIntervalMs, + `${target.environment} manifestIntervalMs must be at least ${minimumManifestIntervalMs}`); + assert(profile.maxInflightPerShard <= target.limits.maxInflightPerShard, + 'maxInflightPerShard exceeds the target policy'); + assert(profile.maxSegmentsPerPoll <= target.limits.maxSegmentsPerPoll, + 'maxSegmentsPerPoll exceeds the target policy'); + const steadyRequestStartsPerSecond = Math.ceil( + profile.clients * 1000 / profile.manifestIntervalMs, + ) * (1 + profile.maxSegmentsPerPoll); + const rampRequestStartsPerSecond = profile.rampPerSecond * (1 + profile.startupSegments); + const plannedMaxRequestStartsPerSecond = Math.max( + steadyRequestStartsPerSecond, + rampRequestStartsPerSecond, + ); + assert(plannedMaxRequestStartsPerSecond <= target.limits.maxRequestsPerSecond, + 'derived request starts per second exceed the target policy'); + if (profile.clients > 250) assert(shardCount >= 2, 'more than 250 clients require sharding'); + if (profile.clients > 1_000) assert(shardCount >= 4, 'more than 1,000 clients require at least four shards'); + + const ordinals = clientOrdinals(profile.clients, shardIndex, shardCount); + assert(ordinals.length <= HARD_LIMITS.clientsPerShard, + `each shard is limited to ${HARD_LIMITS.clientsPerShard} clients`); + const startAt = parseUtc(rawStartAt); + const startMs = Date.parse(startAt); + if (networkRun && target.environment === 'staging') { + assert(startMs >= nowMs + 10_000, 'staging startAt must be at least ten seconds in the future'); + assert(startMs <= nowMs + 6 * 60 * 60 * 1000, + 'staging startAt cannot be more than six hours in the future'); + } + const rampDurationSeconds = Math.ceil(Math.max(0, profile.clients - 1) / profile.rampPerSecond); + const endAt = new Date(startMs + (rampDurationSeconds + profile.soakSeconds) * 1000).toISOString(); + const globalPlan = { + runId, + profileName, + profile, + target: { + id: target.id, + environment: target.environment, + origins: target.origins, + limits: target.limits, + attestation: target.attestation, + }, + startAt, + endAt, + rampDurationSeconds, + shardCount, + plannedMaxRequestStartsPerSecond, + }; + const planHash = sha256(canonicalJson(globalPlan)); + const confirmation = expectedConfirmation({ + targetId: target.id, + runId, + profile, + shardCount, + startAt, + }); + return Object.freeze({ + ...globalPlan, + planHash, + confirmation: `${confirmation} plan=${planHash}`, + shard: Object.freeze({ + index: shardIndex, + count: shardCount, + localClients: ordinals.length, + clientOrdinals: Object.freeze(ordinals), + clientOrdinalsSha256: sha256(ordinals.join(',')), + }), + }); +} + +export function assertExternalGenerator({ + hostname, + declaredRole, + externalConfirmed, + targetEnvironment, + networkRun, +}) { + if (!networkRun || targetEnvironment === 'synthetic') return; + const normalizedHostname = String(hostname ?? '').trim().toLowerCase(); + const hostLabels = normalizedHostname.replace(/\.+$/, '').split('.'); + assert(!hostLabels.some((label) => label === 'mona' || label.startsWith('mona-')), + 'network load is forbidden from mona'); + assert(declaredRole === GENERATOR_ROLE, + `EARLY_BIRDS_GENERATOR_ROLE must equal ${GENERATOR_ROLE}`); + assert(externalConfirmed === true, '--external-generator is required for staging network runs'); +} + +export function assertAllowedUrl(value, allowedOrigins) { + const url = new URL(String(value)); + assert(['http:', 'https:'].includes(url.protocol), 'request URL must use HTTP or HTTPS'); + assert(!url.username && !url.password, 'request URL cannot contain credentials'); + assert(!url.hash, 'request URL cannot contain a fragment'); + assert(allowedOrigins.includes(url.origin), `request URL escaped the exact target allowlist: ${url.origin}`); + return url; +} + +export function parseMediaPlaylist(text, manifestUrl) { + assert(typeof text === 'string' && text.length > 0, 'manifest body is empty'); + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + assert(lines[0] === '#EXTM3U', 'manifest is not HLS'); + assert(!lines.some((line) => line.startsWith('#EXT-X-STREAM-INF')), + 'master playlists are not supported; target one media playlist explicitly'); + assert(!lines.some((line) => line.startsWith('#EXT-X-KEY:') && !line.includes('METHOD=NONE')), + 'encrypted media playlists are not supported by this fetch model'); + assert(!lines.some((line) => line.startsWith('#EXT-X-PART:') + || line.startsWith('#EXT-X-PRELOAD-HINT:')), + 'low-latency HLS parts are not supported by this fetch model'); + let mediaSequence = 0; + let durationSeconds = null; + let byteRange = null; + let previousByteRangeEnd = null; + let previousByteRangeUrl = null; + let initialization = null; + let declaredGap = false; + const segments = []; + for (const line of lines.slice(1)) { + if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) { + mediaSequence = Number(line.slice('#EXT-X-MEDIA-SEQUENCE:'.length)); + assert(Number.isSafeInteger(mediaSequence) && mediaSequence >= 0, + 'manifest media sequence is invalid'); + } else if (line.startsWith('#EXTINF:')) { + durationSeconds = Number(line.slice('#EXTINF:'.length).split(',')[0]); + assert(Number.isFinite(durationSeconds) && durationSeconds > 0 && durationSeconds <= 600, + 'manifest segment duration is invalid'); + } else if (line.startsWith('#EXT-X-BYTERANGE:')) { + const raw = line.slice('#EXT-X-BYTERANGE:'.length); + assert(/^\d+(?:@\d+)?$/.test(raw), 'manifest byte range is invalid'); + const [length, offset] = raw.split('@').map(Number); + assert(Number.isSafeInteger(length) && length > 0, + 'manifest byte range length must be a positive safe integer'); + assert(offset === undefined || (Number.isSafeInteger(offset) && offset >= 0), + 'manifest byte range offset must be a non-negative safe integer'); + byteRange = { length, offset: offset ?? null }; + } else if (line.startsWith('#EXT-X-MAP:')) { + const attributes = line.slice('#EXT-X-MAP:'.length); + const uri = attributes.match(/(?:^|,)\s*URI="([^"]+)"/)?.[1]; + assert(uri, 'manifest initialization map URI is invalid'); + const rawRange = attributes.match(/(?:^|,)\s*BYTERANGE="(\d+)(?:@(\d+))?"/)?.slice(1); + let mapRange = null; + if (rawRange) { + const length = Number(rawRange[0]); + const start = rawRange[1] === undefined ? 0 : Number(rawRange[1]); + assert(Number.isSafeInteger(length) && length > 0 + && Number.isSafeInteger(start) && start >= 0 + && Number.isSafeInteger(start + length - 1), + 'manifest initialization byte range is invalid'); + mapRange = { start, end: start + length - 1 }; + } + initialization = { + url: new URL(uri, manifestUrl).toString(), + byteRange: mapRange, + }; + } else if (line === '#EXT-X-GAP') { + declaredGap = true; + } else if (!line.startsWith('#')) { + assert(durationSeconds !== null, 'segment URI is missing EXTINF duration'); + const url = new URL(line, manifestUrl).toString(); + let normalizedByteRange = null; + if (byteRange) { + const start = byteRange.offset ?? ( + previousByteRangeUrl === url && previousByteRangeEnd !== null + ? previousByteRangeEnd + 1 + : null + ); + assert(start !== null, 'implicit byte range offset has no preceding range for the same URI'); + normalizedByteRange = { start, end: start + byteRange.length - 1 }; + assert(Number.isSafeInteger(normalizedByteRange.end), + 'manifest byte range end exceeds safe integer precision'); + previousByteRangeEnd = normalizedByteRange.end; + previousByteRangeUrl = url; + } else { + previousByteRangeEnd = null; + previousByteRangeUrl = null; + } + segments.push({ + sequence: mediaSequence + segments.length, + durationSeconds, + url, + byteRange: normalizedByteRange, + initialization, + declaredGap, + }); + durationSeconds = null; + byteRange = null; + declaredGap = false; + } + } + assert(segments.length > 0, 'media playlist contains no segments'); + return Object.freeze({ mediaSequence, segments: Object.freeze(segments) }); +} + +export function createLatencyHistogram() { + return { + bucketsMs: [...LATENCY_BUCKETS_MS], + counts: Array.from({ length: LATENCY_BUCKETS_MS.length + 1 }, () => 0), + count: 0, + sumMs: 0, + minMs: null, + maxMs: null, + }; +} + +export function observeLatency(histogram, durationMs) { + assert(Number.isFinite(durationMs) && durationMs >= 0, 'durationMs must be non-negative'); + const index = histogram.bucketsMs.findIndex((upperBound) => durationMs <= upperBound); + histogram.counts[index < 0 ? histogram.counts.length - 1 : index] += 1; + histogram.count += 1; + histogram.sumMs += durationMs; + histogram.minMs = histogram.minMs === null ? durationMs : Math.min(histogram.minMs, durationMs); + histogram.maxMs = histogram.maxMs === null ? durationMs : Math.max(histogram.maxMs, durationMs); +} + +export function mergeLatencyHistograms(histograms) { + const merged = createLatencyHistogram(); + for (const histogram of histograms) { + assert(JSON.stringify(histogram.bucketsMs) === JSON.stringify(merged.bucketsMs), + 'latency histogram buckets differ'); + histogram.counts.forEach((count, index) => { merged.counts[index] += count; }); + merged.count += histogram.count; + merged.sumMs += histogram.sumMs; + if (histogram.minMs !== null) { + merged.minMs = merged.minMs === null ? histogram.minMs : Math.min(merged.minMs, histogram.minMs); + merged.maxMs = merged.maxMs === null ? histogram.maxMs : Math.max(merged.maxMs, histogram.maxMs); + } + } + return merged; +} + +function approximatePercentile(histogram, percentile) { + if (histogram.count === 0) return null; + const wanted = Math.ceil(histogram.count * percentile); + let cumulative = 0; + for (let index = 0; index < histogram.counts.length; index += 1) { + cumulative += histogram.counts[index]; + if (cumulative >= wanted) return histogram.bucketsMs[index] ?? histogram.maxMs; + } + return histogram.maxMs; +} + +export function summarizeLatency(histogram) { + return { + ...histogram, + meanMs: histogram.count ? histogram.sumMs / histogram.count : null, + p50UpperBoundMs: approximatePercentile(histogram, 0.5), + p95UpperBoundMs: approximatePercentile(histogram, 0.95), + p99UpperBoundMs: approximatePercentile(histogram, 0.99), + }; +} + +export function redactedTarget(manifestUrl, target) { + const url = assertAllowedUrl(manifestUrl, target.origins); + return { + id: target.id, + environment: target.environment, + plane: 'origin-media', + origin: url.origin, + manifestPathSha256: sha256(url.pathname), + allowlistedOrigins: [...target.origins], + }; +} + +export function assertRedactedEvidence(evidence) { + const serialized = JSON.stringify(evidence); + assert(!/https?:\\?\/\\?\/[^"\s]+\?/.test(serialized), + 'evidence cannot contain URLs with query strings'); + assert(!/(?:sig|token|authorization|cookie|password|secret)=/i.test(serialized), + 'evidence contains credential-like material'); + assert(!/manifestUrl/i.test(serialized), 'evidence cannot contain a raw manifest URL field'); + const visit = (value, key = '') => { + const normalizedKey = key.toLowerCase().replace(/[^a-z]/g, ''); + assert(!['authorization', 'cookie', 'setcookie', 'password', 'secret', 'token', 'signature', 'sig', 'manifesturl'] + .includes(normalizedKey), + `evidence contains forbidden sensitive field ${key}`); + if (typeof value === 'string') { + assert(!/\bbearer\s+\S+/i.test(value), 'evidence contains a bearer value'); + assert(!/[?&](?:sig|token|authorization)=/i.test(value), + 'evidence contains signed or authorized query material'); + } else if (Array.isArray(value)) { + value.forEach((item) => visit(item)); + } else if (value && typeof value === 'object') { + Object.entries(value).forEach(([childKey, child]) => visit(child, childKey)); + } + }; + visit(evidence); + return evidence; +} + +function addObjects(target, source) { + for (const [key, value] of Object.entries(source)) { + target[key] = (target[key] ?? 0) + value; + } +} + +function sameValue(left, right) { + return sha256(canonicalJson(left)) === sha256(canonicalJson(right)); +} + +function nonNegativeInteger(value, field) { + assert(Number.isSafeInteger(value) && value >= 0, `${field} must be a non-negative integer`); +} + +function validateHistogram(histogram, expectedCount, field) { + assert(histogram && Array.isArray(histogram.bucketsMs) && Array.isArray(histogram.counts), + `${field} histogram is missing`); + assert(sameValue(histogram.bucketsMs, LATENCY_BUCKETS_MS), `${field} buckets differ`); + assert(histogram.counts.length === LATENCY_BUCKETS_MS.length + 1, + `${field} bucket count differs`); + histogram.counts.forEach((count) => nonNegativeInteger(count, `${field} bucket count`)); + assert(histogram.counts.reduce((sum, count) => sum + count, 0) === histogram.count, + `${field} histogram counts do not sum to count`); + assert(histogram.count === expectedCount, `${field} histogram count differs from requests`); + const recomputed = summarizeLatency({ + bucketsMs: histogram.bucketsMs, + counts: histogram.counts, + count: histogram.count, + sumMs: histogram.sumMs, + minMs: histogram.minMs, + maxMs: histogram.maxMs, + }); + assert(histogram.p50UpperBoundMs === recomputed.p50UpperBoundMs + && histogram.p95UpperBoundMs === recomputed.p95UpperBoundMs + && histogram.p99UpperBoundMs === recomputed.p99UpperBoundMs, + `${field} percentile summary is inconsistent`); +} + +function validateShardEvidence(evidence) { + assert(evidence?.schemaVersion === EVIDENCE_SCHEMA_VERSION && evidence?.kind === EVIDENCE_KIND, + 'unexpected shard evidence schema'); + assertRedactedEvidence(evidence); + assert(['PASS', 'FAIL', 'ABORTED'].includes(evidence.status), + 'aggregate accepts only terminal shard evidence'); + const { measurements } = evidence; + assert(measurements && evidence.plan && evidence.shard && evidence.target, + 'shard evidence is incomplete'); + assert(evidence.target.plane === 'origin-media', + 'shard evidence must identify the origin-media plane'); + const { planHash, deterministicSchedule, ...publicGlobalPlan } = evidence.plan; + assert(deterministicSchedule === 'utc-start-plus-global-client-ramp-ordinal', + 'deterministic schedule identifier differs'); + assert(sha256(canonicalJson({ runId: evidence.runId, ...publicGlobalPlan })) === planHash, + 'planHash does not match the public deterministic plan'); + const normalizedProfile = validateProfile(evidence.plan.profile, 'evidence plan profile'); + assert(sameValue(evidence.plan.profile, normalizedProfile), + 'evidence plan profile is not normalized'); + assert(sameValue(evidence.thresholds, deriveThresholds(normalizedProfile)), + 'evidence thresholds do not match the deterministic plan profile'); + const expectedConfirmationValue = `${expectedConfirmation({ + targetId: evidence.plan.target.id, + runId: evidence.runId, + profile: evidence.plan.profile, + shardCount: evidence.plan.shardCount, + startAt: evidence.plan.startAt, + })} plan=${planHash}`; + assert(evidence.inputs?.confirmationSha256 === sha256(expectedConfirmationValue), + 'confirmation hash does not match the deterministic plan'); + assert(evidence.target.id === evidence.plan.target.id + && evidence.target.environment === evidence.plan.target.environment + && evidence.plan.target.origins.includes(evidence.target.origin) + && /^[a-f0-9]{64}$/.test(evidence.target.manifestPathSha256), + 'actual target descriptor does not match the plan'); + for (const [field, value] of Object.entries({ + requestsTotal: measurements.requests.total, + requestsSuccessful: measurements.requests.successful, + requestsFailed: measurements.requests.failed, + manifestRequests: measurements.requests.byKind.manifest, + segmentRequests: measurements.requests.byKind.segment, + bytesTotal: measurements.bytes.total, + manifestBytes: measurements.bytes.manifest, + segmentBytes: measurements.bytes.segment, + clientsPlanned: measurements.clients.planned, + clientsStarted: measurements.clients.started, + clientsCompleted: measurements.clients.completed, + generatorScheduleMisses: measurements.clients.generatorScheduleMisses, + fetchOpportunities: measurements.fetchContinuity.opportunities, + successfulMediaFetches: measurements.fetchContinuity.successfulMediaFetches, + fetchMisses: measurements.fetchContinuity.misses, + })) nonNegativeInteger(value, field); + assert(measurements.requests.total + === measurements.requests.successful + measurements.requests.failed, + 'request success/failure counts do not sum to total'); + assert(measurements.requests.total + === measurements.requests.byKind.manifest + measurements.requests.byKind.segment, + 'request kinds do not sum to total'); + assert(measurements.bytes.total === measurements.bytes.manifest + measurements.bytes.segment, + 'byte kinds do not sum to total'); + const expectedErrorRate = measurements.requests.total + ? measurements.requests.failed / measurements.requests.total + : 1; + const expectedFetchMissRate = measurements.fetchContinuity.opportunities + ? measurements.fetchContinuity.misses / measurements.fetchContinuity.opportunities + : 1; + assert(measurements.requests.errorRate === expectedErrorRate, + 'request error rate is inconsistent'); + assert(measurements.fetchContinuity.fetchMissRate === expectedFetchMissRate, + 'fetch miss rate is inconsistent'); + assert(measurements.clients.planned === evidence.shard.localClients, + 'planned clients differ from shard plan'); + assert(measurements.clients.started <= measurements.clients.planned + && measurements.clients.completed <= measurements.clients.started, + 'client lifecycle counts are impossible'); + assert(measurements.fetchContinuity.misses <= measurements.fetchContinuity.opportunities + && measurements.fetchContinuity.successfulMediaFetches + <= measurements.fetchContinuity.opportunities, + 'fetch continuity counts are impossible'); + validateHistogram(measurements.latencyMs.all, measurements.requests.total, 'all latency'); + validateHistogram( + measurements.latencyMs.manifest, + measurements.requests.byKind.manifest, + 'manifest latency', + ); + validateHistogram( + measurements.latencyMs.segment, + measurements.requests.byKind.segment, + 'segment latency', + ); + validateHistogram(measurements.latencyMs.queue, measurements.requests.total, 'queue latency'); + if (evidence.target.environment === 'staging') { + assert(evidence.generator?.role === GENERATOR_ROLE && evidence.generator?.explicitlyExternal === true, + 'staging shard evidence must come from an explicit external generator'); + assert(Number.isFinite(evidence.generator.measuredClockOffsetMs) + && Math.abs(evidence.generator.measuredClockOffsetMs) + <= evidence.plan.target.limits.maxClockOffsetMs, + 'staging shard clock offset exceeds its plan'); + } + if (evidence.status === 'PASS') { + assert(evidence.termination?.reason === 'completed', 'PASS shard did not complete normally'); + assert(measurements.requests.total > 0 + && measurements.requests.errorRate <= evidence.thresholds.maxErrorRate, + 'PASS shard exceeds request error threshold'); + assert(measurements.fetchContinuity.fetchMissRate <= evidence.thresholds.maxFetchMissRate, + 'PASS shard exceeds fetch miss threshold'); + assert(measurements.latencyMs.manifest.p95UpperBoundMs !== null + && measurements.latencyMs.segment.p95UpperBoundMs !== null + && measurements.latencyMs.manifest.p95UpperBoundMs + <= evidence.thresholds.maxManifestP95Ms + && measurements.latencyMs.segment.p95UpperBoundMs + <= evidence.thresholds.maxSegmentP95Ms, + 'PASS shard exceeds latency threshold'); + assert(measurements.clients.completed === measurements.clients.planned + && measurements.clients.generatorScheduleMisses === 0, + 'PASS shard has incomplete clients or schedule misses'); + assert((measurements.rollingWindows.worstRequestErrorRate === null + || measurements.rollingWindows.worstRequestErrorRate + <= evidence.thresholds.maxRollingErrorRate) + && (measurements.rollingWindows.worstFetchMissRate === null + || measurements.rollingWindows.worstFetchMissRate + <= evidence.thresholds.maxRollingFetchMissRate), + 'PASS shard exceeds a rolling-window threshold'); + } +} + +export function aggregateEvidence(entries) { + assert(Array.isArray(entries) && entries.length > 0, 'at least one shard evidence file is required'); + const first = entries[0].evidence; + validateShardEvidence(first); + const shardCount = first.plan?.shardCount; + assert(Number.isInteger(shardCount) && entries.length === shardCount, + 'evidence count must equal shardCount'); + const seen = new Set(); + for (const { evidence, sha256: sourceDigest } of entries) { + assert(/^[a-f0-9]{64}$/.test(sourceDigest), 'source evidence SHA-256 is invalid'); + validateShardEvidence(evidence); + assert(evidence.runId === first.runId && evidence.plan?.planHash === first.plan?.planHash, + 'shard evidence does not share one deterministic plan'); + assert(evidence.plan.shardCount === shardCount, 'shardCount differs between evidence files'); + assert(sameValue(evidence.plan, first.plan), 'public plan differs between shards'); + assert(sameValue(evidence.target, first.target), 'actual target differs between shards'); + assert(sameValue(evidence.thresholds, first.thresholds), 'thresholds differ between shards'); + assert(sameValue(evidence.inputs, first.inputs), 'input/confirmation hashes differ between shards'); + const index = evidence.shard?.index; + assert(Number.isInteger(index) && index >= 0 && index < shardCount && !seen.has(index), + 'shard indices must be unique and complete'); + seen.add(index); + } + const measurements = { + clients: { planned: 0, started: 0, completed: 0, generatorScheduleMisses: 0 }, + requests: { total: 0, successful: 0, failed: 0, byKind: {}, errorsByCategory: {}, httpStatus: {} }, + bytes: { total: 0, manifest: 0, segment: 0 }, + fetchContinuity: { opportunities: 0, successfulMediaFetches: 0, misses: 0, missesByCategory: {} }, + manifest: { samples: 0, sequenceRegressions: 0, windowMisses: 0 }, + latencyMs: {}, + rollingWindows: { + seconds: first.measurements.rollingWindows.seconds, + sampled: 0, + eligibleRequestWindows: 0, + eligibleFetchWindows: 0, + worstRequestErrorRate: null, + worstFetchMissRate: null, + }, + }; + for (const { evidence } of entries) { + const current = evidence.measurements; + measurements.clients.planned += current.clients.planned; + measurements.clients.started += current.clients.started; + measurements.clients.completed += current.clients.completed; + measurements.clients.generatorScheduleMisses += current.clients.generatorScheduleMisses; + measurements.requests.total += current.requests.total; + measurements.requests.successful += current.requests.successful; + measurements.requests.failed += current.requests.failed; + addObjects(measurements.requests.byKind, current.requests.byKind); + addObjects(measurements.requests.errorsByCategory, current.requests.errorsByCategory); + addObjects(measurements.requests.httpStatus, current.requests.httpStatus); + measurements.bytes.total += current.bytes.total; + measurements.bytes.manifest += current.bytes.manifest; + measurements.bytes.segment += current.bytes.segment; + measurements.fetchContinuity.opportunities += current.fetchContinuity.opportunities; + measurements.fetchContinuity.successfulMediaFetches += current.fetchContinuity.successfulMediaFetches; + measurements.fetchContinuity.misses += current.fetchContinuity.misses; + addObjects( + measurements.fetchContinuity.missesByCategory, + current.fetchContinuity.missesByCategory, + ); + measurements.manifest.samples += current.manifest.samples; + measurements.manifest.sequenceRegressions += current.manifest.sequenceRegressions; + measurements.manifest.windowMisses += current.manifest.windowMisses; + measurements.rollingWindows.sampled += current.rollingWindows.sampled; + measurements.rollingWindows.eligibleRequestWindows += current.rollingWindows.eligibleRequestWindows; + measurements.rollingWindows.eligibleFetchWindows += current.rollingWindows.eligibleFetchWindows; + for (const field of ['worstRequestErrorRate', 'worstFetchMissRate']) { + if (current.rollingWindows[field] !== null) { + measurements.rollingWindows[field] = measurements.rollingWindows[field] === null + ? current.rollingWindows[field] + : Math.max(measurements.rollingWindows[field], current.rollingWindows[field]); + } + } + } + for (const kind of ['all', 'manifest', 'segment', 'queue']) { + measurements.latencyMs[kind] = summarizeLatency(mergeLatencyHistograms( + entries.map(({ evidence }) => evidence.measurements.latencyMs[kind]), + )); + } + const errorRate = measurements.requests.total + ? measurements.requests.failed / measurements.requests.total + : 1; + const fetchMissRate = measurements.fetchContinuity.opportunities + ? measurements.fetchContinuity.misses / measurements.fetchContinuity.opportunities + : 1; + const passed = entries.every(({ evidence }) => evidence.status === 'PASS') + && measurements.requests.total > 0 + && errorRate <= first.thresholds.maxErrorRate + && fetchMissRate <= first.thresholds.maxFetchMissRate + && measurements.clients.completed === measurements.clients.planned + && measurements.clients.generatorScheduleMisses === 0 + && measurements.latencyMs.manifest.p95UpperBoundMs !== null + && measurements.latencyMs.segment.p95UpperBoundMs !== null + && measurements.latencyMs.manifest.p95UpperBoundMs <= first.thresholds.maxManifestP95Ms + && measurements.latencyMs.segment.p95UpperBoundMs <= first.thresholds.maxSegmentP95Ms + && (measurements.rollingWindows.worstRequestErrorRate === null + || measurements.rollingWindows.worstRequestErrorRate <= first.thresholds.maxRollingErrorRate) + && (measurements.rollingWindows.worstFetchMissRate === null + || measurements.rollingWindows.worstFetchMissRate <= first.thresholds.maxRollingFetchMissRate); + return assertRedactedEvidence({ + schemaVersion: EVIDENCE_SCHEMA_VERSION, + kind: `${EVIDENCE_KIND}-aggregate`, + runId: first.runId, + status: passed ? 'PASS' : 'FAIL', + generatedAt: new Date().toISOString(), + target: first.target, + plan: first.plan, + thresholds: first.thresholds, + sourceShards: entries + .map(({ sha256: digest, evidence }) => ({ index: evidence.shard.index, sha256: digest })) + .sort((a, b) => a.index - b.index), + measurements: { + ...measurements, + requests: { ...measurements.requests, errorRate }, + fetchContinuity: { ...measurements.fetchContinuity, fetchMissRate }, + }, + redactionChecked: true, + }); +} diff --git a/tools/early-birds-hls-load/src/runner.mjs b/tools/early-birds-hls-load/src/runner.mjs new file mode 100644 index 00000000..62898a74 --- /dev/null +++ b/tools/early-birds-hls-load/src/runner.mjs @@ -0,0 +1,734 @@ +import { hostname as systemHostname } from 'node:os'; + +import { + EVIDENCE_KIND, + EVIDENCE_SCHEMA_VERSION, + assertAllowedUrl, + assertExternalGenerator, + assertRedactedEvidence, + createLatencyHistogram, + deriveThresholds, + observeLatency, + parseMediaPlaylist, + redactedTarget, + sha256, + summarizeLatency, +} from './contracts.mjs'; + +class RequestFailure extends Error { + constructor(category, status = null) { + super(category); + this.name = 'RequestFailure'; + this.category = category; + this.status = status; + } +} + +function increment(object, key, amount = 1) { + object[key] = (object[key] ?? 0) + amount; +} + +function currentWindow(measurements) { + const key = Math.floor(Date.now() / 10_000); + if (!measurements._windows.has(key)) { + measurements._windows.set(key, { + requests: { total: 0, failed: 0 }, + fetch: { opportunities: 0, misses: 0 }, + }); + } + return measurements._windows.get(key); +} + +function recordRequestStart(measurements) { + const window = currentWindow(measurements); + measurements.requests.total += 1; + window.requests.total += 1; + return window; +} + +function recordRequestFailure(measurements, window) { + measurements.requests.failed += 1; + window.requests.failed += 1; +} + +function recordOpportunity(measurements, amount = 1) { + measurements.fetchContinuity.opportunities += amount; + currentWindow(measurements).fetch.opportunities += amount; +} + +function markOpportunityMiss(measurements, category, amount = 1) { + measurements.fetchContinuity.misses += amount; + currentWindow(measurements).fetch.misses += amount; + increment(measurements.fetchContinuity.missesByCategory, category, amount); +} + +function emptyMeasurements(plan) { + return { + clients: { + planned: plan.shard.localClients, + started: 0, + completed: 0, + generatorScheduleMisses: 0, + }, + requests: { + total: 0, + successful: 0, + failed: 0, + byKind: { manifest: 0, segment: 0 }, + errorsByCategory: {}, + httpStatus: {}, + }, + bytes: { total: 0, manifest: 0, segment: 0 }, + fetchContinuity: { + opportunities: 0, + successfulMediaFetches: 0, + misses: 0, + missesByCategory: {}, + }, + manifest: { + samples: 0, + sequenceRegressions: 0, + windowMisses: 0, + }, + latencyMs: { + all: createLatencyHistogram(), + manifest: createLatencyHistogram(), + segment: createLatencyHistogram(), + queue: createLatencyHistogram(), + }, + _windows: new Map(), + }; +} + +function publicPlan(plan) { + return { + planHash: plan.planHash, + profileName: plan.profileName, + profile: plan.profile, + startAt: plan.startAt, + endAt: plan.endAt, + rampDurationSeconds: plan.rampDurationSeconds, + shardCount: plan.shardCount, + target: plan.target, + plannedMaxRequestStartsPerSecond: plan.plannedMaxRequestStartsPerSecond, + deterministicSchedule: 'utc-start-plus-global-client-ramp-ordinal', + }; +} + +function publicShard(plan) { + return { + index: plan.shard.index, + count: plan.shard.count, + localClients: plan.shard.localClients, + clientOrdinalsSha256: plan.shard.clientOrdinalsSha256, + }; +} + +function summarizeMeasurements(measurements) { + const { _windows, ...publicMeasurements } = measurements; + const errorRate = measurements.requests.total + ? measurements.requests.failed / measurements.requests.total + : 1; + const fetchMissRate = measurements.fetchContinuity.opportunities + ? measurements.fetchContinuity.misses / measurements.fetchContinuity.opportunities + : 1; + const windows = [..._windows.values()]; + const eligibleRequestWindows = windows.filter((window) => window.requests.total >= 20); + const eligibleFetchWindows = windows.filter((window) => window.fetch.opportunities >= 10); + const worstRequestErrorRate = eligibleRequestWindows.length + ? Math.max(...eligibleRequestWindows.map((window) => window.requests.failed / window.requests.total)) + : null; + const worstFetchMissRate = eligibleFetchWindows.length + ? Math.max(...eligibleFetchWindows.map((window) => window.fetch.misses / window.fetch.opportunities)) + : null; + return { + ...publicMeasurements, + requests: { ...measurements.requests, errorRate }, + fetchContinuity: { ...measurements.fetchContinuity, fetchMissRate }, + latencyMs: { + all: summarizeLatency(measurements.latencyMs.all), + manifest: summarizeLatency(measurements.latencyMs.manifest), + segment: summarizeLatency(measurements.latencyMs.segment), + queue: summarizeLatency(measurements.latencyMs.queue), + }, + rollingWindows: { + seconds: 10, + sampled: windows.length, + eligibleRequestWindows: eligibleRequestWindows.length, + eligibleFetchWindows: eligibleFetchWindows.length, + worstRequestErrorRate, + worstFetchMissRate, + }, + }; +} + +function targetWithoutManifest(target) { + return { + id: target.id, + environment: target.environment, + plane: 'origin-media', + origin: null, + manifestPathSha256: null, + allowlistedOrigins: [...target.origins], + }; +} + +export function plannedEvidence({ plan, target, policySha256, profileSha256, hostname = systemHostname() }) { + return assertRedactedEvidence({ + schemaVersion: EVIDENCE_SCHEMA_VERSION, + kind: EVIDENCE_KIND, + runId: plan.runId, + status: 'PLANNED', + generatedAt: new Date().toISOString(), + target: targetWithoutManifest(target), + generator: { + role: 'dry-run', + hostFingerprintSha256: sha256(String(hostname)), + networkRequestsMade: false, + }, + plan: publicPlan(plan), + shard: publicShard(plan), + thresholds: deriveThresholds(plan.profile), + inputs: { + targetPolicySha256: policySha256, + profileDocumentSha256: profileSha256, + confirmationSha256: sha256(plan.confirmation), + }, + measurements: summarizeMeasurements(emptyMeasurements(plan)), + redactionChecked: true, + }); +} + +function createLimiter(limit) { + let active = 0; + const waiting = []; + const release = () => { + while (waiting.length > 0) { + const next = waiting.shift(); + next.signal?.removeEventListener('abort', next.onAbort); + if (next.signal?.aborted) continue; + next.resolve(); + return; + } + active -= 1; + }; + return async (operation, signal) => { + if (signal?.aborted) throw abortError(); + if (active < limit) { + active += 1; + } else { + await new Promise((resolve, reject) => { + const entry = { resolve, reject, signal, onAbort: null }; + entry.onAbort = () => { + const index = waiting.indexOf(entry); + if (index >= 0) waiting.splice(index, 1); + reject(abortError()); + }; + signal?.addEventListener('abort', entry.onAbort, { once: true }); + waiting.push(entry); + }); + } + if (signal?.aborted) { + release(); + throw abortError(); + } + try { + return await operation(); + } finally { + release(); + } + }; +} + +function createStartRateLimiter(maxStartsPerSecond) { + const starts = []; + return async (signal) => { + while (true) { + if (signal?.aborted) throw abortError(); + const now = Date.now(); + while (starts.length > 0 && starts[0] <= now - 1_000) starts.shift(); + if (starts.length < maxStartsPerSecond) { + starts.push(now); + return; + } + await sleepUntil(starts[0] + 1_000, signal); + } + }; +} + +function abortError() { + return new DOMException('run aborted', 'AbortError'); +} + +async function sleepUntil(timestampMs, signal) { + const delayMs = timestampMs - Date.now(); + if (delayMs <= 0) return; + if (signal?.aborted) throw abortError(); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, delayMs); + const onAbort = () => { + clearTimeout(timer); + reject(abortError()); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +function recordMiss(measurements, category, amount = 1) { + recordOpportunity(measurements, amount); + markOpportunityMiss(measurements, category, amount); +} + +function categorize(error) { + if (error?.name === 'AbortError') return 'aborted'; + if (error instanceof RequestFailure) return error.category; + return 'network'; +} + +async function readBoundedBody(response, { maxBytes, range, bufferBody }) { + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + await response.body?.cancel(); + throw new RequestFailure('body_limit', response.status); + } + let expectedRangeBytes = null; + if (range) { + if (response.status !== 206) { + await response.body?.cancel(); + throw new RequestFailure('range_status', response.status); + } + const match = response.headers.get('content-range')?.match(/^bytes (\d+)-(\d+)\/(?:\d+|\*)$/); + if (!match || Number(match[1]) !== range.start || Number(match[2]) !== range.end) { + await response.body?.cancel(); + throw new RequestFailure('range_mismatch', response.status); + } + expectedRangeBytes = range.end - range.start + 1; + if (expectedRangeBytes > maxBytes) { + await response.body?.cancel(); + throw new RequestFailure('body_limit', response.status); + } + } + if (!response.body) throw new RequestFailure('empty', response.status); + const reader = response.body.getReader(); + const chunks = bufferBody ? [] : null; + let bytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > maxBytes) { + await reader.cancel(); + throw new RequestFailure('body_limit', response.status); + } + chunks?.push(value); + } + if (bytes === 0) throw new RequestFailure('empty', response.status); + if (expectedRangeBytes !== null && bytes !== expectedRangeBytes) { + throw new RequestFailure('range_mismatch', response.status); + } + return { + byteLength: bytes, + buffer: chunks ? Buffer.concat(chunks, bytes) : null, + }; +} + +async function fetchBody({ + url, + kind, + allowedOrigins, + timeoutMs, + maxBytes, + bufferBody = false, + attestation, + range, + fetchImpl, + limiter, + rateLimiter, + signal, + measurements, +}) { + let parsed; + try { + parsed = assertAllowedUrl(url, allowedOrigins); + } catch { + throw new RequestFailure('target_allowlist'); + } + const queuedAt = performance.now(); + await rateLimiter(signal); + return limiter(async () => { + const admittedAt = performance.now(); + observeLatency(measurements.latencyMs.queue, admittedAt - queuedAt); + const requestWindow = recordRequestStart(measurements); + measurements.requests.byKind[kind] += 1; + let status = null; + try { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + const response = await fetchImpl(parsed, { + method: 'GET', + cache: 'no-store', + redirect: 'manual', + headers: { + 'Accept-Encoding': 'identity', + ...(range ? { Range: `bytes=${range.start}-${range.end}` } : {}), + }, + signal: requestSignal, + }); + status = response.status; + increment(measurements.requests.httpStatus, String(status)); + if (attestation && response.headers.get(attestation.header) !== attestation.value) { + await response.body?.cancel(); + throw new RequestFailure('target_attestation', status); + } + if (status >= 300 && status < 400) { + await response.body?.cancel(); + throw new RequestFailure('redirect', status); + } + if (!response.ok) { + await response.body?.cancel(); + throw new RequestFailure('http', status); + } + const body = await readBoundedBody(response, { maxBytes, range, bufferBody }); + measurements.requests.successful += 1; + measurements.bytes[kind] += body.byteLength; + measurements.bytes.total += body.byteLength; + return body; + } catch (error) { + const failure = error?.name === 'TimeoutError' + ? new RequestFailure('timeout', status) + : error; + recordRequestFailure(measurements, requestWindow); + increment(measurements.requests.errorsByCategory, categorize(failure)); + if (failure?.name === 'AbortError' && signal?.aborted) throw failure; + throw failure; + } finally { + const durationMs = performance.now() - queuedAt; + observeLatency(measurements.latencyMs.all, durationMs); + observeLatency(measurements.latencyMs[kind], durationMs); + } + }, signal); +} + +function chooseSegments({ playlist, nextSequence, profile, measurements }) { + const first = playlist.segments[0].sequence; + const last = playlist.segments.at(-1).sequence; + let wanted = nextSequence; + if (wanted === null) { + wanted = Math.max(first, last - profile.startupSegments + 1); + } + if (wanted < first) { + const misses = first - wanted; + measurements.manifest.windowMisses += misses; + recordMiss(measurements, 'manifest_window', misses); + wanted = first; + } + let segments = playlist.segments.filter((segment) => segment.sequence >= wanted); + if (segments.length > profile.maxSegmentsPerPoll) { + const skipped = segments.length - profile.maxSegmentsPerPoll; + recordMiss(measurements, 'backlog_limit', skipped); + segments = segments.slice(-profile.maxSegmentsPerPoll); + } + return segments; +} + +export async function runShard({ + plan, + target, + policySha256, + profileSha256, + confirmation, + manifestUrlProvider, + externalGenerator, + declaredGeneratorRole, + hostname = systemHostname(), + fetchImpl = fetch, + signal, + clockOffsetMs = null, + onProgress, +}) { + assertExternalGenerator({ + hostname, + declaredRole: declaredGeneratorRole, + externalConfirmed: externalGenerator, + targetEnvironment: target.environment, + networkRun: true, + }); + if (confirmation !== plan.confirmation) throw new Error('exact load confirmation does not match the plan'); + const measuredClockOffsetMs = clockOffsetMs ?? (target.environment === 'synthetic' ? 0 : null); + if (!Number.isFinite(measuredClockOffsetMs) + || Math.abs(measuredClockOffsetMs) > target.limits.maxClockOffsetMs) { + throw new Error(`measured clock offset exceeds ${target.limits.maxClockOffsetMs} ms`); + } + const measurements = emptyMeasurements(plan); + const limiter = createLimiter(plan.profile.maxInflightPerShard); + const rateLimiter = createStartRateLimiter(Math.max( + 1, + Math.floor(target.limits.maxRequestsPerSecond / plan.shardCount), + )); + const startMs = Date.parse(plan.startAt); + const endMs = Date.parse(plan.endAt); + const deadlineSignal = AbortSignal.timeout(Math.max(1, endMs - Date.now())); + const safetyController = new AbortController(); + const runSignal = AbortSignal.any([ + deadlineSignal, + safetyController.signal, + ...(signal ? [signal] : []), + ]); + const startedAt = new Date().toISOString(); + let descriptor = null; + let terminationReason = 'completed'; + let circuitBreakerReason = null; + const runThresholds = deriveThresholds(plan.profile); + const progressTimer = typeof onProgress === 'function' ? setInterval(() => { + onProgress({ + requests: measurements.requests.total, + failed: measurements.requests.failed, + bytes: measurements.bytes.total, + fetchMisses: measurements.fetchContinuity.misses, + clientsStarted: measurements.clients.started, + clientsCompleted: measurements.clients.completed, + }); + }, 10_000) : null; + progressTimer?.unref(); + + function evaluateCircuitBreaker() { + if (safetyController.signal.aborted) return; + const requestErrorRate = measurements.requests.total + ? measurements.requests.failed / measurements.requests.total + : 0; + const fetchMissRate = measurements.fetchContinuity.opportunities + ? measurements.fetchContinuity.misses / measurements.fetchContinuity.opportunities + : 0; + if (measurements.requests.total >= runThresholds.circuitBreakerMinRequests + && requestErrorRate > runThresholds.circuitBreakerErrorRate) { + circuitBreakerReason = 'request_error_rate'; + safetyController.abort(); + } else if ( + measurements.fetchContinuity.opportunities + >= runThresholds.circuitBreakerMinFetchOpportunities + && fetchMissRate > runThresholds.circuitBreakerFetchMissRate + ) { + circuitBreakerReason = 'fetch_miss_rate'; + safetyController.abort(); + } + } + + async function loadManifest() { + let rawUrl; + try { + rawUrl = await manifestUrlProvider(); + } catch { + throw new RequestFailure('manifest_credential_source'); + } + let nextDescriptor; + try { + nextDescriptor = redactedTarget(rawUrl, target); + } catch { + throw new RequestFailure('target_allowlist'); + } + if (descriptor === null) descriptor = nextDescriptor; + if (descriptor.origin !== nextDescriptor.origin + || descriptor.manifestPathSha256 !== nextDescriptor.manifestPathSha256) { + throw new RequestFailure('manifest_target_changed'); + } + const body = await fetchBody({ + url: rawUrl, + kind: 'manifest', + allowedOrigins: target.origins, + timeoutMs: plan.profile.requestTimeoutMs, + maxBytes: target.limits.maxManifestBytes, + bufferBody: true, + attestation: target.attestation, + fetchImpl, + limiter, + rateLimiter, + signal: runSignal, + measurements, + }); + try { + const playlist = parseMediaPlaylist(body.buffer.toString('utf8'), rawUrl); + measurements.manifest.samples += 1; + return playlist; + } catch { + increment(measurements.requests.errorsByCategory, 'manifest_parse'); + throw new RequestFailure('manifest_parse'); + } + } + + async function runClient(ordinal) { + const activationMs = startMs + Math.floor(ordinal / plan.profile.rampPerSecond) * 1000; + await sleepUntil(activationMs, runSignal); + measurements.clients.started += 1; + let nextSequence = null; + let previousMediaSequence = null; + let loadedInitializationIdentity = null; + + async function fetchMediaResource({ resource, missPrefix, deadlineMs = null }) { + recordOpportunity(measurements); + let missed = false; + const resourceStarted = performance.now(); + try { + await fetchBody({ + url: resource.url, + kind: 'segment', + allowedOrigins: target.origins, + timeoutMs: plan.profile.requestTimeoutMs, + maxBytes: target.limits.maxSegmentBytes, + attestation: target.attestation, + range: resource.byteRange, + fetchImpl, + limiter, + rateLimiter, + signal: runSignal, + measurements, + }); + measurements.fetchContinuity.successfulMediaFetches += 1; + if (deadlineMs !== null && performance.now() - resourceStarted >= deadlineMs) { + missed = true; + markOpportunityMiss(measurements, `${missPrefix}_deadline`); + } + } catch (error) { + if (error?.name === 'AbortError') throw error; + missed = true; + markOpportunityMiss(measurements, `${missPrefix}_${categorize(error)}`); + } + evaluateCircuitBreaker(); + return !missed; + } + + for ( + let scheduledMs = activationMs; + scheduledMs < endMs; + scheduledMs += plan.profile.manifestIntervalMs + ) { + await sleepUntil(scheduledMs, runSignal); + if (runSignal.aborted) throw abortError(); + const skippedSlots = Math.floor( + Math.max(0, Date.now() - scheduledMs) / plan.profile.manifestIntervalMs, + ); + if (skippedSlots > 0) { + measurements.clients.generatorScheduleMisses += skippedSlots; + scheduledMs += skippedSlots * plan.profile.manifestIntervalMs; + if (scheduledMs >= endMs) break; + } + let playlist; + try { + playlist = await loadManifest(); + } catch (error) { + if (error?.name === 'AbortError') throw error; + recordMiss(measurements, `manifest_${categorize(error)}`); + evaluateCircuitBreaker(); + continue; + } + if (previousMediaSequence !== null && playlist.mediaSequence < previousMediaSequence) { + measurements.manifest.sequenceRegressions += 1; + recordMiss(measurements, 'sequence_regression'); + } + previousMediaSequence = playlist.mediaSequence; + const segments = chooseSegments({ + playlist, + nextSequence, + profile: plan.profile, + measurements, + }); + evaluateCircuitBreaker(); + for (const segment of segments) { + if (segment.declaredGap) { + recordMiss(measurements, 'declared_gap'); + evaluateCircuitBreaker(); + nextSequence = segment.sequence + 1; + continue; + } + if (segment.initialization) { + const initializationUrl = new URL(segment.initialization.url); + const initializationIdentity = sha256([ + initializationUrl.origin, + initializationUrl.pathname, + segment.initialization.byteRange?.start ?? '', + segment.initialization.byteRange?.end ?? '', + ].join('|')); + if (loadedInitializationIdentity !== initializationIdentity) { + const loaded = await fetchMediaResource({ + resource: segment.initialization, + missPrefix: 'initialization', + }); + if (loaded) loadedInitializationIdentity = initializationIdentity; + } + } + await fetchMediaResource({ + resource: segment, + missPrefix: 'segment', + deadlineMs: segment.durationSeconds * 1000, + }); + nextSequence = segment.sequence + 1; + } + } + measurements.clients.completed += 1; + } + + const settled = await Promise.allSettled(plan.shard.clientOrdinals.map(runClient)); + clearInterval(progressTimer); + const unexpected = settled.find((result) => ( + result.status === 'rejected' && result.reason?.name !== 'AbortError' + )); + if (unexpected) throw unexpected.reason; + if (settled.some((result) => result.status === 'rejected')) { + if (signal?.aborted) terminationReason = 'operator_abort'; + else if (safetyController.signal.aborted) terminationReason = 'circuit_breaker'; + else if (deadlineSignal.aborted) terminationReason = 'deadline_exceeded'; + else terminationReason = 'aborted'; + } + if (terminationReason === 'completed' && deadlineSignal.aborted) { + terminationReason = 'deadline_exceeded'; + } + + const summarized = summarizeMeasurements(measurements); + const passed = terminationReason === 'completed' + && summarized.requests.total > 0 + && summarized.requests.errorRate <= plan.profile.maxErrorRate + && summarized.fetchContinuity.fetchMissRate <= plan.profile.maxFetchMissRate + && summarized.latencyMs.manifest.p95UpperBoundMs !== null + && summarized.latencyMs.manifest.p95UpperBoundMs <= plan.profile.maxManifestP95Ms + && summarized.latencyMs.segment.p95UpperBoundMs !== null + && summarized.latencyMs.segment.p95UpperBoundMs <= plan.profile.maxSegmentP95Ms + && (summarized.rollingWindows.worstRequestErrorRate === null + || summarized.rollingWindows.worstRequestErrorRate <= runThresholds.maxRollingErrorRate) + && (summarized.rollingWindows.worstFetchMissRate === null + || summarized.rollingWindows.worstFetchMissRate <= runThresholds.maxRollingFetchMissRate) + && summarized.clients.completed === summarized.clients.planned + && summarized.clients.generatorScheduleMisses === 0; + return assertRedactedEvidence({ + schemaVersion: EVIDENCE_SCHEMA_VERSION, + kind: EVIDENCE_KIND, + runId: plan.runId, + status: terminationReason === 'operator_abort' ? 'ABORTED' : (passed ? 'PASS' : 'FAIL'), + generatedAt: new Date().toISOString(), + startedAt, + finishedAt: new Date().toISOString(), + target: descriptor ?? targetWithoutManifest(target), + generator: { + role: declaredGeneratorRole, + hostFingerprintSha256: sha256(String(hostname)), + explicitlyExternal: externalGenerator === true, + networkRequestsMade: measurements.requests.total > 0, + measuredClockOffsetMs, + }, + plan: publicPlan(plan), + shard: publicShard(plan), + thresholds: runThresholds, + termination: { + reason: terminationReason, + circuitBreakerReason, + }, + inputs: { + targetPolicySha256: policySha256, + profileDocumentSha256: profileSha256, + confirmationSha256: sha256(plan.confirmation), + }, + measurements: summarized, + redactionChecked: true, + }); +} diff --git a/tools/early-birds-hls-load/target-policy.example.json b/tools/early-birds-hls-load/target-policy.example.json new file mode 100644 index 00000000..39ed00f4 --- /dev/null +++ b/tools/early-birds-hls-load/target-policy.example.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "targets": [ + { + "id": "early-birds-staging", + "environment": "staging", + "production": false, + "attestation": { + "header": "x-harmonic-beacon-environment", + "value": "early-birds-staging" + }, + "origins": [ + "https://earlybirds-staging.example.invalid", + "https://earlybirds-media-staging.example.invalid" + ], + "limits": { + "maxClients": 10, + "maxRampPerSecond": 2, + "maxSoakSeconds": 60, + "maxShardCount": 2, + "minManifestIntervalMs": 3000, + "maxInflightPerShard": 16, + "maxSegmentsPerPoll": 6, + "maxRequestsPerSecond": 30, + "maxManifestBytes": 262144, + "maxSegmentBytes": 8388608, + "maxClockOffsetMs": 100 + } + } + ] +} diff --git a/tools/early-birds-hls-load/test/contracts.test.mjs b/tools/early-birds-hls-load/test/contracts.test.mjs new file mode 100644 index 00000000..bc11c730 --- /dev/null +++ b/tools/early-birds-hls-load/test/contracts.test.mjs @@ -0,0 +1,356 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + GENERATOR_ROLE, + aggregateEvidence, + assertExternalGenerator, + assertRedactedEvidence, + buildPlan, + createLatencyHistogram, + observeLatency, + parseMediaPlaylist, + parseUtc, + selectTarget, + sha256, + summarizeLatency, +} from '../src/contracts.mjs'; +import { plannedEvidence } from '../src/runner.mjs'; + +function policy(overrides = {}) { + return { + schemaVersion: 1, + targets: [{ + id: 'test-staging', + environment: 'staging', + production: false, + attestation: { + header: 'x-harmonic-beacon-environment', + value: 'early-birds-staging-test', + }, + origins: ['https://staging.example.test', 'https://media-staging.example.test'], + limits: { + maxClients: 5000, + maxRampPerSecond: 100, + maxSoakSeconds: 3600, + maxShardCount: 16, + minManifestIntervalMs: 1000, + maxInflightPerShard: 128, + maxSegmentsPerPoll: 6, + maxRequestsPerSecond: 20000, + maxManifestBytes: 262144, + maxSegmentBytes: 4194304, + maxClockOffsetMs: 100, + }, + ...overrides, + }], + }; +} + +function profile(overrides = {}) { + return { + clients: 12, + rampPerSecond: 4, + soakSeconds: 30, + manifestIntervalMs: 3000, + requestTimeoutMs: 3000, + startupSegments: 2, + maxSegmentsPerPoll: 6, + maxInflightPerShard: 8, + minShards: 2, + maxErrorRate: 0.01, + maxFetchMissRate: 0.01, + maxManifestP95Ms: 1000, + maxSegmentP95Ms: 2000, + syntheticOnly: false, + ...overrides, + }; +} + +test('target policy is exact, explicitly non-production, and fails closed for live-like targets', () => { + const target = selectTarget(policy(), 'test-staging'); + assert.deepEqual(target.origins, [ + 'https://staging.example.test', + 'https://media-staging.example.test', + ]); + assert.throws( + () => selectTarget(policy({ environment: 'production' }), 'test-staging'), + /staging or synthetic/, + ); + assert.throws( + () => selectTarget(policy({ production: true }), 'test-staging'), + /production to false/, + ); + assert.throws( + () => selectTarget(policy({ origins: ['https://live.harmonicbeacon.com'] }), 'test-staging'), + /production target hostname/, + ); + for (const origin of [ + 'https://harmonicbeacon.com.', + 'https://app.harmonicbeacon.com', + 'https://foo.live.harmonicbeacon.com', + ]) { + assert.throws(() => selectTarget(policy({ origins: [origin] }), 'test-staging'), + /production.*hostname/); + } + assert.throws( + () => selectTarget(policy({ origins: ['http://staging.example.test'] }), 'test-staging'), + /must use HTTPS/, + ); +}); + +test('distributed shards partition deterministic global client ordinals and share one plan hash', () => { + const target = selectTarget(policy(), 'test-staging'); + const values = Array.from({ length: 3 }, (_, shardIndex) => buildPlan({ + runId: 'reproducible-run', + profileName: 'test-profile', + profile: profile({ clients: 12, minShards: 3 }), + target, + shardIndex, + shardCount: 3, + startAt: '2030-01-01T00:00:00.000Z', + networkRun: false, + })); + assert.equal(new Set(values.map((value) => value.planHash)).size, 1); + assert.deepEqual(values[0].shard.clientOrdinals, [0, 3, 6, 9]); + assert.deepEqual(values[1].shard.clientOrdinals, [1, 4, 7, 10]); + assert.deepEqual(values[2].shard.clientOrdinals, [2, 5, 8, 11]); + assert.match(values[0].confirmation, /clients=12 ramp=4\/s soak=30s shards=3/); + assert.match(values[0].confirmation, new RegExp(`plan=${values[0].planHash}$`)); + const changed = buildPlan({ + runId: 'reproducible-run', + profileName: 'test-profile', + profile: profile({ clients: 12, minShards: 3, manifestIntervalMs: 4000 }), + target, + shardIndex: 0, + shardCount: 3, + startAt: '2030-01-01T00:00:00.000Z', + networkRun: false, + }); + assert.notEqual(changed.confirmation, values[0].confirmation); +}); + +test('large profiles require distributed shards and remain capped per generator', () => { + const target = selectTarget(policy(), 'test-staging'); + assert.throws(() => buildPlan({ + runId: 'too-few-shards', + profileName: 'large-profile', + profile: profile({ clients: 3000, minShards: 1 }), + target, + shardIndex: 0, + shardCount: 2, + startAt: '2030-01-01T00:00:00.000Z', + networkRun: false, + }), /at least four shards/); + const plan = buildPlan({ + runId: 'bounded-shards', + profileName: 'large-profile', + profile: profile({ clients: 3000, minShards: 4 }), + target, + shardIndex: 0, + shardCount: 4, + startAt: '2030-01-01T00:00:00.000Z', + networkRun: false, + }); + assert.equal(plan.shard.localClients, 750); +}); + +test('staging network runs are explicitly external and always refused from mona', () => { + assert.throws(() => assertExternalGenerator({ + hostname: 'mona', + declaredRole: GENERATOR_ROLE, + externalConfirmed: true, + targetEnvironment: 'staging', + networkRun: true, + }), /forbidden from mona/); + assert.throws(() => assertExternalGenerator({ + hostname: 'mona-01.example.test', + declaredRole: GENERATOR_ROLE, + externalConfirmed: true, + targetEnvironment: 'staging', + networkRun: true, + }), /forbidden from mona/); + assert.throws(() => assertExternalGenerator({ + hostname: 'generator-1', + declaredRole: 'web-runtime', + externalConfirmed: true, + targetEnvironment: 'staging', + networkRun: true, + }), /must equal external-load-generator/); + assert.doesNotThrow(() => assertExternalGenerator({ + hostname: 'generator-1', + declaredRole: GENERATOR_ROLE, + externalConfirmed: true, + targetEnvironment: 'staging', + networkRun: true, + })); +}); + +test('UTC timestamps and request-shape bounds fail closed', () => { + assert.throws(() => parseUtc('2026-02-30T12:00:00.000Z'), /invalid UTC civil date/); + const target = selectTarget(policy(), 'test-staging'); + assert.throws(() => buildPlan({ + runId: 'poll-too-fast', + profileName: 'unsafe-profile', + profile: profile({ manifestIntervalMs: 999, requestTimeoutMs: 1000 }), + target, + shardIndex: 0, + shardCount: 2, + startAt: '2030-01-01T00:00:00.000Z', + networkRun: false, + }), /manifestIntervalMs must be at least/); + assert.throws(() => buildPlan({ + runId: 'unsafe-quality-gate', + profileName: 'unsafe-profile', + profile: profile({ maxErrorRate: 1, maxFetchMissRate: 1 }), + target, + shardIndex: 0, + shardCount: 2, + startAt: '2030-01-01T00:00:00.000Z', + networkRun: false, + }), /maxErrorRate must be between zero and 0\.1/); + assert.throws(() => parseMediaPlaylist( + '#EXTM3U\n#EXTINF:1,\n#EXT-X-BYTERANGE:0@0\nsegment.bin\n', + 'https://staging.example.test/live.m3u8', + ), /positive safe integer/); +}); + +test('media parsing is codec/rate/channel neutral and preserves relative URLs and byte ranges', () => { + const manifest = `#EXTM3U +#EXT-X-MEDIA-SEQUENCE:41 +#EXTINF:1.250, +#EXT-X-BYTERANGE:4@0 +opaque.bin?sig=first +#EXTINF:7.750, +#EXT-X-BYTERANGE:3 +opaque.bin?sig=first +#EXTINF:3.000, +#EXT-X-GAP +gap.bin +`; + const parsed = parseMediaPlaylist( + manifest, + 'https://media-staging.example.test/path/live.m3u8?sig=manifest', + ); + assert.deepEqual(parsed.segments.map(({ sequence, durationSeconds, byteRange }) => ({ + sequence, + durationSeconds, + byteRange, + })), [ + { sequence: 41, durationSeconds: 1.25, byteRange: { start: 0, end: 3 } }, + { sequence: 42, durationSeconds: 7.75, byteRange: { start: 4, end: 6 } }, + { sequence: 43, durationSeconds: 3, byteRange: null }, + ]); + assert.equal(parsed.segments[0].url, 'https://media-staging.example.test/path/opaque.bin?sig=first'); + assert.equal(parsed.segments[2].declaredGap, true); + assert.throws(() => parseMediaPlaylist( + '#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="key"\n#EXTINF:1,\nsegment.bin\n', + 'https://media-staging.example.test/live.m3u8', + ), /encrypted media playlists/); +}); + +test('planned and aggregate evidence contain hashes, never signed URLs', () => { + const target = selectTarget(policy(), 'test-staging'); + const plans = [0, 1].map((shardIndex) => buildPlan({ + runId: 'aggregate-run', + profileName: 'test-profile', + profile: profile(), + target, + shardIndex, + shardCount: 2, + startAt: '2030-01-01T00:00:00.000Z', + networkRun: false, + })); + const entries = plans.map((plan) => { + const evidence = plannedEvidence({ + plan, + target, + policySha256: 'a'.repeat(64), + profileSha256: 'b'.repeat(64), + hostname: 'generator', + }); + const histogram = (count) => { + const value = createLatencyHistogram(); + for (let index = 0; index < count; index += 1) observeLatency(value, 10); + return summarizeLatency(value); + }; + evidence.status = 'PASS'; + evidence.target = { + id: target.id, + environment: target.environment, + plane: 'origin-media', + origin: target.origins[0], + manifestPathSha256: sha256('/media/live.m3u8'), + allowlistedOrigins: [...target.origins], + }; + evidence.generator = { + role: GENERATOR_ROLE, + hostFingerprintSha256: sha256(`generator-${plan.shard.index}`), + explicitlyExternal: true, + networkRequestsMade: true, + measuredClockOffsetMs: 5, + }; + evidence.termination = { reason: 'completed', circuitBreakerReason: null }; + evidence.measurements = { + clients: { + planned: plan.shard.localClients, + started: plan.shard.localClients, + completed: plan.shard.localClients, + generatorScheduleMisses: 0, + }, + requests: { + total: 2, + successful: 2, + failed: 0, + byKind: { manifest: 1, segment: 1 }, + errorsByCategory: {}, + httpStatus: { 200: 2 }, + errorRate: 0, + }, + bytes: { total: 6, manifest: 1, segment: 5 }, + fetchContinuity: { + opportunities: 1, + successfulMediaFetches: 1, + misses: 0, + missesByCategory: {}, + fetchMissRate: 0, + }, + manifest: { samples: 1, sequenceRegressions: 0, windowMisses: 0 }, + latencyMs: { + all: histogram(2), + manifest: histogram(1), + segment: histogram(1), + queue: histogram(2), + }, + rollingWindows: { + seconds: 10, + sampled: 1, + eligibleRequestWindows: 0, + eligibleFetchWindows: 0, + worstRequestErrorRate: null, + worstFetchMissRate: null, + }, + }; + return { sha256: 'c'.repeat(64), evidence }; + }); + const aggregate = aggregateEvidence(entries); + assert.equal(aggregate.status, 'PASS'); + assert.equal(aggregate.sourceShards.length, 2); + assert.equal(aggregate.plan.planHash, plans[0].planHash); + assert.doesNotThrow(() => assertRedactedEvidence(aggregate)); + const mismatched = structuredClone(entries); + mismatched[1].evidence.target.origin = 'https://other-staging.example.test'; + assert.throws(() => aggregateEvidence(mismatched), /actual target (?:descriptor does not match|differs)/); + const mismatchedInputs = structuredClone(entries); + mismatchedInputs[1].evidence.inputs.targetPolicySha256 = 'd'.repeat(64); + assert.throws(() => aggregateEvidence(mismatchedInputs), /input\/confirmation hashes differ/); + const relaxedThresholds = structuredClone(entries); + relaxedThresholds[0].evidence.thresholds.maxErrorRate = 0.1; + assert.throws(() => aggregateEvidence(relaxedThresholds), + /thresholds do not match the deterministic plan profile/); + assert.throws(() => assertRedactedEvidence({ + target: 'https://media-staging.example.test/live.m3u8?sig=leak', + }), /query strings|credential-like/); + assert.throws(() => assertRedactedEvidence({ authorization: 'Bearer do-not-store' }), + /sensitive field|bearer/); +}); diff --git a/tools/early-birds-hls-load/test/runner.test.mjs b/tools/early-birds-hls-load/test/runner.test.mjs new file mode 100644 index 00000000..8e08b087 --- /dev/null +++ b/tools/early-birds-hls-load/test/runner.test.mjs @@ -0,0 +1,447 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import test from 'node:test'; + +import { + GENERATOR_ROLE, + aggregateEvidence, + buildPlan, + selectTarget, +} from '../src/contracts.mjs'; +import { runShard } from '../src/runner.mjs'; + +async function listen(handler) { + const server = http.createServer(handler); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + return { server, origin: `http://127.0.0.1:${address.port}` }; +} + +function syntheticTarget(origin, limitOverrides = {}) { + return selectTarget({ + schemaVersion: 1, + targets: [{ + id: 'tiny-origin', + environment: 'synthetic', + production: false, + origins: [origin], + limits: { + maxClients: 2, + maxRampPerSecond: 2, + maxSoakSeconds: 2, + maxShardCount: 1, + minManifestIntervalMs: 25, + maxInflightPerShard: 4, + maxSegmentsPerPoll: 2, + maxRequestsPerSecond: 100, + maxManifestBytes: 65536, + maxSegmentBytes: 1048576, + maxClockOffsetMs: 100, + ...limitOverrides, + }, + }], + }, 'tiny-origin'); +} + +function tinyProfile(overrides = {}) { + return { + clients: 1, + rampPerSecond: 1, + soakSeconds: 1, + manifestIntervalMs: 200, + requestTimeoutMs: 200, + startupSegments: 1, + maxSegmentsPerPoll: 2, + maxInflightPerShard: 2, + minShards: 1, + maxErrorRate: 0.1, + maxFetchMissRate: 0.1, + maxManifestP95Ms: 500, + maxSegmentP95Ms: 500, + syntheticOnly: true, + ...overrides, + }; +} + +test('tiny synthetic origin records latency, bytes, HTTP errors and rebuffer-equivalent misses', async (t) => { + let manifestRequests = 0; + let segmentRequests = 0; + let initializationRequests = 0; + const { server, origin } = await listen((request, response) => { + if (request.url.startsWith('/live.m3u8')) { + manifestRequests += 1; + response.writeHead(200, { 'Content-Type': 'application/vnd.apple.mpegurl' }); + response.end(`#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:7\n#EXT-X-MAP:URI="/init.bin?token=must-not-leak"\n#EXTINF:5.000,\n/segment.bin?token=must-not-leak\n`); + return; + } + if (request.url.startsWith('/init.bin')) { + initializationRequests += 1; + response.writeHead(200, { 'Content-Type': 'application/octet-stream' }); + response.end(Buffer.from([9, 8, 7])); + return; + } + if (request.url.startsWith('/segment.bin')) { + segmentRequests += 1; + if (segmentRequests === 1) { + response.writeHead(503); + response.end('synthetic failure'); + } else { + response.writeHead(200, { 'Content-Type': 'application/octet-stream' }); + response.end(Buffer.from([0, 1, 2, 3, 4])); + } + return; + } + response.writeHead(404); + response.end(); + }); + t.after(() => server.close()); + const target = syntheticTarget(origin); + const plan = buildPlan({ + runId: 'tiny-origin-run', + profileName: 'tiny-profile', + profile: tinyProfile(), + target, + shardIndex: 0, + shardCount: 1, + startAt: new Date(Date.now() + 50).toISOString(), + networkRun: true, + }); + let rotation = 0; + const evidence = await runShard({ + plan, + target, + policySha256: 'a'.repeat(64), + profileSha256: 'b'.repeat(64), + confirmation: plan.confirmation, + manifestUrlProvider: async () => `${origin}/live.m3u8?sig=rotated-${rotation += 1}`, + externalGenerator: false, + declaredGeneratorRole: undefined, + hostname: 'local-test', + }); + assert.equal(evidence.status, 'FAIL'); + assert.ok(manifestRequests >= 4); + assert.equal(initializationRequests, 1); + assert.equal(segmentRequests, 1); + assert.ok(evidence.measurements.bytes.manifest > 0); + assert.equal(evidence.measurements.requests.httpStatus['503'], 1); + assert.equal(evidence.measurements.fetchContinuity.misses, 1); + assert.equal(evidence.measurements.fetchContinuity.missesByCategory.segment_http, 1); + assert.ok(evidence.measurements.latencyMs.manifest.count >= 4); + assert.equal(evidence.target.origin, origin); + assert.equal(evidence.target.manifestPathSha256.length, 64); + assert.doesNotMatch(JSON.stringify(evidence), /rotated-|must-not-leak|token=/); +}); + +test('response bounds, byte-range validation and exact segment-origin allowlist fail closed', async (t) => { + let segmentRequests = 0; + const { server, origin } = await listen((request, response) => { + if (request.url.startsWith('/live.m3u8')) { + response.writeHead(200, { 'Content-Type': 'application/vnd.apple.mpegurl' }); + response.end(`#EXTM3U +#EXT-X-MEDIA-SEQUENCE:20 +#EXTINF:5, +#EXT-X-BYTERANGE:4@0 +/ranged.bin +#EXTINF:5, +http://127.0.0.1:1/escaped.bin +`); + return; + } + if (request.url.startsWith('/ranged.bin')) { + segmentRequests += 1; + response.writeHead(200, { 'Content-Length': '5' }); + response.end('whole'); + return; + } + response.writeHead(404); + response.end(); + }); + t.after(() => server.close()); + const target = syntheticTarget(origin); + const plan = buildPlan({ + runId: 'bounded-response-run', + profileName: 'bounded-profile', + profile: tinyProfile({ startupSegments: 2 }), + target, + shardIndex: 0, + shardCount: 1, + startAt: new Date(Date.now() + 25).toISOString(), + networkRun: true, + }); + const evidence = await runShard({ + plan, + target, + policySha256: 'a'.repeat(64), + profileSha256: 'b'.repeat(64), + confirmation: plan.confirmation, + manifestUrlProvider: async () => `${origin}/live.m3u8?sig=redacted`, + hostname: 'local-test', + }); + assert.equal(segmentRequests, 1); + assert.equal(evidence.measurements.requests.errorsByCategory.range_status, 1); + assert.equal(evidence.measurements.fetchContinuity.missesByCategory.segment_range_status, 1); + assert.equal(evidence.measurements.fetchContinuity.missesByCategory.segment_target_allowlist, 1); +}); + +test('manifest response-body cap aborts oversized responses without storing their body', async (t) => { + const oversized = `#EXTM3U\n${'#'.repeat(256)}`; + const { server, origin } = await listen((_request, response) => { + response.writeHead(200, { 'Content-Length': String(Buffer.byteLength(oversized)) }); + response.end(oversized); + }); + t.after(() => server.close()); + const target = syntheticTarget(origin, { maxManifestBytes: 64 }); + const plan = buildPlan({ + runId: 'manifest-body-limit', + profileName: 'body-limit-profile', + profile: tinyProfile(), + target, + shardIndex: 0, + shardCount: 1, + startAt: new Date(Date.now() + 25).toISOString(), + networkRun: true, + }); + const evidence = await runShard({ + plan, + target, + policySha256: 'a'.repeat(64), + profileSha256: 'b'.repeat(64), + confirmation: plan.confirmation, + manifestUrlProvider: async () => `${origin}/live.m3u8?sig=redacted`, + hostname: 'local-test', + }); + assert.equal(evidence.status, 'FAIL'); + assert.ok(evidence.measurements.requests.errorsByCategory.body_limit >= 1); + assert.equal(evidence.measurements.bytes.total, 0); +}); + +test('overall deadline cancels an in-flight synthetic request and settles every client', async (t) => { + const { server, origin } = await listen(() => {}); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const target = syntheticTarget(origin); + const plan = buildPlan({ + runId: 'deadline-run', + profileName: 'deadline-profile', + profile: tinyProfile({ manifestIntervalMs: 600, requestTimeoutMs: 1200 }), + target, + shardIndex: 0, + shardCount: 1, + startAt: new Date(Date.now() + 25).toISOString(), + networkRun: true, + }); + const started = Date.now(); + const evidence = await runShard({ + plan, + target, + policySha256: 'a'.repeat(64), + profileSha256: 'b'.repeat(64), + confirmation: plan.confirmation, + manifestUrlProvider: async () => `${origin}/live.m3u8?sig=redacted`, + hostname: 'local-test', + }); + assert.equal(evidence.status, 'FAIL'); + assert.equal(evidence.termination.reason, 'deadline_exceeded'); + assert.ok(Date.now() - started < 1500); +}); + +test('operator abort accounts for an in-flight request and remains aggregatable', async (t) => { + const controller = new AbortController(); + const { server, origin } = await listen(() => { + setTimeout(() => controller.abort(), 10); + }); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const target = syntheticTarget(origin); + const plan = buildPlan({ + runId: 'operator-abort-run', + profileName: 'operator-abort-profile', + profile: tinyProfile({ manifestIntervalMs: 600, requestTimeoutMs: 1200 }), + target, + shardIndex: 0, + shardCount: 1, + startAt: new Date(Date.now() + 25).toISOString(), + networkRun: true, + }); + const evidence = await runShard({ + plan, + target, + policySha256: 'a'.repeat(64), + profileSha256: 'b'.repeat(64), + confirmation: plan.confirmation, + manifestUrlProvider: async () => `${origin}/live.m3u8?sig=redacted`, + hostname: 'local-test', + signal: controller.signal, + }); + assert.equal(evidence.status, 'ABORTED'); + assert.equal(evidence.termination.reason, 'operator_abort'); + assert.equal(evidence.measurements.requests.total, 1); + assert.equal(evidence.measurements.requests.failed, 1); + assert.equal(evidence.measurements.requests.errorsByCategory.aborted, 1); + const aggregate = aggregateEvidence([{ sha256: 'c'.repeat(64), evidence }]); + assert.equal(aggregate.status, 'FAIL'); +}); + +test('rolling fetch failures trip the in-run circuit breaker before soak completion', async (t) => { + const { server, origin } = await listen((_request, response) => { + response.writeHead(503); + response.end('synthetic outage'); + }); + t.after(() => server.close()); + const target = syntheticTarget(origin, { maxRequestsPerSecond: 300 }); + const plan = buildPlan({ + runId: 'circuit-run', + profileName: 'circuit-profile', + profile: tinyProfile({ + clients: 2, + rampPerSecond: 2, + manifestIntervalMs: 25, + requestTimeoutMs: 50, + maxErrorRate: 0, + maxFetchMissRate: 0, + }), + target, + shardIndex: 0, + shardCount: 1, + startAt: new Date(Date.now() + 25).toISOString(), + networkRun: true, + }); + const evidence = await runShard({ + plan, + target, + policySha256: 'a'.repeat(64), + profileSha256: 'b'.repeat(64), + confirmation: plan.confirmation, + manifestUrlProvider: async () => `${origin}/live.m3u8?sig=redacted`, + hostname: 'local-test', + }); + assert.equal(evidence.status, 'FAIL'); + assert.equal(evidence.termination.reason, 'circuit_breaker'); + assert.equal(evidence.termination.circuitBreakerReason, 'fetch_miss_rate'); + assert.ok(evidence.measurements.requests.total < 80); +}); + +test('staging responses must present the exact non-production attestation', async () => { + const target = selectTarget({ + schemaVersion: 1, + targets: [{ + id: 'attested-staging', + environment: 'staging', + production: false, + attestation: { + header: 'x-harmonic-beacon-environment', + value: 'early-birds-staging-test', + }, + origins: ['https://staging.example.test'], + limits: { + maxClients: 1, + maxRampPerSecond: 1, + maxSoakSeconds: 1, + maxShardCount: 1, + minManifestIntervalMs: 1000, + maxInflightPerShard: 1, + maxSegmentsPerPoll: 1, + maxRequestsPerSecond: 2, + maxManifestBytes: 65536, + maxSegmentBytes: 1048576, + maxClockOffsetMs: 100, + }, + }], + }, 'attested-staging'); + const plan = buildPlan({ + runId: 'attestation-run', + profileName: 'attestation-profile', + profile: tinyProfile({ + manifestIntervalMs: 1000, + requestTimeoutMs: 1000, + startupSegments: 1, + maxSegmentsPerPoll: 1, + maxInflightPerShard: 1, + maxManifestP95Ms: 1000, + maxSegmentP95Ms: 1000, + syntheticOnly: false, + }), + target, + shardIndex: 0, + shardCount: 1, + startAt: new Date(Date.now() + 25).toISOString(), + networkRun: false, + }); + const evidence = await runShard({ + plan, + target, + policySha256: 'a'.repeat(64), + profileSha256: 'b'.repeat(64), + confirmation: plan.confirmation, + manifestUrlProvider: async () => 'https://staging.example.test/live.m3u8?sig=redacted', + externalGenerator: true, + declaredGeneratorRole: GENERATOR_ROLE, + clockOffsetMs: 5, + hostname: 'external-generator', + fetchImpl: async () => new Response('#EXTM3U\n', { status: 200 }), + }); + assert.equal(evidence.status, 'FAIL'); + assert.equal(evidence.measurements.requests.errorsByCategory.target_attestation, 1); +}); + +test('CLI dry-run writes planned redacted evidence and makes zero network requests', async () => { + const temporary = await mkdtemp(path.join(os.tmpdir(), 'early-birds-hls-dry-')); + const policyPath = path.join(temporary, 'policy.json'); + const evidencePath = path.join(temporary, 'evidence.json'); + await writeFile(policyPath, JSON.stringify({ + schemaVersion: 1, + targets: [{ + id: 'tiny-origin', + environment: 'synthetic', + production: false, + origins: ['http://127.0.0.1:9'], + limits: { + maxClients: 2, + maxRampPerSecond: 2, + maxSoakSeconds: 2, + maxShardCount: 1, + minManifestIntervalMs: 25, + maxInflightPerShard: 4, + maxSegmentsPerPoll: 2, + maxRequestsPerSecond: 100, + maxManifestBytes: 65536, + maxSegmentBytes: 1048576, + maxClockOffsetMs: 100, + }, + }], + }), { mode: 0o600 }); + await chmod(policyPath, 0o600); + const toolRoot = path.resolve(import.meta.dirname, '..'); + const child = spawn(process.execPath, [ + path.join(toolRoot, 'run.mjs'), + '--policy', policyPath, + '--target', 'tiny-origin', + '--profile', 'tiny-synthetic', + '--run-id', 'dry-run-proof', + '--start-at', '2030-01-01T00:00:00.000Z', + '--evidence', evidencePath, + '--dry-run', + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + const [code] = await once(child, 'close'); + assert.equal(code, 0, stderr); + assert.match(stdout, /Network requests: 0/); + assert.match(stdout, /generator=external-not-mona/); + const evidence = JSON.parse(await readFile(evidencePath, 'utf8')); + assert.equal(evidence.status, 'PLANNED'); + assert.equal(evidence.generator.networkRequestsMade, false); + assert.equal(evidence.measurements.requests.total, 0); +}); From 24d27b12310348c963c6c5e360a6ef68f31348e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 04:07:44 -0300 Subject: [PATCH 033/376] fix(early-birds): make observability runtime reachable --- ops/early-birds/docker-compose.yml | 13 +++++++++---- ops/early-birds/test/config.test.mjs | 3 +++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ops/early-birds/docker-compose.yml b/ops/early-birds/docker-compose.yml index e345261f..17f14246 100644 --- a/ops/early-birds/docker-compose.yml +++ b/ops/early-birds/docker-compose.yml @@ -24,13 +24,12 @@ services: - --storage.tsdb.path=/prometheus - --storage.tsdb.retention.time=14d - --storage.tsdb.retention.size=8GB - - --web.enable-lifecycle=false 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] + networks: [observability, ops_edge] deploy: resources: limits: { cpus: "1.0", memory: 1G } @@ -50,7 +49,7 @@ services: - ./alertmanager/telegram.tmpl:/etc/alertmanager/templates/telegram.tmpl:ro - alertmanager-data:/alertmanager ports: [127.0.0.1:9093:9093] - networks: [observability] + networks: [observability, ops_edge] deploy: resources: limits: { cpus: "0.25", memory: 256M } @@ -96,7 +95,9 @@ services: 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 - networks: [observability] + # 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.25", memory: 128M } @@ -105,6 +106,10 @@ 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 volumes: prometheus-data: diff --git a/ops/early-birds/test/config.test.mjs b/ops/early-birds/test/config.test.mjs index e9678eb8..f66d106a 100644 --- a/ops/early-birds/test/config.test.mjs +++ b/ops/early-birds/test/config.test.mjs @@ -14,6 +14,9 @@ test('keeps all metrics and Alertmanager listeners off public interfaces', async 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, /ops_edge:\s+name: earlybirds_observability_edge/); + 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]/); From 91ad83d247df377b84da4bf955dcaa2f6e6bfcc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 04:09:33 -0300 Subject: [PATCH 034/376] fix(early-birds): mount canary secret for non-root runtime --- ops/early-birds/docker-compose.yml | 22 ++++++++++++++++++++-- ops/early-birds/test/config.test.mjs | 4 ++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ops/early-birds/docker-compose.yml b/ops/early-birds/docker-compose.yml index 17f14246..a8d28c24 100644 --- a/ops/early-birds/docker-compose.yml +++ b/ops/early-birds/docker-compose.yml @@ -86,15 +86,32 @@ services: 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 - secrets: [beacon_stream_signing_secret] + depends_on: + canary-secret-init: { condition: service_completed_successfully } environment: - BEACON_STREAM_SIGNING_SECRET_FILE: /run/secrets/beacon_stream_signing_secret + 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 + 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] @@ -115,6 +132,7 @@ volumes: prometheus-data: alertmanager-data: alertmanager-runtime: + canary-runtime: secrets: telegram_bot_token: diff --git a/ops/early-birds/test/config.test.mjs b/ops/early-birds/test/config.test.mjs index f66d106a..af61f27d 100644 --- a/ops/early-birds/test/config.test.mjs +++ b/ops/early-birds/test/config.test.mjs @@ -28,6 +28,10 @@ test('references Telegram and canary credentials as mounted secret files only', 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, /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*[^$]/); From 2bc9f2d7799d00d298da3d18b7805028900d0337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 04:12:40 -0300 Subject: [PATCH 035/376] chore(early-birds): attest isolated staging responses --- .../nginx/earlybirds-staging.harmonicbeacon.com.conf.template | 1 + .../nginx/stream.harmonicbeacon.com.conf.template | 1 + ops/early-birds-preview/test/preview-contract.test.mjs | 1 + 3 files changed, 3 insertions(+) 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 index 450f2148..887a6e7b 100644 --- a/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -27,6 +27,7 @@ server { 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. diff --git a/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template index b77602ad..a8bfc422 100644 --- a/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template @@ -27,6 +27,7 @@ server { 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; diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 3d60842f..82a669c3 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -123,6 +123,7 @@ test('nginx templates name only the two staging hosts and proxy only fixed loopb assert.match(stream, /letsencrypt\/live\/stream\.harmonicbeacon\.com/); assert.match(app, /location \^~ \/api\/internal\//); assert.match(app, /location \^~ \/api\/early-birds\//); + assert.equal((combined.match(/X-Harmonic-Beacon-Environment "early-birds-staging"/g) ?? []).length, 2); assert.match(app, /location = \/ \{\s*return 302 \/early-birds;/); assert.match(app, /location \/ \{\s*return 404;/); assert.doesNotMatch(app, /location \^~ \/api\/(auth|ops)|location \^~ \/(login|ops|session)/); From dc292e2433f7b27aeaa9b36041a472af7c5639b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 04:20:10 -0300 Subject: [PATCH 036/376] docs(early-birds): record isolated staging evidence --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index eb299e94..51256346 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,5 +1,36 @@ # EarlyBirds isolated staging runtime +## 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 SHA: `3ec91cf7589c8ec57f892a4a9e5e190c5bc3462d`. +- Free authority preview SHA: `f91c6416c8980b9862aa372610118bc937f4dda5`, + including hardened authority `8638d6e` and Alembic head `b8c4d1e7f260`. +- Runtime, observability and nginx fixes are on the `early-birds` branch through + `e23ba4c`; the app image was not rebuilt for docs/ops-only commits. +- 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, + node-exporter, cAdvisor and the HTTP segment canary are healthy. The + authority has no published host port and paid checkout returns fail-closed + `503 paid_checkout_disabled`. +- Canonical Free acceptance passed through identity-only synthetic login, + signed one-use invitation, private authority redemption, membership + projection, session cookie and Listener home. +- Rollback stopped only Listener/origin, retained healthy preview PostgreSQL, + kept `live.harmonicbeacon.com` healthy, and restored staging via the normal + start/smoke path. +- Alertmanager remains intentionally stopped until root-owned Telegram bot and + chat-ID files exist. The current origin uses a non-audio synthetic fixture; + no acoustic choice or real derivative has been made. + +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`. From 75280993b7bc00465ca1207d12831076d12bd17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 04:34:14 -0300 Subject: [PATCH 037/376] test(early-birds): prepare Free human acceptance --- .../operations/EARLY_BIRDS_FREE_ACCEPTANCE.md | 75 +++++++++++++++++++ e2e/tests/early-birds-responsive.spec.ts | 73 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md create mode 100644 e2e/tests/early-birds-responsive.spec.ts diff --git a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md new file mode 100644 index 00000000..b386bf33 --- /dev/null +++ b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md @@ -0,0 +1,75 @@ +# EarlyBirds Free acceptance + +This sheet records the human release gate for the Free-first EarlyBirds MVP. +It never authorizes paid checkout, an app-store release, production promotion, +or an acoustic choice. Use only the isolated staging hosts and synthetic +`@e2e.invalid` identities. + +## Fixed scope + +- Listener: `https://earlybirds-staging.harmonicbeacon.com` +- Stream origin: `https://stream.harmonicbeacon.com` +- Membership path: signed one-use Free invitation only +- PayPal and Mercado Pago: disabled and expected to fail closed +- Google Play and Apple App Store: post-MVP +- Event application and `live.harmonicbeacon.com`: out of scope and unchanged + +Do not paste the invitation or temporary team code into GitHub, test notes or +chat. Retrieve them directly on `mona` through the protected files documented +in `EARLY_BIRDS_STAGING_PREVIEW.md`. Use a new browser profile and a unique +`@e2e.invalid` address. The supervised invitation is one-use. + +## Automated preflight + +Record the exact webapp and authority SHAs, then require: + +- webapp PR checks green; +- authority PR checks green; +- Listener, origin, both PostgreSQL databases and authority API/worker healthy; +- staging app/origin return HTTP 200; +- `live.harmonicbeacon.com/api/health` remains HTTP 200; +- canonical Free lifecycle smoke passes redeem, replay isolation, two-device + eviction, revocation projection and post-revocation stream denial; +- paid checkout remains disabled. + +## Human browser flow + +For each row in the result table: + +1. Open the signed invitation in a clean profile. +2. Confirm the page names staging and never displays a paid checkout. +3. Switch ES → EN → ES. Confirm labels change, `lang` follows the selection, + no text clips and no horizontal scrolling appears. +4. Enter a synthetic display name, unique `@e2e.invalid` address and the + protected team code. Confirm the code clears after submission and is absent + from local/session storage. +5. Activate the invitation. Confirm the private Listener shows the chosen name + and an active `FREE` membership, with no camera or microphone prompt and no + LiveKit/event controls. +6. Reload and open a second device/profile. Both must retain access. Open a + third device/profile with the same account only during the supervised + lifecycle test; the oldest active lease must be displaced truthfully. +7. Exercise pause/resume and background/foreground recovery only with an + approved staging audio artifact. If staging still uses the non-audio + fixture, mark acoustic and physical playback rows `BLOCKED — audio approval` + rather than passing them by appearance. +8. Revoke the disposable invitation through the authority. Existing playback + must stop at the next bounded authorization check, a heartbeat must deny + access, and reloading the private home must return to membership-required. + +## Result record + +| Date/time | Webapp SHA | Authority SHA | Device / OS | Browser | Locale | Free entry | 2→3 device | Revocation | No media permission | Audio | Tester / notes | +|---|---|---|---|---|---|---|---|---|---|---|---| +| _pending_ | _pending_ | _pending_ | Desktop | Chromium | ES/EN | PENDING | PENDING | PENDING | PENDING | BLOCKED — audio approval | | +| _pending_ | _pending_ | _pending_ | Desktop | Firefox | ES/EN | PENDING | PENDING | PENDING | PENDING | BLOCKED — audio approval | | +| _pending_ | _pending_ | _pending_ | Android physical | Chrome | ES/EN | PENDING | PENDING | PENDING | PENDING | BLOCKED — audio approval | | +| _pending_ | _pending_ | _pending_ | iPhone physical | Safari | ES/EN | PENDING | PENDING | PENDING | PENDING | BLOCKED — audio approval | | + +## Acceptance and rollback + +Only Nico records the Free human acceptance decision. Failed rows stay failed +or blocked; they are not averaged into a pass. If staging degrades, close the +Listener/team-entry switches and run the isolated rollback from +`EARLY_BIRDS_STAGING_PREVIEW.md`. Retain the preview databases for audit and do +not touch the event runtime. diff --git a/e2e/tests/early-birds-responsive.spec.ts b/e2e/tests/early-birds-responsive.spec.ts new file mode 100644 index 00000000..36c9ed72 --- /dev/null +++ b/e2e/tests/early-birds-responsive.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test'; + +async function expectNoHorizontalScroll(page: import('@playwright/test').Page): Promise { + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow, 'EarlyBirds page has horizontal overflow').toBeLessThanOrEqual(1); +} + +test.describe('EarlyBirds responsive bilingual boundary', () => { + test.beforeEach(async ({ page }) => { + await page.setExtraHTTPHeaders({ 'x-forwarded-proto': 'https' }); + }); + + test('landing remains reachable and complete in ES and EN', async ({ page }) => { + await page.goto('/early-birds'); + await expectNoHorizontalScroll(page); + + const viewport = page.viewportSize(); + expect(viewport).not.toBeNull(); + for (const name of ['Nombre de prueba', 'Cuenta sintética', 'Código de acceso temporal']) { + const control = page.getByLabel(name); + await expect(control).toBeVisible(); + const box = await control.boundingBox(); + expect(box, `${name} has no layout box`).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1); + } + + await page.getByRole('button', { name: 'EN' }).click(); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + await expect(page.getByRole('heading', { name: 'The Beacon, always present.' })).toBeVisible(); + await expect(page.getByLabel('Test name')).toBeVisible(); + await expect(page.getByLabel('Synthetic account')).toBeVisible(); + await expect(page.getByLabel('Temporary access code')).toBeVisible(); + await expectNoHorizontalScroll(page); + }); + + test('private Listener remains in bounds without requesting event media', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, '__earlyBirdMediaRequests', { + value: 0, + writable: true, + }); + const mediaDevices = navigator.mediaDevices; + if (!mediaDevices?.getUserMedia) return; + const original = mediaDevices.getUserMedia.bind(mediaDevices); + mediaDevices.getUserMedia = (...constraints: Parameters) => { + (window as typeof window & { __earlyBirdMediaRequests: number }).__earlyBirdMediaRequests += 1; + return original(...constraints); + }; + }); + 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: `responsive-${test.info().project.name}@e2e.invalid`, + name: 'Responsive Listener', + }, + }); + expect(response.status()).toBe(200); + + await page.goto('/early-birds/home'); + await expect(page.getByRole('heading', { name: 'Beacon 24/7' })).toBeVisible(); + await expect(page.getByText('Responsive Listener')).toBeVisible(); + await expectNoHorizontalScroll(page); + await expect.poll(() => page.evaluate( + () => (window as typeof window & { __earlyBirdMediaRequests: number }).__earlyBirdMediaRequests, + )).toBe(0); + }); +}); From 90034df258051c50702b626329df004265543c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 04:37:58 -0300 Subject: [PATCH 038/376] test(early-birds): isolate responsive identities --- e2e/tests/early-birds-responsive.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/tests/early-birds-responsive.spec.ts b/e2e/tests/early-birds-responsive.spec.ts index 36c9ed72..cc366e22 100644 --- a/e2e/tests/early-birds-responsive.spec.ts +++ b/e2e/tests/early-birds-responsive.spec.ts @@ -27,7 +27,7 @@ test.describe('EarlyBirds responsive bilingual boundary', () => { expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1); } - await page.getByRole('button', { name: 'EN' }).click(); + await page.getByRole('button', { name: 'EN', exact: true }).click(); await expect(page.locator('html')).toHaveAttribute('lang', 'en'); await expect(page.getByRole('heading', { name: 'The Beacon, always present.' })).toBeVisible(); await expect(page.getByLabel('Test name')).toBeVisible(); @@ -56,7 +56,7 @@ test.describe('EarlyBirds responsive bilingual boundary', () => { 'x-forwarded-proto': 'https', }, data: { - email: `responsive-${test.info().project.name}@e2e.invalid`, + email: `responsive-${test.info().project.name}-${Date.now()}@e2e.invalid`, name: 'Responsive Listener', }, }); From df3569d0e7e8af9ed18968c91b7bd52d7df2a163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 04:41:16 -0300 Subject: [PATCH 039/376] test(early-birds): reuse responsive fixture account --- e2e/tests/early-birds-responsive.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/tests/early-birds-responsive.spec.ts b/e2e/tests/early-birds-responsive.spec.ts index cc366e22..34e9e8a9 100644 --- a/e2e/tests/early-birds-responsive.spec.ts +++ b/e2e/tests/early-birds-responsive.spec.ts @@ -56,7 +56,10 @@ test.describe('EarlyBirds responsive bilingual boundary', () => { 'x-forwarded-proto': 'https', }, data: { - email: `responsive-${test.info().project.name}-${Date.now()}@e2e.invalid`, + // Reuse one synthetic identity across viewport projects. This + // exercises sign-up followed by sign-in and stays below Better + // Auth's intentional account-creation rate limit. + email: 'responsive-listener@e2e.invalid', name: 'Responsive Listener', }, }); From e253038ea786b4777d2a2877a7b9896f152c72e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 04:53:25 -0300 Subject: [PATCH 040/376] fix(early-birds): harden listener recovery UX --- .../stream/heartbeat/__tests__/route.test.ts | 81 +++++ .../api/early-birds/stream/heartbeat/route.ts | 10 +- .../stream/manifest/__tests__/route.test.ts | 20 +- .../api/early-birds/stream/manifest/route.ts | 10 +- src/components/early-birds/ListenerPlayer.tsx | 299 +++++++++++++--- .../__tests__/EarlyBirdLanding.test.tsx | 12 + .../__tests__/ListenerPlayer.test.tsx | 336 ++++++++++++++++++ .../__tests__/stream-lease.test.ts | 21 ++ src/lib/early-birds/copy.ts | 8 +- src/lib/early-birds/stream.ts | 17 +- 10 files changed, 753 insertions(+), 61 deletions(-) create mode 100644 src/app/api/early-birds/stream/heartbeat/__tests__/route.test.ts 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..6d6bd93f --- /dev/null +++ b/src/app/api/early-birds/stream/heartbeat/__tests__/route.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + currentEarlyBirdSession: vi.fn(), + heartbeatEarlyBirdStreamLease: vi.fn(), + LeaseInactive: class extends Error { + constructor(readonly reason: 'evicted' | 'expired' | 'missing' = 'missing') { + super('inactive'); + } + }, + AccessDenied: class extends Error {}, +})); + +vi.mock('@/lib/early-birds/auth', () => ({ + currentEarlyBirdSession: mocks.currentEarlyBirdSession, +})); +vi.mock('@/lib/early-birds/stream', () => ({ + heartbeatEarlyBirdStreamLease: mocks.heartbeatEarlyBirdStreamLease, + EarlyBirdLeaseInactiveError: mocks.LeaseInactive, + EarlyBirdAccessDeniedError: mocks.AccessDenied, +})); + +import { POST } from '../route'; + +const LEASE_ID = '00000000-0000-4000-8000-000000000003'; + +function request() { + 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 }), + }); +} + +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 a renewed same-origin grant for an active lease', async () => { + mocks.heartbeatEarlyBirdStreamLease.mockResolvedValue({ + 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 response = await POST(request()); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + stream: { manifestUrl: `/api/early-birds/stream/manifest?leaseId=${LEASE_ID}` }, + }); + }); +}); diff --git a/src/app/api/early-birds/stream/heartbeat/route.ts b/src/app/api/early-birds/stream/heartbeat/route.ts index 6fb5a1f8..19fe08ec 100644 --- a/src/app/api/early-birds/stream/heartbeat/route.ts +++ b/src/app/api/early-birds/stream/heartbeat/route.ts @@ -38,7 +38,15 @@ export async function POST(request: NextRequest): Promise { }); } catch (error) { if (error instanceof EarlyBirdLeaseInactiveError) { - return NextResponse.json({ error: 'Device displaced.' }, { status: 410 }); + 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: 'Membership inactive.' }, { status: 403 }); diff --git a/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts b/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts index e51c106c..b233d556 100644 --- a/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts +++ b/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts @@ -7,7 +7,11 @@ const mocks = vi.hoisted(() => ({ earlyBirdOriginConfig: vi.fn(), signedEarlyBirdOriginManifestUrl: vi.fn(), validSignedOriginManifest: vi.fn(), - LeaseInactive: class extends Error {}, + LeaseInactive: class extends Error { + constructor(readonly reason: 'evicted' | 'expired' | 'missing' = 'missing') { + super('inactive'); + } + }, AccessDenied: class extends Error {}, })); @@ -80,9 +84,21 @@ describe('stable EarlyBird lease manifest', () => { it('cuts off a displaced device on its next manifest refresh', async () => { currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); - authorizeEarlyBirdStreamLease.mockRejectedValue(new LeaseInactive()); + authorizeEarlyBirdStreamLease.mockRejectedValue(new LeaseInactive('evicted')); const response = await GET(request()); expect(response.status).toBe(410); expect(response.headers.get('cache-control')).toContain('no-store'); + await expect(response.json()).resolves.toMatchObject({ reason: 'displaced' }); + }); + + it('reports ordinary lease expiry without claiming another device displaced it', async () => { + currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + authorizeEarlyBirdStreamLease.mockRejectedValue(new LeaseInactive('expired')); + const response = await GET(request()); + expect(response.status).toBe(410); + await expect(response.json()).resolves.toEqual({ + error: 'Listening lease expired.', + reason: 'expired', + }); }); }); diff --git a/src/app/api/early-birds/stream/manifest/route.ts b/src/app/api/early-birds/stream/manifest/route.ts index 4fcfd331..33aa1c23 100644 --- a/src/app/api/early-birds/stream/manifest/route.ts +++ b/src/app/api/early-birds/stream/manifest/route.ts @@ -61,7 +61,15 @@ export async function GET(request: NextRequest): Promise { return new NextResponse(manifest, { status: 200, headers: MANIFEST_HEADERS }); } catch (error) { if (error instanceof EarlyBirdLeaseInactiveError) { - return NextResponse.json({ error: 'Device displaced.' }, { + 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, headers: { 'Cache-Control': 'private, no-store' }, }); diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 08fe253a..7623f4e5 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -7,15 +7,24 @@ import { useLocale } from '@/context/LocaleContext'; import { earlyBirdHomeCopy } from '@/lib/early-birds/copy'; type DropLanguage = 'es' | 'en'; -type LiveState = 'idle' | 'loading' | 'playing' | 'paused' | 'error' | 'displaced'; +type LiveState = 'idle' | 'loading' | 'recovering' | 'playing' | 'paused' | 'error' | 'displaced'; type LeasePayload = { leaseId: string; leaseExpiresAt: string; stream: { manifestUrl: string; expiresAt: string }; }; +type HeartbeatPayload = Omit; +type LeaseProbeResult = + | { kind: 'active'; grant: HeartbeatPayload } + | { kind: 'reacquire' } + | { kind: 'displaced' } + | { kind: 'denied' } + | { kind: 'retry' }; const DEVICE_STORAGE_KEY = 'hb_earlybird_device_id'; const DROP_PROGRESS_PREFIX = 'hb_earlybird_drop_progress_'; +const RECOVERY_DELAYS_MS = [0, 1_000, 3_000] as const; +const STALL_RECOVERY_DELAY_MS = 1_000; export function getOrCreateEarlyBirdDeviceId(storage: Storage): string { const existing = storage.getItem(DEVICE_STORAGE_KEY); @@ -35,6 +44,11 @@ export function seekNativeAudioToLiveEdge(audio: HTMLAudioElement): boolean { return true; } +export function earlyBirdLeaseRecoveryDisposition(payload: unknown): 'displaced' | 'recoverable' { + if (!payload || typeof payload !== 'object') return 'recoverable'; + return 'reason' in payload && payload.reason === 'displaced' ? 'displaced' : 'recoverable'; +} + function formatTime(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; const rounded = Math.floor(seconds); @@ -59,6 +73,13 @@ export default function ListenerPlayer({ const manifestExpiresAt = useRef(0); const leaseId = useRef(null); const liveStateRef = useRef('idle'); + const wantsLivePlayback = useRef(false); + const playbackAttemptRunning = useRef(false); + const recoveryAttempts = useRef(0); + const recoveryTimer = useRef(null); + const queuedRecoveryDelay = useRef(null); + const nativeSuspendObserved = useRef(false); + const automaticRecovery = useRef<(initialDelayMs?: number) => void>(() => undefined); const [liveState, setLiveState] = useState('idle'); const [playingDrop, setPlayingDrop] = useState(null); const [dropProgress, setDropProgress] = useState({ @@ -73,6 +94,15 @@ export default function ListenerPlayer({ setLiveState(state); }, []); + const cancelRecovery = useCallback((resetAttempts = false) => { + if (recoveryTimer.current !== null) { + window.clearTimeout(recoveryTimer.current); + recoveryTimer.current = null; + } + queuedRecoveryDelay.current = null; + if (resetAttempts) recoveryAttempts.current = 0; + }, []); + const stopHls = useCallback(() => { hls.current?.destroy(); hls.current = null; @@ -99,13 +129,13 @@ export default function ListenerPlayer({ }); instance.on(HlsConstructor.Events.ERROR, (_event, data) => { if (!data.fatal) return; - updateLiveState('error'); liveAudio.current?.pause(); + automaticRecovery.current(0); }); instance.loadSource(url); instance.attachMedia(audio); hls.current = instance; - }, [stopHls, updateLiveState]); + }, [stopHls]); const requestLease = useCallback(async (): Promise => { const deviceId = getOrCreateEarlyBirdDeviceId(window.localStorage); @@ -118,6 +148,28 @@ export default function ListenerPlayer({ return response.json() as Promise; }, []); + const probeExistingLease = useCallback(async (): Promise => { + if (!leaseId.current) return { kind: 'reacquire' }; + try { + const response = await fetch('/api/early-birds/stream/heartbeat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ leaseId: leaseId.current }), + }); + if (response.status === 410) { + const payload = await response.json().catch(() => null) as { reason?: unknown } | null; + return earlyBirdLeaseRecoveryDisposition(payload) === 'displaced' + ? { kind: 'displaced' } + : { kind: 'reacquire' }; + } + if (response.status === 401 || response.status === 403) return { kind: 'denied' }; + if (!response.ok) return { kind: 'retry' }; + return { kind: 'active', grant: await response.json() as HeartbeatPayload }; + } catch { + return { kind: 'retry' }; + } + }, []); + const restoreLiveOutput = useCallback(() => { if (!liveSuppressedForDrop.current) return; if (liveAudio.current) liveAudio.current.muted = false; @@ -131,12 +183,35 @@ export default function ListenerPlayer({ restoreLiveOutput(); }, [dropAudio.en, dropAudio.es, restoreLiveOutput]); - const playLive = useCallback(async (forceRefresh = false) => { + const attemptLivePlayback = useCallback(async ( + forceRefresh = false, + verifyExistingLease = false, + ): Promise => { const audio = liveAudio.current; - if (!audio || liveStateRef.current === 'loading') return; - pauseDropIns(); - updateLiveState('loading'); + if (!audio || playbackAttemptRunning.current) return false; + playbackAttemptRunning.current = true; try { + if (verifyExistingLease && leaseId.current) { + const probe = await probeExistingLease(); + if (probe.kind === 'displaced' || probe.kind === 'denied') { + wantsLivePlayback.current = false; + audio.pause(); + stopHls(); + leaseId.current = null; + updateLiveState(probe.kind === 'displaced' ? 'displaced' : 'error'); + return false; + } + if (probe.kind === 'retry') return false; + if (probe.kind === 'reacquire') { + leaseId.current = null; + manifestUrl.current = null; + manifestExpiresAt.current = 0; + } else { + manifestExpiresAt.current = Date.parse(probe.grant.stream.expiresAt); + await attachManifest(probe.grant.stream.manifestUrl); + forceRefresh = false; + } + } if ( forceRefresh || !leaseId.current || @@ -146,7 +221,7 @@ export default function ListenerPlayer({ const grant = await requestLease(); leaseId.current = grant.leaseId; manifestExpiresAt.current = Date.parse(grant.stream.expiresAt); - if (grant.stream.manifestUrl !== manifestUrl.current) { + if (forceRefresh || grant.stream.manifestUrl !== manifestUrl.current) { await attachManifest(grant.stream.manifestUrl); } } @@ -158,17 +233,93 @@ export default function ListenerPlayer({ seekNativeAudioToLiveEdge(audio); } await audio.play(); - updateLiveState('playing'); + return true; } catch { audio.pause(); - updateLiveState('error'); + return false; + } finally { + playbackAttemptRunning.current = false; + } + }, [attachManifest, probeExistingLease, requestLease, stopHls, updateLiveState]); + + const scheduleAutomaticRecovery = useCallback((initialDelayMs = 0) => { + if (!wantsLivePlayback.current || liveStateRef.current === 'displaced') return; + + if (playbackAttemptRunning.current) { + queuedRecoveryDelay.current = queuedRecoveryDelay.current === null + ? Math.max(0, initialDelayMs) + : Math.min(queuedRecoveryDelay.current, Math.max(0, initialDelayMs)); + updateLiveState('recovering'); + return; + } + if (recoveryTimer.current !== null) return; + + updateLiveState('recovering'); + const runAttempt = (delayMs: number) => { + if (!wantsLivePlayback.current) return; + if (recoveryAttempts.current >= RECOVERY_DELAYS_MS.length) { + wantsLivePlayback.current = false; + liveAudio.current?.pause(); + updateLiveState('error'); + return; + } + recoveryTimer.current = window.setTimeout(async () => { + recoveryTimer.current = null; + if (!wantsLivePlayback.current) return; + recoveryAttempts.current += 1; + const recovered = await attemptLivePlayback(true, true); + if (!wantsLivePlayback.current) return; + if (queuedRecoveryDelay.current !== null) { + const queuedDelay = queuedRecoveryDelay.current; + queuedRecoveryDelay.current = null; + runAttempt(queuedDelay); + return; + } + if (recovered) { + recoveryAttempts.current = 0; + updateLiveState('playing'); + return; + } + runAttempt(RECOVERY_DELAYS_MS[recoveryAttempts.current] ?? 0); + }, delayMs); + }; + runAttempt(Math.max(0, initialDelayMs)); + }, [attemptLivePlayback, updateLiveState]); + + useEffect(() => { + automaticRecovery.current = scheduleAutomaticRecovery; + return () => { + automaticRecovery.current = () => undefined; + }; + }, [scheduleAutomaticRecovery]); + + const playLive = useCallback(async (forceRefresh = false) => { + if (!liveAudio.current || ['loading', 'recovering'].includes(liveStateRef.current)) return; + wantsLivePlayback.current = true; + cancelRecovery(true); + pauseDropIns(); + updateLiveState('loading'); + const played = await attemptLivePlayback(forceRefresh); + if (!wantsLivePlayback.current) return; + if (queuedRecoveryDelay.current !== null) { + const queuedDelay = queuedRecoveryDelay.current; + queuedRecoveryDelay.current = null; + scheduleAutomaticRecovery(queuedDelay); + return; } - }, [attachManifest, pauseDropIns, requestLease, updateLiveState]); + if (played) { + updateLiveState('playing'); + return; + } + scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS); + }, [attemptLivePlayback, cancelRecovery, pauseDropIns, scheduleAutomaticRecovery, updateLiveState]); function toggleLive() { const audio = liveAudio.current; if (!audio) return; - if (liveState === 'playing') { + if (liveState === 'playing' || liveState === 'recovering') { + wantsLivePlayback.current = false; + cancelRecovery(true); audio.pause(); audio.muted = false; liveSuppressedForDrop.current = false; @@ -178,6 +329,27 @@ export default function ListenerPlayer({ void playLive(liveState === 'error' || liveState === 'displaced'); } + const handleNativeInterruption = useCallback((kind: 'error' | 'stalled' | 'suspend') => { + if (!wantsLivePlayback.current || !['playing', 'recovering'].includes(liveStateRef.current)) { + return; + } + if (kind === 'suspend') { + // `suspend` commonly means that the browser intentionally stopped + // fetching enough buffered media. It is only supporting evidence; + // stalled/error or a non-progressing page resume drives recovery. + nativeSuspendObserved.current = true; + return; + } + scheduleAutomaticRecovery(kind === 'error' ? 0 : STALL_RECOVERY_DELAY_MS); + }, [scheduleAutomaticRecovery]); + + const handleNativePlaying = useCallback(() => { + if (!wantsLivePlayback.current) return; + nativeSuspendObserved.current = false; + cancelRecovery(true); + updateLiveState('playing'); + }, [cancelRecovery, updateLiveState]); + useEffect(() => { const probe = document.createElement('audio'); probe.volume = 0.37; @@ -189,43 +361,58 @@ export default function ListenerPlayer({ for (const audio of all) if (audio) audio.volume = volume; }, [dropAudio.en, dropAudio.es, volume]); + useEffect(() => { + const recoverAfterResume = () => { + if (!wantsLivePlayback.current || document.visibilityState !== 'visible') return; + const leaseNearExpiry = manifestExpiresAt.current <= Date.now() + 30_000; + const suspendedWithoutFutureData = nativeSuspendObserved.current + && Boolean(liveAudio.current) + && (liveAudio.current?.readyState ?? 0) < 3; + if (liveStateRef.current === 'recovering' || leaseNearExpiry || suspendedWithoutFutureData) { + scheduleAutomaticRecovery(0); + } + }; + document.addEventListener('visibilitychange', recoverAfterResume); + window.addEventListener('online', recoverAfterResume); + window.addEventListener('pageshow', recoverAfterResume); + return () => { + document.removeEventListener('visibilitychange', recoverAfterResume); + window.removeEventListener('online', recoverAfterResume); + window.removeEventListener('pageshow', recoverAfterResume); + }; + }, [scheduleAutomaticRecovery]); + useEffect(() => { const interval = window.setInterval(async () => { if (!leaseId.current || liveStateRef.current === 'idle') return; - try { - const response = await fetch('/api/early-birds/stream/heartbeat', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ leaseId: leaseId.current }), - }); - if (response.status === 410) { - liveAudio.current?.pause(); - stopHls(); - leaseId.current = null; - updateLiveState('displaced'); - return; - } - if (response.status === 403) { - liveAudio.current?.pause(); - updateLiveState('error'); - return; - } - if (!response.ok) return; - const grant = await response.json() as Omit; - manifestExpiresAt.current = Date.parse(grant.stream.expiresAt); - // Keep the current media pipeline uninterrupted when the issuer - // rotates equivalent signed URLs; a fatal HLS error reacquires. - } catch { - // Transient heartbeat loss does not interrupt already-buffered audio. + const probe = await probeExistingLease(); + if (probe.kind === 'displaced' || probe.kind === 'denied') { + wantsLivePlayback.current = false; + cancelRecovery(true); + liveAudio.current?.pause(); + stopHls(); + leaseId.current = null; + updateLiveState(probe.kind === 'displaced' ? 'displaced' : 'error'); + return; + } + if (probe.kind === 'reacquire') { + leaseId.current = null; + scheduleAutomaticRecovery(0); + return; + } + if (probe.kind === 'active') { + manifestExpiresAt.current = Date.parse(probe.grant.stream.expiresAt); } }, 60_000); return () => window.clearInterval(interval); - }, [stopHls, updateLiveState]); + }, [cancelRecovery, probeExistingLease, scheduleAutomaticRecovery, stopHls, updateLiveState]); useEffect(() => () => { + wantsLivePlayback.current = false; + cancelRecovery(true); liveAudio.current?.pause(); stopHls(); - }, [stopHls]); + }, [cancelRecovery, stopHls]); function restoreProgress(language: DropLanguage) { const audio = dropAudio[language].current; @@ -270,7 +457,7 @@ export default function ListenerPlayer({ } // A drop-in overlays the still-running shared Beacon. Muting preserves // its timeline, HLS source and lease; ending the drop only restores output. - if (liveStateRef.current === 'playing' && liveAudio.current) { + if (wantsLivePlayback.current && liveAudio.current) { liveAudio.current.muted = true; liveSuppressedForDrop.current = true; } @@ -289,7 +476,13 @@ export default function ListenerPlayer({ const audio = dropAudio[language].current; if (!audio || !dropIns[language]) return; audio.currentTime = 0; - void toggleDropIn(language); + try { + window.localStorage.removeItem(`${DROP_PROGRESS_PREFIX}${language}`); + } catch {} + setDropProgress((current) => ({ + ...current, + [language]: { current: 0, duration: current[language].duration }, + })); } function seekDropIn(language: DropLanguage, value: number) { @@ -313,15 +506,25 @@ export default function ListenerPlayer({ const liveButton = liveState === 'loading' ? copy.loading - : liveState === 'playing' - ? copy.pause - : liveState === 'paused' - ? copy.resume - : copy.play; + : liveState === 'recovering' + ? copy.reconnecting + : liveState === 'playing' + ? copy.pause + : liveState === 'paused' + ? copy.resume + : copy.play; return (
-
{(liveState === 'error' || liveState === 'displaced') && ( @@ -892,63 +941,47 @@ export default function ListenerPlayer({

{copy.dropIns}

-
+
{(['es', 'en'] as const).map((language) => { - const progress = dropProgress[language]; - const available = Boolean(dropIns[language]); const title = language === 'es' ? copy.spanish : copy.english; return ( -
-
+
+ {selectedDropAvailable ? ( +
+

{selectedDrop.toUpperCase()}

+

{selectedDrop === 'es' ? copy.spanish : copy.english}

+ +
+ ) : ( +

{copy.dropUnavailable}

+ )}
{volumeSupported && ( diff --git a/src/components/early-birds/SyntheticTeamEntryForm.tsx b/src/components/early-birds/SyntheticTeamEntryForm.tsx index b0578f7d..affa22a7 100644 --- a/src/components/early-birds/SyntheticTeamEntryForm.tsx +++ b/src/components/early-birds/SyntheticTeamEntryForm.tsx @@ -7,7 +7,7 @@ import { earlyBirdSyntheticEntryCopy } from '@/lib/early-birds/copy'; export default function SyntheticTeamEntryForm({ authOnly = false, - postLoginPath = '/early-birds/home', + postLoginPath = '/early-birds', }: { authOnly?: boolean; postLoginPath?: string; diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index faeb703f..b469a19e 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -83,7 +83,7 @@ describe('EarlyBird public landing', () => { it('takes an entitled signed-in listener directly to the private home', () => { renderLanding({ signedIn: true, entitled: true }); - expect(screen.getByRole('link', { name: 'Enter the Beacon' })).toHaveAttribute('href', '/early-birds/home'); + expect(screen.getByRole('link', { name: 'Enter the Beacon' })).toHaveAttribute('href', '/early-birds'); expect(screen.queryByRole('button', { name: 'Continue with Google' })).toBeNull(); }); }); diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index 587931d0..0a8b032d 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -40,7 +40,6 @@ vi.mock('hls.js', () => { } return { default: TestHls }; }); - import ListenerPlayer, { earlyBirdLeaseRecoveryDisposition, getOrCreateEarlyBirdDeviceId, @@ -90,259 +89,6 @@ describe('EarlyBird Listener player', () => { expect(earlyBirdLeaseRecoveryDisposition(null)).toBe('recoverable'); }); - it('renders both language controls and fails closed when renders are absent', () => { - render( - - - , - ); - expect(screen.getByRole('button', { name: 'Connecting…' })).toBeInTheDocument(); - expect(screen.getByText('Warm-up · Spanish')).toBeInTheDocument(); - expect(screen.getByText('Warm-up · English')).toBeInTheDocument(); - expect(screen.getAllByText('The approved render has not been published yet.')).toHaveLength(2); - expect(screen.getByText('Master volume')).toBeInTheDocument(); - expect(screen.getByRole('slider', { name: 'Master volume' })).toHaveValue('1'); - }); - - it('keeps the Beacon timeline, source and lease untouched while the intro pauses and ends', async () => { - const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); - const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); - vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); - const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ - leaseId: '00000000-0000-4000-8000-000000000003', - leaseExpiresAt: '2026-08-06T12:03:00.000Z', - stream: { - manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', - expiresAt: '2099-08-06T12:03:00.000Z', - }, - }), { status: 200, headers: { 'content-type': 'application/json' } })); - vi.stubGlobal('fetch', fetchMock); - - render( - - - , - ); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; - const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; - const spanishCard = spanish.closest('article')!; - - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen now' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Listen now' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()); - const liveSource = live.src; - const leaseRequests = fetchMock.mock.calls.length; - const livePlayCalls = play.mock.instances.filter((instance) => instance === live).length; - pause.mockClear(); - - fireEvent.click(within(spanishCard).getByRole('button', { name: 'Play' })); - await waitFor(() => expect(within(spanishCard).getByRole('button', { name: 'Pause' })).toBeInTheDocument()); - expect(live.muted).toBe(true); - expect(pause.mock.instances).not.toContain(live); - expect(fetchMock).toHaveBeenCalledTimes(leaseRequests); - expect(live.src).toBe(liveSource); - - fireEvent.click(within(spanishCard).getByRole('button', { name: 'Pause' })); - expect(live.muted).toBe(true); - expect(pause.mock.instances).not.toContain(live); - expect(fetchMock).toHaveBeenCalledTimes(leaseRequests); - - fireEvent.click(within(spanishCard).getByRole('button', { name: 'Play' })); - await waitFor(() => expect(live.muted).toBe(true)); - Object.defineProperty(live, 'paused', { value: false, configurable: true }); - Object.defineProperty(spanish, 'ended', { value: true, configurable: true }); - fireEvent.playing(live); - fireEvent.ended(spanish); - expect(live.muted).toBe(false); - expect(live.src).toBe(liveSource); - expect(fetchMock).toHaveBeenCalledTimes(leaseRequests); - expect(play.mock.instances.filter((instance) => instance === live)).toHaveLength(livePlayCalls); - expect(pause.mock.instances).not.toContain(live); - }); - - it('starts the shared Beacon muted beneath an intro and reveals it when the intro ends', async () => { - const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); - vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); - const grant = { - leaseId: '00000000-0000-4000-8000-000000000003', - leaseExpiresAt: '2099-08-06T12:03:00.000Z', - stream: { - manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', - expiresAt: '2099-08-06T12:03:00.000Z', - }, - }; - const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response( - JSON.stringify(grant), - { status: 200, headers: { 'content-type': 'application/json' } }, - ))); - vi.stubGlobal('fetch', fetchMock); - render( - - - , - ); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; - const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; - const card = spanish.closest('article')!; - await waitFor(() => expect(within(card).getByRole('button', { name: 'Play' })).toBeEnabled()); - fireEvent.click(within(card).getByRole('button', { name: 'Play' })); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - await waitFor(() => expect(play.mock.instances).toContain(live)); - expect(live.src).toContain('/api/early-birds/stream/manifest'); - expect(live.muted).toBe(true); - Object.defineProperty(live, 'paused', { value: false, configurable: true }); - Object.defineProperty(spanish, 'ended', { value: true, configurable: true }); - fireEvent.playing(live); - fireEvent.ended(spanish); - expect(live.muted).toBe(false); - }); - - it('ignores stale ended events after switching intros', async () => { - vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); - vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); - const grant = { - leaseId: '00000000-0000-4000-8000-000000000003', - leaseExpiresAt: '2099-08-06T12:03:00.000Z', - stream: { - manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', - expiresAt: '2099-08-06T12:03:00.000Z', - }, - }; - const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( - new Response(JSON.stringify(grant), { status: 200 }), - )); - vi.stubGlobal('fetch', fetchMock); - render( - - - , - ); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; - const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; - const english = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; - const spanishCard = spanish.closest('article')!; - const englishCard = english.closest('article')!; - await waitFor(() => expect(within(spanishCard).getByRole('button', { name: 'Play' })).toBeEnabled()); - fireEvent.click(within(spanishCard).getByRole('button', { name: 'Play' })); - await waitFor(() => expect(within(spanishCard).getByRole('button', { name: 'Pause' })).toBeInTheDocument()); - fireEvent.click(within(englishCard).getByRole('button', { name: 'Play' })); - await waitFor(() => expect(within(englishCard).getByRole('button', { name: 'Pause' })).toBeInTheDocument()); - - Object.defineProperty(spanish, 'ended', { value: true, configurable: true }); - fireEvent.ended(spanish); - expect(live.muted).toBe(true); - expect(within(englishCard).getByRole('button', { name: 'Pause' })).toBeInTheDocument(); - - fireEvent.click(within(englishCard).getByRole('button', { name: 'Restart' })); - Object.defineProperty(english, 'ended', { value: false, configurable: true }); - fireEvent.ended(english); - expect(live.muted).toBe(true); - expect(within(englishCard).getByRole('button', { name: 'Pause' })).toBeInTheDocument(); - }); - - it('honors a master mute changed during the live fade', async () => { - const frames: FrameRequestCallback[] = []; - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { - frames.push(callback); - return frames.length; - }); - vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); - vi.spyOn(performance, 'now').mockReturnValue(0); - vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); - vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); - const grant = { - leaseId: '00000000-0000-4000-8000-000000000003', - leaseExpiresAt: '2099-08-06T12:03:00.000Z', - stream: { - manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', - expiresAt: '2099-08-06T12:03:00.000Z', - }, - }; - const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( - new Response(JSON.stringify(grant), { status: 200 }), - )); - vi.stubGlobal('fetch', fetchMock); - render( - - - , - ); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; - const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; - const card = spanish.closest('article')!; - await waitFor(() => expect(within(card).getByRole('button', { name: 'Play' })).toBeEnabled()); - fireEvent.click(within(card).getByRole('button', { name: 'Play' })); - await waitFor(() => expect(live.muted).toBe(true)); - Object.defineProperty(live, 'paused', { value: false, configurable: true }); - Object.defineProperty(spanish, 'ended', { value: true, configurable: true }); - fireEvent.playing(live); - fireEvent.ended(spanish); - expect(frames).toHaveLength(1); - - fireEvent.change(screen.getByRole('slider', { name: 'Master volume' }), { target: { value: '0' } }); - frames.shift()?.(1_500); - expect(live.volume).toBe(0); - }); - - it('restarts a playing drop-in without pausing and resets a paused drop-in without starting it', async () => { - const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); - const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ - leaseId: '00000000-0000-4000-8000-000000000003', - leaseExpiresAt: '2099-08-06T12:03:00.000Z', - stream: { - manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', - expiresAt: '2099-08-06T12:03:00.000Z', - }, - }), { status: 200 }))); - render( - - - , - ); - const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; - const card = spanish.closest('article')!; - - await waitFor(() => expect(within(card).getByRole('button', { name: 'Play' })).toBeEnabled()); - fireEvent.click(within(card).getByRole('button', { name: 'Play' })); - await waitFor(() => expect(within(card).getByRole('button', { name: 'Pause' })).toBeInTheDocument()); - spanish.currentTime = 17; - pause.mockClear(); - fireEvent.click(within(card).getByRole('button', { name: 'Restart' })); - expect(spanish.currentTime).toBe(0); - expect(within(card).getByRole('button', { name: 'Pause' })).toBeInTheDocument(); - expect(pause.mock.instances).not.toContain(spanish); - - fireEvent.click(within(card).getByRole('button', { name: 'Pause' })); - spanish.currentTime = 8; - play.mockClear(); - fireEvent.click(within(card).getByRole('button', { name: 'Restart' })); - expect(spanish.currentTime).toBe(0); - expect(within(card).getByRole('button', { name: 'Play' })).toBeInTheDocument(); - expect(play.mock.instances).not.toContain(spanish); - }); - - it('localizes the drop-in play control in Spanish', () => { - render( - - - , - ); - const spanish = screen.getByLabelText('Caldeamiento · Español'); - expect(within(spanish.closest('article')!).getByRole('button', { name: 'Reproducir' })) - .toBeInTheDocument(); - }); - it('refreshes the active lease and reattaches a fresh hls.js source after a fatal error', async () => { vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); @@ -366,10 +112,10 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen now' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Listen now' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); - await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); hlsHarness.instances[0].emitFatal(); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); @@ -377,7 +123,7 @@ describe('EarlyBird Listener player', () => { expect(fetchMock.mock.calls[2]?.[0]).toBe('/api/early-birds/stream/heartbeat'); expect(hlsHarness.instances[0].destroy).toHaveBeenCalledOnce(); expect(hlsHarness.instances[1].loadedSources).toEqual([grants[1].stream.manifestUrl]); - expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument(); }); it('keeps the pre-attached iOS source lease alive before the first play gesture', async () => { @@ -405,93 +151,14 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen now' })).toBeEnabled()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); expect(fetchMock).toHaveBeenCalledTimes(1); expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ intent: 'prepare' }); await Promise.all(intervalCallbacks.map((callback) => callback())); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/early-birds/stream/heartbeat'); - expect(screen.getByRole('button', { name: 'Listen now' })).toBeInTheDocument(); - }); - - it('disables stale intro controls when an idle prepared lease is displaced', async () => { - const intervalCallbacks: Array<() => void | Promise> = []; - vi.spyOn(window, 'setInterval').mockImplementation((callback) => { - intervalCallbacks.push(callback as () => void | Promise); - return intervalCallbacks.length as unknown as ReturnType; - }); - vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); - const grant = { - leaseId: '00000000-0000-4000-8000-000000000003', - leaseExpiresAt: '2099-08-06T12:03:00.000Z', - stream: { - manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', - expiresAt: '2099-08-06T12:03:00.000Z', - }, - }; - const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify(grant), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ - error: 'Device displaced.', - reason: 'displaced', - }), { status: 410 })); - vi.stubGlobal('fetch', fetchMock); - render( - - - , - ); - const card = screen.getByLabelText('Warm-up · Spanish').closest('article')!; - await waitFor(() => expect(within(card).getByRole('button', { name: 'Play' })).toBeEnabled()); - - await Promise.all(intervalCallbacks.map((callback) => callback())); - - await waitFor(() => expect(within(card).getByRole('button', { name: 'Play' })).toBeDisabled()); - expect(screen.getByRole('button', { name: 'Enable this device' })).toBeEnabled(); - expect(screen.getByRole('alert')).toHaveTextContent('This device was displaced'); - }); - - it('revalidates an idle prepared source after resume before enabling intros', async () => { - vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); - const grant = { - leaseId: '00000000-0000-4000-8000-000000000003', - leaseExpiresAt: '2099-08-06T12:03:00.000Z', - stream: { - manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', - expiresAt: '2099-08-06T12:03:00.000Z', - }, - }; - const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify(grant), { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ - error: 'Listening lease expired.', - reason: 'expired', - }), { status: 410 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ - error: 'Two devices are already active.', - reason: 'device_limit', - }), { status: 409 })); - vi.stubGlobal('fetch', fetchMock); - render( - - - , - ); - const card = screen.getByLabelText('Warm-up · Spanish').closest('article')!; - await waitFor(() => expect(within(card).getByRole('button', { name: 'Play' })).toBeEnabled()); - - Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); - fireEvent(document, new Event('visibilitychange')); - Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); - fireEvent(document, new Event('visibilitychange')); - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); - expect(within(card).getByRole('button', { name: 'Play' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Enable this device' })).toBeEnabled(); - expect(screen.getByRole('status')).toHaveTextContent('Two devices are already active.'); + expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeInTheDocument(); }); it('does not offer resume after a paused source loses preparation', async () => { @@ -525,12 +192,11 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen now' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Listen now' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeEnabled()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - fireEvent.click(screen.getByRole('button', { name: 'Pause' })); - expect(screen.getByRole('button', { name: 'Return to now' })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); fireEvent(document, new Event('visibilitychange')); @@ -538,156 +204,9 @@ describe('EarlyBird Listener player', () => { fireEvent(document, new Event('visibilitychange')); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); - expect(screen.queryByRole('button', { name: 'Return to now' })).toBeNull(); - expect(screen.getByRole('button', { name: 'Enable this device' })).toBeEnabled(); - expect(screen.getByRole('status')).toHaveTextContent('Two devices are already active.'); - }); - - it('claims a capacity-blocked device before enabling iOS-safe playback controls', async () => { - const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); - vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); - const grant = { - leaseId: '00000000-0000-4000-8000-000000000004', - leaseExpiresAt: '2099-08-06T12:03:00.000Z', - stream: { - manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000004', - expiresAt: '2099-08-06T12:03:00.000Z', - }, - }; - const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ - error: 'Two devices are already active.', - reason: 'device_limit', - }), { status: 409 })) - .mockResolvedValue(new Response(JSON.stringify(grant), { status: 200 })); - vi.stubGlobal('fetch', fetchMock); - - render( - - - , - ); - - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; - const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; - const card = spanish.closest('article')!; - const introButton = within(card).getByRole('button', { name: 'Play' }); - await waitFor(() => expect(screen.getByRole('button', { name: 'Enable this device' })).toBeEnabled()); - expect(screen.getByRole('status')).toHaveTextContent( - 'Two devices are already active. Enabling this one will stop playback on the least recent device.', - ); - expect(introButton).toBeDisabled(); - - fireEvent.click(screen.getByRole('button', { name: 'Enable this device' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen now' })).toBeEnabled()); - expect(screen.getByRole('status')).toHaveTextContent('Device ready. Tap again to listen, or choose a drop-in.'); - expect(within(card).getByRole('button', { name: 'Play' })).toBeEnabled(); - expect(play).not.toHaveBeenCalled(); - expect(live.src).toContain('/api/early-birds/stream/manifest'); - expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ intent: 'prepare' }); - expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ intent: 'play' }); - - fireEvent.click(within(card).getByRole('button', { name: 'Play' })); - expect(play.mock.instances).toContain(live); - expect(play.mock.instances).toContain(spanish); - }); - - it('ignores a stale intro play completion after switching languages', async () => { - let finishSpanishPlay: (() => void) | undefined; - const spanishPlay = new Promise((resolve) => { - finishSpanishPlay = resolve; - }); - vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(function (this: HTMLMediaElement) { - return this.getAttribute('aria-label') === 'Warm-up · Spanish' - ? spanishPlay - : Promise.resolve(); - }); - vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ - leaseId: '00000000-0000-4000-8000-000000000003', - leaseExpiresAt: '2099-08-06T12:03:00.000Z', - stream: { - manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', - expiresAt: '2099-08-06T12:03:00.000Z', - }, - }), { status: 200 }))); - render( - - - , - ); - const spanish = screen.getByLabelText('Warm-up · Spanish'); - const english = screen.getByLabelText('Warm-up · English'); - const spanishCard = spanish.closest('article')!; - const englishCard = english.closest('article')!; - await waitFor(() => expect(within(spanishCard).getByRole('button', { name: 'Play' })).toBeEnabled()); - - fireEvent.click(within(spanishCard).getByRole('button', { name: 'Play' })); - fireEvent.click(within(englishCard).getByRole('button', { name: 'Play' })); - await waitFor(() => expect(within(englishCard).getByRole('button', { name: 'Pause' })).toBeInTheDocument()); - finishSpanishPlay?.(); - - await waitFor(() => expect(within(englishCard).getByRole('button', { name: 'Pause' })).toBeInTheDocument()); - expect(within(spanishCard).getByRole('button', { name: 'Play' })).toBeInTheDocument(); - }); - - it('defers an in-flight live fade while hidden and resumes it when visible', async () => { - const frames: FrameRequestCallback[] = []; - const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { - frames.push(callback); - return frames.length; - }); - vi.spyOn(performance, 'now').mockReturnValue(0); - vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); - vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); - vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); - const grant = { - leaseId: '00000000-0000-4000-8000-000000000003', - leaseExpiresAt: '2099-08-06T12:03:00.000Z', - stream: { - manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', - expiresAt: '2099-08-06T12:03:00.000Z', - }, - }; - const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( - new Response(JSON.stringify(grant), { status: 200 }), - )); - vi.stubGlobal('fetch', fetchMock); - render( - - - , - ); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; - const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; - const card = spanish.closest('article')!; - await waitFor(() => expect(within(card).getByRole('button', { name: 'Play' })).toBeEnabled()); - fireEvent.click(within(card).getByRole('button', { name: 'Play' })); - await waitFor(() => expect(live.muted).toBe(true)); - Object.defineProperty(live, 'paused', { value: false, configurable: true }); - Object.defineProperty(spanish, 'ended', { value: true, configurable: true }); - fireEvent.playing(live); - fireEvent.ended(spanish); - expect(frames).toHaveLength(1); - - Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); - fireEvent(document, new Event('visibilitychange')); - expect(cancelFrame).toHaveBeenCalled(); - expect(live.muted).toBe(true); - Object.defineProperty(live, 'paused', { value: true, configurable: true }); - - Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); - fireEvent(document, new Event('visibilitychange')); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); - Object.defineProperty(live, 'paused', { value: false, configurable: true }); - fireEvent.playing(live); - expect(frames).toHaveLength(2); - expect(live.muted).toBe(false); + expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled(); + expect(screen.getByText('Two devices are already active. Enabling this one will stop playback on the least recent device.')) + .toBeInTheDocument(); }); it('bounds automatic hls.js recovery attempts and ends in an honest unavailable state', async () => { @@ -712,8 +231,8 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen now' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Listen now' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); hlsHarness.instances[0].emitFatal(); @@ -749,21 +268,21 @@ describe('EarlyBird Listener player', () => { , ); const live = screen.getByLabelText('Beacon 24/7'); - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen now' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Listen now' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); vi.useFakeTimers(); fireEvent.suspend(live); await vi.advanceTimersByTimeAsync(2_500); - expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledTimes(2); vi.useRealTimers(); fireEvent.error(live); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3), { timeout: 3_000 }); expect(fetchMock.mock.calls[2]?.[0]).toBe('/api/early-birds/stream/heartbeat'); - await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); }); it('makes a displaced fatal recovery terminal without acquiring another lease', async () => { @@ -792,10 +311,10 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen now' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Listen now' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); - await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); hlsHarness.instances[0].emitFatal(); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent( @@ -846,10 +365,10 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen now' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Listen now' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); - await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); hlsHarness.instances[0].emitFatal(); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); @@ -861,7 +380,7 @@ describe('EarlyBird Listener player', () => { ]); await waitFor(() => expect(hlsHarness.instances).toHaveLength(2)); expect(hlsHarness.instances[1].loadedSources).toEqual([replacementGrant.stream.manifestUrl]); - expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument(); }); it('does not strand a fatal hls.js signal raised while playback is still starting', async () => { @@ -900,17 +419,16 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen now' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Listen now' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); hlsHarness.instances[0].emitFatal(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Restoring connection…' })) - .toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Restoring connection…')).toBeInTheDocument()); finishFirstPlay?.(); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); await waitFor(() => expect(hlsHarness.instances).toHaveLength(2)); - await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); expect(hlsHarness.instances[1].loadedSources).toEqual([refreshedGrant.stream.manifestUrl]); }); }); diff --git a/src/components/early-birds/__tests__/ListenerTransport.test.tsx b/src/components/early-birds/__tests__/ListenerTransport.test.tsx new file mode 100644 index 00000000..90e33f6c --- /dev/null +++ b/src/components/early-birds/__tests__/ListenerTransport.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { LocaleProvider } from '@/context/LocaleContext'; + +vi.mock('hls.js', () => { + class TestHls { + static Events = { ERROR: 'error' }; + static isSupported = () => true; + liveSyncPosition: number | null = null; + on() {} + loadSource() {} + attachMedia() {} + destroy() {} + } + return { default: TestHls }; +}); + +import ListenerPlayer from '../ListenerPlayer'; + +const GRANT = { + leaseId: '00000000-0000-4000-8000-000000000003', + leaseExpiresAt: '2099-08-06T12:03:00.000Z', + stream: { + manifestUrl: '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003', + expiresAt: '2099-08-06T12:03:00.000Z', + }, +}; + +function prepareMedia() { + const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); + const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); + vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined); + vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(GRANT), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + return { play, pause }; +} + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('EarlyBird unified playlist transport', () => { + it('defaults to the English intro and exposes exactly two play choices plus Stop', async () => { + prepareMedia(); + render( + + + , + ); + + expect(screen.getByRole('combobox', { name: 'Intro before the Beacon' })).toHaveValue('en'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Play with intro' })).toBeEnabled()); + expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Stop' })).toBeDisabled(); + }); + + it('plays the intro from the beginning while the Beacon is fully stopped', async () => { + const { play, pause } = prepareMedia(); + render( + + + , + ); + const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; + intro.currentTime = 42; + await waitFor(() => expect(screen.getByRole('button', { name: 'Play with intro' })).toBeEnabled()); + pause.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: 'Play with intro' })); + + await waitFor(() => expect(screen.getByText('Playing intro · Beacon follows')).toBeInTheDocument()); + expect(intro.currentTime).toBe(0); + expect(play.mock.instances).toContain(intro); + expect(play.mock.instances).not.toContain(live); + expect(pause.mock.instances).toContain(live); + }); + + it('starts the Beacon at intro end and fades it in', async () => { + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); + vi.spyOn(performance, 'now').mockReturnValue(0); + const { play } = prepareMedia(); + render( + + + , + ); + const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; + await waitFor(() => expect(screen.getByRole('button', { name: 'Play with intro' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Play with intro' })); + await waitFor(() => expect(screen.getByText('Playing intro · Beacon follows')).toBeInTheDocument()); + + Object.defineProperty(intro, 'ended', { value: true, configurable: true }); + fireEvent.ended(intro); + await waitFor(() => expect(play.mock.instances).toContain(live)); + expect(live.muted).toBe(true); + Object.defineProperty(live, 'paused', { value: false, configurable: true }); + fireEvent.playing(live); + expect(live.muted).toBe(false); + expect(live.volume).toBe(0); + frames.shift()?.(3_000); + expect(live.volume).toBeCloseTo(1); + expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument(); + }); + + it('plays Beacon-only with a fade-in and stops it over a short fade-out', async () => { + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); + vi.spyOn(performance, 'now').mockReturnValue(0); + const { pause } = prepareMedia(); + render( + + + , + ); + const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + Object.defineProperty(live, 'paused', { value: false, configurable: true }); + fireEvent.playing(live); + frames.shift()?.(3_000); + expect(live.volume).toBeCloseTo(1); + pause.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + expect(pause.mock.instances).not.toContain(live); + act(() => frames.shift()?.(650)); + expect(pause.mock.instances).toContain(live); + expect(screen.getByText('Stopped')).toBeInTheDocument(); + }); + + it('stops an already playing Beacon before the selected intro starts', async () => { + const { play, pause } = prepareMedia(); + render( + + + , + ); + const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; + await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); + pause.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: 'Play with intro' })); + + await waitFor(() => expect(screen.getByText('Playing intro · Beacon follows')).toBeInTheDocument()); + expect(pause.mock.instances).toContain(live); + expect(play.mock.instances.at(-1)).toBe(intro); + }); +}); diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 73a24b3e..432dcbc8 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -85,6 +85,13 @@ export const earlyBirdHomeCopy = { dropUnavailable: 'El render aprobado todavía no fue publicado.', dropPlay: 'Reproducir', restart: 'Reiniciar', + introSelection: 'Intro antes del Beacon', + playWithIntro: 'Play con intro', + playBeaconOnly: 'Play · solo Beacon', + stop: 'Stop', + playingIntro: 'Sonando intro · después sigue el Beacon', + playingBeacon: 'Sonando Beacon 24/7', + stopped: 'Detenido', master: 'Volumen master', signOut: 'Cerrar sesión', active: 'Membresía activa', @@ -111,6 +118,13 @@ export const earlyBirdHomeCopy = { dropUnavailable: 'The approved render has not been published yet.', dropPlay: 'Play', restart: 'Restart', + introSelection: 'Intro before the Beacon', + playWithIntro: 'Play with intro', + playBeaconOnly: 'Play · Beacon only', + stop: 'Stop', + playingIntro: 'Playing intro · Beacon follows', + playingBeacon: 'Playing Beacon 24/7', + stopped: 'Stopped', master: 'Master volume', signOut: 'Sign out', active: 'Active membership', From 5edfe7334f6450f6a6b8353edc7f2ec2dffa441e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 18:22:09 -0300 Subject: [PATCH 069/376] feat(early-birds): refine listener transport presentation --- .../EARLY_BIRDS_MEDIA_PROVENANCE.md | 10 +- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 6 +- docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md | 95 +++++++++++++++++++ src/components/early-birds/ListenerPlayer.tsx | 18 ++-- 4 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md diff --git a/docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md b/docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md index fc341277..f8b457a5 100644 --- a/docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md +++ b/docs/operations/EARLY_BIRDS_MEDIA_PROVENANCE.md @@ -49,8 +49,10 @@ 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 15:27:25 ART` | `3f75c5ac5fe8edcd133bb8f1d4aec1dfcb0c5dfb2bba10f4b075358bd8bc1a41` | `amara-sol-en-r1-approved-aac320-v2.m4a` | `a249aa16511fffac20ab51f861c9d0119097fc368bdc900016a581105c4b5ac2` | 332.939 s | -11.2 LUFS, -0.4 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 | -The EN v2 source supersedes the earlier same-named export by immutable hash; -the previous artifact remains available only for rollback. ES remains disabled -until a separately approved render exists. +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. ES remains disabled until a +separately approved render exists. diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index e1b59af3..0a77c481 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -60,9 +60,9 @@ evidence, not authorization to promote it to `main` or production. `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-v2.m4a`, re-exported on 2026-08-06 at - 15:27 ART with a complete fade tail and derived without gain processing. - It remains -11.2 LUFS with decoded peak -0.4 dBFS. The obsolete -35.6 + `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 diff --git a/docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md b/docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md new file mode 100644 index 00000000..e875ca92 --- /dev/null +++ b/docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md @@ -0,0 +1,95 @@ +# EarlyBirds Listener experience + +Status: proposal for product review. 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, listener name and a + discreet membership indicator. `TEST` remains visible in staging but is not + styled as part of the 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 Play 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 live**: shared-point visual, active state and Stop. +- **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 intro selection, Play with intro, Beacon-only and Stop 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 “Private drop-ins” 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. + +### 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/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 9d937fca..7fc9f46b 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -879,7 +879,7 @@ export default function ListenerPlayer({

{transportStatus}

-
+
); } diff --git a/src/components/early-birds/__tests__/ListenerTransport.test.tsx b/src/components/early-birds/__tests__/ListenerTransport.test.tsx index 90e33f6c..59404dc2 100644 --- a/src/components/early-birds/__tests__/ListenerTransport.test.tsx +++ b/src/components/early-birds/__tests__/ListenerTransport.test.tsx @@ -58,10 +58,28 @@ describe('EarlyBird unified playlist transport', () => { expect(screen.getByRole('combobox', { name: 'Intro before the Beacon' })).toHaveValue('en'); await waitFor(() => expect(screen.getByRole('button', { name: 'Play with intro' })).toBeEnabled()); + expect(screen.getByRole('button', { name: 'Play with intro' })).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByRole('button', { name: 'Stop' })).toBeDisabled(); }); + it('keeps the intro timeline and master volume inside Beacon 24/7 without a separate drop-ins section', async () => { + prepareMedia(); + render( + + + , + ); + + const beaconPanel = screen.getByRole('heading', { name: 'Beacon 24/7' }).closest('section'); + expect(beaconPanel).not.toBeNull(); + expect(beaconPanel).toContainElement(screen.getByRole('heading', { name: 'Warm-up · English' })); + expect(beaconPanel).toContainElement(screen.getByRole('slider', { name: 'Intro before the Beacon: Warm-up · English' })); + expect(beaconPanel).toContainElement(screen.getByRole('slider', { name: 'Master volume' })); + expect(screen.queryByRole('heading', { name: 'Private drop-ins' })).not.toBeInTheDocument(); + }); + it('plays the intro from the beginning while the Beacon is fully stopped', async () => { const { play, pause } = prepareMedia(); render( @@ -78,6 +96,8 @@ describe('EarlyBird unified playlist transport', () => { fireEvent.click(screen.getByRole('button', { name: 'Play with intro' })); await waitFor(() => expect(screen.getByText('Playing intro · Beacon follows')).toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Play with intro' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'false'); expect(intro.currentTime).toBe(0); expect(play.mock.instances).toContain(intro); expect(play.mock.instances).not.toContain(live); @@ -115,6 +135,8 @@ describe('EarlyBird unified playlist transport', () => { frames.shift()?.(3_000); expect(live.volume).toBeCloseTo(1); expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Play with intro' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'true'); }); it('plays Beacon-only with a fade-in and stops it over a short fade-out', async () => { @@ -138,13 +160,18 @@ describe('EarlyBird unified playlist transport', () => { fireEvent.playing(live); frames.shift()?.(3_000); expect(live.volume).toBeCloseTo(1); + expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'true'); pause.mockClear(); fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + expect(screen.getByRole('button', { name: 'Play with intro' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'false'); expect(pause.mock.instances).not.toContain(live); act(() => frames.shift()?.(650)); expect(pause.mock.instances).toContain(live); expect(screen.getByText('Stopped')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Play with intro' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'false'); }); it('stops an already playing Beacon before the selected intro starts', async () => { diff --git a/src/lib/early-birds/__tests__/copy.test.ts b/src/lib/early-birds/__tests__/copy.test.ts new file mode 100644 index 00000000..e46a1095 --- /dev/null +++ b/src/lib/early-birds/__tests__/copy.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { earlyBirdCopy, earlyBirdHomeCopy } from '@/lib/early-birds/copy'; + +describe('EarlyBirds interface copy', () => { + it.each([ + ['landing · Spanish', earlyBirdCopy.es], + ['landing · English', earlyBirdCopy.en], + ['listener · Spanish', earlyBirdHomeCopy.es], + ['listener · English', earlyBirdHomeCopy.en], + ])('keeps the Beacon source-neutral in %s', (_label, copy) => { + const visibleCopy = Object.values(copy).join(' '); + + expect(visibleCopy).not.toMatch( + /record(?:ed|ing)?|grabaci[oó]n|grabad[oa]|live instrument|instrumento en vivo/i, + ); + }); +}); diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 432dcbc8..679123df 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -67,7 +67,7 @@ export const earlyBirdHomeCopy = { eyebrow: 'EARLYBIRDS · LISTENER', heading: 'Beacon 24/7', sharedPoint: 'Punto compartido', - subheading: 'Estás entrando al mismo punto del Beacon continuo grabado que todas las personas EarlyBird.', + subheading: 'Estás compartiendo el mismo punto del stream Beacon 24/7 con las demás personas EarlyBird.', play: 'Escuchar ahora', prepareDevice: 'Habilitar este dispositivo', deviceReady: 'Dispositivo listo. Tocá otra vez para escuchar o elegí un drop-in.', @@ -100,7 +100,7 @@ export const earlyBirdHomeCopy = { eyebrow: 'EARLYBIRDS · LISTENER', heading: 'Beacon 24/7', sharedPoint: 'Shared point', - subheading: 'You are joining the same point in the continuous recorded Beacon as every other EarlyBird listener.', + subheading: 'You are sharing the same point in the Beacon 24/7 stream with every other EarlyBird listener.', play: 'Listen now', prepareDevice: 'Enable this device', deviceReady: 'Device ready. Tap again to listen, or choose a drop-in.', From 0b186df4fa9abd726839d553dfb652b48059497e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 6 Aug 2026 23:10:22 -0300 Subject: [PATCH 071/376] feat(early-birds): add pause and resume transport --- src/components/early-birds/ListenerPlayer.tsx | 83 +++++++++++++++++-- .../__tests__/ListenerTransport.test.tsx | 59 ++++++++++++- src/lib/early-birds/copy.ts | 6 +- 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 2951360a..459d6212 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -108,6 +108,7 @@ export default function ListenerPlayer({ const [liveState, setLiveState] = useState('idle'); const [playingDrop, setPlayingDrop] = useState(null); const [transportStopped, setTransportStopped] = useState(true); + const [transportPaused, setTransportPaused] = useState(false); const [selectedDrop, setSelectedDrop] = useState(dropIns.en ? 'en' : 'es'); const [dropProgress, setDropProgress] = useState({ es: { current: 0, duration: 0 }, @@ -593,6 +594,7 @@ export default function ListenerPlayer({ return; } if (played) { + setTransportPaused(false); updateLiveState('playing'); return; } @@ -606,12 +608,71 @@ export default function ListenerPlayer({ return; } setTransportStopped(false); + setTransportPaused(false); void playLive(liveState === 'error' || liveState === 'displaced', dropGeneration.current); } + async function toggleTransportPause() { + if (transportStopped) return; + + const language = activeDrop.current; + if (transportPaused) { + if (language) { + const intro = dropAudio[language].current; + if (!intro) return; + try { + await intro.play(); + setTransportPaused(false); + } catch { + // Keep the paused state visible when the browser rejects resume. + } + return; + } + + const live = liveAudio.current; + if (!live) return; + wantsLivePlayback.current = true; + cancelRecovery(true); + try { + await live.play(); + setTransportPaused(false); + updateLiveState('playing'); + void requestLease('play').then((grant) => { + leaseId.current = grant.leaseId; + manifestExpiresAt.current = Date.parse(grant.stream.expiresAt); + }).catch(() => { + // The current authorized source remains usable; heartbeat retries promotion. + }); + } catch { + scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS); + } + return; + } + + if (language) { + cancelDropFade(); + const intro = dropAudio[language].current; + intro?.pause(); + storeProgress(language); + setTransportPaused(true); + return; + } + + const live = liveAudio.current; + if (!live || liveStateRef.current !== 'playing') return; + wantsLivePlayback.current = false; + cancelRecovery(true); + cancelLiveFade(); + pendingLiveFade.current = false; + live.pause(); + updateLiveState('paused'); + setTransportPaused(true); + } + function stopTransport() { dropGeneration.current += 1; setTransportStopped(true); + setTransportPaused(false); wantsLivePlayback.current = false; cancelRecovery(true); pendingLiveFade.current = false; @@ -813,6 +874,7 @@ export default function ListenerPlayer({ await selected.play(); if (!isCurrent()) return; setTransportStopped(false); + setTransportPaused(false); setPlayingDrop(language); } catch { if (!isCurrent()) return; @@ -850,9 +912,11 @@ export default function ListenerPlayer({ const selectedProgress = dropProgress[selectedDrop]; const selectedDropAvailable = Boolean(dropIns[selectedDrop]); const transportBusy = liveState === 'loading' || liveState === 'recovering' || livePreparing; - const introActive = !transportStopped && playingDrop !== null; - const beaconActive = !transportStopped && playingDrop === null && liveState === 'playing'; - const transportStatus = playingDrop + const introActive = !transportStopped && !transportPaused && playingDrop !== null; + const beaconActive = !transportStopped && !transportPaused && playingDrop === null && liveState === 'playing'; + const transportStatus = transportPaused + ? copy.paused + : playingDrop ? copy.playingIntro : liveState === 'playing' ? copy.playingBeacon @@ -886,7 +950,7 @@ export default function ListenerPlayer({

{transportStatus}

-
+
- - +
+
+

{copy.sharedPoint}

+

{copy.heading}

+

{copy.subheading}

+
+ + + +

+

+ +
+ + +
+ +
+ + {transportActive && ( - -
+ )}
+ {(liveState === 'error' || liveState === 'displaced') && ( -

- {liveState === 'displaced' ? copy.displaced : copy.unavailable} -

+ )} {devicePreparedByGesture && liveState === 'idle' && ( -

+

{copy.deviceReady}

)} {!livePreparing && !livePrepared && liveState !== 'error' && ( -

+

{prepareFailure === 'capacity' ? copy.deviceLimitClaim : copy.prepareHelp}

)} @@ -1036,50 +1086,69 @@ export default function ListenerPlayer({ ); })}
- {selectedDropAvailable ? ( -
-

{selectedDrop.toUpperCase()}

-

{selectedDrop === 'es' ? copy.spanish : copy.english}

-
- ) : ( -

{copy.dropUnavailable}

- )} - - {volumeSupported && ( - - )} + )} +
); diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index b469a19e..d8ae4e6e 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -55,10 +55,10 @@ describe('EarlyBird public landing', () => { it('uses audience-neutral account and privacy language in both locales', () => { expect(earlyBirdCopy.es.privacy).toBe( - 'Tu cuenta y membresía administran el acceso a EarlyBirds. No creamos historiales personales de escucha.', + 'Tu cuenta y membresía administran el acceso privado. No creamos historiales personales de escucha.', ); expect(earlyBirdCopy.en.privacy).toBe( - 'Your account and membership manage access to EarlyBirds. We do not create personal listening histories.', + 'Your account and membership manage private access. We do not create personal listening histories.', ); expect(`${earlyBirdCopy.es.privacy} ${earlyBirdCopy.en.privacy}`) .not.toMatch(/adult|child|minor|menor|adulta/i); @@ -83,7 +83,10 @@ describe('EarlyBird public landing', () => { it('takes an entitled signed-in listener directly to the private home', () => { renderLanding({ signedIn: true, entitled: true }); - expect(screen.getByRole('link', { name: 'Enter the Beacon' })).toHaveAttribute('href', '/early-birds'); + expect(screen.getAllByRole('link', { name: 'Enter the Beacon' })) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ href: expect.stringMatching(/\/early-birds$/) }), + ])); expect(screen.queryByRole('button', { name: 'Continue with Google' })).toBeNull(); }); }); diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index 0a8b032d..52ea3872 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { LocaleProvider } from '@/context/LocaleContext'; @@ -112,8 +112,8 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); hlsHarness.instances[0].emitFatal(); @@ -151,14 +151,14 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); expect(fetchMock).toHaveBeenCalledTimes(1); expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ intent: 'prepare' }); await Promise.all(intervalCallbacks.map((callback) => callback())); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/early-birds/stream/heartbeat'); - expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Listen' })).toBeInTheDocument(); }); it('does not offer resume after a paused source loses preparation', async () => { @@ -192,8 +192,8 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); fireEvent.click(screen.getByRole('button', { name: 'Stop' })); @@ -204,7 +204,7 @@ describe('EarlyBird Listener player', () => { fireEvent(document, new Event('visibilitychange')); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); - expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled(); expect(screen.getByText('Two devices are already active. Enabling this one will stop playback on the least recent device.')) .toBeInTheDocument(); }); @@ -231,8 +231,8 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); hlsHarness.instances[0].emitFatal(); @@ -268,8 +268,8 @@ describe('EarlyBird Listener player', () => { , ); const live = screen.getByLabelText('Beacon 24/7'); - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); vi.useFakeTimers(); @@ -311,8 +311,8 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); hlsHarness.instances[0].emitFatal(); @@ -365,8 +365,8 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); hlsHarness.instances[0].emitFatal(); @@ -419,8 +419,8 @@ describe('EarlyBird Listener player', () => { , ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); hlsHarness.instances[0].emitFatal(); await waitFor(() => expect(screen.getByText('Restoring connection…')).toBeInTheDocument()); diff --git a/src/components/early-birds/__tests__/ListenerTransport.test.tsx b/src/components/early-birds/__tests__/ListenerTransport.test.tsx index 0ed64da3..dbdbe151 100644 --- a/src/components/early-birds/__tests__/ListenerTransport.test.tsx +++ b/src/components/early-birds/__tests__/ListenerTransport.test.tsx @@ -40,6 +40,24 @@ function prepareMedia() { return { play, pause }; } +function renderPlayer(dropIns = { es: null, en: '/api/drop-ins/en' }) { + return render( + + + , + ); +} + +async function waitForListen() { + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); +} + +async function chooseBeaconOnly() { + await waitForListen(); + fireEvent.click(screen.getByRole('radio', { name: /Beacon only/ })); + expect(screen.getByRole('radio', { name: /Beacon only/ })).toHaveAttribute('aria-checked', 'true'); +} + afterEach(() => { cleanup(); window.localStorage.clear(); @@ -47,44 +65,43 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('EarlyBird unified playlist transport', () => { - it('defaults to the English intro and exposes two play choices, Pause and Stop', async () => { +describe('Listener one-action playlist transport', () => { + it('defaults to the English introduction and exposes one contextual action', async () => { prepareMedia(); - render( - - - , - ); - - expect(screen.getByRole('combobox', { name: 'Intro before the Beacon' })).toHaveValue('en'); - await waitFor(() => expect(screen.getByRole('button', { name: 'Play with intro' })).toBeEnabled()); - expect(screen.getByRole('button', { name: 'Play with intro' })).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByRole('button', { name: 'Pause' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Stop' })).toBeDisabled(); + renderPlayer(); + + expect(screen.getByRole('radio', { name: /With introduction/ })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getAllByText('Amara Sol · English')).toHaveLength(2); + await waitForListen(); + expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument(); + }); + + it('remembers the device mode without changing the stream contract', async () => { + prepareMedia(); + const first = renderPlayer(); + await chooseBeaconOnly(); + first.unmount(); + + renderPlayer(); + await waitFor(() => expect(screen.getByRole('radio', { name: /Beacon only/ })) + .toHaveAttribute('aria-checked', 'true')); }); it('pauses and resumes the intro at the same position', async () => { const { play, pause } = prepareMedia(); - render( - - - , - ); + renderPlayer(); const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; - await waitFor(() => expect(screen.getByRole('button', { name: 'Play with intro' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play with intro' })); + await waitForListen(); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeEnabled()); intro.currentTime = 42; pause.mockClear(); fireEvent.click(screen.getByRole('button', { name: 'Pause' })); - expect(pause.mock.instances).toContain(intro); expect(intro.currentTime).toBe(42); expect(screen.getByText('Paused')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Resume' })).toHaveAttribute('aria-pressed', 'true'); play.mockClear(); fireEvent.click(screen.getByRole('button', { name: 'Resume' })); @@ -95,24 +112,18 @@ describe('EarlyBird unified playlist transport', () => { it('pauses and resumes the Beacon without restarting the transport', async () => { const { play, pause } = prepareMedia(); - render( - - - , - ); + renderPlayer(); const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await chooseBeaconOnly(); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); Object.defineProperty(live, 'paused', { value: false, configurable: true }); fireEvent.playing(live); live.currentTime = 73; pause.mockClear(); fireEvent.click(screen.getByRole('button', { name: 'Pause' })); - expect(pause.mock.instances).toContain(live); expect(live.currentTime).toBe(73); - expect(screen.getByRole('button', { name: 'Resume' })).toBeEnabled(); play.mockClear(); fireEvent.click(screen.getByRole('button', { name: 'Resume' })); @@ -121,47 +132,21 @@ describe('EarlyBird unified playlist transport', () => { expect(live.currentTime).toBe(73); }); - it('keeps the intro timeline and master volume inside Beacon 24/7 without a separate drop-ins section', async () => { + it('shows real intro progress only while the intro is active', async () => { prepareMedia(); - render( - - - , - ); - - const beaconPanel = screen.getByRole('heading', { name: 'Beacon 24/7' }).closest('section'); - expect(beaconPanel).not.toBeNull(); - expect(beaconPanel).toContainElement(screen.getByRole('heading', { name: 'Warm-up · English' })); - expect(beaconPanel).toContainElement(screen.getByRole('slider', { name: 'Intro before the Beacon: Warm-up · English' })); - expect(beaconPanel).toContainElement(screen.getByRole('slider', { name: 'Master volume' })); - expect(screen.queryByRole('heading', { name: 'Private drop-ins' })).not.toBeInTheDocument(); + renderPlayer(); + await waitForListen(); + expect(screen.queryByRole('slider', { name: 'Intro before the Beacon: Warm-up · English' })) + .not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); + expect(await screen.findByRole('slider', { name: 'Intro before the Beacon: Warm-up · English' })) + .toBeInTheDocument(); + expect(screen.getByText('The Beacon follows')).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument(); }); - it('plays the intro from the beginning while the Beacon is fully stopped', async () => { - const { play, pause } = prepareMedia(); - render( - - - , - ); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; - const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; - intro.currentTime = 42; - await waitFor(() => expect(screen.getByRole('button', { name: 'Play with intro' })).toBeEnabled()); - pause.mockClear(); - - fireEvent.click(screen.getByRole('button', { name: 'Play with intro' })); - - await waitFor(() => expect(screen.getByText('Playing intro · Beacon follows')).toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Play with intro' })).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'false'); - expect(intro.currentTime).toBe(0); - expect(play.mock.instances).toContain(intro); - expect(play.mock.instances).not.toContain(live); - expect(pause.mock.instances).toContain(live); - }); - - it('starts the Beacon at intro end and fades it in', async () => { + it('hands off naturally from the intro to the Beacon using the approved fade', async () => { const frames: FrameRequestCallback[] = []; vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { frames.push(callback); @@ -170,33 +155,39 @@ describe('EarlyBird unified playlist transport', () => { vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); vi.spyOn(performance, 'now').mockReturnValue(0); const { play } = prepareMedia(); - render( - - - , - ); + renderPlayer(); const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; - await waitFor(() => expect(screen.getByRole('button', { name: 'Play with intro' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play with intro' })); - await waitFor(() => expect(screen.getByText('Playing intro · Beacon follows')).toBeInTheDocument()); + await waitForListen(); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); + await screen.findByText('Playing intro · Beacon follows'); Object.defineProperty(intro, 'ended', { value: true, configurable: true }); fireEvent.ended(intro); await waitFor(() => expect(play.mock.instances).toContain(live)); - expect(live.muted).toBe(true); Object.defineProperty(live, 'paused', { value: false, configurable: true }); fireEvent.playing(live); - expect(live.muted).toBe(false); - expect(live.volume).toBe(0); frames.shift()?.(3_000); expect(live.volume).toBeCloseTo(1); expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Play with intro' })).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'true'); }); - it('plays Beacon-only with a fade-in and stops it over a short fade-out', async () => { + it('can skip the private intro into the same Beacon handoff', async () => { + const { play, pause } = prepareMedia(); + renderPlayer(); + const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; + await waitForListen(); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); + await screen.findByText('Playing intro · Beacon follows'); + + pause.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Skip to the Beacon' })); + await waitFor(() => expect(play.mock.instances).toContain(live)); + expect(pause.mock.instances).toContain(intro); + }); + + it('stops Beacon playback over the existing fade and clears the active stage immediately', async () => { const frames: FrameRequestCallback[] = []; vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { frames.push(callback); @@ -205,50 +196,20 @@ describe('EarlyBird unified playlist transport', () => { vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); vi.spyOn(performance, 'now').mockReturnValue(0); const { pause } = prepareMedia(); - render( - - - , - ); + renderPlayer(); const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); + await chooseBeaconOnly(); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); Object.defineProperty(live, 'paused', { value: false, configurable: true }); fireEvent.playing(live); frames.shift()?.(3_000); - expect(live.volume).toBeCloseTo(1); - expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'true'); pause.mockClear(); fireEvent.click(screen.getByRole('button', { name: 'Stop' })); - expect(screen.getByRole('button', { name: 'Play with intro' })).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByText('Stopped')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument(); expect(pause.mock.instances).not.toContain(live); act(() => frames.shift()?.(650)); expect(pause.mock.instances).toContain(live); - expect(screen.getByText('Stopped')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Play with intro' })).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toHaveAttribute('aria-pressed', 'false'); - }); - - it('stops an already playing Beacon before the selected intro starts', async () => { - const { play, pause } = prepareMedia(); - render( - - - , - ); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; - const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; - await waitFor(() => expect(screen.getByRole('button', { name: 'Play · Beacon only' })).toBeEnabled()); - fireEvent.click(screen.getByRole('button', { name: 'Play · Beacon only' })); - await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); - pause.mockClear(); - - fireEvent.click(screen.getByRole('button', { name: 'Play with intro' })); - - await waitFor(() => expect(screen.getByText('Playing intro · Beacon follows')).toBeInTheDocument()); - expect(pause.mock.instances).toContain(live); - expect(play.mock.instances.at(-1)).toBe(intro); }); }); diff --git a/src/components/early-birds/__tests__/listener-presentation.test.ts b/src/components/early-birds/__tests__/listener-presentation.test.ts new file mode 100644 index 00000000..5db7735c --- /dev/null +++ b/src/components/early-birds/__tests__/listener-presentation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveListenerPresentationPhase } from '../listener-presentation'; + +const baseline = { + liveState: 'idle' as const, + livePreparing: false, + playingDrop: null, + transportPaused: false, + transportStopped: true, + hasStarted: false, +}; + +describe('Listener presentation phase', () => { + it('distinguishes first readiness from a deliberate stop', () => { + expect(deriveListenerPresentationPhase(baseline)).toBe('ready'); + expect(deriveListenerPresentationPhase({ ...baseline, hasStarted: true })).toBe('stopped'); + }); + + it('tracks intro, Beacon, pause and recovery without inventing signal state', () => { + expect(deriveListenerPresentationPhase({ + ...baseline, + transportStopped: false, + playingDrop: 'en', + })).toBe('intro'); + expect(deriveListenerPresentationPhase({ + ...baseline, + transportStopped: false, + liveState: 'playing', + })).toBe('beacon'); + expect(deriveListenerPresentationPhase({ + ...baseline, + transportStopped: false, + transportPaused: true, + })).toBe('paused'); + expect(deriveListenerPresentationPhase({ + ...baseline, + transportStopped: false, + liveState: 'recovering', + })).toBe('reconnecting'); + }); + + it('gives access and transport failures precedence', () => { + expect(deriveListenerPresentationPhase({ ...baseline, liveState: 'error' })).toBe('unavailable'); + expect(deriveListenerPresentationPhase({ ...baseline, liveState: 'displaced' })).toBe('displaced'); + }); +}); diff --git a/src/components/early-birds/listener-presentation.ts b/src/components/early-birds/listener-presentation.ts new file mode 100644 index 00000000..68950a04 --- /dev/null +++ b/src/components/early-birds/listener-presentation.ts @@ -0,0 +1,36 @@ +export type ListenerPresentationPhase = + | 'ready' + | 'preparing' + | 'intro' + | 'beacon' + | 'paused' + | 'reconnecting' + | 'stopped' + | 'unavailable' + | 'displaced'; + +export function deriveListenerPresentationPhase({ + liveState, + livePreparing, + playingDrop, + transportPaused, + transportStopped, + hasStarted, +}: { + liveState: 'idle' | 'loading' | 'recovering' | 'playing' | 'paused' | 'error' | 'displaced'; + livePreparing: boolean; + playingDrop: 'es' | 'en' | null; + transportPaused: boolean; + transportStopped: boolean; + hasStarted: boolean; +}): ListenerPresentationPhase { + if (liveState === 'displaced') return 'displaced'; + if (liveState === 'error') return 'unavailable'; + if (liveState === 'recovering') return 'reconnecting'; + if (transportPaused) return 'paused'; + if (transportStopped) return hasStarted ? 'stopped' : 'ready'; + if (playingDrop) return 'intro'; + if (liveState === 'playing') return 'beacon'; + if (livePreparing || liveState === 'loading') return 'preparing'; + return hasStarted ? 'stopped' : 'ready'; +} diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 372738f5..d8769bf0 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -2,40 +2,40 @@ import type { UiLocale } from '@/lib/i18n'; export const earlyBirdCopy = { es: { - eyebrow: 'EARLYBIRDS · ACCESO FUNDADOR', - title: 'El Beacon, siempre presente.', - intro: 'Una línea temporal compartida, disponible las 24 horas para volver a escuchar, respirar y entrar.', - live: 'Beacon 24/7', - privateDropIns: 'Drop-ins privados completos en español e inglés', - membership: 'Membresía fundadora · US$2 por mes', + eyebrow: 'HARMONIC BEACON · FOUNDING LISTENER', + title: 'Recuerda tu centro armónico.', + intro: 'Un campo armónico continuo, compartido alrededor del mundo.', + live: 'Escucha privada, disponible siempre', + privateDropIns: 'Una introducción opcional antes de entrar al Beacon', + membership: 'Acceso Founding Listener', signInGoogle: 'Continuar con Google', signInApple: 'Continuar con Apple', signingIn: 'Abriendo acceso…', providerSoon: 'Configuración pendiente', - signedIn: 'Tu cuenta EarlyBird está lista.', + signedIn: 'Tu cuenta Listener está lista.', enter: 'Entrar al Beacon', redeem: 'Activar mi invitación', accessNeeded: 'Tu cuenta todavía no tiene una membresía activa.', authError: 'No pudimos completar el acceso. Usa el mismo proveedor con el que creaste tu cuenta o contacta a soporte.', - privacy: 'Tu cuenta y membresía administran el acceso a EarlyBirds. No creamos historiales personales de escucha.', + privacy: 'Tu cuenta y membresía administran el acceso privado. No creamos historiales personales de escucha.', }, en: { - eyebrow: 'EARLYBIRDS · FOUNDING ACCESS', - title: 'The Beacon, always present.', - intro: 'One shared timeline, available around the clock whenever you want to listen, breathe, and enter.', - live: 'Beacon 24/7', - privateDropIns: 'Complete private drop-ins in Spanish and English', - membership: 'Founding membership · US$2 per month', + eyebrow: 'HARMONIC BEACON · FOUNDING LISTENER', + title: 'Remember your harmonic center.', + intro: 'A continuous harmonic field, shared across the world.', + live: 'Private listening, available whenever you return', + privateDropIns: 'An optional introduction before entering the Beacon', + membership: 'Founding Listener access', signInGoogle: 'Continue with Google', signInApple: 'Continue with Apple', signingIn: 'Opening access…', providerSoon: 'Configuration pending', - signedIn: 'Your EarlyBird account is ready.', + signedIn: 'Your Listener account is ready.', enter: 'Enter the Beacon', redeem: 'Activate my invitation', accessNeeded: 'Your account does not have an active membership yet.', authError: 'We could not complete sign-in. Use the provider that created your account, or contact support.', - privacy: 'Your account and membership manage access to EarlyBirds. We do not create personal listening histories.', + privacy: 'Your account and membership manage private access. We do not create personal listening histories.', }, } satisfies Record>; @@ -64,11 +64,18 @@ export const earlyBirdSyntheticEntryCopy = { export const earlyBirdHomeCopy = { es: { - eyebrow: 'EARLYBIRDS · LISTENER', + eyebrow: 'HARMONIC BEACON · LISTENER', heading: 'Beacon 24/7', - sharedPoint: 'Punto compartido', - subheading: 'Estás compartiendo el mismo punto del stream Beacon 24/7 con las demás personas EarlyBird.', - play: 'Escuchar ahora', + sharedPoint: 'Escucha compartida', + subheading: 'Un mismo punto del Beacon, disponible para quienes están escuchando alrededor del mundo.', + ready: 'Listo para escuchar', + listen: 'Escuchar', + mode: 'Cómo querés entrar', + withIntro: 'Con introducción', + beaconOnly: 'Solo Beacon', + beaconFollows: 'Después sigue el Beacon', + introBy: 'Amara Sol · English', + skipToBeacon: 'Saltar al Beacon', prepareDevice: 'Habilitar este dispositivo', deviceReady: 'Dispositivo listo. Tocá otra vez para escuchar o elegí un drop-in.', deviceLimitClaim: 'Ya hay dos dispositivos activos. Habilitar éste detendrá la escucha en el menos reciente.', @@ -80,29 +87,32 @@ export const earlyBirdHomeCopy = { reconnecting: 'Restableciendo conexión…', unavailable: 'El Beacon no está disponible en este momento.', displaced: 'Este dispositivo fue desplazado porque la cuenta ya está escuchando en otros dos dispositivos.', - dropIns: 'Drop-ins privados', spanish: 'Caldeamiento · Español', english: 'Warm-up · English', dropUnavailable: 'El render aprobado todavía no fue publicado.', - dropPlay: 'Reproducir', - restart: 'Reiniciar', introSelection: 'Intro antes del Beacon', - playWithIntro: 'Play con intro', - playBeaconOnly: 'Play · solo Beacon', - stop: 'Stop', + stop: 'Detener', playingIntro: 'Sonando intro · después sigue el Beacon', playingBeacon: 'Sonando Beacon 24/7', stopped: 'Detenido', - master: 'Volumen master', + master: 'Volumen', signOut: 'Cerrar sesión', - active: 'Membresía activa', + active: 'Founding Listener activo', + account: 'Cuenta', }, en: { - eyebrow: 'EARLYBIRDS · LISTENER', + eyebrow: 'HARMONIC BEACON · LISTENER', heading: 'Beacon 24/7', - sharedPoint: 'Shared point', - subheading: 'You are sharing the same point in the Beacon 24/7 stream with every other EarlyBird listener.', - play: 'Listen now', + sharedPoint: 'Shared listening', + subheading: 'One point in the Beacon, available to everyone listening across the world.', + ready: 'Ready to listen', + listen: 'Listen', + mode: 'How you want to enter', + withIntro: 'With introduction', + beaconOnly: 'Beacon only', + beaconFollows: 'The Beacon follows', + introBy: 'Amara Sol · English', + skipToBeacon: 'Skip to the Beacon', prepareDevice: 'Enable this device', deviceReady: 'Device ready. Tap again to listen, or choose a drop-in.', deviceLimitClaim: 'Two devices are already active. Enabling this one will stop playback on the least recent device.', @@ -114,21 +124,17 @@ export const earlyBirdHomeCopy = { reconnecting: 'Restoring connection…', unavailable: 'The Beacon is unavailable right now.', displaced: 'This device was displaced because the account is already listening on two other devices.', - dropIns: 'Private drop-ins', spanish: 'Warm-up · Spanish', english: 'Warm-up · English', dropUnavailable: 'The approved render has not been published yet.', - dropPlay: 'Play', - restart: 'Restart', introSelection: 'Intro before the Beacon', - playWithIntro: 'Play with intro', - playBeaconOnly: 'Play · Beacon only', stop: 'Stop', playingIntro: 'Playing intro · Beacon follows', playingBeacon: 'Playing Beacon 24/7', stopped: 'Stopped', - master: 'Master volume', + master: 'Volume', signOut: 'Sign out', - active: 'Active membership', + active: 'Founding Listener active', + account: 'Account', }, } satisfies Record>; From d2e5d88fd540aef885ad755afc7656a9e87062a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 00:26:01 -0300 Subject: [PATCH 073/376] docs(listener): record staging presentation acceptance --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 0a77c481..63e9884c 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,5 +1,32 @@ # EarlyBirds isolated staging runtime +## 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 From 2f057e0a31e384ba4d47cd14652afe1967c830ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 01:12:35 -0300 Subject: [PATCH 074/376] fix(listener): keep Beacon transport on the live edge --- docs/plans/EARLY_BIRDS.md | 1 + docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md | 10 +- e2e/tests/early-birds-responsive.spec.ts | 2 +- e2e/tests/early-birds.spec.ts | 2 +- src/app/globals.css | 134 ++++++----- src/components/early-birds/ListenerPlayer.tsx | 210 ++++++++---------- .../__tests__/ListenerPlayer.test.tsx | 33 ++- .../__tests__/ListenerTransport.test.tsx | 66 ++++-- src/lib/early-birds/copy.ts | 16 +- 9 files changed, 247 insertions(+), 227 deletions(-) diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 14e98495..f785afbc 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -51,6 +51,7 @@ change has passed its own audio and operational acceptance. | 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; event WebRTC is untouched. | +| Favor continuity over low latency in the Listener | Accepted | Desktop HLS stays about five segments behind the edge with a 60-second target buffer; Stop and a later Listen rejoin the current configured edge. | | 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. | | Offer Free and paid access through one contract | Accepted | One-use signed invitations and PayPal/MercadoPago converge on the same revocable membership state machine. | | 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. | diff --git a/docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md b/docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md index adbdde9c..cae28666 100644 --- a/docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md +++ b/docs/plans/EARLY_BIRDS_LISTENER_EXPERIENCE.md @@ -38,9 +38,11 @@ The interface should reward listening by becoming simpler after playback starts. - **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, active state and Stop. Public copy - never claims whether the source is an instrument, a file or another origin. -- **Paused/stopped**: preserve the chosen mode and make restart obvious. +- **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. @@ -76,7 +78,7 @@ The interface should reward listening by becoming simpler after playback starts. - 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. + no fake seek timeline or Pause control. ### UX-4 — polish and acceptance diff --git a/e2e/tests/early-birds-responsive.spec.ts b/e2e/tests/early-birds-responsive.spec.ts index 5e27aba6..d7af0faa 100644 --- a/e2e/tests/early-birds-responsive.spec.ts +++ b/e2e/tests/early-birds-responsive.spec.ts @@ -66,7 +66,7 @@ test.describe('EarlyBirds responsive bilingual boundary', () => { expect(response.status()).toBe(200); await page.goto('/early-birds'); - await expect(page.getByRole('heading', { name: 'Beacon 24/7' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Beacon' })).toBeAttached(); await expect(page.getByRole('button', { name: 'Account' })).toHaveText('R'); await expect(page.getByRole('button', { name: 'Listen' })).toBeVisible(); await expectNoHorizontalScroll(page); diff --git a/e2e/tests/early-birds.spec.ts b/e2e/tests/early-birds.spec.ts index 61cec733..72f845f0 100644 --- a/e2e/tests/early-birds.spec.ts +++ b/e2e/tests/early-birds.spec.ts @@ -63,7 +63,7 @@ test.describe('EarlyBird Listener boundary', () => { await page.goto('/early-birds'); await expect(page.getByText('Synthetic Listener')).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Beacon 24/7' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Beacon' })).toBeAttached(); await expect(page.getByText(/Membresía activa · TEST/)).toBeVisible(); await expect(page.getByRole('button', { name: 'Play con intro' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Play · solo Beacon' })).toBeVisible(); diff --git a/src/app/globals.css b/src/app/globals.css index e3699b09..1b5fa75f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1235,7 +1235,7 @@ body { .listener-experience { width: min(100%, 780px); - margin: clamp(1.5rem, 5vh, 4rem) auto 0; + margin: clamp(0.65rem, 2vh, 1.5rem) auto 0; --listener-phase: var(--gold); } @@ -1267,22 +1267,6 @@ body { text-transform: uppercase; } -.listener-stage__copy h1 { - margin-top: 0.65rem; - font-family: var(--font-cormorant), Georgia, serif; - font-size: clamp(2.7rem, 10vw, 5.2rem); - font-weight: 400; - line-height: 0.92; -} - -.listener-stage__copy > p:last-child { - max-width: 33rem; - margin: 0.85rem auto 0; - color: var(--text-muted); - font-size: clamp(0.88rem, 2.5vw, 1rem); - line-height: 1.65; -} - .listener-field { width: min(100%, 35rem); aspect-ratio: 1.4; @@ -1292,6 +1276,12 @@ body { --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%; @@ -1416,7 +1406,7 @@ body { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.5rem; - margin: 1.5rem auto 0; + margin: 0.6rem auto 0; padding: 0.35rem; border: 1px solid var(--border-subtle); border-radius: 1rem; @@ -1424,8 +1414,8 @@ body { } .listener-mode button { - min-height: 4.2rem; - padding: 0.7rem 0.85rem; + min-height: 3.35rem; + padding: 0.55rem 0.85rem; border: 1px solid transparent; border-radius: 0.75rem; color: var(--text-muted); @@ -1433,10 +1423,8 @@ body { transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease; } -.listener-mode button span, -.listener-mode button small { display: block; } +.listener-mode button span { display: block; } .listener-mode button span { font-size: 0.82rem; } -.listener-mode button small { margin-top: 0.28rem; font-size: 0.64rem; opacity: 0.72; } .listener-mode button[aria-checked='true'] { border-color: color-mix(in srgb, var(--listener-phase) 45%, transparent); color: var(--paper); @@ -1495,44 +1483,81 @@ body { .listener-stage__hint { margin: 0.8rem auto 0; color: var(--text-muted); font-size: 0.75rem; } .listener-details { - width: min(100%, 38rem); + width: min(100%, 32rem); display: grid; - grid-template-columns: minmax(0, 1fr) minmax(9rem, 0.55fr); - gap: 1.5rem; - margin: 1.8rem auto 0; - padding: 1.1rem 0 0; - border-top: 1px solid var(--border-subtle); + grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr)); + gap: 0.8rem; + margin: 0.65rem auto 0; + padding: 0.85rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 1rem; + background: linear-gradient(135deg, rgba(255, 214, 112, 0.045), rgba(124, 234, 255, 0.035)); text-align: left; } -.listener-details__intro > div > span, -.listener-details__volume > span { +.listener-details__control > span, +.listener-details__seek label > span:first-child, +.listener-details__selection > span { display: block; - color: var(--text-muted); + color: var(--paper); + font-family: var(--font-space-mono), monospace; font-size: 0.64rem; - letter-spacing: 0.1em; + letter-spacing: 0.14em; text-transform: uppercase; } -.listener-details__intro strong, -.listener-details__intro select { - display: block; - margin-top: 0.35rem; +.listener-details__selection select { + width: 100%; + margin-top: 0.55rem; + border: 0; color: var(--paper); + background: transparent; font-size: 0.78rem; - font-weight: 400; } -.listener-details__follows { - display: block; - margin-top: 0.9rem; - color: var(--gold); - font-size: 0.72rem; +.listener-details input[type='range'] { + width: 100%; + height: 1.7rem; + 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(--gold), #dff3a4 50%, var(--cyan)); + box-shadow: 0 0 1rem rgba(124, 234, 255, 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 #06100e; + border-radius: 50%; + background: var(--paper); + box-shadow: 0 0 0 3px rgba(255, 214, 112, 0.2), 0 0 1rem rgba(255, 214, 112, 0.45); +} + +.listener-details input[type='range']::-moz-range-track { + height: 0.3rem; + border-radius: 999px; + background: linear-gradient(90deg, var(--gold), #dff3a4 50%, var(--cyan)); } -.listener-details__intro label { display: block; margin-top: 0.55rem; } -.listener-details input[type='range'] { width: 100%; accent-color: var(--gold); } -.listener-details__intro label > span:last-child { +.listener-details input[type='range']::-moz-range-thumb { + width: 1rem; + height: 1rem; + border: 2px solid #06100e; + border-radius: 50%; + background: var(--paper); + box-shadow: 0 0 0 3px rgba(255, 214, 112, 0.2), 0 0 1rem rgba(255, 214, 112, 0.45); +} + +.listener-details__time { display: flex; justify-content: space-between; color: var(--text-muted); @@ -1540,17 +1565,14 @@ body { font-size: 0.62rem; } -.listener-details__intro button { +.listener-details__seek button { min-height: 2.75rem; - margin-top: 0.45rem; + margin-top: 0.15rem; color: var(--cyan); font-size: 0.7rem; cursor: pointer; } -.listener-details__volume { align-self: end; } -.listener-details__volume input { margin-top: 0.75rem; } - .listener-public-hero { min-height: min(760px, calc(100vh - 5rem)); display: grid; @@ -1651,11 +1673,11 @@ body { @media (max-width: 760px) { .listener-shell__frame { padding-inline: 1rem; } .listener-rail .brand-lockup { font-size: 0.72rem; } - .listener-experience { margin-top: 1.3rem; } - .listener-stage__copy > p:last-child { max-width: 28rem; } + .listener-experience { margin-top: 0.55rem; } .listener-field { width: min(100%, 28rem); aspect-ratio: 1.28; } - .listener-mode { margin-top: 0.75rem; } - .listener-mode button { min-height: 4rem; padding-inline: 0.55rem; } + .listener-experience .listener-field { width: min(100%, 25rem); aspect-ratio: 1.55; } + .listener-mode { margin-top: 0.5rem; } + .listener-mode button { min-height: 3.25rem; padding-inline: 0.55rem; } .listener-transport { position: sticky; z-index: 10; @@ -1666,7 +1688,7 @@ body { background: rgba(5, 17, 15, 0.82); backdrop-filter: blur(18px); } - .listener-details { grid-template-columns: 1fr; gap: 1rem; margin-top: 1.2rem; } + .listener-details { gap: 0.65rem; margin-top: 0.55rem; padding-block: 0.7rem; } .listener-public-hero { min-height: auto; grid-template-columns: 1fr; padding: 4rem 0 2rem; } .listener-public-hero__copy { text-align: center; } .listener-public-hero__copy h1, diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 17a4b331..c17b869a 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -39,6 +39,19 @@ const STALL_RECOVERY_DELAY_MS = 1_000; const LIVE_FADE_IN_MS = 3_000; const TRANSPORT_FADE_OUT_MS = 650; +// The Listener does not need low latency. Holding roughly five six-second HLS +// segments behind the edge gives desktop browsers useful network headroom +// while every fresh play still seeks to the current configured live position. +export const LISTENER_HLS_BUFFER_CONFIG = { + lowLatencyMode: false, + liveDurationInfinity: true, + liveSyncDurationCount: 5, + liveMaxLatencyDurationCount: 10, + maxBufferLength: 60, + maxMaxBufferLength: 90, + backBufferLength: 0, +} as const; + export function getOrCreateEarlyBirdDeviceId(storage: Storage): string { const existing = storage.getItem(DEVICE_STORAGE_KEY); if (existing && /^[A-Za-z0-9_-]{16,200}$/.test(existing)) return existing; @@ -121,7 +134,6 @@ export default function ListenerPlayer({ es: { current: 0, duration: 0 }, en: { current: 0, duration: 0 }, }); - const [volume, setVolume] = useState(1); const [volumeSupported, setVolumeSupported] = useState(true); const [livePrepared, setLivePrepared] = useState(false); const [livePreparing, setLivePreparing] = useState(true); @@ -173,11 +185,7 @@ export default function ListenerPlayer({ } throw new Error('HLS is not supported'); } - const instance = new HlsConstructor({ - lowLatencyMode: false, - liveDurationInfinity: true, - backBufferLength: 0, - }); + const instance = new HlsConstructor(LISTENER_HLS_BUFFER_CONFIG); instance.on(HlsConstructor.Events.ERROR, (_event, data) => { if (!data.fatal) return; deferLiveFadeForRecovery.current(); @@ -634,56 +642,25 @@ export default function ListenerPlayer({ if (transportStopped) return; const language = activeDrop.current; + // Pause belongs only to private, seekable introductions. The Beacon + // is a live-edge stream: listeners either hear it now or stop it. + if (!language) return; if (transportPaused) { - if (language) { - const intro = dropAudio[language].current; - if (!intro) return; - try { - await intro.play(); - setTransportPaused(false); - } catch { - // Keep the paused state visible when the browser rejects resume. - } - return; - } - - const live = liveAudio.current; - if (!live) return; - wantsLivePlayback.current = true; - cancelRecovery(true); + const intro = dropAudio[language].current; + if (!intro) return; try { - await live.play(); + await intro.play(); setTransportPaused(false); - updateLiveState('playing'); - void requestLease('play').then((grant) => { - leaseId.current = grant.leaseId; - manifestExpiresAt.current = Date.parse(grant.stream.expiresAt); - }).catch(() => { - // The current authorized source remains usable; heartbeat retries promotion. - }); } catch { - scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS); + // Keep the paused state visible when the browser rejects resume. } return; } - if (language) { - cancelDropFade(); - const intro = dropAudio[language].current; - intro?.pause(); - storeProgress(language); - setTransportPaused(true); - return; - } - - const live = liveAudio.current; - if (!live || liveStateRef.current !== 'playing') return; - wantsLivePlayback.current = false; - cancelRecovery(true); - cancelLiveFade(); - pendingLiveFade.current = false; - live.pause(); - updateLiveState('paused'); + cancelDropFade(); + const intro = dropAudio[language].current; + intro?.pause(); + storeProgress(language); setTransportPaused(true); } @@ -742,12 +719,16 @@ export default function ListenerPlayer({ setVolumeSupported(Math.abs(probe.volume - 0.37) < 0.01); }, []); - useEffect(() => { - const all = [liveAudio.current, dropAudio.es.current, dropAudio.en.current]; - for (const audio of all) { - if (audio && (audio !== liveAudio.current || liveFadeFrame.current === null)) audio.volume = volume; + function changeVolume(next: number) { + volumeRef.current = next; + // Keep slider movement off React's render path. Updating the media + // elements directly avoids decorative UI work competing with audio. + for (const audio of [liveAudio.current, dropAudio.es.current, dropAudio.en.current]) { + if (audio && (audio !== liveAudio.current || liveFadeFrame.current === null)) { + audio.volume = next; + } } - }, [dropAudio.en, dropAudio.es, volume]); + } useEffect(() => { const recoverAfterResume = () => { @@ -994,8 +975,7 @@ export default function ListenerPlayer({

{copy.sharedPoint}

-

{copy.heading}

-

{copy.subheading}

+

{copy.heading}

@@ -1017,7 +997,6 @@ export default function ListenerPlayer({ disabled={transportActive || transportBusy || !selectedDropAvailable} > {copy.withIntro} - {selectedDropAvailable ? copy.introBy : copy.dropUnavailable} +
+ {availableDropCount > 1 && !transportActive && ( + + )} + + {volumeSupported && ( + + )} + + {introProgressVisible && ( +
+ + +
+ )} +
+
- {transportActive && ( + {playingDrop !== null && transportActive && (
-
-
-
- {copy.introSelection} - {availableDropCount > 1 ? ( - - ) : ( - {selectedDropAvailable ? copy.introBy : copy.dropUnavailable} - )} -
- {introProgressVisible && ( - <> - {copy.beaconFollows} - - {!transportPaused && ( - - )} - - )} -
- - {volumeSupported && ( - - )} -
); diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index 52ea3872..568a3759 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -43,6 +43,7 @@ vi.mock('hls.js', () => { import ListenerPlayer, { earlyBirdLeaseRecoveryDisposition, getOrCreateEarlyBirdDeviceId, + LISTENER_HLS_BUFFER_CONFIG, prefersNativeHls, seekNativeAudioToLiveEdge, } from '../ListenerPlayer'; @@ -76,6 +77,16 @@ describe('EarlyBird Listener player', () => { expect(audio.currentTime).toBe(123.25); }); + it('keeps a stability-first desktop HLS buffer without enabling low latency', () => { + expect(LISTENER_HLS_BUFFER_CONFIG).toMatchObject({ + lowLatencyMode: false, + liveSyncDurationCount: 5, + liveMaxLatencyDurationCount: 10, + maxBufferLength: 60, + maxMaxBufferLength: 90, + }); + }); + it('does not trust Chromium native HLS claims while preserving Apple native playback', () => { const audio = { canPlayType: () => 'maybe' } as unknown as HTMLAudioElement; expect(prefersNativeHls(audio, { vendor: 'Google Inc.' })).toBe(false); @@ -115,7 +126,7 @@ describe('EarlyBird Listener player', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); - await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Beacon playing')).toBeInTheDocument()); hlsHarness.instances[0].emitFatal(); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); @@ -123,7 +134,7 @@ describe('EarlyBird Listener player', () => { expect(fetchMock.mock.calls[2]?.[0]).toBe('/api/early-birds/stream/heartbeat'); expect(hlsHarness.instances[0].destroy).toHaveBeenCalledOnce(); expect(hlsHarness.instances[1].loadedSources).toEqual([grants[1].stream.manifestUrl]); - expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument(); + expect(screen.getByText('Beacon playing')).toBeInTheDocument(); }); it('keeps the pre-attached iOS source lease alive before the first play gesture', async () => { @@ -194,7 +205,7 @@ describe('EarlyBird Listener player', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); - await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Beacon playing')).toBeInTheDocument()); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); fireEvent.click(screen.getByRole('button', { name: 'Stop' })); @@ -267,22 +278,22 @@ describe('EarlyBird Listener player', () => { , ); - const live = screen.getByLabelText('Beacon 24/7'); + const live = screen.getByLabelText('Beacon'); await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); - await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Beacon playing')).toBeInTheDocument()); vi.useFakeTimers(); fireEvent.suspend(live); await vi.advanceTimersByTimeAsync(2_500); - expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument(); + expect(screen.getByText('Beacon playing')).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledTimes(2); vi.useRealTimers(); fireEvent.error(live); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3), { timeout: 3_000 }); expect(fetchMock.mock.calls[2]?.[0]).toBe('/api/early-birds/stream/heartbeat'); - await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Beacon playing')).toBeInTheDocument()); }); it('makes a displaced fatal recovery terminal without acquiring another lease', async () => { @@ -314,7 +325,7 @@ describe('EarlyBird Listener player', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); - await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Beacon playing')).toBeInTheDocument()); hlsHarness.instances[0].emitFatal(); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent( @@ -368,7 +379,7 @@ describe('EarlyBird Listener player', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(hlsHarness.instances).toHaveLength(1)); - await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Beacon playing')).toBeInTheDocument()); hlsHarness.instances[0].emitFatal(); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); @@ -380,7 +391,7 @@ describe('EarlyBird Listener player', () => { ]); await waitFor(() => expect(hlsHarness.instances).toHaveLength(2)); expect(hlsHarness.instances[1].loadedSources).toEqual([replacementGrant.stream.manifestUrl]); - expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument(); + expect(screen.getByText('Beacon playing')).toBeInTheDocument(); }); it('does not strand a fatal hls.js signal raised while playback is still starting', async () => { @@ -428,7 +439,7 @@ describe('EarlyBird Listener player', () => { await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); await waitFor(() => expect(hlsHarness.instances).toHaveLength(2)); - await waitFor(() => expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Beacon playing')).toBeInTheDocument()); expect(hlsHarness.instances[1].loadedSources).toEqual([refreshedGrant.stream.manifestUrl]); }); }); diff --git a/src/components/early-birds/__tests__/ListenerTransport.test.tsx b/src/components/early-birds/__tests__/ListenerTransport.test.tsx index dbdbe151..0df91e5f 100644 --- a/src/components/early-birds/__tests__/ListenerTransport.test.tsx +++ b/src/components/early-birds/__tests__/ListenerTransport.test.tsx @@ -71,7 +71,7 @@ describe('Listener one-action playlist transport', () => { renderPlayer(); expect(screen.getByRole('radio', { name: /With introduction/ })).toHaveAttribute('aria-checked', 'true'); - expect(screen.getAllByText('Amara Sol · English')).toHaveLength(2); + expect(screen.queryByText('Amara Sol · English')).not.toBeInTheDocument(); await waitForListen(); expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument(); @@ -110,42 +110,62 @@ describe('Listener one-action playlist transport', () => { expect(intro.currentTime).toBe(42); }); - it('pauses and resumes the Beacon without restarting the transport', async () => { - const { play, pause } = prepareMedia(); + it('treats the Beacon as live: Stop is available but Pause and Seek are not', async () => { + prepareMedia(); renderPlayer(); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + const live = screen.getByLabelText('Beacon'); await chooseBeaconOnly(); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); Object.defineProperty(live, 'paused', { value: false, configurable: true }); fireEvent.playing(live); - live.currentTime = 73; - pause.mockClear(); - - fireEvent.click(screen.getByRole('button', { name: 'Pause' })); - expect(pause.mock.instances).toContain(live); - expect(live.currentTime).toBe(73); - - play.mockClear(); - fireEvent.click(screen.getByRole('button', { name: 'Resume' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Pause' })).toBeEnabled()); - expect(play.mock.instances).toContain(live); - expect(live.currentTime).toBe(73); + expect(screen.getByRole('button', { name: 'Stop' })).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Resume' })).not.toBeInTheDocument(); + expect(screen.queryByRole('slider', { name: 'Seek' })).not.toBeInTheDocument(); }); it('shows real intro progress only while the intro is active', async () => { prepareMedia(); renderPlayer(); await waitForListen(); - expect(screen.queryByRole('slider', { name: 'Intro before the Beacon: Warm-up · English' })) + expect(screen.queryByRole('slider', { name: 'Seek' })) .not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); - expect(await screen.findByRole('slider', { name: 'Intro before the Beacon: Warm-up · English' })) + expect(await screen.findByRole('slider', { name: 'Seek' })) .toBeInTheDocument(); - expect(screen.getByText('The Beacon follows')).toBeInTheDocument(); + expect(screen.queryByText('Amara Sol · English')).not.toBeInTheDocument(); expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument(); }); + it('updates media-element volume directly without changing the live transport state', async () => { + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); + vi.spyOn(performance, 'now').mockReturnValue(0); + const { pause } = prepareMedia(); + renderPlayer(); + const live = screen.getByLabelText('Beacon') as HTMLAudioElement; + await chooseBeaconOnly(); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); + Object.defineProperty(live, 'paused', { value: false, configurable: true }); + fireEvent.playing(live); + frames.shift()?.(3_000); + pause.mockClear(); + + for (const value of ['0.85', '0.62', '0.4']) { + fireEvent.change(screen.getByRole('slider', { name: 'Volume' }), { target: { value } }); + } + + expect(live.volume).toBeCloseTo(0.4); + expect(screen.getByText('Beacon playing')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Pause' })).not.toBeInTheDocument(); + expect(pause.mock.instances).not.toContain(live); + }); + it('hands off naturally from the intro to the Beacon using the approved fade', async () => { const frames: FrameRequestCallback[] = []; vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { @@ -156,7 +176,7 @@ describe('Listener one-action playlist transport', () => { vi.spyOn(performance, 'now').mockReturnValue(0); const { play } = prepareMedia(); renderPlayer(); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + const live = screen.getByLabelText('Beacon') as HTMLAudioElement; const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; await waitForListen(); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); @@ -169,13 +189,13 @@ describe('Listener one-action playlist transport', () => { fireEvent.playing(live); frames.shift()?.(3_000); expect(live.volume).toBeCloseTo(1); - expect(screen.getByText('Playing Beacon 24/7')).toBeInTheDocument(); + expect(screen.getByText('Beacon playing')).toBeInTheDocument(); }); it('can skip the private intro into the same Beacon handoff', async () => { const { play, pause } = prepareMedia(); renderPlayer(); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + const live = screen.getByLabelText('Beacon') as HTMLAudioElement; const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; await waitForListen(); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); @@ -197,7 +217,7 @@ describe('Listener one-action playlist transport', () => { vi.spyOn(performance, 'now').mockReturnValue(0); const { pause } = prepareMedia(); renderPlayer(); - const live = screen.getByLabelText('Beacon 24/7') as HTMLAudioElement; + const live = screen.getByLabelText('Beacon') as HTMLAudioElement; await chooseBeaconOnly(); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); Object.defineProperty(live, 'paused', { value: false, configurable: true }); diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index d8769bf0..03324e73 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -65,17 +65,15 @@ export const earlyBirdSyntheticEntryCopy = { export const earlyBirdHomeCopy = { es: { eyebrow: 'HARMONIC BEACON · LISTENER', - heading: 'Beacon 24/7', + heading: 'Beacon', sharedPoint: 'Escucha compartida', - subheading: 'Un mismo punto del Beacon, disponible para quienes están escuchando alrededor del mundo.', ready: 'Listo para escuchar', listen: 'Escuchar', mode: 'Cómo querés entrar', withIntro: 'Con introducción', beaconOnly: 'Solo Beacon', - beaconFollows: 'Después sigue el Beacon', - introBy: 'Amara Sol · English', skipToBeacon: 'Saltar al Beacon', + seek: 'Posición', prepareDevice: 'Habilitar este dispositivo', deviceReady: 'Dispositivo listo. Tocá otra vez para escuchar o elegí un drop-in.', deviceLimitClaim: 'Ya hay dos dispositivos activos. Habilitar éste detendrá la escucha en el menos reciente.', @@ -93,7 +91,7 @@ export const earlyBirdHomeCopy = { introSelection: 'Intro antes del Beacon', stop: 'Detener', playingIntro: 'Sonando intro · después sigue el Beacon', - playingBeacon: 'Sonando Beacon 24/7', + playingBeacon: 'Beacon activo', stopped: 'Detenido', master: 'Volumen', signOut: 'Cerrar sesión', @@ -102,17 +100,15 @@ export const earlyBirdHomeCopy = { }, en: { eyebrow: 'HARMONIC BEACON · LISTENER', - heading: 'Beacon 24/7', + heading: 'Beacon', sharedPoint: 'Shared listening', - subheading: 'One point in the Beacon, available to everyone listening across the world.', ready: 'Ready to listen', listen: 'Listen', mode: 'How you want to enter', withIntro: 'With introduction', beaconOnly: 'Beacon only', - beaconFollows: 'The Beacon follows', - introBy: 'Amara Sol · English', skipToBeacon: 'Skip to the Beacon', + seek: 'Seek', prepareDevice: 'Enable this device', deviceReady: 'Device ready. Tap again to listen, or choose a drop-in.', deviceLimitClaim: 'Two devices are already active. Enabling this one will stop playback on the least recent device.', @@ -130,7 +126,7 @@ export const earlyBirdHomeCopy = { introSelection: 'Intro before the Beacon', stop: 'Stop', playingIntro: 'Playing intro · Beacon follows', - playingBeacon: 'Playing Beacon 24/7', + playingBeacon: 'Beacon playing', stopped: 'Stopped', master: 'Volume', signOut: 'Sign out', From 6b60cdd0298c1282a26defe79863ba302db42a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 01:19:42 -0300 Subject: [PATCH 075/376] docs(listener): record live-edge staging acceptance --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 63e9884c..c716e7d0 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,5 +1,29 @@ # EarlyBirds isolated staging runtime +## 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. +- Desktop hls.js targets five six-second segments behind the edge, a 60-second + forward buffer and a 90-second hard maximum; low-latency mode remains off. +- 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 From 8baaaac4bfa24ad508d2519c58eda8d0f8b0aec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 01:31:10 -0300 Subject: [PATCH 076/376] feat(listener): add reversible public listening mode --- .env.example | 3 + .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 40 ++++++- ops/early-birds-preview/compose.yml | 1 + .../listen.harmonicbeacon.com.conf.template | 106 ++++++++++++++++++ .../preview.env.synthetic.example | 1 + .../test/preview-contract.test.mjs | 14 ++- scripts/early-birds-preview/lib.sh | 12 +- scripts/early-birds-preview/rollback.sh | 2 +- scripts/early-birds-preview/start.sh | 3 +- scripts/early-birds-preview/validate.mjs | 2 + .../[language]/__tests__/route.test.ts | 14 +++ .../early-birds/drop-ins/[language]/route.ts | 19 +++- .../stream/heartbeat/__tests__/route.test.ts | 19 ++++ .../api/early-birds/stream/heartbeat/route.ts | 32 ++++-- .../stream/lease/__tests__/route.test.ts | 23 ++++ src/app/api/early-birds/stream/lease/route.ts | 24 +++- .../stream/manifest/__tests__/route.test.ts | 23 ++++ .../api/early-birds/stream/manifest/route.ts | 18 ++- src/app/early-birds/__tests__/page.test.tsx | 46 ++++++++ src/app/early-birds/page.tsx | 16 ++- src/components/early-birds/EarlyBirdHome.tsx | 6 +- .../__tests__/EarlyBirdHome.test.tsx | 50 +++++++++ src/lib/early-birds/__tests__/enabled.test.ts | 10 ++ .../__tests__/stream-lease.test.ts | 73 +++++++++++- src/lib/early-birds/enabled.ts | 9 ++ src/lib/early-birds/stream.ts | 103 +++++++++++++++++ 26 files changed, 630 insertions(+), 39 deletions(-) create mode 100644 ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template create mode 100644 src/app/early-birds/__tests__/page.test.tsx create mode 100644 src/components/early-birds/__tests__/EarlyBirdHome.test.tsx diff --git a/.env.example b/.env.example index 4a29940a..cb02bf8a 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,9 @@ SESSION_COOKIE_TTL_SECONDS=604800 # 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 diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index c716e7d0..b531b9c9 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -145,6 +145,8 @@ 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, @@ -173,7 +175,7 @@ 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; -- exactly the two HTTPS staging origins above; +- 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 @@ -266,9 +268,9 @@ 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 (not executed here) +## Nginx and TLS handoff -The two files in `ops/early-birds-preview/nginx/` are standalone vhost +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` @@ -276,6 +278,10 @@ 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, event and checkout surfaces. +The `listen.harmonicbeacon.com` vhost is narrower: it exposes only `/`, Next +static assets, health, stream leases/manifests and configured drop-ins. Auth, +synthetic login, invitation, membership projection and all other app routes +remain unreachable from that host. A host operator must review certificate/DNS ownership, provision each named certificate, install these as new site files, and run `nginx -t` before any @@ -302,6 +308,31 @@ Recreate the Listener through `start.sh`, rerun the smoke, and exercise only Provider buttons remain disabled because OAuth credentials are blank. Return both switches to `0` after the supervised team window. +### Operator-controlled Free for All + +`EARLY_BIRDS_FREE_FOR_ALL` is independent from the Listener kill switch. Set it +to exactly `1` and recreate only the Listener to let anonymous visitors use the +Listener and configured drop-ins 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`, `/api/early-birds/test-login` and +`/api/internal/` all return 404 at the edge. + Normal stop retains all preview data: ```bash @@ -315,7 +346,8 @@ PostgreSQL for diagnosis and a forward fix: scripts/early-birds-preview/rollback.sh /secure/earlybirds-preview.env ``` -Set `EARLY_BIRDS_ENABLED=0` before the next start. None of these scripts uses +Set `EARLY_BIRDS_ENABLED=0` and `EARLY_BIRDS_FREE_FOR_ALL=0` before the next +start. None of these scripts uses `docker compose down`, deletes a volume, or targets the event/live project. ## Staging release gate diff --git a/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index 602639d2..63bb03ca 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -62,6 +62,7 @@ services: NODE_ENV: production 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} 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..17544a6e --- /dev/null +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -0,0 +1,106 @@ +# Public Listener edge for operator-controlled Free for All moments. It shares +# the isolated Listener runtime but exposes neither staging auth nor internal +# membership routes. +server { + listen 80; + listen [::]:80; + server_name listen.harmonicbeacon.com; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + 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"; + } + + 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; + } + + location = /early-birds { + return 302 /; + } + + location = / { + rewrite ^ /early-birds 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; + } + + # Auth, synthetic entry, invitations, projection, staff and event routes + # are deliberately absent from the public Listener host. + location / { + return 404; + } +} diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example index da858c81..3f006774 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -17,6 +17,7 @@ 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 diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 3c995fd5..20044caa 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -29,6 +29,7 @@ test('synthetic guard accepts the example and rejects unsafe effective values', ['real OAuth seam', 'EARLY_BIRDS_GOOGLE_CLIENT_ID=real-client-id', /must stay empty/], ['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/], ['wrong team-entry host', 'EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=staging.example.invalid', /must be earlybirds-staging/], ['non-synthetic secret', 'EARLY_BIRDS_AUTH_SECRET=not-a-real-but-long-enough-secret-value', /visibly synthetic/], @@ -87,6 +88,7 @@ test('compose gates the loopback Listener on a forward-only isolated database mi 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, /NODE_ENV: production/); assert.match(source, /preview_db:[\s\S]*internal: true/); @@ -128,13 +130,15 @@ test('stream publishes only through a dedicated edge network', async () => { assert.match(source, /stream_edge:\s+name: earlybirds_stream_edge/); }); -test('nginx templates name only the two staging hosts and proxy only fixed loopback ports', async () => { +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${stream}`; + 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]); @@ -143,14 +147,20 @@ test('nginx templates name only the two staging hosts and proxy only fixed loopb 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((combined.match(/X-Harmonic-Beacon-Environment "early-birds-staging"/g) ?? []).length, 2); + assert.equal((listener.match(/X-Harmonic-Beacon-Environment "listener-public-free"/g) ?? []).length, 1); assert.match(app, /location = \/ \{[^}]*access_log off;[^}]*rewrite \^ \/early-birds break;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;/s); 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.doesNotMatch(listener, /api\/early-birds\/(auth|test-login|free|membership)/); + assert.doesNotMatch(listener, /location \^~ \/early-birds\//); const invitationEntryLocations = [...app.matchAll( /location = \/early-birds(?:\/redeem)? \{([^}]*)\}/g, diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index c662401c..958ae5fa 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -55,7 +55,12 @@ require_synthetic_env() { require_exact_preview_value EARLY_BIRDS_TRUSTED_ORIGINS https://earlybirds-staging.harmonicbeacon.com "$env_file" require_exact_preview_value EARLY_BIRDS_STREAM_ORIGIN https://stream.harmonicbeacon.com "$env_file" require_exact_preview_value BEACON_STREAM_PUBLIC_ORIGIN https://stream.harmonicbeacon.com "$env_file" - require_exact_preview_value BEACON_STREAM_ALLOWED_ORIGINS https://earlybirds-staging.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' @@ -67,6 +72,8 @@ require_synthetic_env() { 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 require_exact_preview_value EARLY_BIRDS_TEST_ACCESS_ENABLED 1 "$env_file" @@ -108,7 +115,8 @@ require_synthetic_env() { EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com|\ EARLY_BIRDS_STREAM_ORIGIN=https://stream.harmonicbeacon.com|\ 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|\ + 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#*=} diff --git a/scripts/early-birds-preview/rollback.sh b/scripts/early-birds-preview/rollback.sh index 827b7793..2133255a 100755 --- a/scripts/early-birds-preview/rollback.sh +++ b/scripts/early-birds-preview/rollback.sh @@ -8,5 +8,5 @@ require_synthetic_env "$env_file" # intact for inspection and an additive forward fix. preview_compose_command "$env_file" stop listener beacon-stream echo 'EarlyBirds Listener and stream origin stopped; preview PostgreSQL was retained.' -echo 'Set EARLY_BIRDS_ENABLED=0 and EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0 before the next start.' +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.sh b/scripts/early-birds-preview/start.sh index 882f1f66..8d7f2b7f 100755 --- a/scripts/early-birds-preview/start.sh +++ b/scripts/early-birds-preview/start.sh @@ -8,6 +8,7 @@ require_synthetic_env "$env_file" # PostgreSQL health -> forward-only migration -> Listener readiness. preview_compose_command "$env_file" up -d --build listener beacon-stream 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 and EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=$team_entry_switch." +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 'Run health-smoke.sh; keep the public entry disabled until every gate passes.' diff --git a/scripts/early-birds-preview/validate.mjs b/scripts/early-birds-preview/validate.mjs index 0d57bb90..5815c47e 100644 --- a/scripts/early-birds-preview/validate.mjs +++ b/scripts/early-birds-preview/validate.mjs @@ -21,6 +21,7 @@ const syntheticEnv = [ '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', @@ -95,6 +96,7 @@ try { assert.equal(listener.build.target, 'runner'); assert.equal(listener.environment.NODE_ENV, 'production'); 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.EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS, 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 index d8e1cb5f..727a9374 100644 --- 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 @@ -49,6 +49,20 @@ describe('private EarlyBird drop-in media', () => { expect(mocks.open).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.getEarlyBirdAccess).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' }, diff --git a/src/app/api/early-birds/drop-ins/[language]/route.ts b/src/app/api/early-birds/drop-ins/[language]/route.ts index bf6d32a8..f490b629 100644 --- a/src/app/api/early-birds/drop-ins/[language]/route.ts +++ b/src/app/api/early-birds/drop-ins/[language]/route.ts @@ -5,7 +5,11 @@ import { Readable } from 'node:stream'; import { NextRequest, NextResponse } from 'next/server'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; -import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; +import { + earlyBirdsEnabled, + earlyBirdsFreeForAll, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; import { getEarlyBirdAccess } from '@/lib/early-birds/membership'; export const dynamic = 'force-dynamic'; @@ -31,15 +35,20 @@ async function serve( head: boolean, ) { if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); - const session = await currentEarlyBirdSession(request.headers).catch(() => null); - if (!session) { + 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' }, }); } - const access = await getEarlyBirdAccess(session.user.id).catch(() => null); - if (!access?.allowed) { + const access = session + ? await getEarlyBirdAccess(session.user.id).catch(() => null) + : null; + if (!freeForAll && !access?.allowed) { return NextResponse.json({ error: 'Membership inactive.' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' }, 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 index 1c155c0a..3dfdf70e 100644 --- a/src/app/api/early-birds/stream/heartbeat/__tests__/route.test.ts +++ b/src/app/api/early-birds/stream/heartbeat/__tests__/route.test.ts @@ -4,6 +4,7 @@ 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'); @@ -17,6 +18,7 @@ vi.mock('@/lib/early-birds/auth', () => ({ })); vi.mock('@/lib/early-birds/stream', () => ({ heartbeatEarlyBirdStreamLease: mocks.heartbeatEarlyBirdStreamLease, + heartbeatFreeForAllStreamLease: mocks.heartbeatFreeForAllStreamLease, EarlyBirdLeaseInactiveError: mocks.LeaseInactive, EarlyBirdAccessDeniedError: mocks.AccessDenied, })); @@ -82,6 +84,23 @@ describe('EarlyBird stream heartbeat route', () => { ); }); + 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({ + 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); + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(mocks.heartbeatEarlyBirdStreamLease).not.toHaveBeenCalled(); + }); + it('renews a prepared source without promoting its eviction priority', async () => { mocks.heartbeatEarlyBirdStreamLease.mockResolvedValue({ leaseExpiresAt: new Date('2026-08-06T12:03:00.000Z'), diff --git a/src/app/api/early-birds/stream/heartbeat/route.ts b/src/app/api/early-birds/stream/heartbeat/route.ts index d0ed05ed..0291487e 100644 --- a/src/app/api/early-birds/stream/heartbeat/route.ts +++ b/src/app/api/early-birds/stream/heartbeat/route.ts @@ -1,10 +1,15 @@ import { NextResponse, type NextRequest } from 'next/server'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; -import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; +import { + earlyBirdsEnabled, + earlyBirdsFreeForAll, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; import { EarlyBirdAccessDeniedError, EarlyBirdLeaseInactiveError, + heartbeatFreeForAllStreamLease, heartbeatEarlyBirdStreamLease, } from '@/lib/early-birds/stream'; @@ -13,8 +18,13 @@ export const dynamic = 'force-dynamic'; export async function POST(request: NextRequest): Promise { if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); - const session = await currentEarlyBirdSession(request.headers).catch(() => null); - if (!session) return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); + 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 intent: 'play' | 'prepare'; @@ -30,13 +40,15 @@ export async function POST(request: NextRequest): Promise { } try { - const grant = await heartbeatEarlyBirdStreamLease( - session.user.id, - leaseId, - undefined, - undefined, - intent === 'play', - ); + const grant = freeForAll + ? await heartbeatFreeForAllStreamLease(leaseId) + : await heartbeatEarlyBirdStreamLease( + session!.user.id, + leaseId, + undefined, + undefined, + intent === 'play', + ); return NextResponse.json({ leaseExpiresAt: grant.leaseExpiresAt.toISOString(), stream: { 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 index b0884ab6..f59165aa 100644 --- a/src/app/api/early-birds/stream/lease/__tests__/route.test.ts +++ b/src/app/api/early-birds/stream/lease/__tests__/route.test.ts @@ -3,12 +3,14 @@ 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 EarlyBirdDeviceCapacityError = vi.hoisted(() => class extends Error {}); vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession })); vi.mock('@/lib/early-birds/stream', () => ({ acquireEarlyBirdStreamLease, + acquireFreeForAllStreamLease, prepareEarlyBirdStreamLease, EarlyBirdAccessDeniedError: class extends Error {}, EarlyBirdDeviceCapacityError, @@ -39,6 +41,27 @@ describe('EarlyBird stream lease route', () => { 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({ + 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 same-origin manifest grant', async () => { currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); acquireEarlyBirdStreamLease.mockResolvedValue({ diff --git a/src/app/api/early-birds/stream/lease/route.ts b/src/app/api/early-birds/stream/lease/route.ts index 22b5f7f4..9a42b470 100644 --- a/src/app/api/early-birds/stream/lease/route.ts +++ b/src/app/api/early-birds/stream/lease/route.ts @@ -1,9 +1,14 @@ import { NextResponse, type NextRequest } from 'next/server'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; -import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; +import { + earlyBirdsEnabled, + earlyBirdsFreeForAll, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; import { acquireEarlyBirdStreamLease, + acquireFreeForAllStreamLease, EarlyBirdAccessDeniedError, EarlyBirdDeviceCapacityError, EarlyBirdStreamIssuerUnavailableError, @@ -15,8 +20,13 @@ export const dynamic = 'force-dynamic'; export async function POST(request: NextRequest): Promise { if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); - const session = await currentEarlyBirdSession(request.headers).catch(() => null); - if (!session) return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); + 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'; @@ -29,9 +39,11 @@ export async function POST(request: NextRequest): Promise { } try { - const grant = intent === 'prepare' - ? await prepareEarlyBirdStreamLease(session.user.id, deviceId) - : await acquireEarlyBirdStreamLease(session.user.id, deviceId); + const grant = freeForAll + ? await acquireFreeForAllStreamLease(deviceId) + : intent === 'prepare' + ? await prepareEarlyBirdStreamLease(session!.user.id, deviceId) + : await acquireEarlyBirdStreamLease(session!.user.id, deviceId); return NextResponse.json({ leaseId: grant.leaseId, leaseExpiresAt: grant.leaseExpiresAt.toISOString(), diff --git a/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts b/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts index b233d556..f8e55e13 100644 --- a/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts +++ b/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts @@ -4,6 +4,7 @@ import { NextRequest } from 'next/server'; const mocks = vi.hoisted(() => ({ currentEarlyBirdSession: vi.fn(), authorizeEarlyBirdStreamLease: vi.fn(), + authorizeFreeForAllStreamLease: vi.fn(), earlyBirdOriginConfig: vi.fn(), signedEarlyBirdOriginManifestUrl: vi.fn(), validSignedOriginManifest: vi.fn(), @@ -27,6 +28,7 @@ const { vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession: mocks.currentEarlyBirdSession })); vi.mock('@/lib/early-birds/stream', () => ({ authorizeEarlyBirdStreamLease: mocks.authorizeEarlyBirdStreamLease, + authorizeFreeForAllStreamLease: mocks.authorizeFreeForAllStreamLease, earlyBirdOriginConfig: mocks.earlyBirdOriginConfig, signedEarlyBirdOriginManifestUrl: mocks.signedEarlyBirdOriginManifestUrl, validSignedOriginManifest: mocks.validSignedOriginManifest, @@ -82,6 +84,27 @@ describe('stable EarlyBird lease manifest', () => { expect(fetchMock).toHaveBeenCalledOnce(); }); + it('authorizes the bearer lease without session lookup in Free for All mode', async () => { + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + currentEarlyBirdSession.mockResolvedValue(null); + mocks.authorizeFreeForAllStreamLease.mockResolvedValue({ + id: LEASE_ID, + expiresAt: new Date(Date.now() + 120_000), + }); + earlyBirdOriginConfig.mockReturnValue({ origin: 'https://stream.example.test' }); + signedEarlyBirdOriginManifestUrl.mockReturnValue('https://stream.example.test/live.m3u8?exp=1&sig=x'); + validSignedOriginManifest.mockReturnValue(true); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response( + '#EXTM3U\nhttps://stream.example.test/segment?exp=2&sig=x\n', + { status: 200 }, + ))); + + expect((await GET(request())).status).toBe(200); + expect(mocks.authorizeFreeForAllStreamLease).toHaveBeenCalledWith(LEASE_ID, expect.any(Date)); + expect(currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(authorizeEarlyBirdStreamLease).not.toHaveBeenCalled(); + }); + it('cuts off a displaced device on its next manifest refresh', async () => { currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); authorizeEarlyBirdStreamLease.mockRejectedValue(new LeaseInactive('evicted')); diff --git a/src/app/api/early-birds/stream/manifest/route.ts b/src/app/api/early-birds/stream/manifest/route.ts index 33aa1c23..85f98724 100644 --- a/src/app/api/early-birds/stream/manifest/route.ts +++ b/src/app/api/early-birds/stream/manifest/route.ts @@ -1,9 +1,14 @@ import { NextResponse, type NextRequest } from 'next/server'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; -import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; +import { + earlyBirdsEnabled, + earlyBirdsFreeForAll, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; import { authorizeEarlyBirdStreamLease, + authorizeFreeForAllStreamLease, earlyBirdOriginConfig, EarlyBirdAccessDeniedError, EarlyBirdLeaseInactiveError, @@ -22,8 +27,11 @@ const MANIFEST_HEADERS = { export async function GET(request: NextRequest): Promise { if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); - const session = await currentEarlyBirdSession(request.headers).catch(() => null); - if (!session) { + 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' }, @@ -39,7 +47,9 @@ export async function GET(request: NextRequest): Promise { try { const now = new Date(); - const lease = await authorizeEarlyBirdStreamLease(session.user.id, leaseId, now); + const lease = freeForAll + ? await authorizeFreeForAllStreamLease(leaseId, now) + : await authorizeEarlyBirdStreamLease(session!.user.id, leaseId, now); const config = earlyBirdOriginConfig(); const upstreamUrl = signedEarlyBirdOriginManifestUrl({ config, 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..8bad2cce --- /dev/null +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + currentEarlyBirdSession: vi.fn(), + getEarlyBirdAccess: vi.fn(), +})); + +vi.mock('next/headers', () => ({ + cookies: vi.fn(), + headers: vi.fn(), +})); +vi.mock('@/lib/early-birds/auth', () => ({ + currentEarlyBirdSession: mocks.currentEarlyBirdSession, + earlyBirdOAuthAvailability: vi.fn(), +})); +vi.mock('@/lib/early-birds/membership', () => ({ + getEarlyBirdAccess: mocks.getEarlyBirdAccess, +})); + +import EarlyBirdHome from '@/components/early-birds/EarlyBirdHome'; +import EarlyBirdsPage from '../page'; + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('EarlyBird Listener page', () => { + 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, + displayName: '', + membershipSource: null, + dropIns: { es: null, en: '/api/early-birds/drop-ins/en' }, + }); + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(mocks.getEarlyBirdAccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index 1fa77cbe..b7973e2a 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -9,7 +9,7 @@ import { earlyBirdOAuthAvailability, } from '@/lib/early-birds/auth'; import { getEarlyBirdAccess } from '@/lib/early-birds/membership'; -import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; +import { earlyBirdsEnabled, earlyBirdsFreeForAll } from '@/lib/early-birds/enabled'; import { canonicalEarlyBirdInvitation, EARLY_BIRD_INVITATION_COOKIE, @@ -31,6 +31,20 @@ export default async function EarlyBirdsPage({ }) { if (!earlyBirdsEnabled()) return ; + if (earlyBirdsFreeForAll()) { + return ( + + ); + } + const params = await searchParams; const incomingHeaders = new Headers(await requestHeaders()); const cookieStore = await cookies(); diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index 1cf0b830..985d482f 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -12,10 +12,12 @@ export default function EarlyBirdHome({ displayName, membershipSource, dropIns, + publicAccess = false, }: { displayName: string; membershipSource: string | null; dropIns: { es: string | null; en: string | null }; + publicAccess?: boolean; }) { const { locale } = useLocale(); const copy = earlyBirdHomeCopy[locale]; @@ -32,7 +34,7 @@ export default function EarlyBirdHome({
-
+ {!publicAccess &&
{displayName.slice(0, 1).toUpperCase()} @@ -42,7 +44,7 @@ export default function EarlyBirdHome({ {membershipSource && {membershipSource}}
- + } diff --git a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx new file mode 100644 index 00000000..0c5bc3da --- /dev/null +++ b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { LocaleProvider } from '@/context/LocaleContext'; + +vi.mock('../ListenerPlayer', () => ({ + default: () =>
, +})); +vi.mock('@/components/brand/LanguageControl', () => ({ + default: () => , +})); + +import EarlyBirdHome from '../EarlyBirdHome'; + +afterEach(cleanup); + +describe('EarlyBird Listener home access chrome', () => { + it('keeps account controls for a membership-backed Listener', () => { + render( + + + , + ); + + expect(screen.getByLabelText('Account')).toBeInTheDocument(); + expect(screen.getByText('Sign out')).toBeInTheDocument(); + }); + + it('does not imply an account or expose sign-out in public mode', () => { + render( + + + , + ); + + expect(screen.queryByLabelText('Account')).not.toBeInTheDocument(); + expect(screen.queryByText('Sign out')).not.toBeInTheDocument(); + expect(screen.getByLabelText('listener-player')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/early-birds/__tests__/enabled.test.ts b/src/lib/early-birds/__tests__/enabled.test.ts index cc8852d1..ca86b599 100644 --- a/src/lib/early-birds/__tests__/enabled.test.ts +++ b/src/lib/early-birds/__tests__/enabled.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { earlyBirdsEnabled, + earlyBirdsFreeForAll, earlyBirdsUnavailableResponse, } from '@/lib/early-birds/enabled'; @@ -23,6 +24,15 @@ describe('EarlyBird public kill switch', () => { expect(earlyBirdsEnabled()).toBe(true); }); + it('enables the Free for All override only for the explicit value 1', () => { + for (const value of ['', 'true', 'yes', '0', ' 1 ']) { + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', value); + expect(earlyBirdsFreeForAll()).toBe(false); + } + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + expect(earlyBirdsFreeForAll()).toBe(true); + }); + it('returns a non-cacheable, retryable unavailable response', async () => { const response = earlyBirdsUnavailableResponse(); diff --git a/src/lib/early-birds/__tests__/stream-lease.test.ts b/src/lib/early-birds/__tests__/stream-lease.test.ts index 6fbfda15..65075592 100644 --- a/src/lib/early-birds/__tests__/stream-lease.test.ts +++ b/src/lib/early-birds/__tests__/stream-lease.test.ts @@ -14,18 +14,27 @@ const tx = vi.hoisted(() => ({ })); const prisma = vi.hoisted(() => ({ $transaction: vi.fn((callback: (client: typeof tx) => unknown) => callback(tx)), - earlyBirdStreamLease: { updateMany: vi.fn(), findFirst: vi.fn() }, + earlyBirdUser: { upsert: vi.fn() }, + earlyBirdStreamLease: { + updateMany: vi.fn(), + findFirst: vi.fn(), + upsert: vi.fn(), + update: vi.fn(), + }, earlyBirdMembershipProjection: { findUnique: vi.fn() }, })); vi.mock('@/lib/db', () => ({ prisma })); import { acquireEarlyBirdStreamLease, + acquireFreeForAllStreamLease, + authorizeFreeForAllStreamLease, EarlyBirdDeviceCapacityError, earlyBirdDeviceDigest, EARLY_BIRD_LEASE_TTL_MS, heartbeatEarlyBirdStreamLease, prepareEarlyBirdStreamLease, + EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID, type EarlyBirdStreamUrlIssuer, } from '../stream'; @@ -195,4 +204,66 @@ describe('EarlyBird two-device leases', () => { '00000000-0000-4000-8000-000000000002', NOW)) .rejects.toMatchObject({ reason: 'expired' }); }); + + it('creates a non-PII technical account and an unlimited public device lease', async () => { + prisma.earlyBirdStreamLease.upsert.mockResolvedValue({ + id: '00000000-0000-4000-8000-000000000005', + }); + const issuer: EarlyBirdStreamUrlIssuer = { + issue: vi.fn().mockResolvedValue({ + manifestUrl: '/api/early-birds/stream/manifest?leaseId=5', + expiresAt: new Date(NOW.getTime() + EARLY_BIRD_LEASE_TTL_MS), + }), + }; + + const result = await acquireFreeForAllStreamLease( + 'device_abcdefghijklmnopqrstuvwxyz', + NOW, + issuer, + ); + + expect(prisma.earlyBirdUser.upsert).toHaveBeenCalledWith({ + where: { id: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID }, + create: expect.objectContaining({ + id: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID, + email: 'public-listener@free.invalid', + }), + update: {}, + }); + expect(prisma.earlyBirdStreamLease.upsert).toHaveBeenCalledWith(expect.objectContaining({ + where: { accountId_deviceDigest: expect.objectContaining({ + accountId: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID, + }) }, + })); + expect(result.evictedLeaseId).toBeNull(); + expect(tx.earlyBirdMembershipProjection.findUnique).not.toHaveBeenCalled(); + }); + + it('authorizes public leases by technical account and still enforces expiry', async () => { + prisma.earlyBirdStreamLease.findFirst.mockResolvedValueOnce({ + id: '00000000-0000-4000-8000-000000000005', + evictedAt: null, + expiresAt: new Date('2026-08-06T12:03:00.000Z'), + }); + await expect(authorizeFreeForAllStreamLease( + '00000000-0000-4000-8000-000000000005', + NOW, + )).resolves.toMatchObject({ id: '00000000-0000-4000-8000-000000000005' }); + expect(prisma.earlyBirdStreamLease.findFirst).toHaveBeenCalledWith({ + where: { + id: '00000000-0000-4000-8000-000000000005', + accountId: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID, + }, + }); + + prisma.earlyBirdStreamLease.findFirst.mockResolvedValueOnce({ + id: '00000000-0000-4000-8000-000000000005', + evictedAt: null, + expiresAt: NOW, + }); + await expect(authorizeFreeForAllStreamLease( + '00000000-0000-4000-8000-000000000005', + NOW, + )).rejects.toMatchObject({ reason: 'expired' }); + }); }); diff --git a/src/lib/early-birds/enabled.ts b/src/lib/early-birds/enabled.ts index c2681969..1c6e3c11 100644 --- a/src/lib/early-birds/enabled.ts +++ b/src/lib/early-birds/enabled.ts @@ -9,6 +9,15 @@ export function earlyBirdsEnabled(environment: NodeJS.ProcessEnv = process.env): return environment.EARLY_BIRDS_ENABLED === '1'; } +/** + * Reversible operator override for short public listening moments. This does + * not create or mutate memberships: when disabled, anonymous stream and + * drop-in authorization stops on the next request/manifest refresh. + */ +export function earlyBirdsFreeForAll(environment: NodeJS.ProcessEnv = process.env): boolean { + return environment.EARLY_BIRDS_FREE_FOR_ALL === '1'; +} + export function earlyBirdsUnavailableResponse(): NextResponse { return NextResponse.json( { error: 'EarlyBirds is temporarily unavailable.' }, diff --git a/src/lib/early-birds/stream.ts b/src/lib/early-birds/stream.ts index 6973c585..64c9fad4 100644 --- a/src/lib/early-birds/stream.ts +++ b/src/lib/early-birds/stream.ts @@ -10,6 +10,14 @@ export const EARLY_BIRD_LEASE_TTL_MS = 3 * 60 * 1000; export const EARLY_BIRD_ORIGIN_MAX_SIGNATURE_TTL_SECONDS = 10 * 60; export const EARLY_BIRD_ORIGIN_MANIFEST_TTL_SECONDS = 60; export const EARLY_BIRD_LEASE_MANIFEST_PATH = '/api/early-birds/stream/manifest'; +export const EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID = 'early-birds-free-for-all'; + +const EARLY_BIRD_FREE_FOR_ALL_ACCOUNT = { + id: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID, + name: 'Public Listener', + email: 'public-listener@free.invalid', + emailVerified: false, +} as const; export type StreamUrlIssueRequest = { accountId: string; @@ -363,3 +371,98 @@ export async function heartbeatEarlyBirdStreamLease( }); return { leaseExpiresAt, stream }; } + +/** + * Public-mode leases intentionally do not fabricate a membership projection. + * They live under one non-PII technical account and remain usable only while + * the route-level Free for All switch is enabled. Device identifiers are still + * HMACed and origin URLs remain short-lived and signed. + */ +export async function acquireFreeForAllStreamLease( + deviceId: string, + now = new Date(), + issuer = earlyBirdStreamUrlIssuer(), +): Promise { + const deviceDigest = earlyBirdDeviceDigest(deviceId); + const leaseExpiresAt = new Date(now.getTime() + EARLY_BIRD_LEASE_TTL_MS); + + await prisma.earlyBirdUser.upsert({ + where: { id: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID }, + create: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT, + update: {}, + }); + const lease = await prisma.earlyBirdStreamLease.upsert({ + where: { + accountId_deviceDigest: { + accountId: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID, + deviceDigest, + }, + }, + create: { + accountId: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID, + deviceDigest, + lastSeenAt: now, + expiresAt: leaseExpiresAt, + }, + update: { + createdAt: now, + lastSeenAt: now, + expiresAt: leaseExpiresAt, + evictedAt: null, + }, + }); + + try { + const stream = await issuer.issue({ + accountId: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID, + leaseId: lease.id, + issuedAt: now, + leaseExpiresAt, + }); + return { + leaseId: lease.id, + leaseExpiresAt, + evictedLeaseId: null, + stream, + }; + } catch (error) { + await prisma.earlyBirdStreamLease.updateMany({ + where: { id: lease.id, accountId: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID }, + data: { evictedAt: now }, + }); + throw error; + } +} + +export async function authorizeFreeForAllStreamLease( + leaseId: string, + now = new Date(), +) { + const lease = await prisma.earlyBirdStreamLease.findFirst({ + where: { id: leaseId, accountId: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID }, + }); + if (!lease) throw new EarlyBirdLeaseInactiveError('missing'); + if (lease.evictedAt !== null) throw new EarlyBirdLeaseInactiveError('evicted'); + if (lease.expiresAt <= now) throw new EarlyBirdLeaseInactiveError('expired'); + return lease; +} + +export async function heartbeatFreeForAllStreamLease( + leaseId: string, + now = new Date(), + issuer = earlyBirdStreamUrlIssuer(), +): Promise<{ leaseExpiresAt: Date; stream: StreamUrlGrant }> { + const leaseExpiresAt = new Date(now.getTime() + EARLY_BIRD_LEASE_TTL_MS); + const current = await authorizeFreeForAllStreamLease(leaseId, now); + const lease = await prisma.earlyBirdStreamLease.update({ + where: { id: current.id }, + data: { lastSeenAt: now, expiresAt: leaseExpiresAt }, + }); + const stream = await issuer.issue({ + accountId: EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID, + leaseId: lease.id, + issuedAt: now, + leaseExpiresAt, + }); + return { leaseExpiresAt, stream }; +} From 84a9e0cf9470f0039b3e93b8bdc3330776ba1479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 01:39:57 -0300 Subject: [PATCH 077/376] refactor(listener): simplify the listening canvas --- src/components/early-birds/ListenerPlayer.tsx | 30 ++++++------------- .../__tests__/ListenerPlayer.test.tsx | 20 ++++++------- .../__tests__/ListenerTransport.test.tsx | 17 +++++++---- src/lib/early-birds/copy.ts | 4 --- 4 files changed, 30 insertions(+), 41 deletions(-) diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index c17b869a..7b8cb13c 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -6,7 +6,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocale } from '@/context/LocaleContext'; import { earlyBirdHomeCopy } from '@/lib/early-birds/copy'; -import BeaconField from './BeaconField'; import { deriveListenerPresentationPhase } from './listener-presentation'; type DropLanguage = 'es' | 'en'; @@ -920,17 +919,11 @@ export default function ListenerPlayer({ transportStopped, hasStarted, }); - const phaseLabel = { - ready: copy.ready, - preparing: copy.loading, - intro: copy.playingIntro, - beacon: copy.playingBeacon, - paused: copy.paused, - reconnecting: copy.reconnecting, - stopped: copy.stopped, - unavailable: copy.unavailable, - displaced: copy.displaced, - }[phase]; + const phaseLabel = phase === 'preparing' ? copy.loading + : phase === 'reconnecting' ? copy.reconnecting + : phase === 'unavailable' ? copy.unavailable + : phase === 'displaced' ? copy.displaced + : null; const transportActive = !transportStopped; const introProgressVisible = selectedDropAvailable && (playingDrop === selectedDrop || (transportPaused && activeDrop.current === selectedDrop)); @@ -972,21 +965,16 @@ export default function ListenerPlayer({ onSuspend={() => handleNativeInterruption('suspend')} onPlaying={handleNativePlaying} /> -
-
-

{copy.sharedPoint}

-

{copy.heading}

-
- - +
+

{copy.heading}

-

+

}
, -})); - import EarlyBirdHome from '../EarlyBirdHome'; afterEach(cleanup); @@ -45,6 +41,7 @@ describe('EarlyBird Listener home access chrome', () => { expect(screen.queryByLabelText('Account')).not.toBeInTheDocument(); expect(screen.queryByText('Sign out')).not.toBeInTheDocument(); + expect(screen.queryByRole('group', { name: /language|idioma/i })).not.toBeInTheDocument(); expect(screen.getByLabelText('listener-player')).toBeInTheDocument(); }); }); diff --git a/src/components/early-birds/__tests__/ListenerTransport.test.tsx b/src/components/early-birds/__tests__/ListenerTransport.test.tsx index 93b0deb1..263f07a3 100644 --- a/src/components/early-birds/__tests__/ListenerTransport.test.tsx +++ b/src/components/early-birds/__tests__/ListenerTransport.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { LocaleProvider } from '@/context/LocaleContext'; +import type { UiLocale } from '@/lib/i18n'; vi.mock('hls.js', () => { class TestHls { @@ -40,16 +41,19 @@ function prepareMedia() { return { play, pause }; } -function renderPlayer(dropIns = { es: null, en: '/api/drop-ins/en' }) { +function renderPlayer( + dropIns = { es: null, en: '/api/drop-ins/en' }, + initialLocale: UiLocale = 'en', +) { return render( - + , ); } async function waitForListen() { - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); + await waitFor(() => expect(screen.getByRole('button', { name: /^(Listen|Escuchar)$/ })).toBeEnabled()); } async function chooseBeaconOnly() { @@ -82,23 +86,39 @@ describe('Listener one-action playlist transport', () => { expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument(); }); - it('plays only the explicitly selected Spanish introduction', async () => { + it('uses the browser-derived page language to play only the matching introduction', async () => { const { play } = prepareMedia(); - renderPlayer({ es: '/api/drop-ins/es', en: '/api/drop-ins/en' }); - const spanish = screen.getByLabelText('Warm-up · Spanish') as HTMLAudioElement; + renderPlayer({ es: '/api/drop-ins/es', en: '/api/drop-ins/en' }, 'es'); + const spanish = screen.getByLabelText('Caldeamiento · Español') as HTMLAudioElement; const english = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; await waitForListen(); - fireEvent.change(screen.getByRole('combobox', { name: 'Intro before the Beacon' }), { - target: { value: 'es' }, - }); + await waitFor(() => expect(screen.getByRole('combobox', { name: 'Intro antes del Beacon' })) + .toHaveValue('es')); play.mockClear(); - fireEvent.click(screen.getByRole('button', { name: 'Listen' })); + fireEvent.click(screen.getByRole('button', { name: 'Escuchar' })); await waitFor(() => expect(play.mock.instances).toContain(spanish)); expect(play.mock.instances).not.toContain(english); }); + it('lets the intro dropdown override the browser-language default', async () => { + const { play } = prepareMedia(); + renderPlayer({ es: '/api/drop-ins/es', en: '/api/drop-ins/en' }, 'es'); + const spanish = screen.getByLabelText('Caldeamiento · Español') as HTMLAudioElement; + const english = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; + await waitForListen(); + + fireEvent.change(screen.getByRole('combobox', { name: 'Intro antes del Beacon' }), { + target: { value: 'en' }, + }); + play.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Escuchar' })); + + await waitFor(() => expect(play.mock.instances).toContain(english)); + expect(play.mock.instances).not.toContain(spanish); + }); + it('remembers the device mode without changing the stream contract', async () => { prepareMedia(); const first = renderPlayer(); diff --git a/src/lib/__tests__/i18n.test.ts b/src/lib/__tests__/i18n.test.ts index 39f7157c..ad54a323 100644 --- a/src/lib/__tests__/i18n.test.ts +++ b/src/lib/__tests__/i18n.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { STAFF_ROLE_KEYS, + localeForBrowserLanguage, localeForEventLanguage, messages, parseUiLocale, @@ -10,6 +11,14 @@ import { } from '@/lib/i18n'; describe('UI locale policy', () => { + it('uses Spanish only for Spanish browser preferences and English otherwise', () => { + expect(localeForBrowserLanguage('es-AR,es;q=0.9,en;q=0.8')).toBe('es'); + expect(localeForBrowserLanguage('es')).toBe('es'); + expect(localeForBrowserLanguage('en-US,en;q=0.9')).toBe('en'); + expect(localeForBrowserLanguage('pt-BR,pt;q=0.9,es;q=0.8')).toBe('en'); + expect(localeForBrowserLanguage(null)).toBe('en'); + }); + it('accepts only supported persisted locale values', () => { expect(parseUiLocale('es')).toBe('es'); expect(parseUiLocale('en')).toBe('en'); diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 82dcb0c1..758662a7 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -17,6 +17,16 @@ export function parseUiLocale(value: unknown): UiLocale | null { return value === 'es' || value === 'en' ? value : null; } +export function localeForBrowserLanguage(acceptLanguage: string | null | undefined): UiLocale { + const primaryLanguage = acceptLanguage + ?.split(',')[0] + ?.trim() + .split(';')[0] + ?.trim() + .toLowerCase(); + return primaryLanguage === 'es' || primaryLanguage?.startsWith('es-') ? 'es' : 'en'; +} + export function localeForEventLanguage(language: EventLanguage | null | undefined): UiLocale { return language === 'ENGLISH' ? 'en' : 'es'; } From d7ed952e509e570e4f3600b0aa81c95e064e1154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 03:56:40 -0300 Subject: [PATCH 083/376] feat(listener): add recurring free listening windows --- docs/architecture/EARLY_BIRDS_LISTENER.md | 35 +- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 26 +- docs/plans/EARLY_BIRDS.md | 6 +- .../listen.harmonicbeacon.com.conf.template | 15 + .../test/preview-contract.test.mjs | 3 +- .../migration.sql | 29 ++ prisma/schema.prisma | 20 ++ .../[language]/__tests__/route.test.ts | 12 +- .../early-birds/drop-ins/[language]/route.ts | 4 +- .../free-window/__tests__/route.test.ts | 88 +++++ src/app/api/early-birds/free-window/route.ts | 96 ++++++ .../api/early-birds/stream/heartbeat/route.ts | 2 +- src/app/api/early-birds/stream/lease/route.ts | 2 +- .../api/early-birds/stream/manifest/route.ts | 2 +- src/app/early-birds/__tests__/page.test.tsx | 82 ++++- src/app/early-birds/page.tsx | 13 +- src/app/globals.css | 48 +++ src/components/early-birds/EarlyBirdHome.tsx | 4 +- .../early-birds/EarlyBirdLanding.tsx | 15 +- .../early-birds/FreeWindowSetup.tsx | 138 ++++++++ .../__tests__/EarlyBirdLanding.test.tsx | 19 +- .../__tests__/FreeWindowSetup.test.tsx | 85 +++++ .../__tests__/ListenerTransport.test.tsx | 2 +- src/lib/early-birds/__tests__/access.test.ts | 74 +++++ src/lib/early-birds/__tests__/auth.test.ts | 20 +- .../early-birds/__tests__/free-window.test.ts | 158 +++++++++ .../__tests__/stream-lease.test.ts | 38 +++ src/lib/early-birds/access.ts | 65 ++++ src/lib/early-birds/auth.ts | 37 ++- src/lib/early-birds/copy.ts | 22 ++ src/lib/early-birds/free-window.ts | 312 ++++++++++++++++++ src/lib/early-birds/stream.ts | 53 +-- 32 files changed, 1445 insertions(+), 80 deletions(-) create mode 100644 prisma/migrations/20260807070000_early_bird_free_schedule/migration.sql create mode 100644 src/app/api/early-birds/free-window/__tests__/route.test.ts create mode 100644 src/app/api/early-birds/free-window/route.ts create mode 100644 src/components/early-birds/FreeWindowSetup.tsx create mode 100644 src/components/early-birds/__tests__/FreeWindowSetup.test.tsx create mode 100644 src/lib/early-birds/__tests__/access.test.ts create mode 100644 src/lib/early-birds/__tests__/free-window.test.ts create mode 100644 src/lib/early-birds/access.ts create mode 100644 src/lib/early-birds/free-window.ts diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index b6875f89..f2ceefc2 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -21,7 +21,7 @@ Required OAuth callbacks are: 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 button is visibly disabled. Public nginx exposes this dedicated +corresponding provider is absent from the public UI and auth runtime. Public nginx exposes this dedicated auth namespace while continuing to block synthetic login, invitations and internal membership routes. @@ -42,6 +42,39 @@ Byte-exact copies live in `contracts/early-bird-authority/v1` and - `ACTIVE`, time-valid `GRACE`, and time-valid `CANCELLED_PENDING_END` allow access. Every missing, expired, revoked, refunded or unavailable state fails closed. +## Ordinary Free listening window + +Registration does not fabricate a commerce membership. A signed-in account +without current canonical membership may instead select one recurring local +wall-clock start and listen for two real hours each day. The first selection is +either **Listen free now**, derived from server time in the validated browser +IANA zone, or an explicit local time. The selection is account-bound and may be +changed again at or after `selected_at + 7 days`. + +`early_bird_free_schedules` is a separate access layer from +`early_bird_membership_projections`. It stores only account ID, canonical IANA +zone, local start minute, selection/cooldown instants, idempotency request ID and +revision. It never writes provider, offer, price, Purchase or membership state. + +Authorization resolves in this order: + +1. a time-valid canonical membership grants its canonical boundary or anytime + access; +2. otherwise the current recurring Free window grants access until its exact + end; +3. otherwise access fails closed. + +The server resolves wall-clock dates with `Intl` timezone data. A fall-back +ambiguity uses the first occurrence; a spring-forward nonexistent minute moves +to the first real local minute after it. Window duration is always 120 real +minutes. Stream leases, signed manifests and segment signatures are capped at +the resulting boundary. Changing an unlocked schedule evicts existing leases +so every device must reauthorize. Browser time is presentation/input only and +never authorizes a request. + +The operator `EARLY_BIRDS_FREE_FOR_ALL=1` override remains route-level, +anonymous and independent. It creates neither a Free schedule nor membership. + 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 diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 32d81170..1308b14e 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -279,8 +279,9 @@ entry canonically at `/`, plus `/api/early-birds/`, Next static assets and healt legacy `/early-birds/home` redirects to `/`. It blocks `/api/internal/` and returns 404 for the image's weekend, staff, event and checkout surfaces. The `listen.harmonicbeacon.com` vhost is narrower: it exposes only `/`, Next -static assets, health, the dedicated Listener OAuth/session namespace, stream -leases/manifests and configured drop-ins. Synthetic login, invitation, +static assets, health, the dedicated Listener OAuth/session namespace, the +exact ordinary-Free schedule endpoint, stream leases/manifests and configured +drop-ins. Synthetic login, invitation, membership projection and all other app routes remain unreachable from that host. The application additionally returns a hidden 404 for Better Auth's email/password endpoints, so the public namespace offers only configured @@ -308,13 +309,32 @@ 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. -Provider buttons remain disabled while OAuth credentials are blank. A shared +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. +### Ordinary Free listening windows + +When Free for All is off, an authenticated account without a canonical Founder +membership may select one recurring two-hour daily window. The selection is +stored separately from commerce and membership state, is authorized from the +server clock, and may be changed only after a rolling seven-day cooldown. The +client supplies an IANA time zone and local wall-clock start; spring-forward +gaps advance to the first valid local minute and fall-back ambiguity uses the +first occurrence. Selecting “listen now” derives that wall-clock value from the +server instant rather than trusting the browser clock. + +The exact public endpoint is `/api/early-birds/free-window`: `GET` returns the +current window state for an authenticated session and `POST` requires an exact +trusted `Origin`, a UUID idempotency key and either `now` or a validated local +start minute. Active leases are capped at the end of the current window and a +schedule change evicts existing leases. The global Free for All switch remains +an independent operator override and never creates or edits this schedule. + ### Operator-controlled Free for All `EARLY_BIRDS_FREE_FOR_ALL` is independent from the Listener kill switch. Set it diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 35c2991f..97e6fa86 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -417,8 +417,9 @@ 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. An adult owns the account and payment; the -service does not request or persist a minor profile or minor-specific data. +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. ## 11. Fast Forward development lane @@ -618,6 +619,7 @@ event sound and reliability are at least as good as the current path. | 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 one recurring two-hour daily wall-clock window, locked for rolling seven days; the server resolves IANA/DST boundaries and caps leases. Founder membership grants anytime access, while the operator Free for All override remains independent. | ## 16. Card map diff --git a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template index 17906b3d..a45163b9 100644 --- a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -98,6 +98,21 @@ server { proxy_read_timeout 60s; } + # Authenticated ordinary-Free scheduling. This is account-local access + # state, not a commerce membership or a staging synthetic seam. + 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; + } + # 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. diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index a28fe1d5..a9b5d491 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -181,7 +181,8 @@ test('nginx templates isolate staging, stream and the constrained public Listene 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\//); - assert.doesNotMatch(listener, /api\/early-birds\/(test-login|free|membership)/); + assert.match(listener, /location = \/api\/early-birds\/free-window/); + assert.doesNotMatch(listener, /api\/early-birds\/(test-login|free\/|membership)/); assert.doesNotMatch(listener, /location \^~ \/early-birds\//); const invitationEntryLocations = [...app.matchAll( 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/schema.prisma b/prisma/schema.prisma index f91b81cc..1256b3a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -428,6 +428,7 @@ model EarlyBirdUser { identities EarlyBirdIdentity[] authSessions EarlyBirdAuthSession[] membership EarlyBirdMembershipProjection? + freeSchedule EarlyBirdFreeSchedule? streamLeases EarlyBirdStreamLease[] @@map("early_bird_users") @@ -511,6 +512,25 @@ model EarlyBirdMembershipProjection { @@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") +} + model EarlyBirdStreamLease { id String @id @default(uuid()) @db.Uuid accountId String @map("account_id") 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 index 727a9374..380ea6ad 100644 --- 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 @@ -4,7 +4,7 @@ import { NextRequest } from 'next/server'; const mocks = vi.hoisted(() => ({ currentEarlyBirdSession: vi.fn(), - getEarlyBirdAccess: vi.fn(), + getEarlyBirdListeningAccess: vi.fn(), stat: vi.fn(), open: vi.fn(), createReadStream: vi.fn(), @@ -12,7 +12,9 @@ const mocks = vi.hoisted(() => ({ 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/membership', () => ({ getEarlyBirdAccess: mocks.getEarlyBirdAccess })); +vi.mock('@/lib/early-birds/access', () => ({ + getEarlyBirdListeningAccess: mocks.getEarlyBirdListeningAccess, +})); import { GET, HEAD } from '../route'; @@ -22,7 +24,7 @@ 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.getEarlyBirdAccess.mockResolvedValue({ allowed: true }); + 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)]) @@ -43,7 +45,7 @@ describe('private EarlyBird drop-in media', () => { expect(mocks.open).not.toHaveBeenCalled(); mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); - mocks.getEarlyBirdAccess.mockResolvedValue({ allowed: false }); + 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(); @@ -60,7 +62,7 @@ describe('private EarlyBird drop-in media', () => { expect(response.status).toBe(200); expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); - expect(mocks.getEarlyBirdAccess).not.toHaveBeenCalled(); + expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); }); it('streams only the selected byte range from an immutable server-selected path', async () => { diff --git a/src/app/api/early-birds/drop-ins/[language]/route.ts b/src/app/api/early-birds/drop-ins/[language]/route.ts index f490b629..f47a7124 100644 --- a/src/app/api/early-birds/drop-ins/[language]/route.ts +++ b/src/app/api/early-birds/drop-ins/[language]/route.ts @@ -5,12 +5,12 @@ 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'; -import { getEarlyBirdAccess } from '@/lib/early-birds/membership'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; @@ -46,7 +46,7 @@ async function serve( }); } const access = session - ? await getEarlyBirdAccess(session.user.id).catch(() => null) + ? await getEarlyBirdListeningAccess(session.user.id).catch(() => null) : null; if (!freeForAll && !access?.allowed) { return NextResponse.json({ error: 'Membership inactive.' }, { diff --git a/src/app/api/early-birds/free-window/__tests__/route.test.ts b/src/app/api/early-birds/free-window/__tests__/route.test.ts new file mode 100644 index 00000000..83b777d7 --- /dev/null +++ b/src/app/api/early-birds/free-window/__tests__/route.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + currentEarlyBirdSession: vi.fn(), + getEarlyBirdFreeWindow: vi.fn(), + selectEarlyBirdFreeWindow: vi.fn(), +})); +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession: mocks.currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/free-window', async (importOriginal) => ({ + ...await importOriginal(), + getEarlyBirdFreeWindow: mocks.getEarlyBirdFreeWindow, + selectEarlyBirdFreeWindow: mocks.selectEarlyBirdFreeWindow, +})); + +import { + EarlyBirdFreeWindowCooldownError, + freeWindowState, +} from '@/lib/early-birds/free-window'; +import { GET, POST } from '../route'; + +function request(method: 'GET' | 'POST', body?: unknown, origin = 'https://listen.harmonicbeacon.com') { + return new NextRequest('https://listen.harmonicbeacon.com/api/early-birds/free-window', { + method, + headers: { + origin, + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +} + +describe('Free listening schedule API', () => { + beforeEach(() => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_AUTH_BASE_URL', 'https://listen.harmonicbeacon.com'); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + }); + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it('requires an EarlyBird session for reads', async () => { + mocks.currentEarlyBirdSession.mockResolvedValue(null); + expect((await GET(request('GET'))).status).toBe(401); + expect(mocks.getEarlyBirdFreeWindow).not.toHaveBeenCalled(); + }); + + it('rejects cross-origin mutations before touching account state', async () => { + const response = await POST(request('POST', {}, 'https://attacker.invalid')); + expect(response.status).toBe(403); + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(mocks.selectEarlyBirdFreeWindow).not.toHaveBeenCalled(); + }); + + it('selects a validated server-authoritative window and returns no-store state', async () => { + const state = freeWindowState(null); + mocks.selectEarlyBirdFreeWindow.mockResolvedValue({ state, replayed: false }); + const response = await POST(request('POST', { + mode: 'now', + timeZone: 'America/Argentina/Cordoba', + selectionRequestId: '00000000-0000-4000-8000-000000000001', + })); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toContain('no-store'); + expect(mocks.selectEarlyBirdFreeWindow).toHaveBeenCalledWith(expect.objectContaining({ + accountId: 'listener-1', + mode: 'now', + timeZone: 'America/Argentina/Cordoba', + })); + }); + + it('reports the canonical cooldown boundary on conflicting retries', async () => { + const boundary = new Date('2026-08-14T15:30:00.000Z'); + mocks.selectEarlyBirdFreeWindow.mockRejectedValue(new EarlyBirdFreeWindowCooldownError(boundary)); + const response = await POST(request('POST', { + mode: 'custom', + timeZone: 'UTC', + localStartMinute: 600, + selectionRequestId: '00000000-0000-4000-8000-000000000002', + })); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ changeAllowedAt: boundary.toISOString() }); + }); +}); diff --git a/src/app/api/early-birds/free-window/route.ts b/src/app/api/early-birds/free-window/route.ts new file mode 100644 index 00000000..95d8be9f --- /dev/null +++ b/src/app/api/early-birds/free-window/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; +import { + EarlyBirdFreeWindowCooldownError, + EarlyBirdFreeWindowInputError, + getEarlyBirdFreeWindow, + selectEarlyBirdFreeWindow, + serializeFreeWindowState, +} from '@/lib/early-birds/free-window'; + +export const dynamic = 'force-dynamic'; + +const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }; + +function sameOriginMutation(request: NextRequest): boolean { + const origin = request.headers.get('origin'); + if (!origin) return false; + const configured = [ + process.env.EARLY_BIRDS_AUTH_BASE_URL, + ...(process.env.EARLY_BIRDS_TRUSTED_ORIGINS ?? '').split(','), + ] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + const allowed = configured.length > 0 ? configured : [request.nextUrl.origin]; + return allowed.includes(origin); +} + +async function authenticatedAccount(request: NextRequest) { + return currentEarlyBirdSession(request.headers).catch(() => null); +} + +export async function GET(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + const session = await authenticatedAccount(request); + if (!session) { + return NextResponse.json({ error: 'Sign in required.' }, { status: 401, headers: PRIVATE_HEADERS }); + } + try { + const { state } = await getEarlyBirdFreeWindow(session.user.id); + return NextResponse.json({ state: serializeFreeWindowState(state) }, { headers: PRIVATE_HEADERS }); + } catch { + return NextResponse.json({ error: 'Free listening schedule unavailable.' }, { + status: 503, + headers: PRIVATE_HEADERS, + }); + } +} + +export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + if (!sameOriginMutation(request)) { + return NextResponse.json({ error: 'Invalid request origin.' }, { status: 403, headers: PRIVATE_HEADERS }); + } + const session = await authenticatedAccount(request); + if (!session) { + return NextResponse.json({ error: 'Sign in required.' }, { status: 401, headers: PRIVATE_HEADERS }); + } + + let body: Record; + try { + body = await request.json() as Record; + } catch { + return NextResponse.json({ error: 'Malformed request.' }, { status: 400, headers: PRIVATE_HEADERS }); + } + try { + const selected = await selectEarlyBirdFreeWindow({ + accountId: session.user.id, + mode: body.mode === 'now' ? 'now' : body.mode === 'custom' ? 'custom' : (() => { + throw new EarlyBirdFreeWindowInputError('mode must be now or custom'); + })(), + timeZone: typeof body.timeZone === 'string' ? body.timeZone : '', + localStartMinute: typeof body.localStartMinute === 'number' ? body.localStartMinute : undefined, + selectionRequestId: typeof body.selectionRequestId === 'string' ? body.selectionRequestId : '', + }); + return NextResponse.json({ + state: serializeFreeWindowState(selected.state), + replayed: selected.replayed, + }, { headers: PRIVATE_HEADERS }); + } catch (error) { + if (error instanceof EarlyBirdFreeWindowCooldownError) { + return NextResponse.json({ + error: 'Free listening schedule is locked.', + changeAllowedAt: error.changeAllowedAt.toISOString(), + }, { status: 409, headers: PRIVATE_HEADERS }); + } + if (error instanceof EarlyBirdFreeWindowInputError) { + return NextResponse.json({ error: error.message }, { status: 400, headers: PRIVATE_HEADERS }); + } + return NextResponse.json({ error: 'Free listening schedule unavailable.' }, { + status: 503, + headers: PRIVATE_HEADERS, + }); + } +} diff --git a/src/app/api/early-birds/stream/heartbeat/route.ts b/src/app/api/early-birds/stream/heartbeat/route.ts index 0291487e..35284860 100644 --- a/src/app/api/early-birds/stream/heartbeat/route.ts +++ b/src/app/api/early-birds/stream/heartbeat/route.ts @@ -69,7 +69,7 @@ export async function POST(request: NextRequest): Promise { }, { status: 410 }); } if (error instanceof EarlyBirdAccessDeniedError) { - return NextResponse.json({ error: 'Membership inactive.' }, { status: 403 }); + 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/route.ts b/src/app/api/early-birds/stream/lease/route.ts index 9a42b470..4da0898a 100644 --- a/src/app/api/early-birds/stream/lease/route.ts +++ b/src/app/api/early-birds/stream/lease/route.ts @@ -55,7 +55,7 @@ export async function POST(request: NextRequest): Promise { }); } catch (error) { if (error instanceof EarlyBirdAccessDeniedError) { - return NextResponse.json({ error: 'Membership inactive.' }, { status: 403 }); + return NextResponse.json({ error: 'Listening access inactive.' }, { status: 403 }); } if (error instanceof EarlyBirdStreamIssuerUnavailableError) { return NextResponse.json({ error: 'Stream temporarily unavailable.' }, { status: 503 }); diff --git a/src/app/api/early-birds/stream/manifest/route.ts b/src/app/api/early-birds/stream/manifest/route.ts index 85f98724..a82db8da 100644 --- a/src/app/api/early-birds/stream/manifest/route.ts +++ b/src/app/api/early-birds/stream/manifest/route.ts @@ -85,7 +85,7 @@ export async function GET(request: NextRequest): Promise { }); } if (error instanceof EarlyBirdAccessDeniedError) { - return NextResponse.json({ error: 'Membership inactive.' }, { + return NextResponse.json({ error: 'Listening access inactive.' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' }, }); diff --git a/src/app/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx index 8bad2cce..01449fbd 100644 --- a/src/app/early-birds/__tests__/page.test.tsx +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -2,19 +2,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ currentEarlyBirdSession: vi.fn(), - getEarlyBirdAccess: vi.fn(), + earlyBirdOAuthAvailability: vi.fn(), + getEarlyBirdListeningAccess: vi.fn(), + cookies: vi.fn(), + headers: vi.fn(), })); vi.mock('next/headers', () => ({ - cookies: vi.fn(), - headers: vi.fn(), + cookies: mocks.cookies, + headers: mocks.headers, })); vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession: mocks.currentEarlyBirdSession, - earlyBirdOAuthAvailability: vi.fn(), + earlyBirdOAuthAvailability: mocks.earlyBirdOAuthAvailability, })); -vi.mock('@/lib/early-birds/membership', () => ({ - getEarlyBirdAccess: mocks.getEarlyBirdAccess, +vi.mock('@/lib/early-birds/access', () => ({ + getEarlyBirdListeningAccess: mocks.getEarlyBirdListeningAccess, })); import EarlyBirdHome from '@/components/early-birds/EarlyBirdHome'; @@ -25,6 +28,20 @@ afterEach(() => { vi.unstubAllEnvs(); }); +const inactiveFreeWindow = { + configured: true, + active: false, + timeZone: 'UTC', + localStartMinute: 600, + selectedAt: new Date('2026-08-01T00:00:00.000Z'), + changeAllowedAt: new Date('2026-08-08T00:00:00.000Z'), + canChange: false, + activeStart: null, + activeEnd: null, + nextStart: new Date('2026-08-08T10:00:00.000Z'), + nextEnd: new Date('2026-08-08T12:00:00.000Z'), +}; + describe('EarlyBird Listener page', () => { it('renders the Listener directly without auth or membership in Free for All mode', async () => { vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); @@ -41,6 +58,57 @@ describe('EarlyBird Listener page', () => { dropIns: { es: null, en: '/api/early-birds/drop-ins/en' }, }); expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); - expect(mocks.getEarlyBirdAccess).not.toHaveBeenCalled(); + expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); + }); + + it('renders an authenticated Listener during an active Free window', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + mocks.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); + mocks.headers.mockResolvedValue(new Headers()); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); + mocks.getEarlyBirdListeningAccess.mockResolvedValue({ + allowed: true, + kind: 'free-window', + membership: { allowed: false, projection: null }, + freeWindow: { ...inactiveFreeWindow, active: true }, + }); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.type).toBe(EarlyBirdHome); + expect(result.props).toMatchObject({ + displayName: 'Nico', + membershipSource: null, + accessKind: 'free-window', + }); + }); + + it('shows the saved schedule rather than fabricating membership outside the window', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + mocks.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); + mocks.headers.mockResolvedValue(new Headers()); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); + mocks.earlyBirdOAuthAvailability.mockReturnValue({ google: true, apple: false }); + mocks.getEarlyBirdListeningAccess.mockResolvedValue({ + allowed: false, + kind: 'denied', + membership: { allowed: false, projection: null }, + freeWindow: inactiveFreeWindow, + }); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.props).toMatchObject({ + signedIn: true, + entitled: false, + providers: { google: true, apple: false }, + freeWindow: { + configured: true, + active: false, + nextStart: '2026-08-08T10:00:00.000Z', + }, + }); }); }); diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index b7973e2a..a8e40461 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -8,7 +8,7 @@ import { currentEarlyBirdSession, earlyBirdOAuthAvailability, } from '@/lib/early-birds/auth'; -import { getEarlyBirdAccess } from '@/lib/early-birds/membership'; +import { getEarlyBirdListeningAccess } from '@/lib/early-birds/access'; import { earlyBirdsEnabled, earlyBirdsFreeForAll } from '@/lib/early-birds/enabled'; import { canonicalEarlyBirdInvitation, @@ -16,6 +16,7 @@ import { } from '@/lib/early-birds/invitation-cookie'; import { syntheticTeamEntryAllowed } from '@/lib/early-birds/synthetic-team-entry'; import { configuredEarlyBirdDropIn } from '@/lib/early-birds/drop-ins'; +import { freeWindowState, serializeFreeWindowState } from '@/lib/early-birds/free-window'; export const dynamic = 'force-dynamic'; @@ -50,17 +51,18 @@ export default async function EarlyBirdsPage({ const cookieStore = await cookies(); const session = await currentEarlyBirdSession().catch(() => null); const access = session - ? await getEarlyBirdAccess(session.user.id).catch(() => null) + ? await getEarlyBirdListeningAccess(session.user.id).catch(() => null) : null; const invitationAvailable = canonicalEarlyBirdInvitation( cookieStore.get(EARLY_BIRD_INVITATION_COOKIE)?.value, ) !== null; - if (session && access?.allowed === true && access.projection) { + if (session && access?.allowed === true) { return ( ); } diff --git a/src/app/globals.css b/src/app/globals.css index 1b5fa75f..cac20a7d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1661,6 +1661,54 @@ body { backdrop-filter: blur(18px); } +.listener-free-window h3 { + font-family: var(--font-cormorant), Georgia, serif; + font-size: clamp(1.8rem, 5vw, 2.5rem); + font-weight: 500; + line-height: 1; +} + +.listener-free-window > p { + margin-top: 0.85rem; + color: var(--text-secondary); + font-size: 0.88rem; + line-height: 1.6; +} + +.listener-free-window__fact { + padding: 0.85rem 0; + border-bottom: 1px solid var(--border-subtle); +} + +.listener-free-window__fact span, +.listener-free-window__fact strong { + display: block; +} + +.listener-free-window__fact span, +.listener-free-window__chooser label span { + color: var(--text-muted); + font-family: var(--font-space-mono), monospace; + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.listener-free-window__fact strong { margin-top: 0.3rem; color: var(--paper); font-weight: 500; } +.listener-free-window__actions, +.listener-free-window__chooser { display: grid; gap: 0.75rem; margin-top: 1.2rem; } +.listener-free-window__chooser label { display: grid; gap: 0.45rem; } +.listener-free-window__chooser input { + min-height: 3.25rem; + padding: 0.65rem 0.9rem; + border: 1px solid var(--border-subtle); + border-radius: 0.8rem; + color: var(--paper); + background: rgba(255, 255, 255, 0.055); + color-scheme: dark; +} +.listener-free-window__chooser p { color: var(--text-muted); font-size: 0.72rem; } + .listener-footer { max-width: 45rem; padding: 1.5rem 0; diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index 371c5d5a..60ff04c0 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -10,11 +10,13 @@ import ListenerPlayer from './ListenerPlayer'; export default function EarlyBirdHome({ displayName, membershipSource, + accessKind = 'membership', dropIns, publicAccess = false, }: { displayName: string; membershipSource: string | null; + accessKind?: 'membership' | 'free-window'; dropIns: { es: string | null; en: string | null }; publicAccess?: boolean; }) { @@ -38,7 +40,7 @@ export default function EarlyBirdHome({

{displayName}

- {copy.active} + {accessKind === 'free-window' ? copy.freeActive : copy.active} {membershipSource && {membershipSource}}
diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index 96481a47..5a6d0f6b 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -3,12 +3,13 @@ import { useState } from 'react'; import BrandLockup from '@/components/brand/BrandLockup'; -import LanguageControl from '@/components/brand/LanguageControl'; import { useLocale } from '@/context/LocaleContext'; import { earlyBirdAuthClient } from '@/lib/early-birds/auth-client'; import { earlyBirdCopy } from '@/lib/early-birds/copy'; +import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; import BeaconField from './BeaconField'; +import FreeWindowSetup from './FreeWindowSetup'; import SyntheticTeamEntryForm from './SyntheticTeamEntryForm'; type Props = { @@ -18,6 +19,7 @@ type Props = { authError: boolean; providers: { google: boolean; apple: boolean }; syntheticTeamEntryAvailable: boolean; + freeWindow: SerializedEarlyBirdFreeWindowState; }; export default function EarlyBirdLanding(props: Props) { @@ -51,7 +53,6 @@ export default function EarlyBirdLanding(props: Props) {
-
@@ -99,26 +100,22 @@ export default function EarlyBirdLanding(props: Props) { {copy.redeem} ) : ( -

{copy.accessNeeded}

+ )}
) : (
- {(['google', 'apple'] as const).map((provider) => ( + {(['google', 'apple'] as const).filter((provider) => props.providers[provider]).map((provider) => ( ))} {props.syntheticTeamEntryAvailable && ( diff --git a/src/components/early-birds/FreeWindowSetup.tsx b/src/components/early-birds/FreeWindowSetup.tsx new file mode 100644 index 00000000..73d65335 --- /dev/null +++ b/src/components/early-birds/FreeWindowSetup.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { useLocale } from '@/context/LocaleContext'; +import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; +import { earlyBirdCopy } from '@/lib/early-birds/copy'; + +function currentLocalTime(): string { + const now = new Date(); + return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; +} + +function localStartMinute(value: string): number | null { + const match = value.match(/^(\d{2}):(\d{2})$/); + if (!match) return null; + const hour = Number(match[1]); + const minute = Number(match[2]); + if (hour > 23 || minute > 59) return null; + return hour * 60 + minute; +} + +export default function FreeWindowSetup({ state }: { state: SerializedEarlyBirdFreeWindowState }) { + const { locale } = useLocale(); + const copy = earlyBirdCopy[locale]; + const [timeZone, setTimeZone] = useState(null); + const [time, setTime] = useState(currentLocalTime); + const [choosing, setChoosing] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const selectionRequestId = useRef(null); + + useEffect(() => { + setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'); + }, []); + + const formatInstant = (value: string | null) => { + if (!value || !state.timeZone) return null; + return new Intl.DateTimeFormat(locale === 'es' ? 'es-AR' : 'en', { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: state.timeZone, + }).format(new Date(value)); + }; + + async function select(mode: 'now' | 'custom') { + if (!timeZone || busy) return; + const minute = localStartMinute(time); + if (mode === 'custom' && minute === null) return; + selectionRequestId.current ??= crypto.randomUUID(); + setBusy(true); + setError(false); + try { + const response = await fetch('/api/early-birds/free-window', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode, + timeZone, + localStartMinute: mode === 'custom' ? minute : undefined, + selectionRequestId: selectionRequestId.current, + }), + }); + if (response.ok) { + selectionRequestId.current = null; + window.location.reload(); + return; + } + } catch {} + setBusy(false); + setError(true); + } + + const nextWindow = formatInstant(state.nextStart); + const changeAllowed = formatInstant(state.changeAllowedAt); + const mayChoose = !state.configured || state.canChange; + + return ( +
+

{copy.freeTitle}

+

{copy.freeDescription}

+ + {state.configured && nextWindow && ( +

+ {copy.nextFreeWindow} + {nextWindow} +

+ )} + {state.configured && !state.canChange && changeAllowed && ( +

+ {copy.freeScheduleLocked} + {changeAllowed} +

+ )} + + {mayChoose && !choosing && ( +
+ + +
+ )} + + {mayChoose && choosing && ( +
+ +

{copy.freeTimeZone} · {timeZone}

+ +
+ )} + + {error &&

{copy.freeScheduleError}

} +
+ ); +} diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index d8ae4e6e..96101f8a 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -25,6 +25,19 @@ function renderLanding(overrides: Partial , @@ -64,10 +77,10 @@ describe('EarlyBird public landing', () => { .not.toMatch(/adult|child|minor|menor|adulta/i); }); - it('makes an unconfigured provider visibly unavailable', () => { + it('hides an unconfigured provider from the public identity surface', () => { renderLanding({ providers: { google: true, apple: false } }); - expect(screen.getByRole('button', { name: /Continue with Apple/ })).toBeDisabled(); - expect(screen.getByText('Configuration pending')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Continue with Apple/ })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeEnabled(); }); it('reports a rejected social sign-in and re-enables the providers', async () => { diff --git a/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx b/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx new file mode 100644 index 00000000..1739a20e --- /dev/null +++ b/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocaleProvider } from '@/context/LocaleContext'; +import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; + +import FreeWindowSetup from '../FreeWindowSetup'; + +const emptyState: SerializedEarlyBirdFreeWindowState = { + configured: false, + active: false, + timeZone: null, + localStartMinute: null, + selectedAt: null, + changeAllowedAt: null, + canChange: true, + activeStart: null, + activeEnd: null, + nextStart: null, + nextEnd: null, +}; + +function renderSetup(state = emptyState) { + return render( + + + , + ); +} + +describe('Free listening schedule UI', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 400 }))); + }); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('offers immediate or chosen daily Free hours after registration', async () => { + renderSetup(); + expect(screen.getByRole('heading', { name: 'Two hours of Beacon every day' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen free now' })).toBeEnabled()); + expect(screen.getByRole('button', { name: 'Choose another time' })).toBeEnabled(); + }); + + it('submits a chosen wall-clock minute with the browser IANA zone', async () => { + renderSetup(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Choose another time' })).toBeEnabled()); + await userEvent.click(screen.getByRole('button', { name: 'Choose another time' })); + await userEvent.clear(screen.getByLabelText('Start time')); + await userEvent.type(screen.getByLabelText('Start time'), '09:45'); + await userEvent.click(screen.getByRole('button', { name: 'Save my listening time' })); + + expect(fetch).toHaveBeenCalledOnce(); + const init = vi.mocked(fetch).mock.calls[0][1] as RequestInit; + expect(JSON.parse(init.body as string)).toMatchObject({ + mode: 'custom', + localStartMinute: 9 * 60 + 45, + }); + expect(JSON.parse(init.body as string).timeZone).toBeTruthy(); + expect(JSON.parse(init.body as string).selectionRequestId).toMatch(/^[0-9a-f-]{36}$/i); + }); + + it('shows the next window and cooldown without offering a forbidden change', () => { + renderSetup({ + ...emptyState, + configured: true, + timeZone: 'UTC', + localStartMinute: 600, + selectedAt: '2026-08-07T10:00:00.000Z', + changeAllowedAt: '2026-08-14T10:00:00.000Z', + canChange: false, + nextStart: '2026-08-08T10:00:00.000Z', + nextEnd: '2026-08-08T12:00:00.000Z', + }); + + expect(screen.getByText('Your next listening window begins')).toBeInTheDocument(); + expect(screen.getByText('You can change this schedule')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Listen free now' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/early-birds/__tests__/ListenerTransport.test.tsx b/src/components/early-birds/__tests__/ListenerTransport.test.tsx index 263f07a3..2a92c252 100644 --- a/src/components/early-birds/__tests__/ListenerTransport.test.tsx +++ b/src/components/early-birds/__tests__/ListenerTransport.test.tsx @@ -42,7 +42,7 @@ function prepareMedia() { } function renderPlayer( - dropIns = { es: null, en: '/api/drop-ins/en' }, + dropIns: { es: string | null; en: string | null } = { es: null, en: '/api/drop-ins/en' }, initialLocale: UiLocale = 'en', ) { return render( diff --git a/src/lib/early-birds/__tests__/access.test.ts b/src/lib/early-birds/__tests__/access.test.ts new file mode 100644 index 00000000..e441bee9 --- /dev/null +++ b/src/lib/early-birds/__tests__/access.test.ts @@ -0,0 +1,74 @@ +import type { EarlyBirdFreeSchedule, EarlyBirdMembershipProjection } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; + +import { listeningAccessDecision } from '../access'; + +const NOW = new Date('2026-08-07T15:30:00.000Z'); + +function membership(overrides: Partial = {}): EarlyBirdMembershipProjection { + return { + id: '00000000-0000-4000-8000-000000000001', + accountId: 'listener-1', + revision: 1, + commandHash: 'a'.repeat(64), + state: 'ACTIVE', + source: 'PAYPAL', + offerCode: 'EARLY_BIRDS_FOUNDERS_V1', + offerRevision: 1, + effectiveAt: NOW, + paidThrough: null, + graceUntil: null, + provider: 'paypal', + amountMinor: 200, + currency: 'USD', + reasonCode: 'PAYMENT_CONFIRMED', + synthetic: false, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function schedule(): EarlyBirdFreeSchedule { + return { + accountId: 'listener-1', + timeZone: 'America/Argentina/Cordoba', + localStartMinute: 750, + selectedAt: new Date('2026-08-01T12:00:00.000Z'), + changeAllowedAt: new Date('2026-08-08T12:00:00.000Z'), + selectionRequestId: '00000000-0000-4000-8000-000000000001', + revision: 1, + createdAt: NOW, + updatedAt: NOW, + }; +} + +describe('combined Listener access authority', () => { + it('gives an active canonical membership unrestricted priority over Free', () => { + expect(listeningAccessDecision(membership(), schedule(), NOW)).toMatchObject({ + allowed: true, + kind: 'membership', + allowedUntil: null, + }); + }); + + it('allows an active Free window without fabricating a membership', () => { + const decision = listeningAccessDecision(null, schedule(), NOW); + expect(decision).toMatchObject({ + allowed: true, + kind: 'free-window', + allowedUntil: new Date('2026-08-07T17:30:00.000Z'), + }); + expect(decision.membership.projection).toBeNull(); + }); + + it('fails closed outside Free and preserves a canonical paid-through boundary', () => { + expect(listeningAccessDecision(null, schedule(), new Date('2026-08-07T18:00:00.000Z')).allowed) + .toBe(false); + const paidThrough = new Date('2026-08-07T16:00:00.000Z'); + expect(listeningAccessDecision(membership({ paidThrough }), null, NOW)).toMatchObject({ + kind: 'membership', + allowedUntil: paidThrough, + }); + }); +}); diff --git a/src/lib/early-birds/__tests__/auth.test.ts b/src/lib/early-birds/__tests__/auth.test.ts index afff3812..c359f3fb 100644 --- a/src/lib/early-birds/__tests__/auth.test.ts +++ b/src/lib/early-birds/__tests__/auth.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'; import { EARLY_BIRD_SESSION_COOKIE, earlyBirdAuth, + earlyBirdOAuthAvailability, + earlyBirdSocialProviders, } from '../auth'; describe('EarlyBird Better Auth isolation', () => { @@ -15,7 +17,12 @@ describe('EarlyBird Better Auth isolation', () => { expect(options.verification?.modelName).toBe('earlyBirdVerification'); expect(options.advanced?.cookiePrefix).toBe('hb_earlybird'); expect(options.advanced?.cookies?.session_token?.name).toBe(EARLY_BIRD_SESSION_COOKIE); - expect(Object.keys(options.socialProviders ?? {}).sort()).toEqual(['apple', 'google']); + const available = earlyBirdOAuthAvailability(); + expect(Object.keys(options.socialProviders ?? {}).sort()).toEqual( + (Object.keys(available) as Array) + .filter((provider) => available[provider]) + .sort(), + ); expect(options.account?.accountLinking).toMatchObject({ enabled: false, disableImplicitLinking: true, @@ -26,6 +33,17 @@ describe('EarlyBird Better Auth isolation', () => { expect(options.account?.storeAccountCookie).toBe(false); }); + it('installs only providers with complete credential pairs', () => { + const environment = { + EARLY_BIRDS_GOOGLE_CLIENT_ID: 'google-id', + EARLY_BIRDS_GOOGLE_CLIENT_SECRET: 'google-secret', + EARLY_BIRDS_APPLE_CLIENT_ID: 'half-configured-apple', + } as unknown as NodeJS.ProcessEnv; + + expect(earlyBirdOAuthAvailability(environment)).toEqual({ google: true, apple: false }); + expect(Object.keys(earlyBirdSocialProviders(environment))).toEqual(['google']); + }); + it('scrubs provider token material before create and update reach Prisma', async () => { const hooks = earlyBirdAuth().options.databaseHooks?.account; const providerPayload = { diff --git a/src/lib/early-birds/__tests__/free-window.test.ts b/src/lib/early-birds/__tests__/free-window.test.ts new file mode 100644 index 00000000..eb159a52 --- /dev/null +++ b/src/lib/early-birds/__tests__/free-window.test.ts @@ -0,0 +1,158 @@ +import type { EarlyBirdFreeSchedule } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const tx = vi.hoisted(() => ({ + $queryRaw: vi.fn(), + earlyBirdFreeSchedule: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + earlyBirdStreamLease: { updateMany: vi.fn() }, +})); +const prisma = vi.hoisted(() => ({ + $transaction: vi.fn((callback: (client: typeof tx) => unknown) => callback(tx)), + earlyBirdFreeSchedule: { findUnique: vi.fn() }, +})); +vi.mock('@/lib/db', () => ({ prisma })); + +import { + EarlyBirdFreeWindowCooldownError, + canonicalIanaTimeZone, + freeWindowState, + selectEarlyBirdFreeWindow, + wallClockInstant, +} from '../free-window'; + +const NOW = new Date('2026-08-07T15:30:00.000Z'); + +function schedule(overrides: Partial = {}): EarlyBirdFreeSchedule { + return { + accountId: 'listener-1', + timeZone: 'America/Argentina/Cordoba', + localStartMinute: 12 * 60 + 30, + selectedAt: new Date('2026-08-01T12:00:00.000Z'), + changeAllowedAt: new Date('2026-08-08T12:00:00.000Z'), + selectionRequestId: '00000000-0000-4000-8000-000000000001', + revision: 1, + createdAt: new Date('2026-08-01T12:00:00.000Z'), + updatedAt: new Date('2026-08-01T12:00:00.000Z'), + ...overrides, + }; +} + +describe('weekly-locked daily Free window', () => { + beforeEach(() => { + vi.clearAllMocks(); + tx.$queryRaw.mockResolvedValue([{ id: 'listener-1' }]); + tx.earlyBirdFreeSchedule.findUnique.mockResolvedValue(null); + tx.earlyBirdStreamLease.updateMany.mockResolvedValue({ count: 0 }); + }); + + it('canonicalizes IANA zones and rejects non-zones', () => { + expect(canonicalIanaTimeZone('America/Argentina/Cordoba')).toBe('America/Cordoba'); + expect(() => canonicalIanaTimeZone('not/a-zone')).toThrow(/IANA/); + }); + + it('authorizes exactly two real hours and returns the next daily window', () => { + const active = freeWindowState(schedule(), NOW); + expect(active).toMatchObject({ + active: true, + activeStart: new Date('2026-08-07T15:30:00.000Z'), + activeEnd: new Date('2026-08-07T17:30:00.000Z'), + nextStart: new Date('2026-08-08T15:30:00.000Z'), + }); + + expect(freeWindowState(schedule(), active.activeEnd!)).toMatchObject({ + active: false, + nextStart: new Date('2026-08-08T15:30:00.000Z'), + }); + }); + + it('advances a nonexistent spring-forward minute and chooses the first fall-back occurrence', () => { + expect(wallClockInstant( + { year: 2026, month: 3, day: 8 }, + 2 * 60 + 30, + 'America/New_York', + )).toEqual(new Date('2026-03-08T07:00:00.000Z')); + + expect(wallClockInstant( + { year: 2026, month: 11, day: 1 }, + 1 * 60 + 30, + 'America/New_York', + )).toEqual(new Date('2026-11-01T05:30:00.000Z')); + }); + + it('derives Listen free now from server time and locks it for rolling seven days', async () => { + tx.earlyBirdFreeSchedule.create.mockImplementation(({ data }) => schedule({ + ...data, + createdAt: NOW, + updatedAt: NOW, + })); + + const result = await selectEarlyBirdFreeWindow({ + accountId: 'listener-1', + mode: 'now', + timeZone: 'America/Argentina/Cordoba', + selectionRequestId: '00000000-0000-4000-8000-000000000002', + now: NOW, + }); + + expect(result.schedule).toMatchObject({ + localStartMinute: 12 * 60 + 30, + selectedAt: NOW, + changeAllowedAt: new Date('2026-08-14T15:30:00.000Z'), + revision: 1, + }); + expect(result.state.active).toBe(true); + }); + + it('replays the same request ID but rejects a new selection during cooldown', async () => { + const existing = schedule({ + selectionRequestId: '00000000-0000-4000-8000-000000000003', + changeAllowedAt: new Date('2026-08-14T15:30:00.000Z'), + }); + tx.earlyBirdFreeSchedule.findUnique.mockResolvedValue(existing); + + await expect(selectEarlyBirdFreeWindow({ + accountId: 'listener-1', + mode: 'now', + timeZone: existing.timeZone, + selectionRequestId: existing.selectionRequestId, + now: NOW, + })).resolves.toMatchObject({ replayed: true, schedule: existing }); + + await expect(selectEarlyBirdFreeWindow({ + accountId: 'listener-1', + mode: 'custom', + timeZone: existing.timeZone, + localStartMinute: 600, + selectionRequestId: '00000000-0000-4000-8000-000000000004', + now: NOW, + })).rejects.toBeInstanceOf(EarlyBirdFreeWindowCooldownError); + }); + + it('evicts current leases when an unlocked schedule is changed', async () => { + const existing = schedule({ changeAllowedAt: new Date('2026-08-07T15:00:00.000Z') }); + tx.earlyBirdFreeSchedule.findUnique.mockResolvedValue(existing); + tx.earlyBirdFreeSchedule.update.mockImplementation(({ data }) => schedule({ + ...data, + createdAt: existing.createdAt, + updatedAt: NOW, + })); + + await selectEarlyBirdFreeWindow({ + accountId: 'listener-1', + mode: 'custom', + timeZone: 'UTC', + localStartMinute: 900, + selectionRequestId: '00000000-0000-4000-8000-000000000005', + now: NOW, + }); + + expect(tx.earlyBirdStreamLease.updateMany).toHaveBeenCalledWith({ + where: { accountId: 'listener-1', evictedAt: null }, + data: { evictedAt: NOW }, + }); + }); +}); diff --git a/src/lib/early-birds/__tests__/stream-lease.test.ts b/src/lib/early-birds/__tests__/stream-lease.test.ts index 65075592..f04d9931 100644 --- a/src/lib/early-birds/__tests__/stream-lease.test.ts +++ b/src/lib/early-birds/__tests__/stream-lease.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const tx = vi.hoisted(() => ({ $queryRaw: vi.fn(), earlyBirdMembershipProjection: { findUnique: vi.fn() }, + earlyBirdFreeSchedule: { findUnique: vi.fn() }, earlyBirdStreamLease: { findUnique: vi.fn(), findMany: vi.fn(), @@ -22,6 +23,7 @@ const prisma = vi.hoisted(() => ({ update: vi.fn(), }, earlyBirdMembershipProjection: { findUnique: vi.fn() }, + earlyBirdFreeSchedule: { findUnique: vi.fn() }, })); vi.mock('@/lib/db', () => ({ prisma })); @@ -48,6 +50,8 @@ describe('EarlyBird two-device leases', () => { state: 'ACTIVE', paidThrough: null, }); + tx.earlyBirdFreeSchedule.findUnique.mockResolvedValue(null); + prisma.earlyBirdFreeSchedule.findUnique.mockResolvedValue(null); tx.earlyBirdStreamLease.findUnique.mockResolvedValue(null); tx.earlyBirdStreamLease.updateMany.mockResolvedValue({ count: 1 }); prisma.earlyBirdStreamLease.updateMany.mockResolvedValue({ count: 1 }); @@ -134,6 +138,40 @@ describe('EarlyBird two-device leases', () => { }); }); + it('caps a Free lease at the exact listening-window boundary', async () => { + tx.earlyBirdMembershipProjection.findUnique.mockResolvedValue(null); + tx.earlyBirdFreeSchedule.findUnique.mockResolvedValue({ + timeZone: 'UTC', + localStartMinute: 11 * 60, + selectedAt: new Date('2026-08-01T00:00:00.000Z'), + changeAllowedAt: new Date('2026-08-08T00:00:00.000Z'), + }); + tx.earlyBirdStreamLease.findMany.mockResolvedValue([]); + tx.earlyBirdStreamLease.create.mockImplementation(({ data }) => ({ + id: '00000000-0000-4000-8000-000000000003', + ...data, + })); + const issuer: EarlyBirdStreamUrlIssuer = { + issue: vi.fn().mockImplementation(({ leaseExpiresAt }) => ({ + manifestUrl: '/api/early-birds/stream/manifest?leaseId=3', + expiresAt: leaseExpiresAt, + })), + }; + + const nearBoundary = new Date('2026-08-06T12:59:00.000Z'); + const result = await acquireEarlyBirdStreamLease( + 'listener-1', + 'device_abcdefghijklmnopqrstuvwxyz', + nearBoundary, + issuer, + ); + + expect(result.leaseExpiresAt).toEqual(new Date('2026-08-06T13:00:00.000Z')); + expect(tx.earlyBirdStreamLease.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ expiresAt: new Date('2026-08-06T13:00:00.000Z') }), + }); + }); + it('renews an idle prepared lease without raising its eviction priority', async () => { tx.earlyBirdStreamLease.findFirst.mockResolvedValue({ id: '00000000-0000-4000-8000-000000000003', diff --git a/src/lib/early-birds/access.ts b/src/lib/early-birds/access.ts new file mode 100644 index 00000000..e0d115e6 --- /dev/null +++ b/src/lib/early-birds/access.ts @@ -0,0 +1,65 @@ +import type { EarlyBirdFreeSchedule, EarlyBirdMembershipProjection } from '@prisma/client'; + +import { prisma } from '@/lib/db'; + +import { freeWindowState, type EarlyBirdFreeWindowState } from './free-window'; +import { membershipAccessDecision, type EarlyBirdAccessDecision } from './membership'; + +export type EarlyBirdListeningAccess = { + allowed: boolean; + kind: 'membership' | 'free-window' | 'denied'; + allowedUntil: Date | null; + membership: EarlyBirdAccessDecision; + freeWindow: EarlyBirdFreeWindowState; +}; + +function membershipBoundary(projection: EarlyBirdMembershipProjection): Date | null { + if (projection.state === 'GRACE') return projection.graceUntil; + if (projection.state === 'CANCELLED_PENDING_END') return projection.paidThrough; + return projection.paidThrough; +} + +export function listeningAccessDecision( + projection: EarlyBirdMembershipProjection | null, + schedule: EarlyBirdFreeSchedule | null, + now = new Date(), +): EarlyBirdListeningAccess { + const membership = membershipAccessDecision(projection, now); + const freeWindow = freeWindowState(schedule, now); + if (membership.allowed && membership.projection) { + return { + allowed: true, + kind: 'membership', + allowedUntil: membershipBoundary(membership.projection), + membership, + freeWindow, + }; + } + if (freeWindow.active && freeWindow.activeEnd) { + return { + allowed: true, + kind: 'free-window', + allowedUntil: freeWindow.activeEnd, + membership, + freeWindow, + }; + } + return { + allowed: false, + kind: 'denied', + allowedUntil: null, + membership, + freeWindow, + }; +} + +export async function getEarlyBirdListeningAccess( + accountId: string, + now = new Date(), +): Promise { + const [projection, schedule] = await Promise.all([ + prisma.earlyBirdMembershipProjection.findUnique({ where: { accountId } }), + prisma.earlyBirdFreeSchedule.findUnique({ where: { accountId } }), + ]); + return listeningAccessDecision(projection, schedule, now); +} diff --git a/src/lib/early-birds/auth.ts b/src/lib/early-birds/auth.ts index 0627d855..edabca26 100644 --- a/src/lib/early-birds/auth.ts +++ b/src/lib/early-birds/auth.ts @@ -21,19 +21,34 @@ export function earlyBirdTestAuthEnabled(environment: NodeJS.ProcessEnv = proces ); } -export function earlyBirdOAuthAvailability() { +export function earlyBirdOAuthAvailability(environment: NodeJS.ProcessEnv = process.env) { return { google: Boolean( - nonEmpty(process.env.EARLY_BIRDS_GOOGLE_CLIENT_ID) && - nonEmpty(process.env.EARLY_BIRDS_GOOGLE_CLIENT_SECRET), + nonEmpty(environment.EARLY_BIRDS_GOOGLE_CLIENT_ID) && + nonEmpty(environment.EARLY_BIRDS_GOOGLE_CLIENT_SECRET), ), apple: Boolean( - nonEmpty(process.env.EARLY_BIRDS_APPLE_CLIENT_ID) && - nonEmpty(process.env.EARLY_BIRDS_APPLE_CLIENT_SECRET), + nonEmpty(environment.EARLY_BIRDS_APPLE_CLIENT_ID) && + nonEmpty(environment.EARLY_BIRDS_APPLE_CLIENT_SECRET), ), } as const; } +export function earlyBirdSocialProviders(environment: NodeJS.ProcessEnv = process.env) { + const googleId = nonEmpty(environment.EARLY_BIRDS_GOOGLE_CLIENT_ID); + const googleSecret = nonEmpty(environment.EARLY_BIRDS_GOOGLE_CLIENT_SECRET); + const appleId = nonEmpty(environment.EARLY_BIRDS_APPLE_CLIENT_ID); + const appleSecret = nonEmpty(environment.EARLY_BIRDS_APPLE_CLIENT_SECRET); + return { + ...(googleId && googleSecret ? { + google: { clientId: googleId, clientSecret: googleSecret, accessType: 'online' as const }, + } : {}), + ...(appleId && appleSecret ? { + apple: { clientId: appleId, clientSecret: appleSecret }, + } : {}), + }; +} + function authSecret(): string { const configured = nonEmpty(process.env.EARLY_BIRDS_AUTH_SECRET); if (configured) return configured; @@ -78,17 +93,7 @@ function buildEarlyBirdAuth() { secret: authSecret(), trustedOrigins: trustedOrigins(), database: prismaAdapter(prisma, { provider: 'postgresql' }), - socialProviders: { - google: { - clientId: nonEmpty(process.env.EARLY_BIRDS_GOOGLE_CLIENT_ID) ?? 'not-configured', - clientSecret: nonEmpty(process.env.EARLY_BIRDS_GOOGLE_CLIENT_SECRET) ?? 'not-configured', - accessType: 'online', - }, - apple: { - clientId: nonEmpty(process.env.EARLY_BIRDS_APPLE_CLIENT_ID) ?? 'not-configured', - clientSecret: nonEmpty(process.env.EARLY_BIRDS_APPLE_CLIENT_SECRET) ?? 'not-configured', - }, - }, + socialProviders: earlyBirdSocialProviders(), // Email/password is a supervised synthetic-login seam only. The public // product exposes exactly Google and Apple, and the seam is absent // unless both an explicit gate and a separate secret are present. diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 62c0605d..3ff71ac3 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -16,6 +16,16 @@ export const earlyBirdCopy = { enter: 'Entrar al Beacon', redeem: 'Activar mi invitación', accessNeeded: 'Tu cuenta todavía no tiene una membresía activa.', + freeTitle: 'Dos horas de Beacon cada día', + freeDescription: 'Elegí una hora local. Será la misma todos los días y podrás cambiarla después de siete días.', + listenFreeNow: 'Escuchar gratis ahora', + chooseFreeTime: 'Elegir otro horario', + freeStartTime: 'Hora de inicio', + saveFreeTime: 'Guardar mi horario', + freeTimeZone: 'Zona horaria', + nextFreeWindow: 'Tu próxima escucha comienza', + freeScheduleLocked: 'Podrás cambiar este horario', + freeScheduleError: 'No pudimos guardar el horario. Inténtalo nuevamente.', authError: 'No pudimos completar el acceso. Usa el mismo proveedor con el que creaste tu cuenta o contacta a soporte.', privacy: 'Tu cuenta y membresía administran el acceso privado. No creamos historiales personales de escucha.', }, @@ -34,6 +44,16 @@ export const earlyBirdCopy = { enter: 'Enter the Beacon', redeem: 'Activate my invitation', accessNeeded: 'Your account does not have an active membership yet.', + freeTitle: 'Two hours of Beacon every day', + freeDescription: 'Choose a local start time. It repeats daily and can be changed after seven days.', + listenFreeNow: 'Listen free now', + chooseFreeTime: 'Choose another time', + freeStartTime: 'Start time', + saveFreeTime: 'Save my listening time', + freeTimeZone: 'Time zone', + nextFreeWindow: 'Your next listening window begins', + freeScheduleLocked: 'You can change this schedule', + freeScheduleError: 'We could not save the schedule. Please try again.', authError: 'We could not complete sign-in. Use the provider that created your account, or contact support.', privacy: 'Your account and membership manage private access. We do not create personal listening histories.', }, @@ -94,6 +114,7 @@ export const earlyBirdHomeCopy = { master: 'Volumen', signOut: 'Cerrar sesión', active: 'Founding Listener activo', + freeActive: 'Horario Free activo', account: 'Cuenta', }, en: { @@ -127,6 +148,7 @@ export const earlyBirdHomeCopy = { master: 'Volume', signOut: 'Sign out', active: 'Founding Listener active', + freeActive: 'Free window active', account: 'Account', }, } satisfies Record>; diff --git a/src/lib/early-birds/free-window.ts b/src/lib/early-birds/free-window.ts new file mode 100644 index 00000000..5fb2bbe5 --- /dev/null +++ b/src/lib/early-birds/free-window.ts @@ -0,0 +1,312 @@ +import type { EarlyBirdFreeSchedule } from '@prisma/client'; +import { Prisma } from '@prisma/client'; + +import { prisma } from '@/lib/db'; + +export const EARLY_BIRD_FREE_WINDOW_DURATION_MS = 2 * 60 * 60 * 1000; +export const EARLY_BIRD_FREE_WINDOW_CHANGE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; + +type LocalDate = { year: number; month: number; day: number }; +type ZonedParts = LocalDate & { hour: number; minute: number }; +const formatterCache = new Map(); +const windowCache = new Map(); +const MAX_WINDOW_CACHE_ENTRIES = 20_000; + +export type EarlyBirdFreeWindowState = { + configured: boolean; + active: boolean; + timeZone: string | null; + localStartMinute: number | null; + selectedAt: Date | null; + changeAllowedAt: Date | null; + canChange: boolean; + activeStart: Date | null; + activeEnd: Date | null; + nextStart: Date | null; + nextEnd: Date | null; +}; + +export class EarlyBirdFreeWindowCooldownError extends Error { + constructor(readonly changeAllowedAt: Date) { + super('The Free listening window is locked for seven days'); + this.name = 'EarlyBirdFreeWindowCooldownError'; + } +} + +export class EarlyBirdFreeWindowInputError extends Error { + constructor(message: string) { + super(message); + this.name = 'EarlyBirdFreeWindowInputError'; + } +} + +function dateTimeFormatter(timeZone: string, includeOffset = false): Intl.DateTimeFormat { + const key = `${timeZone}:${includeOffset ? 'offset' : 'wall'}`; + const cached = formatterCache.get(key); + if (cached) return cached; + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + ...(includeOffset ? { timeZoneName: 'shortOffset' } : {}), + }); + formatterCache.set(key, formatter); + return formatter; +} + +export function canonicalIanaTimeZone(value: unknown): string { + if (typeof value !== 'string' || value.length < 1 || value.length > 64) { + throw new EarlyBirdFreeWindowInputError('timeZone must be a valid IANA time zone'); + } + try { + return new Intl.DateTimeFormat('en', { timeZone: value }).resolvedOptions().timeZone; + } catch { + throw new EarlyBirdFreeWindowInputError('timeZone must be a valid IANA time zone'); + } +} + +function numericPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): number { + const value = Number(parts.find((part) => part.type === type)?.value); + if (!Number.isInteger(value)) throw new Error(`Unable to resolve ${type} for time zone`); + return value; +} + +export function zonedParts(instant: Date, timeZone: string): ZonedParts { + const parts = dateTimeFormatter(timeZone).formatToParts(instant); + return { + year: numericPart(parts, 'year'), + month: numericPart(parts, 'month'), + day: numericPart(parts, 'day'), + hour: numericPart(parts, 'hour'), + minute: numericPart(parts, 'minute'), + }; +} + +function offsetMinutesAt(instant: Date, timeZone: string): number { + const name = dateTimeFormatter(timeZone, true) + .formatToParts(instant) + .find((part) => part.type === 'timeZoneName')?.value; + if (name === 'GMT' || name === 'UTC') return 0; + const match = name?.match(/^GMT([+-])(\d{1,2})(?::(\d{2}))?$/); + if (!match) throw new Error(`Unable to resolve offset for ${timeZone}`); + const magnitude = Number(match[2]) * 60 + Number(match[3] ?? 0); + return match[1] === '-' ? -magnitude : magnitude; +} + +function addLocalDays(date: LocalDate, days: number): LocalDate { + const shifted = new Date(Date.UTC(date.year, date.month - 1, date.day + days)); + return { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth() + 1, + day: shifted.getUTCDate(), + }; +} + +function sameWallMinute(parts: ZonedParts, target: ZonedParts): boolean { + return parts.year === target.year + && parts.month === target.month + && parts.day === target.day + && parts.hour === target.hour + && parts.minute === target.minute; +} + +/** + * Resolve a local wall-clock minute without relying on the host time zone. + * Ambiguous fall-back times choose the first occurrence. A spring-forward + * gap advances to the first real local minute after the requested time. + */ +export function wallClockInstant( + date: LocalDate, + localStartMinute: number, + timeZone: string, +): Date { + if (!Number.isInteger(localStartMinute) || localStartMinute < 0 || localStartMinute >= 1440) { + throw new EarlyBirdFreeWindowInputError('localStartMinute must be between 0 and 1439'); + } + const baseWallMs = Date.UTC(date.year, date.month - 1, date.day, 0, localStartMinute); + const offsets = new Set(); + for (let sample = -8; sample <= 8; sample += 1) { + offsets.add(offsetMinutesAt(new Date(baseWallMs + sample * 6 * 60 * 60 * 1000), timeZone)); + } + + // Most dates resolve on the first iteration. The bounded scan also gives + // deterministic behavior for rare political offset jumps and skipped days. + for (let shiftedMinute = 0; shiftedMinute <= 1440; shiftedMinute += 1) { + const targetWallMs = baseWallMs + shiftedMinute * 60 * 1000; + const targetDate = new Date(targetWallMs); + const target: ZonedParts = { + year: targetDate.getUTCFullYear(), + month: targetDate.getUTCMonth() + 1, + day: targetDate.getUTCDate(), + hour: targetDate.getUTCHours(), + minute: targetDate.getUTCMinutes(), + }; + const candidates = [...offsets] + .map((offset) => new Date(targetWallMs - offset * 60 * 1000)) + .filter((candidate) => sameWallMinute(zonedParts(candidate, timeZone), target)) + .sort((left, right) => left.getTime() - right.getTime()); + if (candidates[0]) return candidates[0]; + } + throw new Error(`Unable to resolve local Free window in ${timeZone}`); +} + +function windowForDate( + date: LocalDate, + schedule: Pick, +): { start: Date; end: Date } { + const key = `${schedule.timeZone}:${schedule.localStartMinute}:${date.year}-${date.month}-${date.day}`; + const cached = windowCache.get(key); + if (cached) return { start: new Date(cached.startMs), end: new Date(cached.endMs) }; + const start = wallClockInstant(date, schedule.localStartMinute, schedule.timeZone); + const end = new Date(start.getTime() + EARLY_BIRD_FREE_WINDOW_DURATION_MS); + if (windowCache.size >= MAX_WINDOW_CACHE_ENTRIES) windowCache.clear(); + windowCache.set(key, { startMs: start.getTime(), endMs: end.getTime() }); + return { start, end }; +} + +export function freeWindowState( + schedule: EarlyBirdFreeSchedule | null, + now = new Date(), +): EarlyBirdFreeWindowState { + if (!schedule) { + return { + configured: false, + active: false, + timeZone: null, + localStartMinute: null, + selectedAt: null, + changeAllowedAt: null, + canChange: true, + activeStart: null, + activeEnd: null, + nextStart: null, + nextEnd: null, + }; + } + + const todayParts = zonedParts(now, schedule.timeZone); + const today = { year: todayParts.year, month: todayParts.month, day: todayParts.day }; + const candidates = [ + windowForDate(addLocalDays(today, -1), schedule), + windowForDate(today, schedule), + ]; + const activeWindow = candidates + .filter(({ start, end }) => start <= now && now < end) + .sort((left, right) => right.start.getTime() - left.start.getTime())[0] ?? null; + const todayWindow = candidates[1]; + const nextWindow = todayWindow.start > now + ? todayWindow + : windowForDate(addLocalDays(today, 1), schedule); + + return { + configured: true, + active: activeWindow !== null, + timeZone: schedule.timeZone, + localStartMinute: schedule.localStartMinute, + selectedAt: schedule.selectedAt, + changeAllowedAt: schedule.changeAllowedAt, + canChange: schedule.changeAllowedAt <= now, + activeStart: activeWindow?.start ?? null, + activeEnd: activeWindow?.end ?? null, + nextStart: nextWindow.start, + nextEnd: nextWindow.end, + }; +} + +export async function getEarlyBirdFreeWindow( + accountId: string, + now = new Date(), +): Promise<{ schedule: EarlyBirdFreeSchedule | null; state: EarlyBirdFreeWindowState }> { + const schedule = await prisma.earlyBirdFreeSchedule.findUnique({ where: { accountId } }); + return { schedule, state: freeWindowState(schedule, now) }; +} + +export async function selectEarlyBirdFreeWindow(input: { + accountId: string; + mode: 'now' | 'custom'; + timeZone: string; + localStartMinute?: number; + selectionRequestId: string; + now?: Date; +}): Promise<{ schedule: EarlyBirdFreeSchedule; state: EarlyBirdFreeWindowState; replayed: boolean }> { + const now = input.now ?? new Date(); + const timeZone = canonicalIanaTimeZone(input.timeZone); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + .test(input.selectionRequestId)) { + throw new EarlyBirdFreeWindowInputError('selectionRequestId must be a UUID'); + } + const requestedMinute = input.mode === 'now' + ? null + : input.localStartMinute; + if (input.mode === 'custom' && ( + !Number.isInteger(requestedMinute) + || requestedMinute! < 0 + || requestedMinute! >= 1440 + )) { + throw new EarlyBirdFreeWindowInputError('localStartMinute must be between 0 and 1439'); + } + + const outcome = await prisma.$transaction(async (tx) => { + const accounts = await tx.$queryRaw>( + Prisma.sql`SELECT "id" FROM "early_bird_users" WHERE "id" = ${input.accountId} FOR UPDATE`, + ); + if (accounts.length !== 1) throw new EarlyBirdFreeWindowInputError('Listener account does not exist'); + + const existing = await tx.earlyBirdFreeSchedule.findUnique({ where: { accountId: input.accountId } }); + if (existing?.selectionRequestId === input.selectionRequestId) { + return { schedule: existing, replayed: true }; + } + if (existing && existing.changeAllowedAt > now) { + throw new EarlyBirdFreeWindowCooldownError(existing.changeAllowedAt); + } + + const localStartMinute = input.mode === 'now' + ? (() => { + const local = zonedParts(now, timeZone); + return local.hour * 60 + local.minute; + })() + : requestedMinute!; + const changeAllowedAt = new Date(now.getTime() + EARLY_BIRD_FREE_WINDOW_CHANGE_COOLDOWN_MS); + const data = { + timeZone, + localStartMinute, + selectedAt: now, + changeAllowedAt, + selectionRequestId: input.selectionRequestId, + revision: (existing?.revision ?? 0) + 1, + }; + const schedule = existing + ? await tx.earlyBirdFreeSchedule.update({ where: { accountId: input.accountId }, data }) + : await tx.earlyBirdFreeSchedule.create({ data: { accountId: input.accountId, ...data } }); + + // A changed schedule may move the authorization boundary immediately. + // Force every device to obtain a grant checked against the new window. + if (existing) { + await tx.earlyBirdStreamLease.updateMany({ + where: { accountId: input.accountId, evictedAt: null }, + data: { evictedAt: now }, + }); + } + return { schedule, replayed: false }; + }); + return { ...outcome, state: freeWindowState(outcome.schedule, now) }; +} + +export function serializeFreeWindowState(state: EarlyBirdFreeWindowState) { + return { + ...state, + selectedAt: state.selectedAt?.toISOString() ?? null, + changeAllowedAt: state.changeAllowedAt?.toISOString() ?? null, + activeStart: state.activeStart?.toISOString() ?? null, + activeEnd: state.activeEnd?.toISOString() ?? null, + nextStart: state.nextStart?.toISOString() ?? null, + nextEnd: state.nextEnd?.toISOString() ?? null, + }; +} + +export type SerializedEarlyBirdFreeWindowState = ReturnType; diff --git a/src/lib/early-birds/stream.ts b/src/lib/early-birds/stream.ts index 64c9fad4..b99fffc0 100644 --- a/src/lib/early-birds/stream.ts +++ b/src/lib/early-birds/stream.ts @@ -3,7 +3,7 @@ import { createHmac } from 'node:crypto'; import { Prisma } from '@prisma/client'; import { prisma } from '@/lib/db'; -import { membershipAccessDecision } from './membership'; +import { listeningAccessDecision } from './access'; export const EARLY_BIRD_MAX_STREAM_DEVICES = 2; export const EARLY_BIRD_LEASE_TTL_MS = 3 * 60 * 1000; @@ -117,6 +117,11 @@ export class EarlyBirdLeaseInactiveError extends Error { } } +function cappedLeaseExpiry(now: Date, allowedUntil: Date | null): Date { + const ttlExpiry = new Date(now.getTime() + EARLY_BIRD_LEASE_TTL_MS); + return allowedUntil && allowedUntil < ttlExpiry ? allowedUntil : ttlExpiry; +} + export type EarlyBirdOriginConfig = { origin: string; artifactId: string; @@ -229,11 +234,12 @@ export async function authorizeEarlyBirdStreamLease( leaseId: string, now = new Date(), ) { - const [projection, lease] = await Promise.all([ + const [projection, schedule, lease] = await Promise.all([ prisma.earlyBirdMembershipProjection.findUnique({ where: { accountId } }), + prisma.earlyBirdFreeSchedule.findUnique({ where: { accountId } }), prisma.earlyBirdStreamLease.findFirst({ where: { id: leaseId, accountId } }), ]); - if (!membershipAccessDecision(projection, now).allowed) { + if (!listeningAccessDecision(projection, schedule, now).allowed) { throw new EarlyBirdAccessDeniedError(); } if (!lease) throw new EarlyBirdLeaseInactiveError('missing'); @@ -250,7 +256,6 @@ export async function acquireEarlyBirdStreamLease( evictOldest = true, ): Promise { const deviceDigest = earlyBirdDeviceDigest(deviceId); - const leaseExpiresAt = new Date(now.getTime() + EARLY_BIRD_LEASE_TTL_MS); const lease = await prisma.$transaction(async (tx) => { const accountRows = await tx.$queryRaw>( @@ -258,12 +263,16 @@ export async function acquireEarlyBirdStreamLease( ); if (accountRows.length !== 1) throw new EarlyBirdAccessDeniedError(); - const projection = await tx.earlyBirdMembershipProjection.findUnique({ - where: { accountId }, - }); - if (!membershipAccessDecision(projection, now).allowed) { + const [projection, schedule] = await Promise.all([ + tx.earlyBirdMembershipProjection.findUnique({ where: { accountId } }), + tx.earlyBirdFreeSchedule.findUnique({ where: { accountId } }), + ]); + const access = listeningAccessDecision(projection, schedule, now); + if (!access.allowed) { throw new EarlyBirdAccessDeniedError(); } + const leaseExpiresAt = cappedLeaseExpiry(now, access.allowedUntil); + if (leaseExpiresAt <= now) throw new EarlyBirdAccessDeniedError(); const previous = await tx.earlyBirdStreamLease.findUnique({ where: { accountId_deviceDigest: { accountId, deviceDigest } }, @@ -300,7 +309,7 @@ export async function acquireEarlyBirdStreamLease( : await tx.earlyBirdStreamLease.create({ data: { accountId, deviceDigest, lastSeenAt: prioritySeenAt, expiresAt: leaseExpiresAt }, }); - return { current, evictedLeaseId: evicted[0]?.id ?? null }; + return { current, evictedLeaseId: evicted[0]?.id ?? null, leaseExpiresAt }; }); try { @@ -308,11 +317,11 @@ export async function acquireEarlyBirdStreamLease( accountId, leaseId: lease.current.id, issuedAt: now, - leaseExpiresAt, + leaseExpiresAt: lease.leaseExpiresAt, }); return { leaseId: lease.current.id, - leaseExpiresAt, + leaseExpiresAt: lease.leaseExpiresAt, evictedLeaseId: lease.evictedLeaseId, stream, }; @@ -341,35 +350,39 @@ export async function heartbeatEarlyBirdStreamLease( issuer = earlyBirdStreamUrlIssuer(), refreshPriority = true, ): Promise<{ leaseExpiresAt: Date; stream: StreamUrlGrant }> { - const leaseExpiresAt = new Date(now.getTime() + EARLY_BIRD_LEASE_TTL_MS); const lease = await prisma.$transaction(async (tx) => { - const projection = await tx.earlyBirdMembershipProjection.findUnique({ - where: { accountId }, - }); - if (!membershipAccessDecision(projection, now).allowed) { + const [projection, schedule] = await Promise.all([ + tx.earlyBirdMembershipProjection.findUnique({ where: { accountId } }), + tx.earlyBirdFreeSchedule.findUnique({ where: { accountId } }), + ]); + const access = listeningAccessDecision(projection, schedule, now); + if (!access.allowed) { throw new EarlyBirdAccessDeniedError(); } + const leaseExpiresAt = cappedLeaseExpiry(now, access.allowedUntil); + if (leaseExpiresAt <= now) throw new EarlyBirdAccessDeniedError(); const current = await tx.earlyBirdStreamLease.findFirst({ where: { id: leaseId, accountId }, }); if (!current) throw new EarlyBirdLeaseInactiveError('missing'); if (current.evictedAt !== null) throw new EarlyBirdLeaseInactiveError('evicted'); if (current.expiresAt <= now) throw new EarlyBirdLeaseInactiveError('expired'); - return tx.earlyBirdStreamLease.update({ + const updated = await tx.earlyBirdStreamLease.update({ where: { id: current.id }, data: refreshPriority ? { lastSeenAt: now, expiresAt: leaseExpiresAt } : { expiresAt: leaseExpiresAt }, }); + return { updated, leaseExpiresAt }; }); const stream = await issuer.issue({ accountId, - leaseId: lease.id, + leaseId: lease.updated.id, issuedAt: now, - leaseExpiresAt, + leaseExpiresAt: lease.leaseExpiresAt, }); - return { leaseExpiresAt, stream }; + return { leaseExpiresAt: lease.leaseExpiresAt, stream }; } /** From 4b9e0fa224bbd1d4fb733320bfe8751c13ff0c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 04:16:35 -0300 Subject: [PATCH 084/376] fix(listener): harden public oauth mutations --- docs/architecture/EARLY_BIRDS_LISTENER.md | 15 ++++++-- .../auth/[...all]/__tests__/route.test.ts | 36 +++++++++++++++++++ .../api/early-birds/auth/[...all]/route.ts | 26 ++++++++++++++ src/lib/early-birds/__tests__/auth.test.ts | 28 +++++++++++++++ src/lib/early-birds/auth.ts | 20 +++++++++++ 5 files changed, 122 insertions(+), 3 deletions(-) diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index f2ceefc2..7493edb1 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -11,7 +11,8 @@ Better Auth uses dedicated `early_bird_*` tables and the `hb_earlybird_session` offers exactly Google and Apple. 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. -The test suite locks this pre-adapter invariant. +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: @@ -25,6 +26,13 @@ corresponding provider is absent from the public UI and auth runtime. Public ngi auth namespace while continuing to block synthetic login, invitations 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. + ## Canonical membership boundary Byte-exact copies live in `contracts/early-bird-authority/v1` and @@ -125,8 +133,9 @@ HLS source and lease are prepared without autoplay. One click then starts the in 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. The live pause control produces silence and its resume seeks to the current live edge. No -AudioContext, LiveKit, chat or session-event behavior is changed, and the initial gain remains native 1.0. +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 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 index f27f6721..79b62b03 100644 --- a/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts +++ b/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts @@ -10,6 +10,9 @@ describe('EarlyBird public auth route', () => { beforeEach(() => { vi.clearAllMocks(); vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_AUTH_BASE_URL', 'https://listen.example.test'); + vi.stubEnv('EARLY_BIRDS_TRUSTED_ORIGINS', 'https://listen.example.test,https://earlybirds-staging.example.test'); + handler.mockResolvedValue(new Response(null, { status: 204 })); }); afterEach(() => vi.unstubAllEnvs()); @@ -23,4 +26,37 @@ describe('EarlyBird public auth route', () => { expect(response.headers.get('cache-control')).toBe('private, no-store'); expect(handler).not.toHaveBeenCalled(); }); + + it.each([null, 'https://attacker.invalid'])('rejects an untrusted mutation origin: %s', async (origin) => { + const request = new NextRequest( + 'https://listen.example.test/api/early-birds/auth/sign-in/social', + { + method: 'POST', + headers: origin ? { origin } : {}, + body: '{}', + }, + ); + const response = await POST(request); + expect(response.status).toBe(403); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('allows an exact trusted Listener origin', async () => { + const request = new NextRequest( + 'https://listen.example.test/api/early-birds/auth/sign-in/social', + { method: 'POST', headers: { origin: 'https://listen.example.test' }, body: '{}' }, + ); + expect((await POST(request)).status).toBe(204); + expect(handler).toHaveBeenCalledOnce(); + }); + + it('lets the provider callback reach its one-time state verifier', async () => { + const request = new NextRequest( + 'https://listen.example.test/api/early-birds/auth/callback/apple', + { method: 'POST', headers: { origin: 'https://appleid.apple.com' }, body: '{}' }, + ); + expect((await POST(request)).status).toBe(204); + expect(handler).toHaveBeenCalledOnce(); + }); }); diff --git a/src/app/api/early-birds/auth/[...all]/route.ts b/src/app/api/early-birds/auth/[...all]/route.ts index 127bb6ed..bbdde4ec 100644 --- a/src/app/api/early-birds/auth/[...all]/route.ts +++ b/src/app/api/early-birds/auth/[...all]/route.ts @@ -17,6 +17,23 @@ function hiddenSyntheticEmailEndpoint(request: NextRequest): Response | null { }); } +function trustedMutationOrigin(request: NextRequest): boolean { + const origin = request.headers.get('origin'); + if (!origin) return false; + const configured = [ + process.env.EARLY_BIRDS_AUTH_BASE_URL, + ...(process.env.EARLY_BIRDS_TRUSTED_ORIGINS ?? '').split(','), + ] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + const allowed = configured.length > 0 ? configured : [request.nextUrl.origin]; + return allowed.includes(origin); +} + +function oauthCallback(request: NextRequest): boolean { + return request.nextUrl.pathname.includes('/api/early-birds/auth/callback/'); +} + export function GET(request: NextRequest): Promise | Response { if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); return earlyBirdAuth().handler(request); @@ -26,5 +43,14 @@ export function POST(request: NextRequest): Promise | Response { if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); const hidden = hiddenSyntheticEmailEndpoint(request); if (hidden) return hidden; + // Provider callbacks are protected by the one-time state/cookie verifier + // and Apple uses a cross-site form_post. Every browser-initiated mutation + // must instead originate from one of the exact Listener hosts. + if (!oauthCallback(request) && !trustedMutationOrigin(request)) { + return Response.json({ error: 'Invalid request origin.' }, { + status: 403, + headers: { 'Cache-Control': 'private, no-store' }, + }); + } return earlyBirdAuth().handler(request); } diff --git a/src/lib/early-birds/__tests__/auth.test.ts b/src/lib/early-birds/__tests__/auth.test.ts index c359f3fb..4c6088dc 100644 --- a/src/lib/early-birds/__tests__/auth.test.ts +++ b/src/lib/early-birds/__tests__/auth.test.ts @@ -82,4 +82,32 @@ describe('EarlyBird Better Auth isolation', () => { expect(JSON.stringify(outcome)).not.toContain('identity-secret'); } }); + + it('does not retain IP addresses or user agents in Listener sessions', async () => { + const hooks = earlyBirdAuth().options.databaseHooks?.session; + const sessionPayload = { + id: 'session-1', + token: 'opaque-session-token', + userId: 'listener-1', + expiresAt: new Date('2026-09-07T00:00:00.000Z'), + ipAddress: '203.0.113.42', + userAgent: 'Synthetic Browser/1.0', + createdAt: new Date('2026-08-07T00:00:00.000Z'), + updatedAt: new Date('2026-08-07T00:00:00.000Z'), + }; + + const created = await hooks?.create?.before?.(sessionPayload); + const updated = await hooks?.update?.before?.(sessionPayload); + + for (const outcome of [created, updated]) { + expect(outcome).not.toBe(false); + expect(outcome && 'data' in outcome ? outcome.data : null).toMatchObject({ + id: 'session-1', + ipAddress: null, + userAgent: null, + }); + expect(JSON.stringify(outcome)).not.toContain('203.0.113.42'); + expect(JSON.stringify(outcome)).not.toContain('Synthetic Browser'); + } + }); }); diff --git a/src/lib/early-birds/auth.ts b/src/lib/early-birds/auth.ts index edabca26..6566887d 100644 --- a/src/lib/early-birds/auth.ts +++ b/src/lib/early-birds/auth.ts @@ -82,6 +82,14 @@ function scrubOAuthTokens>(account: T): T { }; } +function scrubSessionMetadata>(session: T): T { + return { + ...session, + ipAddress: null, + userAgent: null, + }; +} + function buildEarlyBirdAuth() { const testAuth = earlyBirdTestAuthEnabled(); const baseURL = nonEmpty(process.env.EARLY_BIRDS_AUTH_BASE_URL); @@ -137,6 +145,18 @@ function buildEarlyBirdAuth() { }, }, }, + session: { + create: { + async before(session) { + return { data: scrubSessionMetadata(session) }; + }, + }, + update: { + async before(session) { + return { data: scrubSessionMetadata(session) }; + }, + }, + }, }, advanced: { cookiePrefix: EARLY_BIRD_COOKIE_PREFIX, From 575b75aae5609b1813485d955a3e8ea753018084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 04:20:28 -0300 Subject: [PATCH 085/376] fix(listener): keep logout available outside free hours --- src/app/globals.css | 9 +++++++++ src/components/early-birds/EarlyBirdLanding.tsx | 12 ++++++++++++ .../early-birds/__tests__/EarlyBirdLanding.test.tsx | 6 +++++- src/lib/early-birds/copy.ts | 2 ++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/app/globals.css b/src/app/globals.css index cac20a7d..5e5c6f19 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1709,6 +1709,15 @@ body { } .listener-free-window__chooser p { color: var(--text-muted); font-size: 0.72rem; } +.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-footer { max-width: 45rem; padding: 1.5rem 0; diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index 5a6d0f6b..9608d032 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -48,6 +48,11 @@ export default function EarlyBirdLanding(props: Props) { setError(true); } + async function signOut() { + await earlyBirdAuthClient.signOut(); + window.location.assign('/early-birds'); + } + return (
@@ -102,6 +107,13 @@ export default function EarlyBirdLanding(props: Props) { ) : ( )} +
) : (
diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index 96101f8a..3a778ff2 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -7,8 +7,9 @@ import { LocaleProvider } from '@/context/LocaleContext'; import { earlyBirdCopy } from '@/lib/early-birds/copy'; const signInSocial = vi.hoisted(() => vi.fn()); +const signOut = vi.hoisted(() => vi.fn()); vi.mock('@/lib/early-birds/auth-client', () => ({ - earlyBirdAuthClient: { signIn: { social: signInSocial } }, + earlyBirdAuthClient: { signIn: { social: signInSocial }, signOut }, })); vi.mock('@/components/brand/LanguageControl', () => ({ default: () =>
})); vi.mock('@/components/brand/BrandLockup', () => ({ default: () => Harmonic Beacon })); @@ -48,6 +49,8 @@ describe('EarlyBird public landing', () => { beforeEach(() => { signInSocial.mockReset(); signInSocial.mockResolvedValue({ error: null }); + signOut.mockReset(); + signOut.mockResolvedValue({ error: null }); window.localStorage.clear(); }); afterEach(() => cleanup()); @@ -101,5 +104,6 @@ describe('EarlyBird public landing', () => { expect.objectContaining({ href: expect.stringMatching(/\/early-birds$/) }), ])); expect(screen.queryByRole('button', { name: 'Continue with Google' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Sign out' })).toBeEnabled(); }); }); diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 3ff71ac3..880fc45b 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -13,6 +13,7 @@ export const earlyBirdCopy = { signingIn: 'Abriendo acceso…', providerSoon: 'Configuración pendiente', signedIn: 'Tu cuenta Listener está lista.', + signOut: 'Cerrar sesión', enter: 'Entrar al Beacon', redeem: 'Activar mi invitación', accessNeeded: 'Tu cuenta todavía no tiene una membresía activa.', @@ -41,6 +42,7 @@ export const earlyBirdCopy = { signingIn: 'Opening access…', providerSoon: 'Configuration pending', signedIn: 'Your Listener account is ready.', + signOut: 'Sign out', enter: 'Enter the Beacon', redeem: 'Activate my invitation', accessNeeded: 'Your account does not have an active membership yet.', From 637c5e014620581a100f976cdbe68ff56cd68a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 04:33:31 -0300 Subject: [PATCH 086/376] docs(listener): record free identity staging evidence --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 1308b14e..79a7e4ce 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,5 +1,40 @@ # EarlyBirds isolated staging runtime +## 2026-08-07 registered Free and identity hardening + +The isolated Listener runs application SHA +`575b75aae5609b1813485d955a3e8ea753018084` and schema +`20260807070000_early_bird_free_schedule`. The global Free for All override is +ON after the acceptance exercises below; it remains 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 From e0bc3291a27f864b3e22cc5c52563e9bb6756a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 04:44:19 -0300 Subject: [PATCH 087/376] docs(listener): refresh public acceptance runbook --- .../operations/EARLY_BIRDS_FREE_ACCEPTANCE.md | 188 ++++++++++++------ 1 file changed, 123 insertions(+), 65 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md index bfdf117b..e68743ab 100644 --- a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md +++ b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md @@ -1,75 +1,133 @@ -# EarlyBirds Free acceptance +# Founding Listener public acceptance -This sheet records the human release gate for the Free-first EarlyBirds MVP. -It never authorizes paid checkout, an app-store release, production promotion, -or an acoustic choice. Use only the isolated staging hosts and synthetic -`@e2e.invalid` identities. +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. -## Fixed scope +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. -- Listener: `https://earlybirds-staging.harmonicbeacon.com` +## Fixed candidate + +- Listener application: `575b75aae5609b1813485d955a3e8ea753018084` +- Documentation head: `637c5e014620581a100f976cdbe68ff56cd68a31` +- Listener schema: `20260807070000_early_bird_free_schedule` - Stream origin: `https://stream.harmonicbeacon.com` -- Membership path: signed one-use Free invitation only -- PayPal and Mercado Pago: disabled and expected to fail closed -- Google Play and Apple App Store: post-MVP +- Approved intro languages: Spanish and English +- Ordinary Free access: registered account plus one recurring two-hour daily + window, changeable again after seven days +- Founding Listener access: canonical active membership projection +- Connections: at most two active devices per account +- Free for All: an independent reversible operator override, currently ON +- 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 -Do not paste the invitation or temporary team code into GitHub, test notes or -chat. Retrieve them directly on `mona` through the protected files documented -in `EARLY_BIRDS_STAGING_PREVIEW.md`. Use a new browser profile and a unique -`@e2e.invalid` address. The supervised invitation is one-use. +The deployed application SHA deliberately differs from the documentation-only +branch head. Health must attest the application SHA above. ## Automated preflight -Record the exact webapp and authority SHAs, then require: - -- webapp PR checks green; -- authority PR checks green; -- Listener, origin, both PostgreSQL databases and authority API/worker healthy; -- staging app/origin return HTTP 200; -- `live.harmonicbeacon.com/api/health` remains HTTP 200; -- canonical Free lifecycle smoke passes redeem, replay isolation, two-device - eviction, revocation projection and post-revocation stream denial; -- paid checkout remains disabled. - -## Human browser flow - -For each row in the result table: - -1. Open the signed invitation in a clean profile. -2. Confirm the page names staging and never displays a paid checkout. -3. Switch ES → EN → ES. Confirm labels change, `lang` follows the selection, - no text clips and no horizontal scrolling appears. -4. Enter a synthetic display name, unique `@e2e.invalid` address and the - protected team code. Confirm the code clears after submission and is absent - from local/session storage. -5. Activate the invitation. Confirm the private Listener shows the chosen name - and an active `FREE` membership, with no camera or microphone prompt and no - LiveKit/event controls. -6. Reload and open a second device/profile. Both must retain access. Open a - third device/profile with the same account only during the supervised - lifecycle test; the oldest active lease must be displaced truthfully. -7. Exercise Beacon pause/resume and background/foreground recovery, then play, - pause, seek and finish the approved EN intro. Confirm its natural end hands - off to the current Beacon live edge. ES must state that no intro is - available; it must not reuse the obsolete low-gain file. -8. Revoke the disposable invitation through the authority. Existing playback - must stop at the next bounded authorization check, a heartbeat must deny - access, and reloading the private home must return to membership-required. - -## Result record - -| Date/time | Webapp SHA | Authority SHA | Device / OS | Browser | Locale | Free entry | 2→3 device | Revocation | No media permission | Audio | Tester / notes | +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; +- 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. Confirm a configured account sees either **Listen free now** or a local-time + selection. Choose **Listen free now** for the first human pass. +4. 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. +5. Begin **With introduction**. The intro may pause and seek. Confirm its + natural completion hands off to the current Beacon live edge. +6. Stop, choose **Beacon only** and listen again. The Beacon exposes Stop but + no Pause or Seek; listening again rejoins the current live point. +7. 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. +8. 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. +9. 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. +10. Confirm logout is available both during and outside the Free window. After + logout, the Listener session endpoint must be anonymous. +11. At the exact Free-window boundary, 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. + +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. + +## Free for All operator flow + +1. With the override OFF, verify an anonymous lease and manifest fail closed. +2. Enable only the isolated Listener override and recreate only that app. +3. Verify health, then confirm anonymous playback works without creating an + account, Free schedule, membership or Purchase. +4. 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 | Free schedule | Intro / handoff | Beacon live edge | 2→3 devices | Reconnect | Logout | Tester / notes | |---|---|---|---|---|---|---|---|---|---|---|---| -| _pending_ | _pending_ | _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_ | _pending_ | iPhone physical | Safari | ES/EN | PENDING | PENDING | PENDING | PENDING | PENDING | | - -## Acceptance and rollback - -Only Nico records the Free human acceptance decision. Failed rows stay failed -or blocked; they are not averaged into a pass. If staging degrades, close the -Listener/team-entry switches and run the isolated rollback from -`EARLY_BIRDS_STAGING_PREVIEW.md`. Retain the preview databases for audit and do -not touch the event runtime. +| _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 | | +| _pending_ | iPhone physical | Safari | ES/EN | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | PENDING | | + +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 with 2FA, a primary App ID, Services ID, key/team identifiers, private +key and generated client-secret JWT for the documented callback. It must remain +absent until all material exists. + +If the isolated Listener degrades, restore root-only +`/etc/harmonic-beacon/earlybirds-preview.env.pre-575b75a`, select Listener image +`d7ed952`, retain both preview databases and origin media, and run the preview +health smoke. Do not touch the event runtime. From d4a79862c22472ee8c13b7607fd59c729bb28447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 04:44:53 -0300 Subject: [PATCH 088/376] docs(listener): avoid self-referential release hash --- docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md index e68743ab..83f2c048 100644 --- a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md +++ b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md @@ -12,7 +12,6 @@ device/browser, result and a non-sensitive symptom. ## Fixed candidate - Listener application: `575b75aae5609b1813485d955a3e8ea753018084` -- Documentation head: `637c5e014620581a100f976cdbe68ff56cd68a31` - Listener schema: `20260807070000_early_bird_free_schedule` - Stream origin: `https://stream.harmonicbeacon.com` - Approved intro languages: Spanish and English @@ -26,8 +25,8 @@ device/browser, result and a non-sensitive symptom. - Apple: absent until Apple Developer Program credentials and 2FA are supplied - Event application and `live.harmonicbeacon.com`: out of scope and unchanged -The deployed application SHA deliberately differs from the documentation-only -branch head. Health must attest the application SHA above. +Documentation may advance without rebuilding the application. Health must +attest the application SHA above rather than the current branch head. ## Automated preflight From aba205789323f089427d7982825c37d04dd336dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 04:56:40 -0300 Subject: [PATCH 089/376] test(listener): add registered free runtime smoke --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 16 ++ ops/early-birds-preview/package.json | 2 +- .../test/preview-contract.test.mjs | 15 ++ .../registered-free-smoke.sh | 146 ++++++++++++++++++ 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100755 scripts/early-birds-preview/registered-free-smoke.sh diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 79a7e4ce..33e46465 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -370,6 +370,22 @@ start minute. Active leases are capped at the end of the current window and a schedule change evicts existing leases. The global Free for All switch remains an independent operator override and never creates or edits this schedule. +With Free for All temporarily disabled, the protected staging host can exercise +the complete registered-Free boundary using only unique `@e2e.invalid` +identities: + +```bash +sudo scripts/early-birds-preview/registered-free-smoke.sh \ + /etc/harmonic-beacon/earlybirds-preview.env +``` + +The smoke keeps the synthetic bearer in a mode-0600 curl config, selects a +future schedule, proves idempotent replay and the seven-day cooldown, selects +Listen now for a second account, verifies the exact two-hour lease cap, opens +three devices, observes oldest-device displacement and fetches a valid signed +manifest. It deliberately does not toggle Free for All itself; the operator +must restore the configured public mode and rerun health after the test. + ### Operator-controlled Free for All `EARLY_BIRDS_FREE_FOR_ALL` is independent from the Listener kill switch. Set it diff --git a/ops/early-birds-preview/package.json b/ops/early-birds-preview/package.json index f6ce0e76..b6be2527 100644 --- a/ops/early-birds-preview/package.json +++ b/ops/early-birds-preview/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "check": "node --check ../../scripts/early-birds-preview/validate.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/rehearse-migration.sh ../../scripts/early-birds-preview/health-smoke.sh", + "check": "node --check ../../scripts/early-birds-preview/validate.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/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/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index a9b5d491..d7afc3c5 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -252,3 +252,18 @@ test('canonical Free smoke keeps credentials out of argv and verifies the entitl assert.match(source, /trap 'rm -rf "\$temporary"'/); assert.doesNotMatch(source, /echo[^\n]*(login_secret|invitation_token)/); }); + +test('registered Free smoke covers schedule 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, /"mode":"custom"/); + assert.match(source, /"mode":"now"/); + assert.match(source, /test "\$cooldown_status" = 409/); + assert.match(source, /for ordinal in 1 2 3/); + assert.match(source, /evictedAnotherDevice/); + assert.match(source, /\.reason == "displaced"/); + assert.match(source, /api\/early-birds\/stream\/manifest/); + assert.match(source, /trap 'rm -rf "\$temporary"'/); + assert.doesNotMatch(source, /echo[^\n]*login_secret/); +}); 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..3874eb54 --- /dev/null +++ b/scripts/early-birds-preview/registered-free-smoke.sh @@ -0,0 +1,146 @@ +#!/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) ;; + *) preview_fail "BASE_URL must be the protected EarlyBirds staging 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)-$$" + +printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' \ + "$login_secret" >"$temporary/login.curl" + +synthetic_login() { + identity=$1 + cookie_jar=$2 + login_body=$3 + printf '{"name":"Registered Free smoke","email":"%s","authOnly":true}' \ + "$identity" >"$login_body" + login_status=$(curl --silent --show-error --output "$login_body.response" \ + --write-out '%{http_code}' --request POST --config "$temporary/login.curl" \ + --cookie-jar "$cookie_jar" --data-binary @"$login_body" \ + "$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' "$login_body.response" >/dev/null || \ + preview_fail "synthetic auth-only login response is invalid" +} + +post_schedule() { + cookie_jar=$1 + request_body=$2 + output_file=$3 + curl --silent --show-error --output "$output_file" --write-out '%{http_code}' \ + --request POST --header "Origin: $base_url" --header 'Content-Type: application/json' \ + --cookie "$cookie_jar" --data-binary @"$request_body" \ + "$base_url/api/early-birds/free-window" +} + +custom_email="free-custom-$run_id@e2e.invalid" +custom_cookies="$temporary/custom.cookies" +synthetic_login "$custom_email" "$custom_cookies" "$temporary/custom-login.json" + +initial_status=$(curl --silent --show-error --output "$temporary/custom-initial.json" \ + --write-out '%{http_code}' --cookie "$custom_cookies" \ + "$base_url/api/early-birds/free-window") +test "$initial_status" = 200 || preview_fail "initial Free schedule returned HTTP $initial_status" +jq -e '.state.configured == false and .state.canChange == true' \ + "$temporary/custom-initial.json" >/dev/null || \ + preview_fail "new account Free schedule is not empty/changeable" + +custom_minute=$(((($(date -u +%s) / 60) + 180) % 1440)) +custom_request_id=$(tr -d '\r\n' "$temporary/custom-select.json" + +custom_status=$(post_schedule "$custom_cookies" "$temporary/custom-select.json" "$temporary/custom-selected.json") +test "$custom_status" = 200 || preview_fail "custom Free selection returned HTTP $custom_status" +jq -e --argjson expected_minute "$custom_minute" ' + .replayed == false and + .state.configured == true and + .state.active == false and + .state.timeZone == "UTC" and + .state.localStartMinute == $expected_minute and + .state.canChange == false and + (.state.nextStart | type == "string") and + (.state.changeAllowedAt | type == "string") +' "$temporary/custom-selected.json" >/dev/null || preview_fail "custom Free selection state is invalid" + +replay_status=$(post_schedule "$custom_cookies" "$temporary/custom-select.json" "$temporary/custom-replayed.json") +test "$replay_status" = 200 || preview_fail "idempotent Free selection replay returned HTTP $replay_status" +jq -e '.replayed == true' "$temporary/custom-replayed.json" >/dev/null || \ + preview_fail "Free selection replay was not idempotent" + +cooldown_request_id=$(tr -d '\r\n' "$temporary/custom-change.json" +cooldown_status=$(post_schedule "$custom_cookies" "$temporary/custom-change.json" "$temporary/custom-cooldown.json") +test "$cooldown_status" = 409 || preview_fail "Free schedule cooldown returned HTTP $cooldown_status" +jq -e '.error == "Free listening schedule is locked." and (.changeAllowedAt | type == "string")' \ + "$temporary/custom-cooldown.json" >/dev/null || \ + preview_fail "Free schedule cooldown response is invalid" + +now_email="free-now-$run_id@e2e.invalid" +now_cookies="$temporary/now.cookies" +synthetic_login "$now_email" "$now_cookies" "$temporary/now-login.json" +now_request_id=$(tr -d '\r\n' "$temporary/now-select.json" +now_status=$(post_schedule "$now_cookies" "$temporary/now-select.json" "$temporary/now-selected.json") +test "$now_status" = 200 || preview_fail "Listen-now Free selection returned HTTP $now_status" +jq -e ' + .replayed == false and + .state.active == true and + ((.state.activeEnd | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) - + (.state.activeStart | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) == 7200) +' "$temporary/now-selected.json" >/dev/null || \ + preview_fail "Listen-now Free window is not active for two hours" + +for ordinal in 1 2 3; do + printf '{"deviceId":"registered_free_%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 "$now_cookies" --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" +done + +jq -e -s ' + .[0].evictedAnotherDevice == false and + .[1].evictedAnotherDevice == false and + .[2].evictedAnotherDevice == true and + (.[3].state.activeEnd | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) as $active_end | + (.[0:3] | all((.leaseExpiresAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) <= $active_end)) +' "$temporary/lease-1.response" "$temporary/lease-2.response" "$temporary/lease-3.response" \ + "$temporary/now-selected.json" >/dev/null || \ + preview_fail "two-device eviction or Free-window lease cap is invalid" + +first_lease=$(jq -er '.leaseId' "$temporary/lease-1.response") +printf '{"leaseId":"%s","intent":"play"}' "$first_lease" >"$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 "$now_cookies" --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") +manifest_status=$(curl --silent --show-error --output "$temporary/manifest.m3u8" \ + --write-out '%{http_code}' --cookie "$now_cookies" \ + "$base_url/api/early-birds/stream/manifest?leaseId=$third_lease") +test "$manifest_status" = 200 || preview_fail "active Free manifest returned HTTP $manifest_status" +grep -q '^#EXTM3U' "$temporary/manifest.m3u8" || preview_fail "active Free manifest is invalid" + +echo "Registered Free smoke passed: future schedule, idempotency, cooldown, Listen now, exact lease cap, two-device eviction, and signed manifest." From a285ce783ab6c585c77500cc2b4ef1d651602b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 04:58:19 -0300 Subject: [PATCH 090/376] docs(listener): add release candidate handoff --- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md new file mode 100644 index 00000000..ec6d39c4 --- /dev/null +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -0,0 +1,123 @@ +# Founding Listener release-candidate handoff + +Date: 2026-08-07 + +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. + +## Candidate identity + +| Artifact | Exact value | +|---|---| +| Deployed Listener application | `575b75aae5609b1813485d955a3e8ea753018084` | +| Branch/documentation head | `aba2057` | +| Listener database schema | `20260807070000_early_bird_free_schedule` | +| Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | +| Public mode | Free for All ON | +| Immediate Listener rollback | `d7ed952` | +| Additional Listener rollback | `f6e57b0` | + +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 pending | No real account was selected; this must not be simulated or inferred. | +| 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, invitation/internal/event/staff surfaces. | +| 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. | +| Two-hour recurring Free window | Proven | Unit/integration matrix plus deployed registered-Free smoke for custom and Listen now. | +| Seven-day change lock and idempotency | Proven | Runtime custom selection, exact replay and 409 cooldown response. | +| IANA zone, DST and server clock | Proven in tests | Spring gap, fall ambiguity, canonical zone and server-derived Listen now are deterministic. Human review covers comprehension only. | +| Current/next/change state | Proven | Serialized API state and ES/EN component tests; custom runtime response included next start and change boundary. | +| Lease cannot outlive Free window | Proven | Runtime three-device smoke compared every lease expiry with the exact active end; manifest authorization repeats the same 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. | +| Intro to Beacon lifecycle | Automated/browser proven; physical pending | Intro play/pause/seek, natural handoff, mutual exclusion, live-edge Stop/rejoin and duplicate guards pass. Acoustic/device acceptance remains human. | +| Mobile one-screen interaction | Browser proven; physical pending | Chromium 390x844 has no overflow; mode targets are 52 px and primary action 56 px. Physical keyboard/screen-reader/touch review remains. | +| Audio guardrail | Proven | Frozen-audio gate is green; this registration slice changed no asset, codec, rate, channel, gain, fade, buffer, routing or event audio. | +| 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`; root is 48% used/99 GB free and secondary volume is 6% used. | +| 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,087 tests, ESLint, TypeScript, build, frozen-audio, preview, origin and observability checks are green. | + +## Delivered commits + +- `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. + +The branch is clean and pushed. PR #203 remains draft and mergeable. The +application was not rebuilt for documentation/smoke-only heads. + +## Current runtime and operations + +- Listener, PostgreSQL and stream origin are isolated from the event project. +- Free for All was OFF only during short synthetic audits and is restored ON. +- 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/rollback images, every active image, containers, volumes, databases + and approved media remain. Removed historical images are reproducible from + their Git commits. +- `live.harmonicbeacon.com`, LiveKit, event Beacon audio and the event database + were not changed. + +## GitHub coordination truth + +- #214 is closed/Done: ordinary Free schedule. +- #195 remains open: measured external load/CDN rehearsal. +- #196 remains open: human Google callback and Apple credentials. +- #197 remains open: separately approved paid-provider rollout; canonical + membership consumption itself is complete. +- #198 remains open: physical acoustic/accessibility and 60-minute acceptance. +- #201 is In Progress: the human acceptance matrix. +- #213 is Todo; #211/#212 and the larger campaign/cosmic-campfire journey are + post-MVP and do not block this bounded test. + +## Remaining human sequence + +Use `docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md` as the authoritative +worksheet. + +1. Coordinate a short FFA-OFF interval. +2. In a clean profile, complete Google sign-in and confirm callback return, + Listen now, playback and logout. Restore FFA ON immediately afterward. +3. Complete physical Chrome, Firefox, Android Chrome and iPhone Safari rows. +4. Run one 60-minute physical listen with intro, handoff, background/foreground, + network transition and Stop/rejoin. +5. Run stepwise load from external generators before claiming measured scale. + +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. + +## Rollback + +Restore root-only +`/etc/harmonic-beacon/earlybirds-preview.env.pre-575b75a`, select Listener image +`d7ed952`, retain the preview database and origin media, recreate only the +isolated Listener and run the preview health smoke. The prior nginx snapshot is +`/etc/nginx/sites-available/listen.harmonicbeacon.com.pre-d7ed952`. + +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. From 2e5db73478cf3b8b9fa2ca70383c14021ec4ac66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 05:31:41 -0300 Subject: [PATCH 091/376] docs(listener): record human Google acceptance --- .../operations/EARLY_BIRDS_FREE_ACCEPTANCE.md | 22 +++++++++++++--- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 25 +++++++++++-------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md index 83f2c048..bbebdb3d 100644 --- a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md +++ b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md @@ -19,7 +19,8 @@ device/browser, result and a non-sensitive symptom. window, changeable again after seven days - Founding Listener access: canonical active membership projection - Connections: at most two active devices per account -- Free for All: an independent reversible operator override, currently ON +- 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 @@ -90,6 +91,14 @@ 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 tracks that behavior and is +explicitly conditional on retaining the recurring two-hour Free model. 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. @@ -122,9 +131,14 @@ 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 with 2FA, a primary App ID, Services ID, key/team identifiers, private -key and generated client-secret JWT for the documented callback. It must remain -absent until all material exists. +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-575b75a`, select Listener image diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index ec6d39c4..eae98014 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -17,10 +17,10 @@ an event-stack deployment or an acoustic change. | Artifact | Exact value | |---|---| | Deployed Listener application | `575b75aae5609b1813485d955a3e8ea753018084` | -| Branch/documentation head | `aba2057` | +| Branch/documentation head | `a285ce7` | | Listener database schema | `20260807070000_early_bird_free_schedule` | | Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | -| Public mode | Free for All ON | +| Public mode | Free for All OFF during coordinated registered-Free acceptance | | Immediate Listener rollback | `d7ed952` | | Additional Listener rollback | `f6e57b0` | @@ -33,14 +33,14 @@ test-only branch head. |---|---|---| | 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 pending | No real account was selected; this must not be simulated or inferred. | +| 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, invitation/internal/event/staff surfaces. | | 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. | | Two-hour recurring Free window | Proven | Unit/integration matrix plus deployed registered-Free smoke for custom and Listen now. | | Seven-day change lock and idempotency | Proven | Runtime custom selection, exact replay and 409 cooldown response. | | IANA zone, DST and server clock | Proven in tests | Spring gap, fall ambiguity, canonical zone and server-derived Listen now are deterministic. Human review covers comprehension only. | -| Current/next/change state | Proven | Serialized API state and ES/EN component tests; custom runtime response included next start and change boundary. | +| Current/next/change state | Proven with known refresh gap | Serialized API state and ES/EN component tests; custom runtime response included next start and change boundary. Human acceptance found that an already-open waiting page needed reload when the window began; #216 records the conditional follow-up. | | Lease cannot outlive Free window | Proven | Runtime three-device smoke compared every lease expiry with the exact active end; manifest authorization repeats the same 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. | @@ -71,7 +71,8 @@ application was not rebuilt for documentation/smoke-only heads. ## Current runtime and operations - Listener, PostgreSQL and stream origin are isolated from the event project. -- Free for All was OFF only during short synthetic audits and is restored ON. +- Free for All is temporarily OFF for the coordinated registered-Free human + acceptance. Restore it ON after this test before resuming anonymous sharing. - 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. @@ -85,11 +86,14 @@ application was not rebuilt for documentation/smoke-only heads. - #214 is closed/Done: ordinary Free schedule. - #195 remains open: measured external load/CDN rehearsal. -- #196 remains open: human Google callback and Apple credentials. +- #196 remains open only for Apple developer credentials and physical Apple + acceptance; the real Google callback/logout/relogin passed. - #197 remains open: separately approved paid-provider rollout; canonical membership consumption itself is complete. - #198 remains open: physical acoustic/accessibility and 60-minute acceptance. - #201 is In Progress: the human acceptance matrix. +- #216 is Todo and conditional on retaining the recurring two-hour Free model: + an open waiting page must revalidate when its window begins or ends. - #213 is Todo; #211/#212 and the larger campaign/cosmic-campfire journey are post-MVP and do not block this bounded test. @@ -98,12 +102,11 @@ application was not rebuilt for documentation/smoke-only heads. Use `docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md` as the authoritative worksheet. -1. Coordinate a short FFA-OFF interval. -2. In a clean profile, complete Google sign-in and confirm callback return, - Listen now, playback and logout. Restore FFA ON immediately afterward. -3. Complete physical Chrome, Firefox, Android Chrome and iPhone Safari rows. -4. Run one 60-minute physical listen with intro, handoff, background/foreground, +1. Finish playback/reconnect checks in the already active FFA-OFF interval. +2. Complete physical Chrome, Firefox, Android Chrome and iPhone Safari rows. +3. Run one 60-minute physical listen with intro, handoff, background/foreground, network transition and Stop/rejoin. +4. Restore FFA ON and verify anonymous playback before public sharing. 5. Run stepwise load from external generators before claiming measured scale. Do not select a user's Google account, provision Apple, charge a provider, From a2290fed67c3868c4ae7101e0962150741bfc7ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 06:10:23 -0300 Subject: [PATCH 092/376] fix(listener): preserve iOS intro handoff gesture --- src/components/early-birds/ListenerPlayer.tsx | 66 ++++++++++++++++--- .../__tests__/ListenerTransport.test.tsx | 16 ++++- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index fcba2c49..9bedf1b5 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -858,19 +858,17 @@ export default function ListenerPlayer({ async function playWithIntro(language: DropLanguage) { const selected = dropAudio[language].current; if (!selected || !dropIns[language]) return; - // The intro already contains the Beacon. Stop the live source before - // the first intro frame so the two sources can never overlap. - wantsLivePlayback.current = false; - cancelRecovery(true); + // The intro already contains the Beacon, so the shared stream must stay + // inaudible underneath it. Starting both prepared elements inside this + // gesture preserves iOS authorization for the later automatic handoff. + // Pausing the live element here would make Safari require another tap. cancelLiveFade(); pendingLiveFade.current = false; - liveSuppressedForDrop.current = false; - liveAudio.current?.pause(); if (liveAudio.current) { - liveAudio.current.muted = false; - liveAudio.current.volume = volumeRef.current; + liveAudio.current.muted = true; + liveSuppressedForDrop.current = true; + if (volumeSupported) liveAudio.current.volume = 0; } - updateLiveState('paused'); const other: DropLanguage = language === 'es' ? 'en' : 'es'; dropAudio[other].current?.pause(); cancelDropFade(); @@ -881,8 +879,41 @@ export default function ListenerPlayer({ activeDrop.current = language; const isCurrent = () => dropGeneration.current === generation && activeDrop.current === language; try { - await selected.play(); + let liveStarted: Promise | null = null; + if (!wantsLivePlayback.current && livePreparedRef.current && liveAudio.current) { + wantsLivePlayback.current = true; + cancelRecovery(true); + updateLiveState('loading'); + seekNativeAudioToLiveEdge(liveAudio.current); + // Promote the already prepared same-device lease without + // delaying either media play() call beyond this user gesture. + void requestLease('play').then((grant) => { + leaseId.current = grant.leaseId; + manifestExpiresAt.current = Date.parse(grant.stream.expiresAt); + }).catch(() => { + // The prepared source remains authorized. Heartbeat/recovery + // will retry without interrupting the introduction. + }); + liveStarted = liveAudio.current.play(); + } + const introStarted = selected.play(); + if (!wantsLivePlayback.current) { + wantsLivePlayback.current = true; + cancelRecovery(true); + updateLiveState('loading'); + void attemptLivePlayback().then((played) => { + if (!wantsLivePlayback.current) return; + if (played) updateLiveState('playing'); + else scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS); + }); + } + await introStarted; if (!isCurrent()) return; + if (liveStarted) { + void liveStarted.then(() => updateLiveState('playing')).catch(() => { + scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS); + }); + } setHasStarted(true); setTransportStopped(false); setTransportPaused(false); @@ -892,6 +923,13 @@ export default function ListenerPlayer({ activeDrop.current = null; setTransportStopped(true); setPlayingDrop(null); + wantsLivePlayback.current = false; + liveAudio.current?.pause(); + if (liveAudio.current) { + liveAudio.current.muted = false; + liveAudio.current.volume = volumeRef.current; + } + liveSuppressedForDrop.current = false; } } @@ -917,6 +955,14 @@ export default function ListenerPlayer({ [language]: { current: 0, duration: current[language].duration }, })); audio!.currentTime = 0; + if (liveAudio.current && !liveAudio.current.paused) { + cancelRecovery(true); + setTransportPaused(false); + updateLiveState('playing'); + pendingLiveFade.current = true; + beginLiveFade(); + return; + } void playLive(false, dropGeneration.current); } diff --git a/src/components/early-birds/__tests__/ListenerTransport.test.tsx b/src/components/early-birds/__tests__/ListenerTransport.test.tsx index 2a92c252..fe70e4ba 100644 --- a/src/components/early-birds/__tests__/ListenerTransport.test.tsx +++ b/src/components/early-birds/__tests__/ListenerTransport.test.tsx @@ -221,16 +221,26 @@ describe('Listener one-action playlist transport', () => { const live = screen.getByLabelText('Beacon') as HTMLAudioElement; const intro = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; await waitForListen(); + play.mockClear(); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expectPhase('intro')); + // Safari authorizes each media element independently. The live element + // must begin muted in the original gesture; starting it only from the + // later ended event is rejected on physical iPhones. + expect(play.mock.instances).toContain(live); + expect(play.mock.instances).toContain(intro); + expect(live.muted).toBe(true); + Object.defineProperty(live, 'paused', { value: false, configurable: true }); + fireEvent.playing(live); + expect(live.muted).toBe(true); + play.mockClear(); Object.defineProperty(intro, 'ended', { value: true, configurable: true }); fireEvent.ended(intro); - await waitFor(() => expect(play.mock.instances).toContain(live)); - Object.defineProperty(live, 'paused', { value: false, configurable: true }); - fireEvent.playing(live); + expect(play.mock.instances).not.toContain(live); frames.shift()?.(3_000); expect(live.volume).toBeCloseTo(1); + expect(live.muted).toBe(false); expectPhase('beacon'); }); From 9a9c28af3c63a9ef7f13d7fd00c5b1e32e692b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 06:16:11 -0300 Subject: [PATCH 093/376] feat(listener): add passwordless email fallback seam --- .env.example | 7 + docs/architecture/EARLY_BIRDS_LISTENER.md | 32 ++- docs/architecture/EARLY_BIRDS_MAGIC_LINK.md | 97 +++++++ .../0002-earlybird-identity-boundary.md | 9 +- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 19 ++ docs/plans/EARLY_BIRDS.md | 23 +- ops/early-birds-preview/compose.yml | 3 + .../preview.env.synthetic.example | 5 + .../migration.sql | 15 ++ prisma/schema.prisma | 14 + .../auth/[...all]/__tests__/route.test.ts | 79 +++++- .../api/early-birds/auth/[...all]/route.ts | 71 +++++- src/app/early-birds/__tests__/page.test.tsx | 6 + src/app/early-birds/page.tsx | 2 + src/app/globals.css | 61 +++++ .../early-birds/EarlyBirdLanding.tsx | 65 ++++- .../__tests__/EarlyBirdLanding.test.tsx | 38 ++- src/lib/early-birds/__tests__/auth.test.ts | 4 +- .../__tests__/magic-link-protocol.test.ts | 117 +++++++++ .../early-birds/__tests__/magic-link.test.ts | 202 +++++++++++++++ src/lib/early-birds/auth-client.ts | 2 + src/lib/early-birds/auth.ts | 30 ++- src/lib/early-birds/copy.ts | 12 + src/lib/early-birds/magic-link.ts | 241 ++++++++++++++++++ 24 files changed, 1133 insertions(+), 21 deletions(-) create mode 100644 docs/architecture/EARLY_BIRDS_MAGIC_LINK.md create mode 100644 prisma/migrations/20260807090000_early_bird_magic_link_throttles/migration.sql create mode 100644 src/lib/early-birds/__tests__/magic-link-protocol.test.ts create mode 100644 src/lib/early-birds/__tests__/magic-link.test.ts create mode 100644 src/lib/early-birds/magic-link.ts diff --git a/.env.example b/.env.example index cb02bf8a..56af2478 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,13 @@ EARLY_BIRDS_GOOGLE_CLIENT_SECRET= EARLY_BIRDS_APPLE_CLIENT_ID= EARLY_BIRDS_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 diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 7493edb1..95304b8a 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -8,8 +8,8 @@ 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 exactly Google and Apple. Account linking, implicit linking, unlinking and the account cookie -are disabled. The adapter requires nullable OAuth token columns, but Better Auth database hooks scrub +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. @@ -33,6 +33,34 @@ 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 Byte-exact copies live in `contracts/early-bird-authority/v1` and diff --git a/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md b/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md new file mode 100644 index 00000000..b8569491 --- /dev/null +++ b/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md @@ -0,0 +1,97 @@ +# Founding Listener email magic-link boundary + +Status: Listener side implemented; private mail adapter pending in +`SairaAsua/proyecciones-mito`. + +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 + existing `pmp-myth-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 current deployed PMP service already owns the Gmail API OAuth grant for the +Google Workspace sender and its worker/durable delivery machinery. It does not +yet expose this purpose/endpoint. That small adapter belongs in +`proyecciones-mito`; mounting the same grant in the Listener would create a +second email authority and is explicitly rejected. + +## Configuration and rollout + +The Listener feature remains absent unless all are set: + +```dotenv +EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL=http://pmp-myth-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`, configure the +mail adapter 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 schedule 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. diff --git a/docs/decisions/0002-earlybird-identity-boundary.md b/docs/decisions/0002-earlybird-identity-boundary.md index 62bd8266..e1ee9604 100644 --- a/docs/decisions/0002-earlybird-identity-boundary.md +++ b/docs/decisions/0002-earlybird-identity-boundary.md @@ -4,11 +4,18 @@ ## Decision -EarlyBirds uses an exact stable Better Auth release with Google and Apple only. +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; diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 33e46465..e1413f7a 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -352,6 +352,25 @@ 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 existing PMP mail authority implements +the exact private contract in +`docs/architecture/EARLY_BIRDS_MAGIC_LINK.md`. Do not copy or mount its Gmail +OAuth grant into the Listener. After that adapter is deployed on +`earlybirds_authority_private`, apply the additive +`20260807090000_early_bird_magic_link_throttles` migration and configure all +three values together: + +```dotenv +EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL=http://pmp-myth-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 listening windows When Free for All is off, an authenticated account without a canonical Founder diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 97e6fa86..1bdaafc8 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -19,7 +19,7 @@ continuous relationship with the Beacon outside scheduled events. The first useful release lets a Listener: -1. sign in with Google or Apple; +1. sign in with a configured Google/Apple provider or a passwordless email link; 2. obtain one Free invitation grant or a valid paid EarlyBird membership through the provider-neutral commerce authority; 3. open a private, receive-only listening home; @@ -127,7 +127,8 @@ steps are not safe to execute literally. - `/early-birds` unified entry: public sign-in without access, private Listener with a valid projection. - `/early-birds/home` compatibility redirect only. The dedicated staging hostname exposes the unified entry canonically at `/`. -- Google and Apple sign-in through an exact, stable Better Auth version. +- 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. - One-use, signed, auditable, revocable Free invitations and canonical paid membership entitlements from the commerce service. @@ -167,9 +168,10 @@ offline reviewed derivative ----> 24/7 stream origin ----> cache/CDN boundary v external canary -Google/Apple OIDC ---> EarlyBird account/session ---> EarlyBird web routes - | - v +Google/Apple OIDC --\ +email magic link ----> EarlyBird account/session ---> EarlyBird web routes + | + v membership authority <--- Free invites / PayPal / MercadoPago / future stores ``` @@ -360,7 +362,9 @@ Proposed additive concepts: 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. No provider +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 @@ -528,7 +532,8 @@ payment provider. ### Batch C — identity and provider-neutral membership - approve the identity ADR; -- implement Google and Apple sign-in, callback, session/revocation and logout; +- 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. @@ -608,7 +613,7 @@ event sound and reliability are at least as good as the current path. |---|---| | D1 | `EarlyBirds`; preview `earlybirds-staging.harmonicbeacon.com`; production route `/early-birds`; origin `stream.harmonicbeacon.com`. | | D2 | USD 2/month founder offer; 14-day involuntary grace; voluntary cancellation loses founder terms after paid-through; refund/dispute/admin revoke immediately. | -| D3 | Google and Apple through exact stable Better Auth; no Facebook and no account linking. | +| 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. | @@ -628,7 +633,7 @@ 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 identity and isolated Listener sessions. +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. diff --git a/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index 7e3c77a6..f5aba521 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -73,6 +73,9 @@ services: EARLY_BIRDS_GOOGLE_CLIENT_SECRET: ${EARLY_BIRDS_GOOGLE_CLIENT_SECRET:-} EARLY_BIRDS_APPLE_CLIENT_ID: ${EARLY_BIRDS_APPLE_CLIENT_ID:-} EARLY_BIRDS_APPLE_CLIENT_SECRET: ${EARLY_BIRDS_APPLE_CLIENT_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} diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example index 3f006774..74041c8c 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -27,6 +27,11 @@ EARLY_BIRDS_GOOGLE_CLIENT_ID= EARLY_BIRDS_GOOGLE_CLIENT_SECRET= EARLY_BIRDS_APPLE_CLIENT_ID= EARLY_BIRDS_APPLE_CLIENT_SECRET= +# 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 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/schema.prisma b/prisma/schema.prisma index 1256b3a8..66e70c92 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -484,6 +484,20 @@ model EarlyBirdVerification { @@map("early_bird_verifications") } +// 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. 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 index 79b62b03..69920546 100644 --- a/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts +++ b/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const handler = vi.hoisted(() => vi.fn()); vi.mock('@/lib/early-birds/auth', () => ({ earlyBirdAuth: () => ({ handler }) })); -import { POST } from '../route'; +import { GET, POST } from '../route'; describe('EarlyBird public auth route', () => { beforeEach(() => { @@ -12,6 +12,12 @@ describe('EarlyBird public auth route', () => { vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); vi.stubEnv('EARLY_BIRDS_AUTH_BASE_URL', 'https://listen.example.test'); vi.stubEnv('EARLY_BIRDS_TRUSTED_ORIGINS', 'https://listen.example.test,https://earlybirds-staging.example.test'); + vi.stubEnv( + 'EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL', + 'http://pmp-myth-mail:8765/api/internal/v1/listener-magic-links/deliver', + ); + vi.stubEnv('EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN', 'delivery-token-with-at-least-32-characters'); + vi.stubEnv('EARLY_BIRDS_MAGIC_LINK_RATE_SECRET', 'rate-secret-with-at-least-32-characters-long'); handler.mockResolvedValue(new Response(null, { status: 204 })); }); afterEach(() => vi.unstubAllEnvs()); @@ -59,4 +65,75 @@ describe('EarlyBird public auth route', () => { expect((await POST(request)).status).toBe(204); expect(handler).toHaveBeenCalledOnce(); }); + + it('accepts only fixed Listener callbacks and constrained locale metadata for magic links', async () => { + const request = new NextRequest( + 'https://listen.example.test/api/early-birds/auth/sign-in/magic-link', + { + method: 'POST', + headers: { + origin: 'https://listen.example.test', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + email: 'listener@example.test', + callbackURL: '/early-birds', + errorCallbackURL: '/early-birds?authError=1', + metadata: { locale: 'es' }, + }), + }, + ); + + expect((await POST(request)).status).toBe(204); + expect(handler).toHaveBeenCalledOnce(); + }); + + it.each([ + {}, + { callbackURL: 'https://attacker.invalid/collect' }, + { callbackURL: '/ops' }, + { callbackURL: '/early-birds', metadata: { locale: 'en', token: 'leak' } }, + ])('rejects unsafe magic-link request fields: %j', async (body) => { + const request = new NextRequest( + 'https://listen.example.test/api/early-birds/auth/sign-in/magic-link', + { + method: 'POST', + headers: { + origin: 'https://listen.example.test', + 'content-type': 'application/json', + }, + body: JSON.stringify({ email: 'listener@example.test', ...body }), + }, + ); + + expect((await POST(request)).status).toBe(400); + expect(handler).not.toHaveBeenCalled(); + }); + + it('hides both magic-link endpoints when delivery is not fully configured', async () => { + vi.stubEnv('EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN', 'short'); + const request = new NextRequest( + 'https://listen.example.test/api/early-birds/auth/sign-in/magic-link', + { + method: 'POST', + headers: { + origin: 'https://listen.example.test', + 'content-type': 'application/json', + }, + body: JSON.stringify({ email: 'listener@example.test', callbackURL: '/early-birds' }), + }, + ); + + expect((await POST(request)).status).toBe(404); + expect(handler).not.toHaveBeenCalled(); + }); + + it('rejects an off-surface callback before a token can mint a session', async () => { + const request = new NextRequest( + 'https://listen.example.test/api/early-birds/auth/magic-link/verify?token=opaque&callbackURL=%2Fops', + ); + + expect((await GET(request)).status).toBe(400); + expect(handler).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/early-birds/auth/[...all]/route.ts b/src/app/api/early-birds/auth/[...all]/route.ts index bbdde4ec..2d9bae77 100644 --- a/src/app/api/early-birds/auth/[...all]/route.ts +++ b/src/app/api/early-birds/auth/[...all]/route.ts @@ -1,6 +1,11 @@ import type { NextRequest } from 'next/server'; import { earlyBirdAuth } from '@/lib/early-birds/auth'; +import { + EARLY_BIRD_MAGIC_LINK_PATH, + EARLY_BIRD_MAGIC_LINK_VERIFY_PATH, + earlyBirdMagicLinkAvailable, +} from '@/lib/early-birds/magic-link'; import { earlyBirdsEnabled, earlyBirdsUnavailableResponse, @@ -34,15 +39,79 @@ function oauthCallback(request: NextRequest): boolean { return request.nextUrl.pathname.includes('/api/early-birds/auth/callback/'); } +function magicLinkRequest(request: NextRequest): boolean { + return request.nextUrl.pathname.endsWith(EARLY_BIRD_MAGIC_LINK_PATH); +} + +function magicLinkVerification(request: NextRequest): boolean { + return request.nextUrl.pathname.endsWith(EARLY_BIRD_MAGIC_LINK_VERIFY_PATH); +} + +const LISTENER_CALLBACKS = new Set(['/early-birds', '/early-birds/redeem']); + +function safeListenerCallback(value: unknown, kind: 'success' | 'error'): boolean { + if (value === undefined) return true; + if (typeof value !== 'string') return false; + if (kind === 'success') return LISTENER_CALLBACKS.has(value); + return value === '/early-birds?authError=1'; +} + +async function safeMagicLinkRequest(request: NextRequest): Promise { + try { + const body = await request.clone().json() as Record; + const metadata = body.metadata; + return typeof body.callbackURL === 'string' && + safeListenerCallback(body.callbackURL, 'success') && + safeListenerCallback(body.newUserCallbackURL, 'success') && + safeListenerCallback(body.errorCallbackURL, 'error') && + (metadata === undefined || ( + typeof metadata === 'object' && metadata !== null && + Object.keys(metadata).every((key) => key === 'locale') && + ['es', 'en'].includes(String((metadata as Record).locale)) + )); + } catch { + return false; + } +} + +function safeMagicLinkVerification(request: NextRequest): boolean { + const callbackURL = request.nextUrl.searchParams.get('callbackURL'); + return callbackURL !== null && safeListenerCallback(callbackURL, 'success') && + safeListenerCallback(request.nextUrl.searchParams.get('newUserCallbackURL') ?? undefined, 'success') && + safeListenerCallback(request.nextUrl.searchParams.get('errorCallbackURL') ?? undefined, 'error'); +} + +function hiddenMagicLinkResponse(): Response { + return Response.json({ error: 'Not found.' }, { + status: 404, + headers: { 'Cache-Control': 'private, no-store' }, + }); +} + +function invalidMagicLinkResponse(): Response { + return Response.json({ error: 'Invalid request.' }, { + status: 400, + headers: { 'Cache-Control': 'private, no-store' }, + }); +} + export function GET(request: NextRequest): Promise | Response { if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + if (magicLinkVerification(request)) { + if (!earlyBirdMagicLinkAvailable()) return hiddenMagicLinkResponse(); + if (!safeMagicLinkVerification(request)) return invalidMagicLinkResponse(); + } return earlyBirdAuth().handler(request); } -export function POST(request: NextRequest): Promise | Response { +export async function POST(request: NextRequest): Promise { if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); const hidden = hiddenSyntheticEmailEndpoint(request); if (hidden) return hidden; + if (magicLinkRequest(request)) { + if (!earlyBirdMagicLinkAvailable()) return hiddenMagicLinkResponse(); + if (!await safeMagicLinkRequest(request)) return invalidMagicLinkResponse(); + } // Provider callbacks are protected by the one-time state/cookie verifier // and Apple uses a cross-site form_post. Every browser-initiated mutation // must instead originate from one of the exact Listener hosts. diff --git a/src/app/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx index 01449fbd..306f9bd2 100644 --- a/src/app/early-birds/__tests__/page.test.tsx +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ currentEarlyBirdSession: vi.fn(), earlyBirdOAuthAvailability: vi.fn(), + earlyBirdMagicLinkAvailable: vi.fn(), getEarlyBirdListeningAccess: vi.fn(), cookies: vi.fn(), headers: vi.fn(), @@ -16,6 +17,9 @@ 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, })); @@ -91,6 +95,7 @@ describe('EarlyBird Listener page', () => { 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', @@ -104,6 +109,7 @@ describe('EarlyBird Listener page', () => { signedIn: true, entitled: false, providers: { google: true, apple: false }, + emailMagicLinkAvailable: true, freeWindow: { configured: true, active: false, diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index a8e40461..6908a7da 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -17,6 +17,7 @@ import { import { syntheticTeamEntryAllowed } from '@/lib/early-birds/synthetic-team-entry'; import { configuredEarlyBirdDropIn } from '@/lib/early-birds/drop-ins'; import { freeWindowState, serializeFreeWindowState } from '@/lib/early-birds/free-window'; +import { earlyBirdMagicLinkAvailable } from '@/lib/early-birds/magic-link'; export const dynamic = 'force-dynamic'; @@ -78,6 +79,7 @@ export default async function EarlyBirdsPage({ invitationAvailable={invitationAvailable} authError={params.authError === '1'} providers={earlyBirdOAuthAvailability()} + emailMagicLinkAvailable={earlyBirdMagicLinkAvailable()} syntheticTeamEntryAvailable={syntheticTeamEntryAllowed({ headers: incomingHeaders })} freeWindow={serializeFreeWindowState(access?.freeWindow ?? freeWindowState(null))} /> diff --git a/src/app/globals.css b/src/app/globals.css index 5e5c6f19..665cc4bb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1718,6 +1718,67 @@ body { 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(--font-space-mono), monospace; + 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(--paper); + background: rgba(255, 255, 255, 0.055); +} + +.listener-email-access__form input:focus-visible { + outline: 2px solid var(--cyan); + 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; diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index 9608d032..166bc5aa 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -18,6 +18,7 @@ type Props = { invitationAvailable: boolean; authError: boolean; providers: { google: boolean; apple: boolean }; + emailMagicLinkAvailable: boolean; syntheticTeamEntryAvailable: boolean; freeWindow: SerializedEarlyBirdFreeWindowState; }; @@ -25,8 +26,10 @@ type Props = { export default function EarlyBirdLanding(props: Props) { const { locale } = useLocale(); const copy = earlyBirdCopy[locale]; - const [busy, setBusy] = useState<'google' | 'apple' | null>(null); + const [busy, setBusy] = useState<'google' | 'apple' | 'email' | null>(null); const [error, setError] = useState(false); + const [email, setEmail] = useState(''); + const [emailRequested, setEmailRequested] = useState(false); const callbackURL = props.invitationAvailable ? '/early-birds/redeem' : '/early-birds'; @@ -53,6 +56,30 @@ export default function EarlyBirdLanding(props: Props) { window.location.assign('/early-birds'); } + async function requestMagicLink(event: React.FormEvent) { + event.preventDefault(); + if (busy || !props.emailMagicLinkAvailable) return; + setBusy('email'); + setError(false); + try { + await earlyBirdAuthClient.signIn.magicLink({ + email, + callbackURL, + errorCallbackURL: '/early-birds?authError=1', + metadata: { locale }, + }); + // The same response is intentionally shown for unknown accounts, + // throttled requests and provider delivery uncertainty. + setEmailRequested(true); + setEmail(''); + } catch { + setEmailRequested(true); + setEmail(''); + } finally { + setBusy(null); + } + } + return (
@@ -130,6 +157,42 @@ export default function EarlyBirdLanding(props: Props) { : provider === 'google' ? copy.signInGoogle : copy.signInApple} ))} + {props.emailMagicLinkAvailable && ( +
+ {(props.providers.google || props.providers.apple) && ( +

+ {copy.magicLinkDivider} +

+ )} + {emailRequested ? ( +

+ {copy.magicLinkSent} +

+ ) : ( +
+ + setEmail(event.target.value)} + placeholder={copy.magicLinkPlaceholder} + /> + +
+ )} +
+ )} {props.syntheticTeamEntryAvailable && ( vi.fn()); +const signInMagicLink = vi.hoisted(() => vi.fn()); const signOut = vi.hoisted(() => vi.fn()); vi.mock('@/lib/early-birds/auth-client', () => ({ - earlyBirdAuthClient: { signIn: { social: signInSocial }, signOut }, + earlyBirdAuthClient: { signIn: { social: signInSocial, magicLink: signInMagicLink }, signOut }, })); vi.mock('@/components/brand/LanguageControl', () => ({ default: () =>
})); vi.mock('@/components/brand/BrandLockup', () => ({ default: () => Harmonic Beacon })); @@ -25,6 +26,7 @@ function renderLanding(overrides: Partial { beforeEach(() => { signInSocial.mockReset(); signInSocial.mockResolvedValue({ error: null }); + signInMagicLink.mockReset(); + signInMagicLink.mockResolvedValue({ data: { status: true }, error: null }); signOut.mockReset(); signOut.mockResolvedValue({ error: null }); window.localStorage.clear(); @@ -97,6 +101,38 @@ describe('EarlyBird public landing', () => { expect(screen.getByRole('button', { name: 'Continue with Apple' })).toBeEnabled(); }); + it('offers an enumeration-resistant email fallback only when delivery is configured', async () => { + renderLanding({ providers: { google: true, apple: false }, emailMagicLinkAvailable: true }); + + await userEvent.type(screen.getByLabelText('Email address'), 'listener@example.test'); + await userEvent.click(screen.getByRole('button', { name: 'Email me a sign-in link' })); + + expect(signInMagicLink).toHaveBeenCalledWith({ + email: 'listener@example.test', + callbackURL: '/early-birds', + errorCallbackURL: '/early-birds?authError=1', + metadata: { locale: 'en' }, + }); + expect(screen.getByRole('status')).toHaveTextContent('If this email can be used for access'); + expect(screen.queryByDisplayValue('listener@example.test')).not.toBeInTheDocument(); + }); + + it('shows the same generic response when the email request transport rejects', async () => { + signInMagicLink.mockRejectedValueOnce(new Error('provider unavailable')); + renderLanding({ emailMagicLinkAvailable: true }); + + await userEvent.type(screen.getByLabelText('Email address'), 'unknown@example.test'); + await userEvent.click(screen.getByRole('button', { name: 'Email me a sign-in link' })); + + expect(await screen.findByRole('status')).toHaveTextContent('If this email can be used for access'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('hides email login when the delivery boundary is incomplete', () => { + renderLanding({ emailMagicLinkAvailable: false }); + expect(screen.queryByLabelText('Email address')).not.toBeInTheDocument(); + }); + it('takes an entitled signed-in listener directly to the private home', () => { renderLanding({ signedIn: true, entitled: true }); expect(screen.getAllByRole('link', { name: 'Enter the Beacon' })) diff --git a/src/lib/early-birds/__tests__/auth.test.ts b/src/lib/early-birds/__tests__/auth.test.ts index 4c6088dc..b6650ad9 100644 --- a/src/lib/early-birds/__tests__/auth.test.ts +++ b/src/lib/early-birds/__tests__/auth.test.ts @@ -96,12 +96,12 @@ describe('EarlyBird Better Auth isolation', () => { updatedAt: new Date('2026-08-07T00:00:00.000Z'), }; - const created = await hooks?.create?.before?.(sessionPayload); + const created = await hooks?.create?.before?.(sessionPayload, null); const updated = await hooks?.update?.before?.(sessionPayload); for (const outcome of [created, updated]) { expect(outcome).not.toBe(false); - expect(outcome && 'data' in outcome ? outcome.data : null).toMatchObject({ + expect(outcome && outcome !== true && 'data' in outcome ? outcome.data : null).toMatchObject({ id: 'session-1', ipAddress: null, userAgent: null, diff --git a/src/lib/early-birds/__tests__/magic-link-protocol.test.ts b/src/lib/early-birds/__tests__/magic-link-protocol.test.ts new file mode 100644 index 00000000..5586cc74 --- /dev/null +++ b/src/lib/early-birds/__tests__/magic-link-protocol.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { memoryAdapter } from 'better-auth/adapters/memory'; +import { betterAuth } from 'better-auth/minimal'; +import { magicLink } from 'better-auth/plugins'; + +import { + EARLY_BIRD_MAGIC_LINK_TTL_SECONDS, + hashEarlyBirdMagicLinkToken, +} from '../magic-link'; + +type MemoryRow = Record; + +function protocol(expiresIn = EARLY_BIRD_MAGIC_LINK_TTL_SECONDS) { + const database: Record = { + user: [], + session: [], + account: [], + verification: [], + }; + let deliveredURL = ''; + const auth = betterAuth({ + baseURL: 'https://listen.example.test', + secret: 'test-auth-secret-with-at-least-32-characters', + trustedOrigins: ['https://listen.example.test'], + database: memoryAdapter(database), + rateLimit: { enabled: false }, + plugins: [magicLink({ + expiresIn, + storeToken: { + type: 'custom-hasher', + hash: async (token) => hashEarlyBirdMagicLinkToken(token), + }, + async sendMagicLink({ url }) { + deliveredURL = url; + }, + })], + }); + return { + auth, + database, + deliveredURL: () => deliveredURL, + }; +} + +async function requestLink( + auth: { handler(request: Request): Promise }, + email = 'new@example.test', +) { + return auth.handler(new Request('https://listen.example.test/api/auth/sign-in/magic-link', { + method: 'POST', + headers: { + origin: 'https://listen.example.test', + 'content-type': 'application/json', + }, + body: JSON.stringify({ email, callbackURL: '/early-birds' }), + })); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('pinned Better Auth magic-link protocol', () => { + it('stores only a verifier and consumes a valid token into one session', async () => { + const state = protocol(); + expect((await requestLink(state.auth)).status).toBe(200); + const deliveredURL = state.deliveredURL(); + const token = new URL(deliveredURL).searchParams.get('token')!; + + expect(state.database.verification).toHaveLength(1); + expect(state.database.verification[0].identifier).toBe(hashEarlyBirdMagicLinkToken(token)); + expect(JSON.stringify(state.database.verification)).not.toContain(token); + + const response = await state.auth.handler(new Request(deliveredURL)); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('https://listen.example.test/early-birds'); + expect(response.headers.get('set-cookie')).toContain('better-auth.session_token'); + expect(state.database.user).toHaveLength(1); + expect(state.database.session).toHaveLength(1); + expect(state.database.verification).toHaveLength(0); + }); + + it('rejects replay and altered tokens without minting another session', async () => { + const state = protocol(); + await requestLink(state.auth); + const deliveredURL = state.deliveredURL(); + await state.auth.handler(new Request(deliveredURL)); + const sessionsAfterFirstUse = state.database.session.length; + + const replay = await state.auth.handler(new Request(deliveredURL)); + expect(replay.status).toBe(302); + expect(replay.headers.get('location')).toContain('error=INVALID_TOKEN'); + expect(state.database.session).toHaveLength(sessionsAfterFirstUse); + + const altered = new URL(deliveredURL); + altered.searchParams.set('token', `${altered.searchParams.get('token')}altered`); + const alteredResponse = await state.auth.handler(new Request(altered)); + expect(alteredResponse.status).toBe(302); + expect(alteredResponse.headers.get('location')).toContain('error=INVALID_TOKEN'); + expect(state.database.session).toHaveLength(sessionsAfterFirstUse); + }); + + it('rejects an expired token without creating a user or session', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-07T10:00:00.000Z')); + const state = protocol(1); + await requestLink(state.auth); + const deliveredURL = state.deliveredURL(); + vi.setSystemTime(new Date('2026-08-07T10:00:02.000Z')); + + const expired = await state.auth.handler(new Request(deliveredURL)); + expect(expired.status).toBe(302); + expect(expired.headers.get('location')).toContain('error=INVALID_TOKEN'); + expect(state.database.user).toHaveLength(0); + expect(state.database.session).toHaveLength(0); + }); +}); diff --git a/src/lib/early-birds/__tests__/magic-link.test.ts b/src/lib/early-birds/__tests__/magic-link.test.ts new file mode 100644 index 00000000..21cbc239 --- /dev/null +++ b/src/lib/early-birds/__tests__/magic-link.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const database = vi.hoisted(() => ({ + earlyBirdUser: { findUnique: vi.fn() }, + earlyBirdIdentity: { count: vi.fn() }, + $transaction: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ prisma: database })); + +import { + consumeEarlyBirdMagicLinkRateLimit, + deliverEarlyBirdMagicLink, + EARLY_BIRD_MAGIC_LINK_DELIVERY_PATH, + earlyBirdMagicLinkAvailable, + earlyBirdMagicLinkConfiguration, + earlyBirdMagicLinkSessionAllowed, + hashEarlyBirdMagicLinkToken, +} from '../magic-link'; + +type Bucket = { + key: string; + kind: string; + windowStartedAt: Date; + attempts: number; + blockedUntil: Date | null; + updatedAt: Date; +}; + +function throttleClient() { + const buckets = new Map(); + const transaction = { + earlyBirdMagicLinkThrottle: { + async deleteMany() { + return { count: 0 }; + }, + async upsert(input: { + where: { key: string }; + create: Omit; + }) { + const existing = buckets.get(input.where.key); + if (existing) return { ...existing }; + const created: Bucket = { + ...input.create, + blockedUntil: null, + updatedAt: input.create.windowStartedAt, + }; + buckets.set(created.key, created); + return { ...created }; + }, + async update(input: { + where: { key: string }; + data: Omit, 'attempts'> & { + attempts?: number | { increment: number }; + }; + }) { + const current = buckets.get(input.where.key)!; + const attempts = typeof input.data.attempts === 'object' + ? current.attempts + input.data.attempts.increment + : input.data.attempts ?? current.attempts; + const next = { ...current, ...input.data, attempts } as Bucket; + buckets.set(next.key, next); + return { ...next }; + }, + }, + }; + const client = { + $transaction: vi.fn(async (operation: (value: typeof transaction) => Promise) => ( + operation(transaction) + )), + }; + return { buckets, client }; +} + +describe('EarlyBird email magic link', () => { + beforeEach(() => { + vi.stubEnv( + 'EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL', + `http://pmp-myth-mail:8765${EARLY_BIRD_MAGIC_LINK_DELIVERY_PATH}`, + ); + vi.stubEnv('EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN', 'delivery-token-with-at-least-32-characters'); + vi.stubEnv('EARLY_BIRDS_MAGIC_LINK_RATE_SECRET', 'rate-secret-with-at-least-32-characters-long'); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('fails closed unless the exact versioned delivery boundary and both secrets are valid', () => { + expect(earlyBirdMagicLinkAvailable()).toBe(true); + expect(earlyBirdMagicLinkConfiguration()?.url).toContain(EARLY_BIRD_MAGIC_LINK_DELIVERY_PATH); + + expect(earlyBirdMagicLinkAvailable({ + EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL: 'https://mail.example.test/other', + EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN: 'delivery-token-with-at-least-32-characters', + EARLY_BIRDS_MAGIC_LINK_RATE_SECRET: 'rate-secret-with-at-least-32-characters-long', + })).toBe(false); + expect(earlyBirdMagicLinkAvailable({ + EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL: `https://user:pass@mail.example.test${EARLY_BIRD_MAGIC_LINK_DELIVERY_PATH}`, + EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN: 'delivery-token-with-at-least-32-characters', + EARLY_BIRDS_MAGIC_LINK_RATE_SECRET: 'rate-secret-with-at-least-32-characters-long', + })).toBe(false); + }); + + it('stores a one-way verifier rather than the raw token', () => { + const token = 'raw-token-that-must-not-be-persisted'; + const verifier = hashEarlyBirdMagicLinkToken(token); + expect(verifier).toMatch(/^[a-f0-9]{64}$/); + expect(verifier).not.toContain(token); + expect(hashEarlyBirdMagicLinkToken(`${token}-changed`)).not.toBe(verifier); + }); + + it('limits normalized email and origin/IP through HMAC-only durable buckets', async () => { + const { buckets, client } = throttleClient(); + const request = new Request('https://listen.example.test', { + headers: { + origin: 'https://listen.example.test', + 'x-forwarded-for': '203.0.113.44', + }, + }); + const input = { + email: 'Listener@Example.Test', + request, + secret: 'rate-secret-with-at-least-32-characters-long', + now: new Date('2026-08-07T10:00:00.000Z'), + client: client as never, + }; + + await expect(consumeEarlyBirdMagicLinkRateLimit(input)).resolves.toBe(true); + await expect(consumeEarlyBirdMagicLinkRateLimit({ ...input, email: 'listener@example.test' })).resolves.toBe(true); + await expect(consumeEarlyBirdMagicLinkRateLimit(input)).resolves.toBe(true); + await expect(consumeEarlyBirdMagicLinkRateLimit(input)).resolves.toBe(false); + + expect([...buckets.keys()]).toHaveLength(2); + expect(JSON.stringify([...buckets.entries()])).not.toContain('listener@example.test'); + expect(JSON.stringify([...buckets.entries()])).not.toContain('203.0.113.44'); + }); + + it('never lets magic-link verification become a credential for a social identity', async () => { + const noIdentity = { earlyBirdIdentity: { count: vi.fn().mockResolvedValue(0) } }; + const socialIdentity = { earlyBirdIdentity: { count: vi.fn().mockResolvedValue(1) } }; + + await expect(earlyBirdMagicLinkSessionAllowed( + 'listener-1', '/magic-link/verify', noIdentity as never, + )).resolves.toBe(true); + await expect(earlyBirdMagicLinkSessionAllowed( + 'listener-1', '/magic-link/verify', socialIdentity as never, + )).resolves.toBe(false); + await expect(earlyBirdMagicLinkSessionAllowed( + 'listener-1', '/callback/google', socialIdentity as never, + )).resolves.toBe(true); + }); + + it('sends the minimal authenticated contract without exposing the raw token in headers', async () => { + const { client } = throttleClient(); + database.$transaction.mockImplementation(client.$transaction); + database.earlyBirdUser.findUnique.mockResolvedValue(null); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ status: 'accepted' }), { status: 202 }), + ); + const token = 'one-use-link-token'; + + await deliverEarlyBirdMagicLink({ + email: 'Listener@Example.Test', + url: `https://listen.example.test/api/early-birds/auth/magic-link/verify?token=${token}`, + token, + metadata: { locale: 'es', ignored: 'value' }, + }, new Request('https://listen.example.test', { + headers: { origin: 'https://listen.example.test', 'x-real-ip': '203.0.113.9' }, + })); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [endpoint, init] = fetchMock.mock.calls[0]; + expect(endpoint).toContain(EARLY_BIRD_MAGIC_LINK_DELIVERY_PATH); + expect(JSON.stringify(init?.headers)).not.toContain(token); + const body = JSON.parse(String(init?.body)); + expect(body).toMatchObject({ + contract_version: 'listener-magic-link.v1', + purpose: 'listener_sign_in', + recipient: 'listener@example.test', + locale: 'es', + }); + expect(body.magic_link_url).toContain(token); + expect(body).not.toHaveProperty('metadata'); + }); + + it('returns the same way without delivery for an email owned by another identity', async () => { + const { client } = throttleClient(); + database.$transaction.mockImplementation(client.$transaction); + database.earlyBirdUser.findUnique.mockResolvedValue({ identities: [{ id: 'google-identity' }] }); + const fetchMock = vi.spyOn(globalThis, 'fetch'); + + await expect(deliverEarlyBirdMagicLink({ + email: 'social@example.test', + url: 'https://listen.example.test/verify?token=hidden', + token: 'hidden', + })).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/early-birds/auth-client.ts b/src/lib/early-birds/auth-client.ts index af83d321..3f082088 100644 --- a/src/lib/early-birds/auth-client.ts +++ b/src/lib/early-birds/auth-client.ts @@ -1,9 +1,11 @@ 'use client'; import { createAuthClient } from 'better-auth/react'; +import { magicLinkClient } from 'better-auth/client/plugins'; import { EARLY_BIRD_AUTH_BASE_PATH } from './auth-contract'; export const earlyBirdAuthClient = createAuthClient({ basePath: EARLY_BIRD_AUTH_BASE_PATH, + plugins: [magicLinkClient()], }); diff --git a/src/lib/early-birds/auth.ts b/src/lib/early-birds/auth.ts index 6566887d..4ef428b2 100644 --- a/src/lib/early-birds/auth.ts +++ b/src/lib/early-birds/auth.ts @@ -1,8 +1,16 @@ import { headers as requestHeaders } from 'next/headers'; import { betterAuth } from 'better-auth/minimal'; import { prismaAdapter } from 'better-auth/adapters/prisma'; +import { magicLink } from 'better-auth/plugins'; import { prisma } from '@/lib/db'; +import { + deliverEarlyBirdMagicLink, + EARLY_BIRD_MAGIC_LINK_TTL_SECONDS, + earlyBirdMagicLinkAvailable, + earlyBirdMagicLinkSessionAllowed, + hashEarlyBirdMagicLinkToken, +} from '@/lib/early-birds/magic-link'; export const EARLY_BIRD_AUTH_BASE_PATH = '/api/early-birds/auth'; export const EARLY_BIRD_COOKIE_PREFIX = 'hb_earlybird'; @@ -93,6 +101,7 @@ function scrubSessionMetadata>(session: T): T function buildEarlyBirdAuth() { const testAuth = earlyBirdTestAuthEnabled(); const baseURL = nonEmpty(process.env.EARLY_BIRDS_AUTH_BASE_URL); + const magicLinkEnabled = earlyBirdMagicLinkAvailable(); return betterAuth({ appName: 'Harmonic Beacon Listener', @@ -102,8 +111,19 @@ function buildEarlyBirdAuth() { trustedOrigins: trustedOrigins(), database: prismaAdapter(prisma, { provider: 'postgresql' }), socialProviders: earlyBirdSocialProviders(), - // Email/password is a supervised synthetic-login seam only. The public - // product exposes exactly Google and Apple, and the seam is absent + plugins: magicLinkEnabled ? [magicLink({ + expiresIn: EARLY_BIRD_MAGIC_LINK_TTL_SECONDS, + storeToken: { + type: 'custom-hasher', + hash: async (token) => hashEarlyBirdMagicLinkToken(token), + }, + rateLimit: { window: 60, max: 3 }, + async sendMagicLink(data, context) { + await deliverEarlyBirdMagicLink(data, context?.request); + }, + })] : [], + // Email/password is a supervised synthetic-login seam only. Public + // passwordless email is a separate one-use plugin, and this seam is absent // unless both an explicit gate and a separate secret are present. emailAndPassword: { enabled: testAuth }, user: { @@ -147,7 +167,11 @@ function buildEarlyBirdAuth() { }, session: { create: { - async before(session) { + async before(session, context) { + if (!await earlyBirdMagicLinkSessionAllowed( + session.userId, + context?.path, + )) return false; return { data: scrubSessionMetadata(session) }; }, }, diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 880fc45b..c6106db5 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -11,6 +11,12 @@ export const earlyBirdCopy = { signInGoogle: 'Continuar con Google', signInApple: 'Continuar con Apple', signingIn: 'Abriendo acceso…', + magicLinkDivider: 'o', + magicLinkEmail: 'Correo electrónico', + magicLinkPlaceholder: 'tu@correo.com', + magicLinkSend: 'Recibir enlace por correo', + magicLinkSending: 'Solicitando enlace…', + magicLinkSent: 'Si el correo puede usarse para este acceso, recibirás un enlace en unos minutos. Caduca a los diez minutos.', providerSoon: 'Configuración pendiente', signedIn: 'Tu cuenta Listener está lista.', signOut: 'Cerrar sesión', @@ -40,6 +46,12 @@ export const earlyBirdCopy = { signInGoogle: 'Continue with Google', signInApple: 'Continue with Apple', signingIn: 'Opening access…', + magicLinkDivider: 'or', + magicLinkEmail: 'Email address', + magicLinkPlaceholder: 'you@example.com', + magicLinkSend: 'Email me a sign-in link', + magicLinkSending: 'Requesting link…', + magicLinkSent: 'If this email can be used for access, a link will arrive within a few minutes. It expires after ten minutes.', providerSoon: 'Configuration pending', signedIn: 'Your Listener account is ready.', signOut: 'Sign out', diff --git a/src/lib/early-birds/magic-link.ts b/src/lib/early-birds/magic-link.ts new file mode 100644 index 00000000..70e320d7 --- /dev/null +++ b/src/lib/early-birds/magic-link.ts @@ -0,0 +1,241 @@ +import { createHash, createHmac } from 'node:crypto'; + +import { Prisma } from '@prisma/client'; + +import { prisma } from '@/lib/db'; + +export const EARLY_BIRD_MAGIC_LINK_PATH = '/sign-in/magic-link'; +export const EARLY_BIRD_MAGIC_LINK_VERIFY_PATH = '/magic-link/verify'; +export const EARLY_BIRD_MAGIC_LINK_TTL_SECONDS = 10 * 60; +export const EARLY_BIRD_MAGIC_LINK_DELIVERY_PATH = '/api/internal/v1/listener-magic-links/deliver'; + +const EMAIL_WINDOW_MS = 15 * 60 * 1_000; +const EMAIL_WINDOW_MAX = 3; +const ORIGIN_IP_WINDOW_MS = 15 * 60 * 1_000; +const ORIGIN_IP_WINDOW_MAX = 10; +const GENERIC_RESPONSE_FLOOR_MS = 250; +const THROTTLE_RETENTION_MS = 24 * 60 * 60 * 1_000; + +type MagicLinkEnvironment = Record & { + EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL?: string; + EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN?: string; + EARLY_BIRDS_MAGIC_LINK_RATE_SECRET?: string; +}; + +type DeliveryConfiguration = { + url: string; + token: string; + rateSecret: string; +}; + +type ThrottleClient = Pick; + +function nonEmpty(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +export function earlyBirdMagicLinkConfiguration( + environment: MagicLinkEnvironment = process.env, +): DeliveryConfiguration | null { + const rawURL = nonEmpty(environment.EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL); + const token = nonEmpty(environment.EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN); + const rateSecret = nonEmpty(environment.EARLY_BIRDS_MAGIC_LINK_RATE_SECRET); + if (!rawURL || !token || token.length < 32 || !rateSecret || rateSecret.length < 32) return null; + + try { + const url = new URL(rawURL); + if (!['http:', 'https:'].includes(url.protocol) || + url.username || url.password || url.search || url.hash || + url.pathname !== EARLY_BIRD_MAGIC_LINK_DELIVERY_PATH) return null; + return { url: url.toString(), token, rateSecret }; + } catch { + return null; + } +} + +export function earlyBirdMagicLinkAvailable( + environment: MagicLinkEnvironment = process.env, +): boolean { + return earlyBirdMagicLinkConfiguration(environment) !== null; +} + +export function hashEarlyBirdMagicLinkToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); +} + +function bucketKey(kind: 'email' | 'origin_ip', value: string, secret: string): string { + const digest = createHmac('sha256', secret).update(value, 'utf8').digest('hex'); + return `${kind}:${digest}`; +} + +function sourceAddress(request: Request | undefined): string { + const forwarded = request?.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + const address = forwarded || request?.headers.get('x-real-ip')?.trim(); + return address && address.length <= 128 ? address : 'unavailable'; +} + +async function consumeThrottleBucket( + transaction: Prisma.TransactionClient, + input: { + key: string; + kind: 'email' | 'origin_ip'; + now: Date; + windowMs: number; + max: number; + }, +): Promise { + const current = await transaction.earlyBirdMagicLinkThrottle.upsert({ + where: { key: input.key }, + create: { + key: input.key, + kind: input.kind, + windowStartedAt: input.now, + attempts: 0, + }, + update: {}, + }); + const windowEnd = new Date(current.windowStartedAt.getTime() + input.windowMs); + if (current.blockedUntil && current.blockedUntil > input.now) return false; + if (windowEnd <= input.now) { + await transaction.earlyBirdMagicLinkThrottle.update({ + where: { key: input.key }, + data: { + windowStartedAt: input.now, + attempts: 1, + blockedUntil: null, + }, + }); + return true; + } + if (current.attempts >= input.max) { + await transaction.earlyBirdMagicLinkThrottle.update({ + where: { key: input.key }, + data: { blockedUntil: windowEnd }, + }); + return false; + } + await transaction.earlyBirdMagicLinkThrottle.update({ + where: { key: input.key }, + data: { attempts: { increment: 1 } }, + }); + return true; +} + +export async function consumeEarlyBirdMagicLinkRateLimit(input: { + email: string; + request?: Request; + secret: string; + now?: Date; + client?: ThrottleClient; +}): Promise { + const now = input.now ?? new Date(); + const origin = input.request?.headers.get('origin')?.trim() || 'no-origin'; + const keys = { + email: bucketKey('email', input.email.trim().toLowerCase(), input.secret), + originIp: bucketKey('origin_ip', `${origin}\n${sourceAddress(input.request)}`, input.secret), + }; + try { + return await (input.client ?? prisma).$transaction(async (transaction) => { + await transaction.earlyBirdMagicLinkThrottle.deleteMany({ + where: { updatedAt: { lt: new Date(now.getTime() - THROTTLE_RETENTION_MS) } }, + }); + const emailAllowed = await consumeThrottleBucket(transaction, { + key: keys.email, + kind: 'email', + now, + windowMs: EMAIL_WINDOW_MS, + max: EMAIL_WINDOW_MAX, + }); + const originIpAllowed = await consumeThrottleBucket(transaction, { + key: keys.originIp, + kind: 'origin_ip', + now, + windowMs: ORIGIN_IP_WINDOW_MS, + max: ORIGIN_IP_WINDOW_MAX, + }); + return emailAllowed && originIpAllowed; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } catch { + // Abuse-control uncertainty must never turn into an unbounded send. + return false; + } +} + +export async function earlyBirdMagicLinkSessionAllowed( + userId: string, + contextPath: string | undefined, + client: Pick = prisma, +): Promise { + if (!contextPath?.endsWith(EARLY_BIRD_MAGIC_LINK_VERIFY_PATH)) return true; + return (await client.earlyBirdIdentity.count({ where: { userId } })) === 0; +} + +function localeFromMetadata(metadata: Record | undefined): 'es' | 'en' { + return metadata?.locale === 'es' ? 'es' : 'en'; +} + +async function waitForGenericResponseFloor(startedAt: number): Promise { + const remaining = GENERIC_RESPONSE_FLOOR_MS - (Date.now() - startedAt); + if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining)); +} + +/** + * Deliver through a narrow internal boundary owned by the existing mail service. + * This callback deliberately resolves for every outcome so the public endpoint + * cannot enumerate accounts or mail-provider state. + */ +export async function deliverEarlyBirdMagicLink(input: { + email: string; + url: string; + token: string; + metadata?: Record; +}, request?: Request): Promise { + const startedAt = Date.now(); + try { + const configuration = earlyBirdMagicLinkConfiguration(); + if (!configuration) return; + const email = input.email.trim().toLowerCase(); + const allowed = await consumeEarlyBirdMagicLinkRateLimit({ + email, + request, + secret: configuration.rateSecret, + }); + if (!allowed) return; + + // A magic link may create and later authenticate an email-only Listener, + // but it must not silently become a second credential for a social or + // supervised synthetic identity that happens to share the address. + const account = await prisma.earlyBirdUser.findUnique({ + where: { email }, + select: { identities: { select: { id: true }, take: 1 } }, + }); + if (account?.identities.length) return; + + const expiresAt = new Date(Date.now() + EARLY_BIRD_MAGIC_LINK_TTL_SECONDS * 1_000); + const idempotencyKey = createHmac('sha256', configuration.rateSecret) + .update(`listener-magic-link:${hashEarlyBirdMagicLinkToken(input.token)}`) + .digest('hex'); + await fetch(configuration.url, { + method: 'POST', + headers: { + Authorization: `Bearer ${configuration.token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ + contract_version: 'listener-magic-link.v1', + purpose: 'listener_sign_in', + recipient: email, + locale: localeFromMetadata(input.metadata), + magic_link_url: input.url, + expires_at: expiresAt.toISOString(), + }), + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(5_000), + }).catch(() => null); + } finally { + await waitForGenericResponseFloor(startedAt); + } +} From d2acd36ed56584deca7385dbc9802e39114b6839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 06:23:45 -0300 Subject: [PATCH 094/376] fix(listener): harden magic-link proxy throttling --- .../early-birds/__tests__/magic-link.test.ts | 35 +++++++++++++++++++ src/lib/early-birds/magic-link.ts | 12 +++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/lib/early-birds/__tests__/magic-link.test.ts b/src/lib/early-birds/__tests__/magic-link.test.ts index 21cbc239..cfc69fa7 100644 --- a/src/lib/early-birds/__tests__/magic-link.test.ts +++ b/src/lib/early-birds/__tests__/magic-link.test.ts @@ -138,6 +138,41 @@ describe('EarlyBird email magic link', () => { expect(JSON.stringify([...buckets.entries()])).not.toContain('203.0.113.44'); }); + it('does not let a prefixed forwarded address manufacture a new throttle bucket', async () => { + const { buckets, client } = throttleClient(); + const input = { + email: 'listener@example.test', + secret: 'rate-secret-with-at-least-32-characters-long', + now: new Date('2026-08-07T10:00:00.000Z'), + client: client as never, + }; + + await consumeEarlyBirdMagicLinkRateLimit({ + ...input, + request: new Request('https://listen.example.test', { + headers: { + origin: 'https://listen.example.test', + 'x-real-ip': '203.0.113.44', + 'x-forwarded-for': '198.51.100.1, 203.0.113.44', + }, + }), + }); + await consumeEarlyBirdMagicLinkRateLimit({ + ...input, + request: new Request('https://listen.example.test', { + headers: { + origin: 'https://listen.example.test', + 'x-real-ip': '203.0.113.44', + 'x-forwarded-for': '198.51.100.2, 203.0.113.44', + }, + }), + }); + + expect([...buckets.keys()]).toHaveLength(2); + expect(JSON.stringify([...buckets.entries()])).not.toContain('198.51.100'); + expect(JSON.stringify([...buckets.entries()])).not.toContain('203.0.113.44'); + }); + it('never lets magic-link verification become a credential for a social identity', async () => { const noIdentity = { earlyBirdIdentity: { count: vi.fn().mockResolvedValue(0) } }; const socialIdentity = { earlyBirdIdentity: { count: vi.fn().mockResolvedValue(1) } }; diff --git a/src/lib/early-birds/magic-link.ts b/src/lib/early-birds/magic-link.ts index 70e320d7..9b89f2f2 100644 --- a/src/lib/early-birds/magic-link.ts +++ b/src/lib/early-birds/magic-link.ts @@ -70,8 +70,16 @@ function bucketKey(kind: 'email' | 'origin_ip', value: string, secret: string): } function sourceAddress(request: Request | undefined): string { - const forwarded = request?.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); - const address = forwarded || request?.headers.get('x-real-ip')?.trim(); + // Listener nginx overwrites X-Real-IP with its direct peer. A caller may + // prefix X-Forwarded-For before `$proxy_add_x_forwarded_for`, so trusting + // its first value would let one client manufacture fresh throttle buckets. + const direct = request?.headers.get('x-real-ip')?.trim(); + const forwarded = request?.headers.get('x-forwarded-for') + ?.split(',') + .map((value) => value.trim()) + .filter(Boolean) + .at(-1); + const address = direct || forwarded; return address && address.length <= 128 ? address : 'unavailable'; } From f8325a988c474d4fecf61f08b7f12687741d4aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 06:39:41 -0300 Subject: [PATCH 095/376] feat(listener): add first-listen welcome access --- docs/architecture/EARLY_BIRDS_LISTENER.md | 19 ++- .../operations/EARLY_BIRDS_FREE_ACCEPTANCE.md | 35 ++++-- .../migration.sql | 22 ++++ prisma/schema.prisma | 17 +++ .../access-state/__tests__/route.test.ts | 61 +++++++++ src/app/api/early-birds/access-state/route.ts | 37 ++++++ .../welcome-access/__tests__/route.test.ts | 87 +++++++++++++ .../api/early-birds/welcome-access/route.ts | 103 +++++++++++++++ src/app/early-birds/__tests__/page.test.tsx | 42 +++++++ src/app/early-birds/page.tsx | 10 +- src/app/globals.css | 14 +++ .../early-birds/AccessBoundarySync.tsx | 88 +++++++++++++ src/components/early-birds/EarlyBirdHome.tsx | 18 ++- .../early-birds/EarlyBirdLanding.tsx | 17 ++- .../early-birds/WelcomeAccessAction.tsx | 49 ++++++++ .../__tests__/AccessBoundarySync.test.tsx | 53 ++++++++ .../__tests__/EarlyBirdLanding.test.tsx | 31 +++++ src/lib/early-birds/__tests__/access.test.ts | 37 +++++- .../__tests__/stream-lease.test.ts | 58 +++++++++ .../__tests__/welcome-access.test.ts | 104 +++++++++++++++ src/lib/early-birds/access.ts | 34 ++++- src/lib/early-birds/copy.ts | 10 ++ src/lib/early-birds/stream.ts | 17 +-- src/lib/early-birds/welcome-access.ts | 119 ++++++++++++++++++ 24 files changed, 1052 insertions(+), 30 deletions(-) create mode 100644 prisma/migrations/20260807100000_early_bird_welcome_access/migration.sql create mode 100644 src/app/api/early-birds/access-state/__tests__/route.test.ts create mode 100644 src/app/api/early-birds/access-state/route.ts create mode 100644 src/app/api/early-birds/welcome-access/__tests__/route.test.ts create mode 100644 src/app/api/early-birds/welcome-access/route.ts create mode 100644 src/components/early-birds/AccessBoundarySync.tsx create mode 100644 src/components/early-birds/WelcomeAccessAction.tsx create mode 100644 src/components/early-birds/__tests__/AccessBoundarySync.test.tsx create mode 100644 src/lib/early-birds/__tests__/welcome-access.test.ts create mode 100644 src/lib/early-birds/welcome-access.ts diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 95304b8a..1520ef85 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -81,7 +81,14 @@ Byte-exact copies live in `contracts/early-bird-authority/v1` and ## Ordinary Free listening window Registration does not fabricate a commerce membership. A signed-in account -without current canonical membership may instead select one recurring local +without current canonical membership and without a previously selected Free +schedule may explicitly start one 30-minute first listen. Registration, OAuth +callback, page view, Free for All and canonical membership never create or +consume it. Its durable one-row marker is account-bound and cannot be reset by +retry, refresh or a second device; leases and manifests are capped at the exact +server-side end. + +The same account may instead select one recurring local wall-clock start and listen for two real hours each day. The first selection is either **Listen free now**, derived from server time in the validated browser IANA zone, or an explicit local time. The selection is account-bound and may be @@ -98,7 +105,15 @@ Authorization resolves in this order: access; 2. otherwise the current recurring Free window grants access until its exact end; -3. otherwise access fails closed. +3. otherwise an already-started first listen grants access until its exact + 30-minute end; +4. otherwise access fails closed. + +Starting the first listen requires a same-origin authenticated idempotent POST. +It is available only before a recurring schedule exists. Selecting the schedule +first does not create or consume the first-listen row. The operational Free for +All override rejects first-listen activation, so public access never spends an +account's welcome session. The server resolves wall-clock dates with `Intl` timezone data. A fall-back ambiguity uses the first occurrence; a spring-forward nonexistent minute moves diff --git a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md index bbebdb3d..32a26d9c 100644 --- a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md +++ b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md @@ -17,6 +17,8 @@ device/browser, result and a non-sensitive symptom. - 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 @@ -43,6 +45,8 @@ Before a human session, require: - 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 @@ -62,29 +66,36 @@ remain open. 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. Confirm a configured account sees either **Listen free now** or a local-time - selection. Choose **Listen free now** for the first human pass. -4. Confirm the two-hour window is shown in the browser's local time together +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. -5. Begin **With introduction**. The intro may pause and seek. Confirm its +6. Begin **With introduction**. The intro may pause and seek. Confirm its natural completion hands off to the current Beacon live edge. -6. Stop, choose **Beacon only** and listen again. The Beacon exposes Stop but +7. Stop, choose **Beacon only** and listen again. The Beacon exposes Stop but no Pause or Seek; listening again rejoins the current live point. -7. Change the intro selector. Spanish must play the Spanish Amara Sol asset and +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. -8. Reload, background/foreground the browser and reconnect the network once. +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. -9. Open the same account on a second device; both may listen. A supervised +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. -10. Confirm logout is available both during and outside the Free window. After +11. Confirm logout is available both during and outside the Free window. After logout, the Listener session endpoint must be anonymous. -11. At the exact Free-window boundary, playback must stop after the bounded +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 @@ -94,8 +105,8 @@ 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 tracks that behavior and is -explicitly conditional on retaining the recurring two-hour Free model. The +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. 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/schema.prisma b/prisma/schema.prisma index 66e70c92..ea0074b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -429,6 +429,7 @@ model EarlyBirdUser { authSessions EarlyBirdAuthSession[] membership EarlyBirdMembershipProjection? freeSchedule EarlyBirdFreeSchedule? + welcomeAccess EarlyBirdWelcomeAccess? streamLeases EarlyBirdStreamLease[] @@map("early_bird_users") @@ -545,6 +546,22 @@ model EarlyBirdFreeSchedule { @@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") 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..2188d809 --- /dev/null +++ b/src/app/api/early-birds/access-state/__tests__/route.test.ts @@ -0,0 +1,61 @@ +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, +})); + +import { freeWindowState } from '@/lib/early-birds/free-window'; +import { welcomeAccessState } from '@/lib/early-birds/welcome-access'; +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: 'welcome', + allowedUntil: new Date('2026-08-07T16:00:00.000Z'), + membership: { allowed: false, projection: null }, + freeWindow: freeWindowState(null), + welcome: welcomeAccessState({ + accountId: 'listener-1', + startedAt: new Date('2026-08-07T15:30:00.000Z'), + endsAt: new Date('2026-08-07T16:00:00.000Z'), + activationRequestId: crypto.randomUUID(), + createdAt: new Date('2026-08-07T15:30:00.000Z'), + updatedAt: new Date('2026-08-07T15:30:00.000Z'), + }, 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({ + access: { kind: 'welcome', allowedUntil: '2026-08-07T16:00:00.000Z' }, + welcome: { active: true }, + }); + 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(); + }); +}); 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..38f07591 --- /dev/null +++ b/src/app/api/early-birds/access-state/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getEarlyBirdListeningAccess } from '@/lib/early-birds/access'; +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; +import { serializeFreeWindowState } from '@/lib/early-birds/free-window'; +import { serializeWelcomeAccessState } from '@/lib/early-birds/welcome-access'; + +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(); + 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: new Date().toISOString(), + access: { + allowed: access.allowed, + kind: access.kind, + allowedUntil: access.allowedUntil?.toISOString() ?? null, + }, + freeWindow: serializeFreeWindowState(access.freeWindow), + welcome: serializeWelcomeAccessState(access.welcome), + }, { headers: PRIVATE_HEADERS }); + } catch { + return NextResponse.json({ error: 'Listener access unavailable.' }, { + status: 503, + headers: PRIVATE_HEADERS, + }); + } +} diff --git a/src/app/api/early-birds/welcome-access/__tests__/route.test.ts b/src/app/api/early-birds/welcome-access/__tests__/route.test.ts new file mode 100644 index 00000000..0c2a66ef --- /dev/null +++ b/src/app/api/early-birds/welcome-access/__tests__/route.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + currentEarlyBirdSession: vi.fn(), + getEarlyBirdListeningAccess: vi.fn(), + startEarlyBirdWelcomeAccess: vi.fn(), +})); +vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession: mocks.currentEarlyBirdSession })); +vi.mock('@/lib/early-birds/access', () => ({ + getEarlyBirdListeningAccess: mocks.getEarlyBirdListeningAccess, +})); +vi.mock('@/lib/early-birds/welcome-access', async (importOriginal) => ({ + ...await importOriginal(), + startEarlyBirdWelcomeAccess: mocks.startEarlyBirdWelcomeAccess, +})); + +import { welcomeAccessState } from '@/lib/early-birds/welcome-access'; +import { POST } from '../route'; + +function request(body: unknown, origin = 'https://listen.harmonicbeacon.com') { + return new NextRequest('https://listen.harmonicbeacon.com/api/early-birds/welcome-access', { + method: 'POST', + headers: { origin, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('first-listen welcome API', () => { + beforeEach(() => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + vi.stubEnv('EARLY_BIRDS_AUTH_BASE_URL', 'https://listen.harmonicbeacon.com'); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); + }); + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it('requires same-origin authenticated explicit activation', async () => { + expect((await POST(request({}, 'https://attacker.invalid'))).status).toBe(403); + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + + mocks.currentEarlyBirdSession.mockResolvedValue(null); + expect((await POST(request({ activationRequestId: crypto.randomUUID() }))).status).toBe(401); + expect(mocks.startEarlyBirdWelcomeAccess).not.toHaveBeenCalled(); + }); + + it('does not consume welcome access while Free for All is active', async () => { + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + const response = await POST(request({ activationRequestId: crypto.randomUUID() })); + + expect(response.status).toBe(409); + expect(mocks.currentEarlyBirdSession).not.toHaveBeenCalled(); + expect(mocks.startEarlyBirdWelcomeAccess).not.toHaveBeenCalled(); + }); + + it('starts through the durable idempotent command and returns private state', async () => { + const activationRequestId = crypto.randomUUID(); + const active = { + accountId: 'listener-1', + startedAt: new Date('2026-08-07T15:30:00.000Z'), + endsAt: new Date('2026-08-07T16:00:00.000Z'), + activationRequestId, + createdAt: new Date('2026-08-07T15:30:00.000Z'), + updatedAt: new Date('2026-08-07T15:30:00.000Z'), + }; + mocks.startEarlyBirdWelcomeAccess.mockResolvedValue({ + access: active, + state: welcomeAccessState(active, new Date('2026-08-07T15:31:00.000Z')), + replayed: false, + }); + + const response = await POST(request({ activationRequestId })); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toContain('no-store'); + expect(mocks.startEarlyBirdWelcomeAccess).toHaveBeenCalledWith({ + accountId: 'listener-1', + activationRequestId, + }); + expect(await response.json()).toMatchObject({ + state: { active: true, endsAt: '2026-08-07T16:00:00.000Z' }, + }); + }); +}); diff --git a/src/app/api/early-birds/welcome-access/route.ts b/src/app/api/early-birds/welcome-access/route.ts new file mode 100644 index 00000000..ef1432af --- /dev/null +++ b/src/app/api/early-birds/welcome-access/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getEarlyBirdListeningAccess } from '@/lib/early-birds/access'; +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + earlyBirdsEnabled, + earlyBirdsFreeForAll, + earlyBirdsUnavailableResponse, +} from '@/lib/early-birds/enabled'; +import { + EarlyBirdWelcomeAccessInputError, + EarlyBirdWelcomeAccessUnavailableError, + serializeWelcomeAccessState, + startEarlyBirdWelcomeAccess, +} from '@/lib/early-birds/welcome-access'; + +export const dynamic = 'force-dynamic'; + +const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }; + +function sameOriginMutation(request: NextRequest): boolean { + const origin = request.headers.get('origin'); + if (!origin) return false; + const configured = [ + process.env.EARLY_BIRDS_AUTH_BASE_URL, + ...(process.env.EARLY_BIRDS_TRUSTED_ORIGINS ?? '').split(','), + ] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + const allowed = configured.length > 0 ? configured : [request.nextUrl.origin]; + return allowed.includes(origin); +} + +export async function GET(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + 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({ + state: serializeWelcomeAccessState(access.welcome), + serverNow: new Date().toISOString(), + }, { headers: PRIVATE_HEADERS }); + } catch { + return NextResponse.json({ error: 'Welcome access unavailable.' }, { + status: 503, + headers: PRIVATE_HEADERS, + }); + } +} + +export async function POST(request: NextRequest): Promise { + if (!earlyBirdsEnabled()) return earlyBirdsUnavailableResponse(); + if (earlyBirdsFreeForAll()) { + return NextResponse.json({ error: 'Public access is already active.' }, { + status: 409, + headers: PRIVATE_HEADERS, + }); + } + if (!sameOriginMutation(request)) { + return NextResponse.json({ error: 'Invalid request origin.' }, { status: 403, 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 }); + } + + let body: Record; + try { + body = await request.json() as Record; + } catch { + return NextResponse.json({ error: 'Malformed request.' }, { status: 400, headers: PRIVATE_HEADERS }); + } + + try { + const result = await startEarlyBirdWelcomeAccess({ + accountId: session.user.id, + activationRequestId: typeof body.activationRequestId === 'string' + ? body.activationRequestId + : '', + }); + return NextResponse.json({ + state: serializeWelcomeAccessState(result.state), + replayed: result.replayed, + }, { headers: PRIVATE_HEADERS }); + } catch (error) { + if (error instanceof EarlyBirdWelcomeAccessInputError) { + return NextResponse.json({ error: error.message }, { status: 400, headers: PRIVATE_HEADERS }); + } + if (error instanceof EarlyBirdWelcomeAccessUnavailableError) { + return NextResponse.json({ error: 'Welcome access is no longer available.' }, { + status: 409, + headers: PRIVATE_HEADERS, + }); + } + return NextResponse.json({ error: 'Welcome access unavailable.' }, { + status: 503, + headers: PRIVATE_HEADERS, + }); + } +} diff --git a/src/app/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx index 306f9bd2..1d2523c8 100644 --- a/src/app/early-birds/__tests__/page.test.tsx +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -46,6 +46,14 @@ const inactiveFreeWindow = { nextEnd: new Date('2026-08-08T12:00:00.000Z'), }; +const unusedWelcome = { + available: true, + active: false, + used: false, + startedAt: null, + endsAt: null, +}; + describe('EarlyBird Listener page', () => { it('renders the Listener directly without auth or membership in Free for All mode', async () => { vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); @@ -76,6 +84,8 @@ describe('EarlyBird Listener page', () => { kind: 'free-window', membership: { allowed: false, projection: null }, freeWindow: { ...inactiveFreeWindow, active: true }, + welcome: { ...unusedWelcome, available: false }, + allowedUntil: new Date('2026-08-07T17:30:00.000Z'), }); const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); @@ -101,6 +111,7 @@ describe('EarlyBird Listener page', () => { kind: 'denied', membership: { allowed: false, projection: null }, freeWindow: inactiveFreeWindow, + welcome: { ...unusedWelcome, available: false }, }); const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); @@ -115,6 +126,37 @@ describe('EarlyBird Listener page', () => { active: false, nextStart: '2026-08-08T10:00:00.000Z', }, + welcome: expect.objectContaining({ available: false }), + }); + }); + + it('renders the Listener during the one-time welcome session', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); + mocks.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); + mocks.headers.mockResolvedValue(new Headers()); + mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); + mocks.getEarlyBirdListeningAccess.mockResolvedValue({ + allowed: true, + kind: 'welcome', + allowedUntil: new Date('2026-08-07T16:00:00.000Z'), + membership: { allowed: false, projection: null }, + freeWindow: { ...inactiveFreeWindow, configured: false, nextStart: null, nextEnd: null }, + welcome: { + available: false, + active: true, + used: true, + startedAt: new Date('2026-08-07T15:30:00.000Z'), + endsAt: new Date('2026-08-07T16:00:00.000Z'), + }, + }); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.type).toBe(EarlyBirdHome); + expect(result.props).toMatchObject({ + accessKind: 'welcome', + accessUntil: '2026-08-07T16:00:00.000Z', }); }); }); diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index 6908a7da..75aec750 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -17,6 +17,7 @@ import { import { syntheticTeamEntryAllowed } from '@/lib/early-birds/synthetic-team-entry'; import { configuredEarlyBirdDropIn } from '@/lib/early-birds/drop-ins'; import { freeWindowState, serializeFreeWindowState } from '@/lib/early-birds/free-window'; +import { serializeWelcomeAccessState, welcomeAccessState } from '@/lib/early-birds/welcome-access'; import { earlyBirdMagicLinkAvailable } from '@/lib/early-birds/magic-link'; export const dynamic = 'force-dynamic'; @@ -48,6 +49,7 @@ export default async function EarlyBirdsPage({ } const params = await searchParams; + const serverNow = new Date().toISOString(); const incomingHeaders = new Headers(await requestHeaders()); const cookieStore = await cookies(); const session = await currentEarlyBirdSession().catch(() => null); @@ -63,7 +65,11 @@ export default async function EarlyBirdsPage({ ); } diff --git a/src/app/globals.css b/src/app/globals.css index 665cc4bb..b37ac3c3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1668,6 +1668,20 @@ body { line-height: 1; } +.listener-welcome-access { + display: grid; + gap: 0.65rem; + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border-subtle); +} + +.listener-welcome-access > p { + color: var(--text-muted); + font-size: 0.78rem; + line-height: 1.5; +} + .listener-free-window > p { margin-top: 0.85rem; color: var(--text-secondary); diff --git a/src/components/early-birds/AccessBoundarySync.tsx b/src/components/early-birds/AccessBoundarySync.tsx new file mode 100644 index 00000000..244c427d --- /dev/null +++ b/src/components/early-birds/AccessBoundarySync.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useEffect } from 'react'; + +type AccessKind = 'membership' | 'free-window' | 'welcome' | 'denied'; +const reloadPage = () => window.location.reload(); + +/** + * Revalidates once at a server-computed authorization boundary. Stream grants + * remain authoritative; this only keeps an already-open UI in sync without a + * reload or a continuous polling loop. + */ +export default function AccessBoundarySync({ + expectedKind, + boundaryAt, + serverNow, + onAccessChanged = reloadPage, +}: { + expectedKind: AccessKind; + boundaryAt: string | null; + serverNow: string; + onAccessChanged?: () => void; +}) { + useEffect(() => { + if (!boundaryAt) return; + let cancelled = false; + let inFlight = false; + let timer: number | null = null; + const mountedAt = Date.now(); + const boundaryDelay = new Date(boundaryAt).getTime() - new Date(serverNow).getTime(); + + const revalidate = async () => { + if (cancelled || inFlight) return; + inFlight = true; + if (timer !== null) window.clearTimeout(timer); + try { + const response = await fetch('/api/early-birds/access-state', { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }); + if (cancelled) return; + if (!response.ok) throw new Error('access state unavailable'); + const payload = await response.json() as { + access?: { kind?: AccessKind; allowedUntil?: string | null }; + }; + if ( + payload.access?.kind !== expectedKind + || (expectedKind !== 'denied' && payload.access?.allowedUntil !== boundaryAt) + ) { + onAccessChanged(); + return; + } + // A client clock may be ahead of the server by a few seconds. + timer = window.setTimeout(revalidate, 2_000); + } catch { + // Authorization and media leases still fail closed. Visibility + // or pageshow will provide another bounded opportunity. + timer = window.setTimeout(revalidate, 5_000); + } finally { + inFlight = false; + } + }; + + const arm = () => { + const elapsed = Date.now() - mountedAt; + const remaining = Math.max(0, boundaryDelay - elapsed + 750); + timer = window.setTimeout(revalidate, remaining); + }; + const revalidateAfterResume = () => { + const elapsed = Date.now() - mountedAt; + if (document.visibilityState !== 'hidden' && elapsed + 750 >= boundaryDelay) { + void revalidate(); + } + }; + + arm(); + window.addEventListener('pageshow', revalidateAfterResume); + document.addEventListener('visibilitychange', revalidateAfterResume); + return () => { + cancelled = true; + if (timer !== null) window.clearTimeout(timer); + window.removeEventListener('pageshow', revalidateAfterResume); + document.removeEventListener('visibilitychange', revalidateAfterResume); + }; + }, [boundaryAt, expectedKind, onAccessChanged, serverNow]); + + return null; +} diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index 60ff04c0..07370763 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -6,17 +6,22 @@ import { earlyBirdAuthClient } from '@/lib/early-birds/auth-client'; import { earlyBirdHomeCopy } from '@/lib/early-birds/copy'; import ListenerPlayer from './ListenerPlayer'; +import AccessBoundarySync from './AccessBoundarySync'; export default function EarlyBirdHome({ displayName, membershipSource, accessKind = 'membership', + accessUntil = null, + serverNow = new Date(0).toISOString(), dropIns, publicAccess = false, }: { displayName: string; membershipSource: string | null; - accessKind?: 'membership' | 'free-window'; + accessKind?: 'membership' | 'free-window' | 'welcome'; + accessUntil?: string | null; + serverNow?: string; dropIns: { es: string | null; en: string | null }; publicAccess?: boolean; }) { @@ -30,6 +35,13 @@ export default function EarlyBirdHome({ return (
+ {accessKind !== 'membership' && ( + + )}
@@ -40,7 +52,9 @@ export default function EarlyBirdHome({

{displayName}

- {accessKind === 'free-window' ? copy.freeActive : copy.active} + {accessKind === 'free-window' + ? copy.freeActive + : accessKind === 'welcome' ? copy.welcomeActive : copy.active} {membershipSource && {membershipSource}}
diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index 166bc5aa..44007198 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -7,10 +7,13 @@ import { useLocale } from '@/context/LocaleContext'; import { earlyBirdAuthClient } from '@/lib/early-birds/auth-client'; import { earlyBirdCopy } from '@/lib/early-birds/copy'; import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; +import type { SerializedEarlyBirdWelcomeAccessState } from '@/lib/early-birds/welcome-access'; +import AccessBoundarySync from './AccessBoundarySync'; import BeaconField from './BeaconField'; import FreeWindowSetup from './FreeWindowSetup'; import SyntheticTeamEntryForm from './SyntheticTeamEntryForm'; +import WelcomeAccessAction from './WelcomeAccessAction'; type Props = { signedIn: boolean; @@ -21,6 +24,8 @@ type Props = { emailMagicLinkAvailable: boolean; syntheticTeamEntryAvailable: boolean; freeWindow: SerializedEarlyBirdFreeWindowState; + welcome: SerializedEarlyBirdWelcomeAccessState; + serverNow: string; }; export default function EarlyBirdLanding(props: Props) { @@ -82,6 +87,13 @@ export default function EarlyBirdLanding(props: Props) { return (
+ {props.signedIn && props.freeWindow.nextStart && ( + + )}
@@ -132,7 +144,10 @@ export default function EarlyBirdLanding(props: Props) { {copy.redeem} ) : ( - + <> + {props.welcome.available && } + + )} +

{copy.welcomeDescription}

+ {error &&

{copy.welcomeError}

} +
+ ); +} diff --git a/src/components/early-birds/__tests__/AccessBoundarySync.test.tsx b/src/components/early-birds/__tests__/AccessBoundarySync.test.tsx new file mode 100644 index 00000000..88dfdd3f --- /dev/null +++ b/src/components/early-birds/__tests__/AccessBoundarySync.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import AccessBoundarySync from '../AccessBoundarySync'; + +describe('Listener access boundary synchronization', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('revalidates once at the server boundary and enters an active Free window', async () => { + const changed = vi.fn(); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + access: { kind: 'free-window', allowedUntil: '2026-08-07T17:00:00.000Z' }, + }), { status: 200 })); + render( + , + ); + + await act(async () => { await vi.advanceTimersByTimeAsync(60_750); }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('revalidates an active welcome at expiry and leaves the player', async () => { + const changed = vi.fn(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + access: { kind: 'denied', allowedUntil: null }, + }), { status: 200 })); + render( + , + ); + + await act(async () => { await vi.advanceTimersByTimeAsync(1_750); }); + + expect(changed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index cc66d905..50e57e37 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -41,6 +41,14 @@ function renderLanding(overrides: Partial , @@ -142,4 +150,27 @@ describe('EarlyBird public landing', () => { expect(screen.queryByRole('button', { name: 'Continue with Google' })).toBeNull(); expect(screen.getByRole('button', { name: 'Sign out' })).toBeEnabled(); }); + + it('offers the one-time welcome listen without selecting a recurring schedule', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('{}', { status: 409 })); + renderLanding({ + signedIn: true, + providers: { google: true, apple: false }, + welcome: { + available: true, + active: false, + used: false, + startedAt: null, + endsAt: null, + }, + }); + + await userEvent.click(screen.getByRole('button', { name: 'Listen for 30 minutes now' })); + + expect(fetchMock).toHaveBeenCalledWith('/api/early-birds/welcome-access', expect.objectContaining({ + method: 'POST', + })); + expect(fetchMock.mock.calls[0]?.[1]?.body).toContain('activationRequestId'); + fetchMock.mockRestore(); + }); }); diff --git a/src/lib/early-birds/__tests__/access.test.ts b/src/lib/early-birds/__tests__/access.test.ts index e441bee9..439dba0c 100644 --- a/src/lib/early-birds/__tests__/access.test.ts +++ b/src/lib/early-birds/__tests__/access.test.ts @@ -1,4 +1,8 @@ -import type { EarlyBirdFreeSchedule, EarlyBirdMembershipProjection } from '@prisma/client'; +import type { + EarlyBirdFreeSchedule, + EarlyBirdMembershipProjection, + EarlyBirdWelcomeAccess, +} from '@prisma/client'; import { describe, expect, it } from 'vitest'; import { listeningAccessDecision } from '../access'; @@ -43,6 +47,18 @@ function schedule(): EarlyBirdFreeSchedule { }; } +function welcome(overrides: Partial = {}): EarlyBirdWelcomeAccess { + return { + accountId: 'listener-1', + startedAt: new Date('2026-08-07T15:30:00.000Z'), + endsAt: new Date('2026-08-07T16:00:00.000Z'), + activationRequestId: '00000000-0000-4000-8000-000000000002', + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + describe('combined Listener access authority', () => { it('gives an active canonical membership unrestricted priority over Free', () => { expect(listeningAccessDecision(membership(), schedule(), NOW)).toMatchObject({ @@ -71,4 +87,23 @@ describe('combined Listener access authority', () => { allowedUntil: paidThrough, }); }); + + it('allows an active welcome listen only until its exact durable boundary', () => { + expect(listeningAccessDecision(null, null, NOW, welcome())).toMatchObject({ + allowed: true, + kind: 'welcome', + allowedUntil: new Date('2026-08-07T16:00:00.000Z'), + }); + expect(listeningAccessDecision( + null, + null, + new Date('2026-08-07T16:00:00.000Z'), + welcome(), + )).toMatchObject({ allowed: false, kind: 'denied' }); + }); + + it('does not let welcome access override membership or an active Free window', () => { + expect(listeningAccessDecision(membership(), null, NOW, welcome()).kind).toBe('membership'); + expect(listeningAccessDecision(null, schedule(), NOW, welcome()).kind).toBe('free-window'); + }); }); diff --git a/src/lib/early-birds/__tests__/stream-lease.test.ts b/src/lib/early-birds/__tests__/stream-lease.test.ts index f04d9931..c6e485fb 100644 --- a/src/lib/early-birds/__tests__/stream-lease.test.ts +++ b/src/lib/early-birds/__tests__/stream-lease.test.ts @@ -4,6 +4,7 @@ const tx = vi.hoisted(() => ({ $queryRaw: vi.fn(), earlyBirdMembershipProjection: { findUnique: vi.fn() }, earlyBirdFreeSchedule: { findUnique: vi.fn() }, + earlyBirdWelcomeAccess: { findUnique: vi.fn() }, earlyBirdStreamLease: { findUnique: vi.fn(), findMany: vi.fn(), @@ -24,6 +25,7 @@ const prisma = vi.hoisted(() => ({ }, earlyBirdMembershipProjection: { findUnique: vi.fn() }, earlyBirdFreeSchedule: { findUnique: vi.fn() }, + earlyBirdWelcomeAccess: { findUnique: vi.fn() }, })); vi.mock('@/lib/db', () => ({ prisma })); @@ -31,6 +33,8 @@ import { acquireEarlyBirdStreamLease, acquireFreeForAllStreamLease, authorizeFreeForAllStreamLease, + authorizeEarlyBirdStreamLease, + EarlyBirdAccessDeniedError, EarlyBirdDeviceCapacityError, earlyBirdDeviceDigest, EARLY_BIRD_LEASE_TTL_MS, @@ -52,6 +56,8 @@ describe('EarlyBird two-device leases', () => { }); tx.earlyBirdFreeSchedule.findUnique.mockResolvedValue(null); prisma.earlyBirdFreeSchedule.findUnique.mockResolvedValue(null); + tx.earlyBirdWelcomeAccess.findUnique.mockResolvedValue(null); + prisma.earlyBirdWelcomeAccess.findUnique.mockResolvedValue(null); tx.earlyBirdStreamLease.findUnique.mockResolvedValue(null); tx.earlyBirdStreamLease.updateMany.mockResolvedValue({ count: 1 }); prisma.earlyBirdStreamLease.updateMany.mockResolvedValue({ count: 1 }); @@ -172,6 +178,58 @@ describe('EarlyBird two-device leases', () => { }); }); + it('caps a welcome lease at the exact thirty-minute boundary', async () => { + tx.earlyBirdMembershipProjection.findUnique.mockResolvedValue(null); + tx.earlyBirdFreeSchedule.findUnique.mockResolvedValue(null); + tx.earlyBirdWelcomeAccess.findUnique.mockResolvedValue({ + accountId: 'listener-1', + startedAt: new Date('2026-08-06T11:32:00.000Z'), + endsAt: new Date('2026-08-06T12:02:00.000Z'), + activationRequestId: '00000000-0000-4000-8000-000000000004', + }); + tx.earlyBirdStreamLease.findMany.mockResolvedValue([]); + tx.earlyBirdStreamLease.create.mockImplementation(({ data }) => ({ + id: '00000000-0000-4000-8000-000000000003', + ...data, + })); + const issuer: EarlyBirdStreamUrlIssuer = { + issue: vi.fn().mockImplementation(({ leaseExpiresAt }) => ({ + manifestUrl: '/api/early-birds/stream/manifest?leaseId=3', + expiresAt: leaseExpiresAt, + })), + }; + + const result = await acquireEarlyBirdStreamLease( + 'listener-1', + 'device_abcdefghijklmnopqrstuvwxyz', + NOW, + issuer, + ); + + expect(result.leaseExpiresAt).toEqual(new Date('2026-08-06T12:02:00.000Z')); + }); + + it('rechecks active welcome access before authorizing a manifest', async () => { + prisma.earlyBirdMembershipProjection.findUnique.mockResolvedValue(null); + prisma.earlyBirdFreeSchedule.findUnique.mockResolvedValue(null); + prisma.earlyBirdWelcomeAccess.findUnique.mockResolvedValue({ + accountId: 'listener-1', + startedAt: new Date('2026-08-06T11:30:00.000Z'), + endsAt: new Date('2026-08-06T12:00:00.000Z'), + }); + prisma.earlyBirdStreamLease.findFirst.mockResolvedValue({ + id: '00000000-0000-4000-8000-000000000003', + evictedAt: null, + expiresAt: new Date('2026-08-06T12:03:00.000Z'), + }); + + await expect(authorizeEarlyBirdStreamLease( + 'listener-1', + '00000000-0000-4000-8000-000000000003', + NOW, + )).rejects.toBeInstanceOf(EarlyBirdAccessDeniedError); + }); + it('renews an idle prepared lease without raising its eviction priority', async () => { tx.earlyBirdStreamLease.findFirst.mockResolvedValue({ id: '00000000-0000-4000-8000-000000000003', diff --git a/src/lib/early-birds/__tests__/welcome-access.test.ts b/src/lib/early-birds/__tests__/welcome-access.test.ts new file mode 100644 index 00000000..9928ec29 --- /dev/null +++ b/src/lib/early-birds/__tests__/welcome-access.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const tx = vi.hoisted(() => ({ + $queryRaw: vi.fn(), + earlyBirdMembershipProjection: { findUnique: vi.fn() }, + earlyBirdFreeSchedule: { findUnique: vi.fn() }, + earlyBirdWelcomeAccess: { findUnique: vi.fn(), create: vi.fn() }, + earlyBirdStreamLease: { updateMany: vi.fn() }, +})); +const prisma = vi.hoisted(() => ({ + $transaction: vi.fn((callback: (value: typeof tx) => unknown) => callback(tx)), +})); + +vi.mock('@/lib/db', () => ({ prisma })); + +import { + EARLY_BIRD_WELCOME_DURATION_MS, + EarlyBirdWelcomeAccessUnavailableError, + startEarlyBirdWelcomeAccess, + welcomeAccessState, +} from '../welcome-access'; + +const NOW = new Date('2026-08-07T15:30:00.000Z'); +const REQUEST_ID = '00000000-0000-4000-8000-000000000003'; + +function row() { + return { + accountId: 'listener-1', + startedAt: NOW, + endsAt: new Date(NOW.getTime() + EARLY_BIRD_WELCOME_DURATION_MS), + activationRequestId: REQUEST_ID, + createdAt: NOW, + updatedAt: NOW, + }; +} + +describe('one-time Listener welcome access', () => { + beforeEach(() => { + vi.clearAllMocks(); + tx.$queryRaw.mockResolvedValue([{ id: 'listener-1' }]); + tx.earlyBirdMembershipProjection.findUnique.mockResolvedValue(null); + tx.earlyBirdFreeSchedule.findUnique.mockResolvedValue(null); + tx.earlyBirdWelcomeAccess.findUnique.mockResolvedValue(null); + tx.earlyBirdWelcomeAccess.create.mockResolvedValue(row()); + tx.earlyBirdStreamLease.updateMany.mockResolvedValue({ count: 0 }); + }); + + it('starts exactly thirty minutes only after the explicit idempotent command', async () => { + const result = await startEarlyBirdWelcomeAccess({ + accountId: 'listener-1', + activationRequestId: REQUEST_ID, + now: NOW, + }); + + expect(result.state).toMatchObject({ available: false, active: true, used: true }); + expect(tx.earlyBirdWelcomeAccess.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + startedAt: NOW, + endsAt: new Date(NOW.getTime() + 30 * 60 * 1000), + }), + }); + }); + + it('replays the same activation without extending the original end', async () => { + tx.earlyBirdWelcomeAccess.findUnique.mockResolvedValue(row()); + + const result = await startEarlyBirdWelcomeAccess({ + accountId: 'listener-1', + activationRequestId: REQUEST_ID, + now: new Date(NOW.getTime() + 5 * 60 * 1000), + }); + + expect(result.replayed).toBe(true); + expect(result.access.endsAt).toEqual(new Date(NOW.getTime() + 30 * 60 * 1000)); + expect(tx.earlyBirdWelcomeAccess.create).not.toHaveBeenCalled(); + }); + + it('fails closed after any prior use or recurring schedule selection', async () => { + tx.earlyBirdWelcomeAccess.findUnique.mockResolvedValue({ ...row(), activationRequestId: 'other' }); + await expect(startEarlyBirdWelcomeAccess({ + accountId: 'listener-1', + activationRequestId: REQUEST_ID, + now: NOW, + })).rejects.toBeInstanceOf(EarlyBirdWelcomeAccessUnavailableError); + + tx.earlyBirdWelcomeAccess.findUnique.mockResolvedValue(null); + tx.earlyBirdFreeSchedule.findUnique.mockResolvedValue({ accountId: 'listener-1' }); + await expect(startEarlyBirdWelcomeAccess({ + accountId: 'listener-1', + activationRequestId: REQUEST_ID, + now: NOW, + })).rejects.toBeInstanceOf(EarlyBirdWelcomeAccessUnavailableError); + }); + + it('does not expose an unused welcome when eligibility is absent', () => { + expect(welcomeAccessState(null, NOW, false)).toEqual({ + available: false, + active: false, + used: false, + startedAt: null, + endsAt: null, + }); + }); +}); diff --git a/src/lib/early-birds/access.ts b/src/lib/early-birds/access.ts index e0d115e6..fc22a1b9 100644 --- a/src/lib/early-birds/access.ts +++ b/src/lib/early-birds/access.ts @@ -1,16 +1,22 @@ -import type { EarlyBirdFreeSchedule, EarlyBirdMembershipProjection } from '@prisma/client'; +import type { + EarlyBirdFreeSchedule, + EarlyBirdMembershipProjection, + EarlyBirdWelcomeAccess, +} from '@prisma/client'; import { prisma } from '@/lib/db'; import { freeWindowState, type EarlyBirdFreeWindowState } from './free-window'; import { membershipAccessDecision, type EarlyBirdAccessDecision } from './membership'; +import { welcomeAccessState, type EarlyBirdWelcomeAccessState } from './welcome-access'; export type EarlyBirdListeningAccess = { allowed: boolean; - kind: 'membership' | 'free-window' | 'denied'; + kind: 'membership' | 'free-window' | 'welcome' | 'denied'; allowedUntil: Date | null; membership: EarlyBirdAccessDecision; freeWindow: EarlyBirdFreeWindowState; + welcome: EarlyBirdWelcomeAccessState; }; function membershipBoundary(projection: EarlyBirdMembershipProjection): Date | null { @@ -23,9 +29,15 @@ export function listeningAccessDecision( projection: EarlyBirdMembershipProjection | null, schedule: EarlyBirdFreeSchedule | null, now = new Date(), + welcomeAccess: EarlyBirdWelcomeAccess | null = null, ): EarlyBirdListeningAccess { const membership = membershipAccessDecision(projection, now); const freeWindow = freeWindowState(schedule, now); + const welcome = welcomeAccessState( + welcomeAccess, + now, + !membership.allowed && schedule === null, + ); if (membership.allowed && membership.projection) { return { allowed: true, @@ -33,6 +45,7 @@ export function listeningAccessDecision( allowedUntil: membershipBoundary(membership.projection), membership, freeWindow, + welcome, }; } if (freeWindow.active && freeWindow.activeEnd) { @@ -42,6 +55,17 @@ export function listeningAccessDecision( allowedUntil: freeWindow.activeEnd, membership, freeWindow, + welcome, + }; + } + if (welcome.active && welcome.endsAt) { + return { + allowed: true, + kind: 'welcome', + allowedUntil: welcome.endsAt, + membership, + freeWindow, + welcome, }; } return { @@ -50,6 +74,7 @@ export function listeningAccessDecision( allowedUntil: null, membership, freeWindow, + welcome, }; } @@ -57,9 +82,10 @@ export async function getEarlyBirdListeningAccess( accountId: string, now = new Date(), ): Promise { - const [projection, schedule] = await Promise.all([ + const [projection, schedule, welcome] = await Promise.all([ prisma.earlyBirdMembershipProjection.findUnique({ where: { accountId } }), prisma.earlyBirdFreeSchedule.findUnique({ where: { accountId } }), + prisma.earlyBirdWelcomeAccess.findUnique({ where: { accountId } }), ]); - return listeningAccessDecision(projection, schedule, now); + return listeningAccessDecision(projection, schedule, now, welcome); } diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index c6106db5..1bb5b89b 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -33,6 +33,10 @@ export const earlyBirdCopy = { nextFreeWindow: 'Tu próxima escucha comienza', freeScheduleLocked: 'Podrás cambiar este horario', freeScheduleError: 'No pudimos guardar el horario. Inténtalo nuevamente.', + welcomeListen: 'Escuchar 30 minutos ahora', + welcomeStarting: 'Abriendo el Beacon…', + welcomeDescription: 'Tu primera escucha no fija todavía el horario diario.', + welcomeError: 'No pudimos abrir esta primera escucha. Inténtalo nuevamente.', authError: 'No pudimos completar el acceso. Usa el mismo proveedor con el que creaste tu cuenta o contacta a soporte.', privacy: 'Tu cuenta y membresía administran el acceso privado. No creamos historiales personales de escucha.', }, @@ -68,6 +72,10 @@ export const earlyBirdCopy = { nextFreeWindow: 'Your next listening window begins', freeScheduleLocked: 'You can change this schedule', freeScheduleError: 'We could not save the schedule. Please try again.', + welcomeListen: 'Listen for 30 minutes now', + welcomeStarting: 'Opening the Beacon…', + welcomeDescription: 'Your first listen does not set your daily time yet.', + welcomeError: 'We could not open this first listen. Please try again.', authError: 'We could not complete sign-in. Use the provider that created your account, or contact support.', privacy: 'Your account and membership manage private access. We do not create personal listening histories.', }, @@ -129,6 +137,7 @@ export const earlyBirdHomeCopy = { signOut: 'Cerrar sesión', active: 'Founding Listener activo', freeActive: 'Horario Free activo', + welcomeActive: 'Primera escucha activa', account: 'Cuenta', }, en: { @@ -163,6 +172,7 @@ export const earlyBirdHomeCopy = { signOut: 'Sign out', active: 'Founding Listener active', freeActive: 'Free window active', + welcomeActive: 'First listen active', account: 'Account', }, } satisfies Record>; diff --git a/src/lib/early-birds/stream.ts b/src/lib/early-birds/stream.ts index b99fffc0..08b44d26 100644 --- a/src/lib/early-birds/stream.ts +++ b/src/lib/early-birds/stream.ts @@ -95,7 +95,7 @@ export type LeaseAcquisition = { export class EarlyBirdAccessDeniedError extends Error { constructor() { - super('An active EarlyBird membership is required'); + super('Active Listener access is required'); this.name = 'EarlyBirdAccessDeniedError'; } } @@ -234,12 +234,13 @@ export async function authorizeEarlyBirdStreamLease( leaseId: string, now = new Date(), ) { - const [projection, schedule, lease] = await Promise.all([ + const [projection, schedule, welcome, lease] = await Promise.all([ prisma.earlyBirdMembershipProjection.findUnique({ where: { accountId } }), prisma.earlyBirdFreeSchedule.findUnique({ where: { accountId } }), + prisma.earlyBirdWelcomeAccess.findUnique({ where: { accountId } }), prisma.earlyBirdStreamLease.findFirst({ where: { id: leaseId, accountId } }), ]); - if (!listeningAccessDecision(projection, schedule, now).allowed) { + if (!listeningAccessDecision(projection, schedule, now, welcome).allowed) { throw new EarlyBirdAccessDeniedError(); } if (!lease) throw new EarlyBirdLeaseInactiveError('missing'); @@ -263,11 +264,12 @@ export async function acquireEarlyBirdStreamLease( ); if (accountRows.length !== 1) throw new EarlyBirdAccessDeniedError(); - const [projection, schedule] = await Promise.all([ + const [projection, schedule, welcome] = await Promise.all([ tx.earlyBirdMembershipProjection.findUnique({ where: { accountId } }), tx.earlyBirdFreeSchedule.findUnique({ where: { accountId } }), + tx.earlyBirdWelcomeAccess.findUnique({ where: { accountId } }), ]); - const access = listeningAccessDecision(projection, schedule, now); + const access = listeningAccessDecision(projection, schedule, now, welcome); if (!access.allowed) { throw new EarlyBirdAccessDeniedError(); } @@ -351,11 +353,12 @@ export async function heartbeatEarlyBirdStreamLease( refreshPriority = true, ): Promise<{ leaseExpiresAt: Date; stream: StreamUrlGrant }> { const lease = await prisma.$transaction(async (tx) => { - const [projection, schedule] = await Promise.all([ + const [projection, schedule, welcome] = await Promise.all([ tx.earlyBirdMembershipProjection.findUnique({ where: { accountId } }), tx.earlyBirdFreeSchedule.findUnique({ where: { accountId } }), + tx.earlyBirdWelcomeAccess.findUnique({ where: { accountId } }), ]); - const access = listeningAccessDecision(projection, schedule, now); + const access = listeningAccessDecision(projection, schedule, now, welcome); if (!access.allowed) { throw new EarlyBirdAccessDeniedError(); } diff --git a/src/lib/early-birds/welcome-access.ts b/src/lib/early-birds/welcome-access.ts new file mode 100644 index 00000000..a7fa896a --- /dev/null +++ b/src/lib/early-birds/welcome-access.ts @@ -0,0 +1,119 @@ +import type { + EarlyBirdFreeSchedule, + EarlyBirdMembershipProjection, + EarlyBirdWelcomeAccess, +} from '@prisma/client'; +import { Prisma } from '@prisma/client'; + +import { prisma } from '@/lib/db'; + +import { membershipAccessDecision } from './membership'; + +export const EARLY_BIRD_WELCOME_DURATION_MS = 30 * 60 * 1000; + +export type EarlyBirdWelcomeAccessState = { + available: boolean; + active: boolean; + used: boolean; + startedAt: Date | null; + endsAt: Date | null; +}; + +export class EarlyBirdWelcomeAccessInputError extends Error { + constructor(message: string) { + super(message); + this.name = 'EarlyBirdWelcomeAccessInputError'; + } +} + +export class EarlyBirdWelcomeAccessUnavailableError extends Error { + constructor() { + super('The first-listen welcome access is not available'); + this.name = 'EarlyBirdWelcomeAccessUnavailableError'; + } +} + +export function welcomeAccessState( + access: EarlyBirdWelcomeAccess | null, + now = new Date(), + eligible = true, +): EarlyBirdWelcomeAccessState { + const active = Boolean(access && access.startedAt <= now && now < access.endsAt); + return { + available: eligible && access === null, + active, + used: access !== null, + startedAt: access?.startedAt ?? null, + endsAt: access?.endsAt ?? null, + }; +} + +function eligibleForWelcome( + projection: EarlyBirdMembershipProjection | null, + schedule: EarlyBirdFreeSchedule | null, + now: Date, +): boolean { + return !membershipAccessDecision(projection, now).allowed && schedule === null; +} + +export async function startEarlyBirdWelcomeAccess(input: { + accountId: string; + activationRequestId: string; + now?: Date; +}): Promise<{ access: EarlyBirdWelcomeAccess; state: EarlyBirdWelcomeAccessState; replayed: boolean }> { + const now = input.now ?? new Date(); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + .test(input.activationRequestId)) { + throw new EarlyBirdWelcomeAccessInputError('activationRequestId must be a UUID'); + } + + const outcome = await prisma.$transaction(async (tx) => { + const accounts = await tx.$queryRaw>( + Prisma.sql`SELECT "id" FROM "early_bird_users" WHERE "id" = ${input.accountId} FOR UPDATE`, + ); + if (accounts.length !== 1) { + throw new EarlyBirdWelcomeAccessInputError('Listener account does not exist'); + } + + const [projection, schedule, existing] = await Promise.all([ + tx.earlyBirdMembershipProjection.findUnique({ where: { accountId: input.accountId } }), + tx.earlyBirdFreeSchedule.findUnique({ where: { accountId: input.accountId } }), + tx.earlyBirdWelcomeAccess.findUnique({ where: { accountId: input.accountId } }), + ]); + if (existing?.activationRequestId === input.activationRequestId) { + return { access: existing, replayed: true }; + } + if (existing || !eligibleForWelcome(projection, schedule, now)) { + throw new EarlyBirdWelcomeAccessUnavailableError(); + } + + const access = await tx.earlyBirdWelcomeAccess.create({ + data: { + accountId: input.accountId, + startedAt: now, + endsAt: new Date(now.getTime() + EARLY_BIRD_WELCOME_DURATION_MS), + activationRequestId: input.activationRequestId, + }, + }); + await tx.earlyBirdStreamLease.updateMany({ + where: { accountId: input.accountId, evictedAt: null }, + data: { evictedAt: now }, + }); + return { access, replayed: false }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + + return { + ...outcome, + state: welcomeAccessState(outcome.access, now), + }; +} + +export function serializeWelcomeAccessState(state: EarlyBirdWelcomeAccessState) { + return { + ...state, + startedAt: state.startedAt?.toISOString() ?? null, + endsAt: state.endsAt?.toISOString() ?? null, + }; +} + +export type SerializedEarlyBirdWelcomeAccessState = ReturnType; From 029f220d347db59ca4581e0f9039ef55e1d1094c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 06:52:19 -0300 Subject: [PATCH 096/376] docs(listener): record welcome access rollout --- .../operations/EARLY_BIRDS_FREE_ACCEPTANCE.md | 4 +- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 36 ++++++++++++++-- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 43 +++++++++++-------- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md index 32a26d9c..2ac50fca 100644 --- a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md +++ b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md @@ -11,8 +11,8 @@ device/browser, result and a non-sensitive symptom. ## Fixed candidate -- Listener application: `575b75aae5609b1813485d955a3e8ea753018084` -- Listener schema: `20260807070000_early_bird_free_schedule` +- 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 diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index e1413f7a..3043bcac 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,12 +1,42 @@ # EarlyBirds isolated staging runtime -## 2026-08-07 registered Free and identity hardening +## 2026-08-07 first-listen access and boundary synchronization The isolated Listener runs 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 -ON after the acceptance exercises below; it remains independent from account -schedules and membership. +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 diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index eae98014..87fc88c4 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -16,13 +16,13 @@ an event-stack deployment or an acoustic change. | Artifact | Exact value | |---|---| -| Deployed Listener application | `575b75aae5609b1813485d955a3e8ea753018084` | -| Branch/documentation head | `a285ce7` | -| Listener database schema | `20260807070000_early_bird_free_schedule` | +| Deployed Listener application | `dad29d4dc5010603a5bbc7ed309c8f78e7c0f384` | +| Branch/documentation head | `dad29d4` | +| Listener database schema | `20260807100000_early_bird_welcome_access` | | Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | | Public mode | Free for All OFF during coordinated registered-Free acceptance | -| Immediate Listener rollback | `d7ed952` | -| Additional Listener rollback | `f6e57b0` | +| Immediate Listener rollback | `55bf282` | +| Additional Listener rollback | `575b75a` | Health must attest the deployed application SHA, not the later documentation or test-only branch head. @@ -37,10 +37,12 @@ test-only branch head. | 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, invitation/internal/event/staff surfaces. | | 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 | Listener ready; authority blocked | #221 is merged and hidden/fail-closed until the existing Gmail authority implements `SairaAsua/proyecciones-mito#44` and protected delivery values are installed. | | Two-hour recurring Free window | Proven | Unit/integration matrix plus deployed registered-Free smoke for custom and Listen now. | +| One-time 30-minute first listen | Proven | Deployed protected smoke covered virgin state, explicit activation, exact duration, idempotent replay, lease/manifest cap and one-time rejection. | | Seven-day change lock and idempotency | Proven | Runtime custom selection, exact replay and 409 cooldown response. | | IANA zone, DST and server clock | Proven in tests | Spring gap, fall ambiguity, canonical zone and server-derived Listen now are deterministic. Human review covers comprehension only. | -| Current/next/change state | Proven with known refresh gap | Serialized API state and ES/EN component tests; custom runtime response included next start and change boundary. Human acceptance found that an already-open waiting page needed reload when the window began; #216 records the conditional follow-up. | +| Current/next/change state | Deployed; human timing pending | The page arms server-boundary revalidation without continuous polling and has automated start/end tests. #216 retains the physical boundary gate. | | Lease cannot outlive Free window | Proven | Runtime three-device smoke compared every lease expiry with the exact active end; manifest authorization repeats the same 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. | @@ -52,9 +54,9 @@ test-only branch head. | Audio guardrail | Proven | Frozen-audio gate is green; this registration slice changed no asset, codec, rate, channel, gain, fade, buffer, routing or event audio. | | 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`; root is 48% used/99 GB free and secondary volume is 6% used. | +| Storage | Proven | Approved media is on `/mnt/beacon-data`; root is 51% used/93 GB free and secondary volume is 6% used. | | 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,087 tests, ESLint, TypeScript, build, frozen-audio, preview, origin and observability checks are green. | +| Full gates | Proven | 1,124 tests, ESLint, TypeScript, build, Prisma, preview, origin and observability checks are green. | ## Delivered commits @@ -63,10 +65,13 @@ test-only branch head. - `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. +- `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. -The branch is clean and pushed. PR #203 remains draft and mergeable. The -application was not rebuilt for documentation/smoke-only heads. +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 @@ -92,8 +97,12 @@ application was not rebuilt for documentation/smoke-only heads. membership consumption itself is complete. - #198 remains open: physical acoustic/accessibility and 60-minute acceptance. - #201 is In Progress: the human acceptance matrix. -- #216 is Todo and conditional on retaining the recurring two-hour Free model: - an open waiting page must revalidate when its window begins or ends. +- #216 is In Progress: the boundary implementation is deployed and needs only + physical start/end confirmation. +- #217 remains open on the external Gmail delivery endpoint #44. +- #218 is closed/Done with deployed runtime evidence. +- #219 remains In Progress for physical iPhone natural-handoff/reconnect + acceptance; the gesture-safe implementation is deployed. - #213 is Todo; #211/#212 and the larger campaign/cosmic-campfire journey are post-MVP and do not block this bounded test. @@ -115,10 +124,10 @@ alter audio or merge/promote the branch as part of an automated test. ## Rollback Restore root-only -`/etc/harmonic-beacon/earlybirds-preview.env.pre-575b75a`, select Listener image -`d7ed952`, retain the preview database and origin media, recreate only the -isolated Listener and run the preview health smoke. The prior nginx snapshot is -`/etc/nginx/sites-available/listen.harmonicbeacon.com.pre-d7ed952`. +`/etc/harmonic-beacon/earlybirds-preview.env.pre-dad29d4`, select Listener image +`55bf282`, retain the preview database, additive welcome table and origin media, +recreate only the isolated Listener and run the preview health smoke. Nginx was +not changed by this deployment. 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 From 3f2a615dae19d137c02da72c38805192f2f47d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 16:53:37 -0300 Subject: [PATCH 097/376] docs(listener): preserve founder price for life MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolás Echániz --- ...4-provider-neutral-earlybird-membership.md | 17 ++++- docs/plans/EARLY_BIRDS.md | 76 +++++++++++-------- 2 files changed, 57 insertions(+), 36 deletions(-) diff --git a/docs/decisions/0004-provider-neutral-earlybird-membership.md b/docs/decisions/0004-provider-neutral-earlybird-membership.md index 3ba34941..990d9db4 100644 --- a/docs/decisions/0004-provider-neutral-earlybird-membership.md +++ b/docs/decisions/0004-provider-neutral-earlybird-membership.md @@ -9,10 +9,19 @@ 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 2/month offer revision. Involuntary -payment failure receives 14 days of grace. Voluntary cancellation remains active -through paid-through time and then loses founder terms. Refund, dispute and -administrative revoke end access immediately. +The founder offer is an immutable USD 2/month offer revision. The right to that +price is granted only after the first canonical paid activation and remains +attached for life to the opaque Listener account, independently of email or +identity provider. Voluntary cancellation ends active access after paid-through +time but does not remove the account's founder-price eligibility; a later +reactivation uses the founder offer again. Involuntary payment failure receives +14 days of grace. Refund, dispute and administrative revoke end access +immediately without using a browser redirect as commercial truth. + +Founder-price eligibility, active membership, current listening authorization +and payment/reconciliation history are separate durable concepts. 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 diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 1bdaafc8..64d432b6 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -1,7 +1,7 @@ # EarlyBirds: product and delivery plan > **Status:** Accepted implementation baseline -> **Date:** 2026-08-06 +> **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 @@ -14,15 +14,17 @@ the current repository. ## 1. Outcome -EarlyBirds is a simple paid listening membership for people who want a private, -continuous relationship with the Beacon outside scheduled events. +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. obtain one Free invitation grant or a valid paid EarlyBird membership through - the provider-neutral commerce authority; -3. open a private, receive-only listening home; +2. use one immediate 30-minute welcome listen, select a recurring two-hour daily + Free window, 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; @@ -50,10 +52,11 @@ change has passed its own audio and operational acceptance. | 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; event WebRTC is untouched. | +| 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 | Desktop HLS stays about five segments behind the edge with a 60-second target buffer; Stop and a later Listen rejoin the current configured edge. | | 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. | -| Offer Free and paid access through one contract | Accepted | One-use signed invitations and PayPal/MercadoPago converge on the same revocable membership state machine. | +| Separate ordinary Free from canonical membership | Accepted | Welcome access and recurring Free windows are server-authoritative access layers that never fabricate membership or Purchase; invitations and PayPal/MercadoPago converge on the revocable membership state machine. | +| Preserve the Founder price for life | Accepted | First canonical paid activation grants the opaque account a lifetime right to the USD 2/month founder offer; cancellation ends access but not that price eligibility. | | 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. | @@ -112,26 +115,31 @@ steps are not safe to execute literally. 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 lifetime-price contract. Founder terms - require a versioned offer and durable commercial evidence. +6. They treat a boolean `isFounder` as a lifetime-price contract. Founder-price + eligibility requires a versioned offer, first canonical paid activation and + durable account-bound commercial evidence separate from active membership. 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 source is explicitly a continuous - recorded stream. + 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 -- `/early-birds` unified entry: public sign-in without access, private Listener with a valid projection. -- `/early-birds/home` compatibility redirect only. The dedicated staging hostname exposes the unified entry canonically at `/`. +- 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. -- One-use, signed, auditable, revocable Free invitations and canonical paid - membership entitlements from the commerce service. +- One explicit 30-minute welcome listen, a recurring two-hour daily Free window + locked for seven days, 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. @@ -224,7 +232,7 @@ consumer. ### 7.1 Source and artifacts - The WAV master at - `/home/nicolas/Music/beacon/luz_de_manana_20260624-155633.wav` is immutable + `/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. @@ -240,12 +248,12 @@ 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 delivery codec is deliberately **not chosen in this document**. Browser -support and acoustic quality conflict here, especially on Safari/iOS. The spike -must compare the original standard player, the encoded artifact, the streamed -artifact in a standard player and the actual EarlyBird player. Selecting and -deploying that encoding is an audio-touching decision requiring Nico's explicit -approval. +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: @@ -315,7 +323,8 @@ 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. -- Beacon-only is the default and remains available if an intro fails. +- 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 @@ -401,10 +410,13 @@ staging and production. Upgrading Free to paid consumes the free grant so two independent memberships cannot remain active. "Founder price locked for life" is not a boolean. It is a versioned USD 2/month -offer grant recording amount/currency, acquisition time and continuity policy. -Voluntary cancellation preserves access through paid-through time and then -loses the founder offer. Involuntary payment failure receives 14 days of grace. -Refund, dispute and administrative revocation remove access immediately. +offer grant recording amount/currency, first canonical paid activation and the +opaque account that owns the durable eligibility. Voluntary cancellation +preserves access through paid-through time and then ends active access, but the +same account retains the founder price for a later reactivation. Involuntary +payment failure receives 14 days of grace. Refund, dispute and administrative +revocation remove access immediately; they do not authorize the browser to +invent or erase 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 @@ -611,20 +623,20 @@ event sound and reliability are at least as good as the current path. | ID | Accepted decision | |---|---| -| D1 | `EarlyBirds`; preview `earlybirds-staging.harmonicbeacon.com`; production route `/early-birds`; origin `stream.harmonicbeacon.com`. | -| D2 | USD 2/month founder offer; 14-day involuntary grace; voluntary cancellation loses founder terms after paid-through; refund/dispute/admin revoke immediately. | +| 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 2/month founder offer; first canonical paid activation grants lifetime account-bound price eligibility; voluntary cancellation ends access after paid-through but a later reactivation retains that price; 14-day involuntary grace; refund/dispute/admin revoke access immediately. | | 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`; codec remains unselected. | +| 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 one recurring two-hour daily wall-clock window, locked for rolling seven days; the server resolves IANA/DST boundaries and caps leases. Founder membership grants anytime access, while the operator Free for All override remains independent. | +| D14 | Ordinary Free requires Listener registration, offers one explicit 30-minute first listen, then grants one recurring two-hour daily wall-clock window locked for rolling seven days; the server resolves IANA/DST boundaries and caps leases. Founder membership grants anytime access, while the operator Free for All override remains independent. | ## 16. Card map From b413a44c2994098901ca9cb2b414fb108e3dcf8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 16:58:03 -0300 Subject: [PATCH 098/376] docs(listener): record iPhone acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicolás Echániz --- docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md | 8 ++++---- .../operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md index 2ac50fca..782d41d7 100644 --- a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md +++ b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md @@ -130,7 +130,7 @@ instant revocation. | _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 | | -| _pending_ | iPhone physical | Safari | 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 @@ -152,6 +152,6 @@ 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-575b75a`, select Listener image -`d7ed952`, retain both preview databases and origin media, and run the preview -health smoke. Do not touch the event runtime. +`/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/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index 87fc88c4..a0ebf2f5 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -17,7 +17,7 @@ an event-stack deployment or an acoustic change. | Artifact | Exact value | |---|---| | Deployed Listener application | `dad29d4dc5010603a5bbc7ed309c8f78e7c0f384` | -| Branch/documentation head | `dad29d4` | +| Branch/documentation head | `4cd9968` (documentation may advance independently) | | Listener database schema | `20260807100000_early_bird_welcome_access` | | Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | | Public mode | Free for All OFF during coordinated registered-Free acceptance | @@ -49,7 +49,7 @@ test-only branch head. | 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. | -| Intro to Beacon lifecycle | Automated/browser proven; physical pending | Intro play/pause/seek, natural handoff, mutual exclusion, live-edge Stop/rejoin and duplicate guards pass. Acoustic/device acceptance remains human. | +| 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 proven; physical pending | Chromium 390x844 has no overflow; mode targets are 52 px and primary action 56 px. Physical keyboard/screen-reader/touch review remains. | | Audio guardrail | Proven | Frozen-audio gate is green; this registration slice changed no asset, codec, rate, channel, gain, fade, buffer, routing or event audio. | | App/origin/DB/canary | Proven | Public readiness, exact schema/SHA, stream health and decoded canary are green. | @@ -101,8 +101,8 @@ deployed image; later documentation-only commits do not require rebuilding it. physical start/end confirmation. - #217 remains open on the external Gmail delivery endpoint #44. - #218 is closed/Done with deployed runtime evidence. -- #219 remains In Progress for physical iPhone natural-handoff/reconnect - acceptance; the gesture-safe implementation is deployed. +- #219 is closed/Done after positive physical iPhone acceptance of the deployed + gesture-safe handoff. - #213 is Todo; #211/#212 and the larger campaign/cosmic-campfire journey are post-MVP and do not block this bounded test. @@ -112,7 +112,8 @@ Use `docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md` as the authoritative worksheet. 1. Finish playback/reconnect checks in the already active FFA-OFF interval. -2. Complete physical Chrome, Firefox, Android Chrome and iPhone Safari rows. +2. Complete the remaining physical Chrome, Firefox and Android Chrome rows; + retain the accepted iPhone result. 3. Run one 60-minute physical listen with intro, handoff, background/foreground, network transition and Stop/rejoin. 4. Restore FFA ON and verify anonymous playback before public sharing. From 52b9a4d33de1ab9e95a48bd6c50620926b47c574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 17:10:23 -0300 Subject: [PATCH 099/376] feat(listener): prototype flagged cosmic campfire --- .../LISTENER_COSMIC_CAMPFIRE_PROTOTYPE.md | 37 +++ src/app/early-birds/__tests__/page.test.tsx | 15 ++ src/app/early-birds/page.tsx | 6 + src/app/globals.css | 27 ++ src/components/early-birds/CosmicCampfire.tsx | 235 ++++++++++++++++++ src/components/early-birds/EarlyBirdHome.tsx | 7 + .../__tests__/EarlyBirdHome.test.tsx | 33 +++ .../__tests__/cosmic-campfire-scene.test.ts | 98 ++++++++ .../early-birds/cosmic-campfire-scene.ts | 88 +++++++ .../__tests__/campfire-prototype.test.ts | 24 ++ src/lib/early-birds/campfire-prototype.ts | 24 ++ 11 files changed, 594 insertions(+) create mode 100644 docs/design/LISTENER_COSMIC_CAMPFIRE_PROTOTYPE.md create mode 100644 src/components/early-birds/CosmicCampfire.tsx create mode 100644 src/components/early-birds/__tests__/cosmic-campfire-scene.test.ts create mode 100644 src/components/early-birds/cosmic-campfire-scene.ts create mode 100644 src/lib/early-birds/__tests__/campfire-prototype.test.ts create mode 100644 src/lib/early-birds/campfire-prototype.ts diff --git a/docs/design/LISTENER_COSMIC_CAMPFIRE_PROTOTYPE.md b/docs/design/LISTENER_COSMIC_CAMPFIRE_PROTOTYPE.md new file mode 100644 index 00000000..852a5c4d --- /dev/null +++ b/docs/design/LISTENER_COSMIC_CAMPFIRE_PROTOTYPE.md @@ -0,0 +1,37 @@ +# Listener cosmic campfire prototype + +Status: isolated, presentation-only prototype for #212. It is disabled by +default and is not part of the accepted Listener MVP. + +## Boundary + +- No presence endpoint, network request, location input or account data. +- No Web Audio, media-element, HLS, lease, transport or event-audio integration. +- The canvas is decorative, has no pointer interaction and stays outside the + accessibility tree. +- `prefers-reduced-motion`, Save-Data and a hidden document produce a static or + paused scene. Animated rendering is capped at 20 frames per second and device + pixel ratio is capped at 1.5. +- The only data is one of four deterministic fixtures: `empty`, `near`, + `middle` or `far`. They represent anonymous distance bands, not real people. + +## Explicit preview opt-in + +Both values are server-side runtime configuration. Nothing is enabled when +they are absent. + +```text +LISTENER_CAMPFIRE_PROTOTYPE=1 +LISTENER_CAMPFIRE_FIXTURE=empty|near|middle|far +``` + +An invalid fixture fails to `empty`; any flag value other than the exact `1` +keeps the established blank MVP. No production or staging configuration in the +repository enables the prototype. + +## Review gate + +Review all four fixtures at 320, 390, 768, 1024 and 1440 CSS pixels. The +prototype may connect to the future privacy-preserving #211 contract only +after that contract exists and after an explicit product/performance review. +Until then it must not replace `BeaconField` or imply a real crowd. diff --git a/src/app/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx index 1d2523c8..defe5018 100644 --- a/src/app/early-birds/__tests__/page.test.tsx +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -73,6 +73,21 @@ describe('EarlyBird Listener page', () => { expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); }); + it('passes the campfire fixture only behind the exact server-side prototype flag', async () => { + vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); + vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); + vi.stubEnv('LISTENER_CAMPFIRE_PROTOTYPE', '1'); + vi.stubEnv('LISTENER_CAMPFIRE_FIXTURE', 'near'); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + + expect(result.type).toBe(EarlyBirdHome); + expect(result.props).toMatchObject({ + campfirePrototype: true, + campfireFixture: 'near', + }); + }); + it('renders an authenticated Listener during an active Free window', async () => { vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index 75aec750..327794d9 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -19,6 +19,7 @@ import { configuredEarlyBirdDropIn } from '@/lib/early-birds/drop-ins'; import { freeWindowState, serializeFreeWindowState } from '@/lib/early-birds/free-window'; import { serializeWelcomeAccessState, welcomeAccessState } from '@/lib/early-birds/welcome-access'; import { earlyBirdMagicLinkAvailable } from '@/lib/early-birds/magic-link'; +import { listenerCampfirePrototypeConfig } from '@/lib/early-birds/campfire-prototype'; export const dynamic = 'force-dynamic'; @@ -33,11 +34,14 @@ export default async function EarlyBirdsPage({ searchParams: Promise>; }) { if (!earlyBirdsEnabled()) return ; + const campfire = listenerCampfirePrototypeConfig(); if (earlyBirdsFreeForAll()) { return ( (null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const context = canvas.getContext('2d'); + if (!context) return; + + const reducedMotion = typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const saveData = Boolean((navigator as NavigatorWithConnection).connection?.saveData); + const motion = !reducedMotion && !saveData; + let animationFrame: number | null = null; + let lastFrameAt = -FRAME_INTERVAL_MS; + let startAt = performance.now(); + + const render = (now: number) => { + const bounds = canvas.getBoundingClientRect(); + const ratio = Math.min(window.devicePixelRatio || 1, MAX_DEVICE_PIXEL_RATIO); + const width = Math.max(1, Math.round(bounds.width * ratio)); + const height = Math.max(1, Math.round(bounds.height * ratio)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + drawCampfireFrame(context, width, height, buildCampfireFrame(fixture, now - startAt, motion)); + }; + + const tick = (now: number) => { + if (now - lastFrameAt >= FRAME_INTERVAL_MS) { + lastFrameAt = now; + render(now); + } + animationFrame = window.requestAnimationFrame(tick); + }; + + const start = () => { + if (!motion || document.visibilityState === 'hidden' || animationFrame !== null) return; + startAt = performance.now(); + animationFrame = window.requestAnimationFrame(tick); + }; + const stop = () => { + if (animationFrame !== null) window.cancelAnimationFrame(animationFrame); + animationFrame = null; + }; + const handleVisibility = () => { + if (document.visibilityState === 'hidden') stop(); + else { + render(performance.now()); + start(); + } + }; + + const observer = typeof ResizeObserver === 'function' + ? new ResizeObserver(() => render(performance.now())) + : null; + observer?.observe(canvas); + window.addEventListener('resize', handleVisibility, { passive: true }); + document.addEventListener('visibilitychange', handleVisibility); + render(startAt); + start(); + + return () => { + stop(); + observer?.disconnect(); + window.removeEventListener('resize', handleVisibility); + document.removeEventListener('visibilitychange', handleVisibility); + }; + }, [fixture]); + + return ( + + ); +} diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index 07370763..ea398dce 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -3,10 +3,12 @@ import BrandLockup from '@/components/brand/BrandLockup'; import { useLocale } from '@/context/LocaleContext'; import { earlyBirdAuthClient } from '@/lib/early-birds/auth-client'; +import type { ListenerCampfireFixture } from '@/lib/early-birds/campfire-prototype'; import { earlyBirdHomeCopy } from '@/lib/early-birds/copy'; import ListenerPlayer from './ListenerPlayer'; import AccessBoundarySync from './AccessBoundarySync'; +import CosmicCampfire from './CosmicCampfire'; export default function EarlyBirdHome({ displayName, @@ -16,6 +18,8 @@ export default function EarlyBirdHome({ serverNow = new Date(0).toISOString(), dropIns, publicAccess = false, + campfirePrototype = false, + campfireFixture = 'empty', }: { displayName: string; membershipSource: string | null; @@ -24,6 +28,8 @@ export default function EarlyBirdHome({ serverNow?: string; dropIns: { es: string | null; en: string | null }; publicAccess?: boolean; + campfirePrototype?: boolean; + campfireFixture?: ListenerCampfireFixture; }) { const { locale } = useLocale(); const copy = earlyBirdHomeCopy[locale]; @@ -35,6 +41,7 @@ export default function EarlyBirdHome({ return (
+ {campfirePrototype && } {accessKind !== 'membership' && ( ({ default: () =>
, })); +vi.mock('../CosmicCampfire', () => ({ + default: ({ fixture }: { fixture: string }) => ( + } diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index 44007198..d2e99bb2 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -5,9 +5,10 @@ import { useState } from 'react'; import BrandLockup from '@/components/brand/BrandLockup'; import { useLocale } from '@/context/LocaleContext'; import { earlyBirdAuthClient } from '@/lib/early-birds/auth-client'; -import { earlyBirdCopy } from '@/lib/early-birds/copy'; +import { earlyBirdCopy, listenerMembershipPresentationCopy } from '@/lib/early-birds/copy'; import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; import type { SerializedEarlyBirdWelcomeAccessState } from '@/lib/early-birds/welcome-access'; +import type { ListenerMembershipPresentation } from '@/lib/early-birds/membership-presentation'; import AccessBoundarySync from './AccessBoundarySync'; import BeaconField from './BeaconField'; @@ -25,6 +26,7 @@ type Props = { syntheticTeamEntryAvailable: boolean; freeWindow: SerializedEarlyBirdFreeWindowState; welcome: SerializedEarlyBirdWelcomeAccessState; + membership: ListenerMembershipPresentation; serverNow: string; }; @@ -35,6 +37,7 @@ export default function EarlyBirdLanding(props: Props) { const [error, setError] = useState(false); const [email, setEmail] = useState(''); const [emailRequested, setEmailRequested] = useState(false); + const membership = listenerMembershipPresentationCopy(copy, props.membership); const callbackURL = props.invitationAvailable ? '/early-birds/redeem' : '/early-birds'; @@ -145,6 +148,12 @@ export default function EarlyBirdLanding(props: Props) { ) : ( <> + {membership && props.membership.state !== 'active' && ( +
+ {membership.title} + {membership.detail &&

{membership.detail}

} +
+ )} {props.welcome.available && } diff --git a/src/components/early-birds/EarlyBirdUnavailable.tsx b/src/components/early-birds/EarlyBirdUnavailable.tsx index 37461b66..fc699b97 100644 --- a/src/components/early-birds/EarlyBirdUnavailable.tsx +++ b/src/components/early-birds/EarlyBirdUnavailable.tsx @@ -1,19 +1,18 @@ 'use client'; import BrandLockup from '@/components/brand/BrandLockup'; -import LanguageControl from '@/components/brand/LanguageControl'; import { useLocale } from '@/context/LocaleContext'; const copy = { es: { - eyebrow: 'EARLYBIRDS', + eyebrow: 'HARMONIC BEACON · LISTENER', title: 'Estamos preparando el Beacon.', - body: 'El acceso fundador todavía no está disponible. Volvé a intentarlo más tarde.', + body: 'El acceso Listener no está disponible en este momento. Vuelve a intentarlo más tarde.', }, en: { - eyebrow: 'EARLYBIRDS', + eyebrow: 'HARMONIC BEACON · LISTENER', title: 'We are preparing the Beacon.', - body: 'Founding access is not available yet. Please try again later.', + body: 'Listener access is unavailable right now. Please try again later.', }, } as const; @@ -26,7 +25,6 @@ export default function EarlyBirdUnavailable() {
-
diff --git a/src/components/early-birds/FreeInvitationRedeemer.tsx b/src/components/early-birds/FreeInvitationRedeemer.tsx index 092a54a8..0d98f537 100644 --- a/src/components/early-birds/FreeInvitationRedeemer.tsx +++ b/src/components/early-birds/FreeInvitationRedeemer.tsx @@ -3,22 +3,21 @@ import { useState } from 'react'; import BrandLockup from '@/components/brand/BrandLockup'; -import LanguageControl from '@/components/brand/LanguageControl'; import { useLocale } from '@/context/LocaleContext'; export default function FreeInvitationRedeemer() { const { locale } = useLocale(); const copy = locale === 'es' ? { - eyebrow: 'INVITACIÓN PERSONAL', - heading: 'Activa tu acceso EarlyBird.', - body: 'Esta invitación es individual y de un solo uso. Al activarla, tu cuenta recibirá el mismo acceso Listener que una membresía paga.', + eyebrow: 'HARMONIC BEACON · LISTENER', + heading: 'Activa tu invitación Listener.', + body: 'Esta invitación es individual y de un solo uso. Al activarla, tu cuenta recibirá acceso Listener por invitación; no crea una compra ni una membresía paga.', action: 'Activar invitación', activating: 'Activando…', error: 'Esta invitación no está disponible. Si crees que es un error, contacta a soporte.', } : { - eyebrow: 'PERSONAL INVITATION', - heading: 'Activate your EarlyBird access.', - body: 'This invitation is individual and can be used once. Activating it gives your account the same Listener access as a paid membership.', + eyebrow: 'HARMONIC BEACON · LISTENER', + heading: 'Activate your Listener invitation.', + body: 'This invitation is individual and can be used once. Activating it grants invitation access; it does not create a purchase or paid membership.', action: 'Activate invitation', activating: 'Activating…', error: 'This invitation is unavailable. Contact support if you believe this is a mistake.', @@ -48,7 +47,6 @@ export default function FreeInvitationRedeemer() {
-

{copy.eyebrow}

diff --git a/src/components/early-birds/FreeWindowSetup.tsx b/src/components/early-birds/FreeWindowSetup.tsx index 73d65335..14efc716 100644 --- a/src/components/early-birds/FreeWindowSetup.tsx +++ b/src/components/early-birds/FreeWindowSetup.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { useLocale } from '@/context/LocaleContext'; import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; @@ -21,12 +22,13 @@ function localStartMinute(value: string): number | null { } export default function FreeWindowSetup({ state }: { state: SerializedEarlyBirdFreeWindowState }) { + const router = useRouter(); const { locale } = useLocale(); const copy = earlyBirdCopy[locale]; const [timeZone, setTimeZone] = useState(null); const [time, setTime] = useState(currentLocalTime); const [choosing, setChoosing] = useState(false); - const [busy, setBusy] = useState(false); + const [busy, setBusy] = useState<'now' | 'custom' | null>(null); const [error, setError] = useState(false); const selectionRequestId = useRef(null); @@ -43,12 +45,22 @@ export default function FreeWindowSetup({ state }: { state: SerializedEarlyBirdF }).format(new Date(value)); }; + const formatLocalStart = (minute: number | null) => { + if (minute === null) return null; + const instant = new Date(Date.UTC(2026, 0, 1, Math.floor(minute / 60), minute % 60)); + return new Intl.DateTimeFormat(locale === 'es' ? 'es' : 'en', { + hour: 'numeric', + minute: '2-digit', + timeZone: 'UTC', + }).format(instant); + }; + async function select(mode: 'now' | 'custom') { - if (!timeZone || busy) return; + if (!timeZone || busy !== null) return; const minute = localStartMinute(time); if (mode === 'custom' && minute === null) return; selectionRequestId.current ??= crypto.randomUUID(); - setBusy(true); + setBusy(mode); setError(false); try { const response = await fetch('/api/early-birds/free-window', { @@ -63,23 +75,32 @@ export default function FreeWindowSetup({ state }: { state: SerializedEarlyBirdF }); if (response.ok) { selectionRequestId.current = null; - window.location.reload(); + setBusy(null); + setChoosing(false); + router.refresh(); return; } } catch {} - setBusy(false); + setBusy(null); setError(true); } const nextWindow = formatInstant(state.nextStart); const changeAllowed = formatInstant(state.changeAllowedAt); + const savedStart = formatLocalStart(state.localStartMinute); const mayChoose = !state.configured || state.canChange; return ( -
+

{copy.freeTitle}

{copy.freeDescription}

+ {state.configured && savedStart && state.timeZone && ( +

+ {copy.savedFreeTime} + {savedStart} · {state.timeZone} +

+ )} {state.configured && nextWindow && (

{copy.nextFreeWindow} @@ -98,15 +119,15 @@ export default function FreeWindowSetup({ state }: { state: SerializedEarlyBirdF +

)} diff --git a/src/components/early-birds/WelcomeAccessAction.tsx b/src/components/early-birds/WelcomeAccessAction.tsx index 8f66dd39..cb0a4a8f 100644 --- a/src/components/early-birds/WelcomeAccessAction.tsx +++ b/src/components/early-birds/WelcomeAccessAction.tsx @@ -33,7 +33,9 @@ export default function WelcomeAccessAction() { } return ( -
+
+

{copy.welcomeTitle}

+

{copy.welcomeDescription}

-

{copy.welcomeDescription}

{error &&

{copy.welcomeError}

}
); diff --git a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx index 67827864..0bece594 100644 --- a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx @@ -22,7 +22,7 @@ describe('EarlyBird Listener home access chrome', () => { , @@ -30,6 +30,8 @@ describe('EarlyBird Listener home access chrome', () => { expect(screen.getByLabelText('Account')).toBeInTheDocument(); expect(screen.getByText('Sign out')).toBeInTheDocument(); + expect(screen.getByText('Invitation access')).toBeInTheDocument(); + expect(screen.queryByText('FREE')).not.toBeInTheDocument(); }); it('does not imply an account or expose sign-out in public mode', () => { @@ -38,7 +40,7 @@ describe('EarlyBird Listener home access chrome', () => { , @@ -55,7 +57,7 @@ describe('EarlyBird Listener home access chrome', () => { , @@ -68,7 +70,7 @@ describe('EarlyBird Listener home access chrome', () => { campfirePrototype campfireFixture="far" displayName="Nico" - membershipSource="FREE" + membership={{ kind: 'invitation', state: 'active' }} dropIns={{ es: null, en: null }} /> , @@ -77,4 +79,20 @@ describe('EarlyBird Listener home access chrome', () => { expect(screen.getByTestId('listener-campfire')).toHaveAttribute('data-fixture', 'far'); expect(screen.getByTestId('listener-campfire')).toHaveAttribute('aria-hidden', 'true'); }); + + it('presents a normalized Founder status and provider without raw membership source', () => { + render( + + + , + ); + + expect(screen.getByText('Founder · active until the end of the period')).toBeInTheDocument(); + expect(screen.getByText('Mercado Pago')).toBeInTheDocument(); + expect(screen.queryByText('MERCADO_PAGO')).not.toBeInTheDocument(); + }); }); diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index 50e57e37..76cfba78 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -9,10 +9,11 @@ import { earlyBirdCopy } from '@/lib/early-birds/copy'; const signInSocial = vi.hoisted(() => vi.fn()); const signInMagicLink = vi.hoisted(() => vi.fn()); const signOut = vi.hoisted(() => vi.fn()); +const refresh = vi.hoisted(() => vi.fn()); +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) })); vi.mock('@/lib/early-birds/auth-client', () => ({ earlyBirdAuthClient: { signIn: { social: signInSocial, magicLink: signInMagicLink }, signOut }, })); -vi.mock('@/components/brand/LanguageControl', () => ({ default: () =>
})); vi.mock('@/components/brand/BrandLockup', () => ({ default: () => Harmonic Beacon })); import EarlyBirdLanding from '../EarlyBirdLanding'; @@ -48,6 +49,7 @@ function renderLanding(overrides: Partial @@ -63,6 +65,7 @@ describe('EarlyBird public landing', () => { signInMagicLink.mockResolvedValue({ data: { status: true }, error: null }); signOut.mockReset(); signOut.mockResolvedValue({ error: null }); + refresh.mockReset(); window.localStorage.clear(); }); afterEach(() => cleanup()); @@ -83,13 +86,17 @@ describe('EarlyBird public landing', () => { it('uses audience-neutral account and privacy language in both locales', () => { expect(earlyBirdCopy.es.privacy).toBe( - 'Tu cuenta y membresía administran el acceso privado. No creamos historiales personales de escucha.', + 'Tu cuenta y membresía administran el acceso. Durante la escucha sólo compartimos una presencia regional amplia y efímera; nunca tu ubicación exacta ni un historial personal de escucha.', ); expect(earlyBirdCopy.en.privacy).toBe( - 'Your account and membership manage private access. We do not create personal listening histories.', + 'Your account and membership manage access. While you listen, we share only broad, ephemeral regional presence—never your exact location or a personal listening history.', ); expect(`${earlyBirdCopy.es.privacy} ${earlyBirdCopy.en.privacy}`) .not.toMatch(/adult|child|minor|menor|adulta/i); + expect(`${earlyBirdCopy.es.privacy} ${earlyBirdCopy.en.privacy}`) + .toMatch(/regional.*efímera|ephemeral regional/i); + expect(`${earlyBirdCopy.es.privacy} ${earlyBirdCopy.en.privacy}`) + .toMatch(/ubicación exacta|exact location/i); }); it('hides an unconfigured provider from the public identity surface', () => { @@ -165,7 +172,9 @@ describe('EarlyBird public landing', () => { }, }); - await userEvent.click(screen.getByRole('button', { name: 'Listen for 30 minutes now' })); + expect(screen.getByRole('heading', { name: 'Your first listen · 30 minutes' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Your daily time · 2 hours' })).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Listen now' })); expect(fetchMock).toHaveBeenCalledWith('/api/early-birds/welcome-access', expect.objectContaining({ method: 'POST', @@ -173,4 +182,31 @@ describe('EarlyBird public landing', () => { expect(fetchMock.mock.calls[0]?.[1]?.body).toContain('activationRequestId'); fetchMock.mockRestore(); }); + + it('explains terminal Founder access and returns the account to truthful Free choices', () => { + renderLanding({ + signedIn: true, + membership: { kind: 'founder', provider: 'mercado-pago', state: 'refunded' }, + }); + + expect(screen.getByRole('status')).toHaveTextContent( + 'The payment was refunded and Founder access has ended.', + ); + expect(screen.getByRole('status')).toHaveTextContent( + 'You can continue with the Free listening available to your account.', + ); + expect(screen.getByRole('heading', { name: 'Your daily time · 2 hours' })).toBeInTheDocument(); + expect(screen.queryByText('MERCADO_PAGO')).not.toBeInTheDocument(); + }); + + it('uses neutral Listener positioning in both languages', () => { + expect(earlyBirdCopy.es.eyebrow).toBe('HARMONIC BEACON · LISTENER'); + expect(earlyBirdCopy.en.eyebrow).toBe('HARMONIC BEACON · LISTENER'); + expect(`${earlyBirdCopy.es.live} ${earlyBirdCopy.en.live}`) + .not.toMatch(/disponible siempre|available whenever/i); + expect(`${earlyBirdCopy.es.membership} ${earlyBirdCopy.en.membership}`) + .not.toMatch(/Founding Listener/i); + expect(Object.values(earlyBirdCopy.es).join(' ')) + .not.toMatch(/\b(elegí|tocá|habilitá|querés)\b/i); + }); }); diff --git a/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx b/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx index 83ff6a8d..a288f255 100644 --- a/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx +++ b/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx @@ -5,7 +5,6 @@ import userEvent from '@testing-library/user-event'; import { LocaleProvider } from '@/context/LocaleContext'; -vi.mock('@/components/brand/LanguageControl', () => ({ default: () =>
})); vi.mock('@/components/brand/BrandLockup', () => ({ default: () => Harmonic Beacon })); import FreeInvitationRedeemer from '../FreeInvitationRedeemer'; @@ -16,6 +15,18 @@ afterEach(() => { }); describe('EarlyBird free invitation redeemer', () => { + it('presents invitation access without claiming a paid membership', () => { + render( + + + , + ); + + expect(screen.getByRole('heading', { name: 'Activate your Listener invitation.' })).toBeInTheDocument(); + expect(screen.getByText(/does not create a purchase or paid membership/)).toBeInTheDocument(); + expect(screen.queryByText(/same Listener access as a paid membership/)).not.toBeInTheDocument(); + }); + it('reports a network failure and re-enables redemption', async () => { const request = vi.fn().mockRejectedValue(new Error('network unavailable')); vi.stubGlobal('fetch', request); diff --git a/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx b/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx index 1739a20e..26194fbb 100644 --- a/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx +++ b/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx @@ -8,6 +8,9 @@ import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free- import FreeWindowSetup from '../FreeWindowSetup'; +const refresh = vi.hoisted(() => vi.fn()); +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) })); + const emptyState: SerializedEarlyBirdFreeWindowState = { configured: false, active: false, @@ -32,6 +35,7 @@ function renderSetup(state = emptyState) { describe('Free listening schedule UI', () => { beforeEach(() => { + refresh.mockReset(); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 400 }))); }); afterEach(() => { @@ -42,7 +46,7 @@ describe('Free listening schedule UI', () => { it('offers immediate or chosen daily Free hours after registration', async () => { renderSetup(); - expect(screen.getByRole('heading', { name: 'Two hours of Beacon every day' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Your daily time · 2 hours' })).toBeInTheDocument(); await waitFor(() => expect(screen.getByRole('button', { name: 'Listen free now' })).toBeEnabled()); expect(screen.getByRole('button', { name: 'Choose another time' })).toBeEnabled(); }); @@ -51,6 +55,7 @@ describe('Free listening schedule UI', () => { renderSetup(); await waitFor(() => expect(screen.getByRole('button', { name: 'Choose another time' })).toBeEnabled()); await userEvent.click(screen.getByRole('button', { name: 'Choose another time' })); + expect(screen.getByRole('button', { name: 'Back' })).toBeEnabled(); await userEvent.clear(screen.getByLabelText('Start time')); await userEvent.type(screen.getByLabelText('Start time'), '09:45'); await userEvent.click(screen.getByRole('button', { name: 'Save my listening time' })); @@ -65,6 +70,28 @@ describe('Free listening schedule UI', () => { expect(JSON.parse(init.body as string).selectionRequestId).toMatch(/^[0-9a-f-]{36}$/i); }); + it('can leave the custom chooser without changing the saved schedule', async () => { + renderSetup(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Choose another time' })).toBeEnabled()); + await userEvent.click(screen.getByRole('button', { name: 'Choose another time' })); + await userEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect(screen.getByRole('button', { name: 'Listen free now' })).toBeEnabled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('refreshes authoritative state without leaving the chooser busy after save', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response('{}', { status: 200 })); + renderSetup(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Choose another time' })).toBeEnabled()); + await userEvent.click(screen.getByRole('button', { name: 'Choose another time' })); + await userEvent.click(screen.getByRole('button', { name: 'Save my listening time' })); + + expect(refresh).toHaveBeenCalledOnce(); + expect(screen.getByRole('button', { name: 'Listen free now' })).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + }); + it('shows the next window and cooldown without offering a forbidden change', () => { renderSetup({ ...emptyState, @@ -79,6 +106,8 @@ describe('Free listening schedule UI', () => { }); expect(screen.getByText('Your next listening window begins')).toBeInTheDocument(); + expect(screen.getByText('Your daily time')).toBeInTheDocument(); + expect(screen.getByText(/10:00.*UTC/)).toBeInTheDocument(); expect(screen.getByText('You can change this schedule')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Listen free now' })).not.toBeInTheDocument(); }); diff --git a/src/lib/early-birds/__tests__/membership-presentation.test.ts b/src/lib/early-birds/__tests__/membership-presentation.test.ts new file mode 100644 index 00000000..558d4427 --- /dev/null +++ b/src/lib/early-birds/__tests__/membership-presentation.test.ts @@ -0,0 +1,80 @@ +import type { EarlyBirdMembershipProjection } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; + +import { listenerMembershipPresentation } from '../membership-presentation'; + +const NOW = new Date('2026-08-07T12:00:00.000Z'); + +function projection(overrides: Partial = {}): EarlyBirdMembershipProjection { + return { + id: '00000000-0000-4000-8000-000000000001', + accountId: 'listener-1', + revision: 9, + commandHash: 'a'.repeat(64), + state: 'ACTIVE', + source: 'PAYPAL', + offerCode: 'EARLY_BIRDS_FOUNDERS_V1', + offerRevision: 1, + effectiveAt: NOW, + paidThrough: null, + graceUntil: null, + provider: 'paypal', + amountMinor: 200, + currency: 'USD', + reasonCode: 'PAYMENT_CONFIRMED', + synthetic: false, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +describe('public Listener membership presentation', () => { + it('exposes only a normalized Founder provider and lifecycle state', () => { + const result = listenerMembershipPresentation(projection({ + state: 'CANCELLED_PENDING_END', + provider: 'provider-internal-value', + reasonCode: 'PRIVATE_REASON', + })); + + expect(result).toEqual({ kind: 'founder', provider: 'paypal', state: 'ending' }); + expect(JSON.stringify(result)).not.toMatch(/PRIVATE_REASON|provider-internal-value|PAYPAL/); + }); + + it.each([ + ['ACTIVE', 'active'], + ['GRACE', 'grace'], + ['CANCELLED_PENDING_END', 'ending'], + ['EXPIRED', 'expired'], + ['REFUNDED', 'refunded'], + ['REVOKED', 'revoked'], + ['PENDING', 'pending'], + ] as const)('normalizes the %s lifecycle without exposing the raw enum', (state, expected) => { + expect(listenerMembershipPresentation(projection({ state }))).toEqual({ + kind: 'founder', + provider: 'paypal', + state: expected, + }); + }); + + it('distinguishes invitation and preview access without exposing FREE or synthetic internals', () => { + expect(listenerMembershipPresentation(projection({ source: 'FREE' }))) + .toEqual({ kind: 'invitation', state: 'active' }); + expect(listenerMembershipPresentation(projection({ source: null, synthetic: true }))) + .toEqual({ kind: 'preview', state: 'active' }); + }); + + it('does not infer Founder status from offer or price fields', () => { + expect(listenerMembershipPresentation(projection({ source: null, synthetic: false }))) + .toEqual({ kind: 'none', state: 'none' }); + expect(listenerMembershipPresentation(projection({ + source: 'PAYPAL', + offerCode: 'FUTURE_PRODUCT', + }))).toEqual({ kind: 'none', state: 'none' }); + expect(listenerMembershipPresentation(projection({ + source: 'MERCADO_PAGO', + offerCode: null, + }))).toEqual({ kind: 'none', state: 'none' }); + expect(listenerMembershipPresentation(null)).toEqual({ kind: 'none', state: 'none' }); + }); +}); diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 1bb5b89b..186d587b 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -1,13 +1,14 @@ import type { UiLocale } from '@/lib/i18n'; +import type { ListenerMembershipPresentation } from './membership-presentation'; export const earlyBirdCopy = { es: { - eyebrow: 'HARMONIC BEACON · FOUNDING LISTENER', + eyebrow: 'HARMONIC BEACON · LISTENER', title: 'Recuerda tu centro armónico.', intro: 'Un campo armónico continuo, compartido alrededor del mundo.', - live: 'Escucha privada, disponible siempre', + live: 'Escucha durante el acceso disponible para tu cuenta', privateDropIns: 'Una introducción opcional antes de entrar al Beacon', - membership: 'Acceso Founding Listener', + membership: 'Primera escucha y horario Free diario', signInGoogle: 'Continuar con Google', signInApple: 'Continuar con Apple', signingIn: 'Abriendo acceso…', @@ -23,30 +24,47 @@ export const earlyBirdCopy = { enter: 'Entrar al Beacon', redeem: 'Activar mi invitación', accessNeeded: 'Tu cuenta todavía no tiene una membresía activa.', - freeTitle: 'Dos horas de Beacon cada día', - freeDescription: 'Elegí una hora local. Será la misma todos los días y podrás cambiarla después de siete días.', + freeTitle: 'Tu horario diario · 2 horas', + freeDescription: 'Elige una hora local. Será la misma todos los días y podrás cambiarla después de siete días.', listenFreeNow: 'Escuchar gratis ahora', chooseFreeTime: 'Elegir otro horario', freeStartTime: 'Hora de inicio', saveFreeTime: 'Guardar mi horario', + savingFreeTime: 'Guardando horario…', + startingFreeTime: 'Abriendo horario Free…', + cancelFreeTime: 'Volver', freeTimeZone: 'Zona horaria', + savedFreeTime: 'Tu horario diario', nextFreeWindow: 'Tu próxima escucha comienza', freeScheduleLocked: 'Podrás cambiar este horario', freeScheduleError: 'No pudimos guardar el horario. Inténtalo nuevamente.', - welcomeListen: 'Escuchar 30 minutos ahora', + welcomeTitle: 'Tu primera escucha · 30 minutos', + welcomeListen: 'Escuchar ahora', welcomeStarting: 'Abriendo el Beacon…', welcomeDescription: 'Tu primera escucha no fija todavía el horario diario.', welcomeError: 'No pudimos abrir esta primera escucha. Inténtalo nuevamente.', authError: 'No pudimos completar el acceso. Usa el mismo proveedor con el que creaste tu cuenta o contacta a soporte.', - privacy: 'Tu cuenta y membresía administran el acceso privado. No creamos historiales personales de escucha.', + privacy: 'Tu cuenta y membresía administran el acceso. Durante la escucha sólo compartimos una presencia regional amplia y efímera; nunca tu ubicación exacta ni un historial personal de escucha.', + membershipInvitation: 'Acceso por invitación', + membershipPreview: 'Acceso de prueba', + membershipFounderActive: 'Founder activo', + membershipFounderGrace: 'Founder · acceso temporal', + membershipFounderEnding: 'Founder · activo hasta el final del período', + membershipFounderPending: 'La membresía Founder todavía no está confirmada.', + membershipFounderExpired: 'La membresía Founder finalizó.', + membershipFounderRefunded: 'El pago fue reembolsado y el acceso Founder finalizó.', + membershipFounderRevoked: 'El acceso Founder fue cerrado.', + membershipFreeFallback: 'Puedes continuar con la escucha Free disponible para tu cuenta.', + membershipInvitationEnded: 'La invitación ya no habilita el acceso.', + membershipPreviewEnded: 'El acceso de prueba finalizó.', }, en: { - eyebrow: 'HARMONIC BEACON · FOUNDING LISTENER', + eyebrow: 'HARMONIC BEACON · LISTENER', title: 'Remember your harmonic center.', intro: 'A continuous harmonic field, shared across the world.', - live: 'Private listening, available whenever you return', + live: 'Listen during the access available to your account', privateDropIns: 'An optional introduction before entering the Beacon', - membership: 'Founding Listener access', + membership: 'A first listen and a daily Free schedule', signInGoogle: 'Continue with Google', signInApple: 'Continue with Apple', signingIn: 'Opening access…', @@ -62,22 +80,39 @@ export const earlyBirdCopy = { enter: 'Enter the Beacon', redeem: 'Activate my invitation', accessNeeded: 'Your account does not have an active membership yet.', - freeTitle: 'Two hours of Beacon every day', + freeTitle: 'Your daily time · 2 hours', freeDescription: 'Choose a local start time. It repeats daily and can be changed after seven days.', listenFreeNow: 'Listen free now', chooseFreeTime: 'Choose another time', freeStartTime: 'Start time', saveFreeTime: 'Save my listening time', + savingFreeTime: 'Saving your time…', + startingFreeTime: 'Opening your Free time…', + cancelFreeTime: 'Back', freeTimeZone: 'Time zone', + savedFreeTime: 'Your daily time', nextFreeWindow: 'Your next listening window begins', freeScheduleLocked: 'You can change this schedule', freeScheduleError: 'We could not save the schedule. Please try again.', - welcomeListen: 'Listen for 30 minutes now', + welcomeTitle: 'Your first listen · 30 minutes', + welcomeListen: 'Listen now', welcomeStarting: 'Opening the Beacon…', welcomeDescription: 'Your first listen does not set your daily time yet.', welcomeError: 'We could not open this first listen. Please try again.', authError: 'We could not complete sign-in. Use the provider that created your account, or contact support.', - privacy: 'Your account and membership manage private access. We do not create personal listening histories.', + privacy: 'Your account and membership manage access. While you listen, we share only broad, ephemeral regional presence—never your exact location or a personal listening history.', + membershipInvitation: 'Invitation access', + membershipPreview: 'Preview access', + membershipFounderActive: 'Founder active', + membershipFounderGrace: 'Founder · temporary access', + membershipFounderEnding: 'Founder · active until the end of the period', + membershipFounderPending: 'Founder membership is not confirmed yet.', + membershipFounderExpired: 'Founder membership has ended.', + membershipFounderRefunded: 'The payment was refunded and Founder access has ended.', + membershipFounderRevoked: 'Founder access was closed.', + membershipFreeFallback: 'You can continue with the Free listening available to your account.', + membershipInvitationEnded: 'The invitation no longer provides access.', + membershipPreviewEnded: 'Preview access has ended.', }, } satisfies Record>; @@ -109,15 +144,15 @@ export const earlyBirdHomeCopy = { eyebrow: 'HARMONIC BEACON · LISTENER', heading: 'Beacon', listen: 'Escuchar', - mode: 'Cómo querés entrar', + mode: 'Cómo quieres entrar', withIntro: 'Con introducción', beaconOnly: 'Solo Beacon', skipToBeacon: 'Saltar al Beacon', seek: 'Posición', prepareDevice: 'Habilitar este dispositivo', - deviceReady: 'Dispositivo listo. Tocá otra vez para escuchar o elegí un drop-in.', + deviceReady: 'Dispositivo listo. Toca otra vez para escuchar o elige una introducción.', deviceLimitClaim: 'Ya hay dos dispositivos activos. Habilitar éste detendrá la escucha en el menos reciente.', - prepareHelp: 'No pudimos preparar el stream automáticamente. Habilitá este dispositivo antes de escuchar.', + prepareHelp: 'No pudimos preparar el stream automáticamente. Habilita este dispositivo antes de escuchar.', pause: 'Pausar', resume: 'Continuar', paused: 'Pausado', @@ -125,8 +160,8 @@ export const earlyBirdHomeCopy = { reconnecting: 'Restableciendo conexión…', unavailable: 'El Beacon no está disponible en este momento.', displaced: 'Este dispositivo fue desplazado porque la cuenta ya está escuchando en otros dos dispositivos.', - spanish: 'Caldeamiento · Español', - english: 'Warm-up · English', + spanish: 'Introducción · Español', + english: 'Introducción · Inglés', dropUnavailable: 'El render aprobado todavía no fue publicado.', introSelection: 'Intro antes del Beacon', stop: 'Detener', @@ -135,7 +170,7 @@ export const earlyBirdHomeCopy = { stopped: 'Detenido', master: 'Volumen', signOut: 'Cerrar sesión', - active: 'Founding Listener activo', + active: 'Listener activo', freeActive: 'Horario Free activo', welcomeActive: 'Primera escucha activa', account: 'Cuenta', @@ -170,9 +205,42 @@ export const earlyBirdHomeCopy = { stopped: 'Stopped', master: 'Volume', signOut: 'Sign out', - active: 'Founding Listener active', + active: 'Listener active', freeActive: 'Free window active', welcomeActive: 'First listen active', account: 'Account', }, } satisfies Record>; + +type MembershipCopy = { [Key in keyof typeof earlyBirdCopy.en]: string }; + +export function listenerMembershipPresentationCopy( + copy: MembershipCopy, + presentation: ListenerMembershipPresentation, +): { title: string; detail: string | null } | null { + if (presentation.kind === 'none') return null; + + if (presentation.kind === 'invitation') { + return { + title: copy.membershipInvitation, + detail: presentation.state === 'active' ? null : copy.membershipInvitationEnded, + }; + } + if (presentation.kind === 'preview') { + return { + title: copy.membershipPreview, + detail: presentation.state === 'active' ? null : copy.membershipPreviewEnded, + }; + } + + const provider = presentation.provider === 'paypal' ? 'PayPal' : 'Mercado Pago'; + switch (presentation.state) { + case 'active': return { title: copy.membershipFounderActive, detail: provider }; + case 'grace': return { title: copy.membershipFounderGrace, detail: provider }; + case 'ending': return { title: copy.membershipFounderEnding, detail: provider }; + case 'pending': return { title: copy.membershipFounderPending, detail: copy.membershipFreeFallback }; + case 'expired': return { title: copy.membershipFounderExpired, detail: copy.membershipFreeFallback }; + case 'refunded': return { title: copy.membershipFounderRefunded, detail: copy.membershipFreeFallback }; + case 'revoked': return { title: copy.membershipFounderRevoked, detail: copy.membershipFreeFallback }; + } +} diff --git a/src/lib/early-birds/membership-presentation.ts b/src/lib/early-birds/membership-presentation.ts new file mode 100644 index 00000000..6f035878 --- /dev/null +++ b/src/lib/early-birds/membership-presentation.ts @@ -0,0 +1,68 @@ +import type { EarlyBirdMembershipProjection } from '@prisma/client'; + +import { EARLY_BIRDS_FOUNDERS_OFFER } from './membership'; + +export type ListenerMembershipPresentationState = + | 'pending' + | 'active' + | 'grace' + | 'ending' + | 'expired' + | 'refunded' + | 'revoked'; + +export type ListenerMembershipPresentation = + | { kind: 'none'; state: 'none' } + | { kind: 'invitation'; state: ListenerMembershipPresentationState } + | { kind: 'preview'; state: ListenerMembershipPresentationState } + | { + kind: 'founder'; + provider: 'paypal' | 'mercado-pago'; + state: ListenerMembershipPresentationState; + }; + +function presentationState( + state: EarlyBirdMembershipProjection['state'], +): ListenerMembershipPresentationState { + switch (state) { + case 'PENDING': return 'pending'; + case 'ACTIVE': return 'active'; + case 'GRACE': return 'grace'; + case 'CANCELLED_PENDING_END': return 'ending'; + case 'EXPIRED': return 'expired'; + case 'REFUNDED': return 'refunded'; + case 'REVOKED': return 'revoked'; + } +} + +/** + * Reduce the canonical read model to the only membership facts the public UI + * may present. Internal source names, revisions, reason codes and payment + * identifiers never cross the Server Component boundary. + */ +export function listenerMembershipPresentation( + projection: EarlyBirdMembershipProjection | null, +): ListenerMembershipPresentation { + if (!projection) return { kind: 'none', state: 'none' }; + + const state = presentationState(projection.state); + if (projection.synthetic) return { kind: 'preview', state }; + if (projection.source === 'FREE') return { kind: 'invitation', state }; + if ( + projection.offerCode === EARLY_BIRDS_FOUNDERS_OFFER + && projection.source === 'PAYPAL' + ) { + return { kind: 'founder', provider: 'paypal', state }; + } + if ( + projection.offerCode === EARLY_BIRDS_FOUNDERS_OFFER + && projection.source === 'MERCADO_PAGO' + ) { + return { kind: 'founder', provider: 'mercado-pago', state }; + } + + // Unknown and incomplete projections fail closed in presentation just as + // they do in authorization. Never infer Founder status from an offer, + // price, redirect or reason code. + return { kind: 'none', state: 'none' }; +} From ff851c68c0930c16dcb2175ca4a323c8b56f0030 Mon Sep 17 00:00:00 2001 From: nicoechaniz Date: Fri, 7 Aug 2026 18:24:21 -0300 Subject: [PATCH 107/376] test(listener): align intro labels with bilingual copy (#233) --- .../early-birds/__tests__/ListenerTransport.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/early-birds/__tests__/ListenerTransport.test.tsx b/src/components/early-birds/__tests__/ListenerTransport.test.tsx index fe70e4ba..0ca50a35 100644 --- a/src/components/early-birds/__tests__/ListenerTransport.test.tsx +++ b/src/components/early-birds/__tests__/ListenerTransport.test.tsx @@ -89,8 +89,8 @@ describe('Listener one-action playlist transport', () => { it('uses the browser-derived page language to play only the matching introduction', async () => { const { play } = prepareMedia(); renderPlayer({ es: '/api/drop-ins/es', en: '/api/drop-ins/en' }, 'es'); - const spanish = screen.getByLabelText('Caldeamiento · Español') as HTMLAudioElement; - const english = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; + const spanish = screen.getByLabelText('Introducción · Español') as HTMLAudioElement; + const english = screen.getByLabelText('Introducción · Inglés') as HTMLAudioElement; await waitForListen(); await waitFor(() => expect(screen.getByRole('combobox', { name: 'Intro antes del Beacon' })) @@ -105,8 +105,8 @@ describe('Listener one-action playlist transport', () => { it('lets the intro dropdown override the browser-language default', async () => { const { play } = prepareMedia(); renderPlayer({ es: '/api/drop-ins/es', en: '/api/drop-ins/en' }, 'es'); - const spanish = screen.getByLabelText('Caldeamiento · Español') as HTMLAudioElement; - const english = screen.getByLabelText('Warm-up · English') as HTMLAudioElement; + const spanish = screen.getByLabelText('Introducción · Español') as HTMLAudioElement; + const english = screen.getByLabelText('Introducción · Inglés') as HTMLAudioElement; await waitForListen(); fireEvent.change(screen.getByRole('combobox', { name: 'Intro antes del Beacon' }), { From 5ae1e030c43c118b5efa749831ba3f8b75fe9a05 Mon Sep 17 00:00:00 2001 From: nicoechaniz Date: Fri, 7 Aug 2026 18:28:43 -0300 Subject: [PATCH 108/376] feat(listener): add host-safe public discovery (#234) --- .../listen.harmonicbeacon.com.conf.template | 21 ++++ .../test/preview-contract.test.mjs | 5 + .../public-discovery/__tests__/route.test.ts | 35 ++++++ .../public-discovery/robots.txt/route.ts | 19 ++++ .../public-discovery/sitemap.xml/route.ts | 19 ++++ src/app/early-birds/page.tsx | 19 +++- src/app/listener/page.tsx | 2 +- .../__tests__/public-discovery.test.ts | 71 ++++++++++++ src/lib/listener/public-discovery.ts | 102 ++++++++++++++++++ 9 files changed, 287 insertions(+), 6 deletions(-) create mode 100644 src/app/api/listener/public-discovery/__tests__/route.test.ts create mode 100644 src/app/api/listener/public-discovery/robots.txt/route.ts create mode 100644 src/app/api/listener/public-discovery/sitemap.xml/route.ts create mode 100644 src/lib/listener/__tests__/public-discovery.test.ts create mode 100644 src/lib/listener/public-discovery.ts diff --git a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template index 3485a8b2..9cfc82ee 100644 --- a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -68,6 +68,27 @@ server { 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 { return 302 /; } diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 6830b261..29ed2c0e 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -186,6 +186,11 @@ test('nginx templates isolate staging, stream and the constrained public Listene assert.match(listener, /location \^~ \/api\/early-birds\/auth\//); assert.match(listener, /location = \/api\/early-birds\/free-window/); 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, /api\/early-birds\/(test-login|free\/|membership)/); assert.doesNotMatch(listener, /location \^~ \/early-birds\//); 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/early-birds/page.tsx b/src/app/early-birds/page.tsx index 88077d8e..e3441728 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -1,4 +1,3 @@ -import type { Metadata } from 'next'; import { cookies, headers as requestHeaders } from 'next/headers'; import EarlyBirdLanding from '@/components/early-birds/EarlyBirdLanding'; @@ -21,13 +20,23 @@ import { serializeWelcomeAccessState, welcomeAccessState } from '@/lib/early-bir import { earlyBirdMagicLinkAvailable } from '@/lib/early-birds/magic-link'; import { listenerCampfirePrototypeConfig } from '@/lib/early-birds/campfire-prototype'; import { listenerMembershipPresentation } from '@/lib/early-birds/membership-presentation'; +import { localeForBrowserLanguage } from '@/lib/i18n'; +import { + isCanonicalListenerHost, + listenerPreviewMetadata, + listenerPublicMetadata, +} from '@/lib/listener/public-discovery'; export const dynamic = 'force-dynamic'; -export const metadata: Metadata = { - title: 'Listen · Harmonic Beacon', - description: 'A continuous harmonic field, shared across the world.', -}; +export async function generateMetadata() { + const incomingHeaders = await requestHeaders(); + if (!isCanonicalListenerHost(incomingHeaders)) return listenerPreviewMetadata(); + + return listenerPublicMetadata( + localeForBrowserLanguage(incomingHeaders.get('accept-language')), + ); +} export default async function EarlyBirdsPage({ searchParams, diff --git a/src/app/listener/page.tsx b/src/app/listener/page.tsx index 5d15c9c5..50a9b0c4 100644 --- a/src/app/listener/page.tsx +++ b/src/app/listener/page.tsx @@ -1,3 +1,3 @@ export const dynamic = 'force-dynamic'; -export { metadata, default } from '../early-birds/page'; +export { generateMetadata, default } from '../early-birds/page'; diff --git a/src/lib/listener/__tests__/public-discovery.test.ts b/src/lib/listener/__tests__/public-discovery.test.ts new file mode 100644 index 00000000..61e6a6ea --- /dev/null +++ b/src/lib/listener/__tests__/public-discovery.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { + isCanonicalListenerHost, + LISTENER_CANONICAL_URL, + listenerPreviewMetadata, + listenerPublicMetadata, + listenerRobotsText, + listenerSitemapXml, +} from '../public-discovery'; + +function requestHeaders(host: string | null): Headers { + const headers = new Headers(); + if (host) headers.set('host', host); + return headers; +} + +describe('Listener public discovery', () => { + it('accepts only the canonical Listener host, including its explicit port', () => { + expect(isCanonicalListenerHost(requestHeaders('listen.harmonicbeacon.com'))).toBe(true); + expect(isCanonicalListenerHost(requestHeaders('listen.harmonicbeacon.com:443'))).toBe(true); + expect(isCanonicalListenerHost(requestHeaders('earlybirds-staging.harmonicbeacon.com'))).toBe(false); + expect(isCanonicalListenerHost(requestHeaders('live.harmonicbeacon.com'))).toBe(false); + expect(isCanonicalListenerHost(requestHeaders('live.harmonicbeacon.com, listen.harmonicbeacon.com'))).toBe(false); + expect(isCanonicalListenerHost(requestHeaders('live.harmonicbeacon.com@listen.harmonicbeacon.com'))).toBe(false); + expect(isCanonicalListenerHost(requestHeaders('listen.harmonicbeacon.com:99999'))).toBe(false); + expect(isCanonicalListenerHost(requestHeaders(null))).toBe(false); + }); + + it.each([ + ['es', 'Harmonic Beacon · Recuerda tu centro armónico.', 'Un campo armónico continuo, compartido alrededor del mundo.'], + ['en', 'Harmonic Beacon · Remember your harmonic center.', 'A continuous harmonic field, shared across the world.'], + ] as const)('builds complete %s metadata for the one canonical URL', (locale, title, description) => { + const metadata = listenerPublicMetadata(locale); + + expect(metadata.metadataBase?.toString()).toBe('https://listen.harmonicbeacon.com/'); + expect(metadata.title).toBe(title); + expect(metadata.description).toBe(description); + expect(metadata.alternates).toEqual({ canonical: '/' }); + expect(metadata.openGraph).toMatchObject({ + type: 'website', + url: '/', + siteName: 'Harmonic Beacon', + title, + description, + }); + expect(metadata.twitter).toEqual({ card: 'summary', title, description }); + expect(metadata.robots).toEqual({ index: true, follow: true }); + expect(JSON.stringify(metadata)).not.toMatch(/early.?bird|projection|psychodrama|therap/i); + }); + + it('keeps non-public hosts out of search indexes without assigning a canonical URL', () => { + const metadata = listenerPreviewMetadata(); + + expect(metadata.metadataBase).toBeUndefined(); + expect(metadata.alternates).toBeUndefined(); + expect(metadata.robots).toEqual({ index: false, follow: false, nocache: true }); + }); + + it('enumerates only the canonical Listener surface in robots and sitemap', () => { + const robots = listenerRobotsText(); + const sitemap = listenerSitemapXml(); + + expect(robots).toBe( + 'User-agent: *\nAllow: /\nSitemap: https://listen.harmonicbeacon.com/sitemap.xml\n', + ); + expect(sitemap).toContain(`${LISTENER_CANONICAL_URL}`); + expect(sitemap.match(//g)).toHaveLength(1); + expect(`${robots}\n${sitemap}`).not.toMatch(/early.?bird|staging|live\.harmonic|event|evento|hreflang/i); + }); +}); diff --git a/src/lib/listener/public-discovery.ts b/src/lib/listener/public-discovery.ts new file mode 100644 index 00000000..78b7ed31 --- /dev/null +++ b/src/lib/listener/public-discovery.ts @@ -0,0 +1,102 @@ +import type { Metadata } from 'next'; + +import type { UiLocale } from '@/lib/i18n'; + +export const LISTENER_PUBLIC_HOST = 'listen.harmonicbeacon.com'; +export const LISTENER_PUBLIC_ORIGIN = `https://${LISTENER_PUBLIC_HOST}`; +export const LISTENER_CANONICAL_URL = `${LISTENER_PUBLIC_ORIGIN}/`; + +const localizedMetadata: Record = { + es: { + title: 'Harmonic Beacon · Recuerda tu centro armónico.', + description: 'Un campo armónico continuo, compartido alrededor del mundo.', + }, + en: { + title: 'Harmonic Beacon · Remember your harmonic center.', + description: 'A continuous harmonic field, shared across the world.', + }, +}; + +function normalizedHost(value: string | null): string | null { + const candidate = value?.trim().toLowerCase(); + if (!candidate) return null; + const authority = /^([a-z0-9.-]+)(?::(\d{1,5}))?$/.exec(candidate); + if (!authority) return null; + if (authority[2] && Number(authority[2]) > 65_535) return null; + return authority[1]; +} + +/** + * Public discovery is deliberately bound to the Listener hostname. This keeps + * the routes inert if the Listener branch is later merged into the event app. + * The Listener nginx template always forwards the original Host header. + */ +export function isCanonicalListenerHost(headers: Pick): boolean { + return normalizedHost(headers.get('host')) === LISTENER_PUBLIC_HOST; +} + +export function listenerPublicMetadata(locale: UiLocale): Metadata { + const copy = localizedMetadata[locale]; + + return { + metadataBase: new URL(LISTENER_PUBLIC_ORIGIN), + applicationName: 'Harmonic Beacon', + title: copy.title, + description: copy.description, + keywords: ['Harmonic Beacon'], + authors: [{ name: 'Harmonic Beacon' }], + creator: 'Harmonic Beacon', + publisher: 'Harmonic Beacon', + alternates: { + canonical: '/', + }, + openGraph: { + type: 'website', + url: '/', + siteName: 'Harmonic Beacon', + title: copy.title, + description: copy.description, + locale: locale === 'es' ? 'es_ES' : 'en_US', + }, + twitter: { + card: 'summary', + title: copy.title, + description: copy.description, + }, + robots: { + index: true, + follow: true, + }, + }; +} + +export function listenerPreviewMetadata(): Metadata { + return { + title: 'Listen · Harmonic Beacon', + description: 'A continuous harmonic field, shared across the world.', + robots: { + index: false, + follow: false, + nocache: true, + }, + }; +} + +export function listenerRobotsText(): string { + return [ + 'User-agent: *', + 'Allow: /', + `Sitemap: ${LISTENER_PUBLIC_ORIGIN}/sitemap.xml`, + '', + ].join('\n'); +} + +export function listenerSitemapXml(): string { + return [ + '', + '', + ` ${LISTENER_CANONICAL_URL}`, + '', + '', + ].join('\n'); +} From ac09bea92a5addc46de8ac1017215d493671db32 Mon Sep 17 00:00:00 2001 From: nicoechaniz Date: Fri, 7 Aug 2026 18:31:54 -0300 Subject: [PATCH 109/376] fix(deps): update nanoid for GHSA-2v37-7h3g-55p8 (#235) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8be0458a..c0bc2bcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8108,9 +8108,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.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "funding": [ { "type": "github", From 4bc9b8a358aad5e3e28027ba69dae57b6ed88a2d Mon Sep 17 00:00:00 2001 From: nicoechaniz Date: Fri, 7 Aug 2026 18:42:04 -0300 Subject: [PATCH 110/376] test(listener): archive zero-request 4k and 5k plans (#237) --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 10 + .../2026-08-07-listener-4k5k-dryruns.md | 57 ++++++ tools/early-birds-hls-load/README.md | 15 ++ tools/early-birds-hls-load/package.json | 2 +- .../test/verify-planned.test.mjs | 125 ++++++++++++ tools/early-birds-hls-load/verify-planned.mjs | 184 ++++++++++++++++++ 6 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 docs/ops/evidence/2026-08-07-listener-4k5k-dryruns.md create mode 100644 tools/early-birds-hls-load/test/verify-planned.test.mjs create mode 100644 tools/early-birds-hls-load/verify-planned.mjs diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 3043bcac..81d69f12 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -202,6 +202,16 @@ evidence, not authorization to promote it to `main` or production. `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 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/tools/early-birds-hls-load/README.md b/tools/early-birds-hls-load/README.md index ffacacf2..d74fef80 100644 --- a/tools/early-birds-hls-load/README.md +++ b/tools/early-birds-hls-load/README.md @@ -65,6 +65,9 @@ runtime code. limits no larger than the reviewed run. - `run.mjs`: one dry-run or network shard. - `aggregate.mjs`: verifies and combines one evidence file from every shard. +- `verify-planned.mjs`: verifies a complete multi-generator dry-run set, including + shared plan hash, complete shard indices, exact client sum, distinct generator + fingerprints, redaction, zero network requests and mode `0600` sources. ## Tiny dry-run @@ -85,6 +88,18 @@ Dry-run validates the complete plan, writes `PLANNED` evidence and prints the exact confirmation required by a network run. It does not read a signed URL or contact the target. +Validate a complete distributed plan before requesting a network run: + +```bash +node tools/early-birds-hls-load/verify-planned.mjs \ + --min-generators 2 \ + /secure/load-plan/shard-*.json +``` + +The verifier emits a redacted JSON summary. It refuses partial/duplicate shard +sets, a mismatched plan or input hash, non-`0600` sources and any evidence that +contains runtime measurements or claims a network request. + See [`docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md`](../../docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md) for the distributed procedure, stop conditions and evidence interpretation. diff --git a/tools/early-birds-hls-load/package.json b/tools/early-birds-hls-load/package.json index da79d764..31986533 100644 --- a/tools/early-birds-hls-load/package.json +++ b/tools/early-birds-hls-load/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "External, bounded, format-neutral HLS load and soak evidence harness", "scripts": { - "check": "node --check run.mjs && node --check aggregate.mjs && node --check src/contracts.mjs && node --check src/runner.mjs", + "check": "node --check run.mjs && node --check aggregate.mjs && node --check verify-planned.mjs && node --check src/contracts.mjs && node --check src/runner.mjs", "test": "node --test test/*.test.mjs" }, "engines": { diff --git a/tools/early-birds-hls-load/test/verify-planned.test.mjs b/tools/early-birds-hls-load/test/verify-planned.test.mjs new file mode 100644 index 00000000..a861c847 --- /dev/null +++ b/tools/early-birds-hls-load/test/verify-planned.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import test from 'node:test'; + +import { buildPlan, selectTarget } from '../src/contracts.mjs'; +import { plannedEvidence } from '../src/runner.mjs'; + +const profile = { + clients: 2, + rampPerSecond: 2, + soakSeconds: 1, + manifestIntervalMs: 100, + requestTimeoutMs: 100, + startupSegments: 1, + maxSegmentsPerPoll: 2, + maxInflightPerShard: 4, + minShards: 1, + maxErrorRate: 0, + maxFetchMissRate: 0, + maxManifestP95Ms: 100, + maxSegmentP95Ms: 100, + syntheticOnly: true, +}; + +const target = selectTarget({ + schemaVersion: 1, + targets: [{ + id: 'tiny-origin', + environment: 'synthetic', + production: false, + origins: ['http://127.0.0.1:9'], + limits: { + maxClients: 2, + maxRampPerSecond: 2, + maxSoakSeconds: 2, + maxShardCount: 2, + minManifestIntervalMs: 25, + maxInflightPerShard: 4, + maxSegmentsPerPoll: 2, + maxRequestsPerSecond: 100, + maxManifestBytes: 65536, + maxSegmentBytes: 1048576, + maxClockOffsetMs: 100, + }, + }], +}, 'tiny-origin'); + +async function invoke(paths, minimumGenerators = 2) { + const script = path.resolve(import.meta.dirname, '..', 'verify-planned.mjs'); + const child = spawn(process.execPath, [ + script, + '--min-generators', String(minimumGenerators), + ...paths, + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + const [code] = await once(child, 'close'); + return { code, stdout, stderr }; +} + +async function fixture() { + const directory = await mkdtemp(path.join(tmpdir(), 'listener-planned-evidence-')); + const paths = []; + for (let index = 0; index < 2; index += 1) { + const plan = buildPlan({ + runId: 'dry-run-proof', + profileName: 'tiny-synthetic', + profile, + target, + shardIndex: index, + shardCount: 2, + startAt: '2030-01-01T00:00:00.000Z', + networkRun: false, + }); + const evidence = plannedEvidence({ + plan, + target, + policySha256: 'a'.repeat(64), + profileSha256: 'b'.repeat(64), + hostname: `generator-${index}`, + }); + const evidencePath = path.join(directory, `shard-${index}.json`); + await writeFile(evidencePath, `${JSON.stringify(evidence)}\n`, { mode: 0o600 }); + await chmod(evidencePath, 0o600); + paths.push(evidencePath); + } + return paths; +} + +test('verifies a complete zero-request multi-generator plan', async () => { + const paths = await fixture(); + const result = await invoke(paths); + assert.equal(result.code, 0, result.stderr); + const summary = JSON.parse(result.stdout); + assert.equal(summary.status, 'PLANNED'); + assert.equal(summary.clients, 2); + assert.equal(summary.shardCount, 2); + assert.equal(summary.distinctGenerators, 2); + assert.equal(summary.networkRequestsMade, false); + assert.deepEqual(summary.sourceShards.map((entry) => entry.index), [0, 1]); +}); + +test('refuses evidence that claims network activity', async () => { + const paths = await fixture(); + const evidence = JSON.parse(await readFile(paths[1], 'utf8')); + evidence.generator.networkRequestsMade = true; + await writeFile(paths[1], `${JSON.stringify(evidence)}\n`); + const result = await invoke(paths); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /zero network requests/); +}); + +test('refuses evidence that is not mode 0600', async () => { + const paths = await fixture(); + await chmod(paths[1], 0o640); + const result = await invoke(paths); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /mode must be exactly 0600/); +}); diff --git a/tools/early-birds-hls-load/verify-planned.mjs b/tools/early-birds-hls-load/verify-planned.mjs new file mode 100644 index 00000000..b55c5b43 --- /dev/null +++ b/tools/early-birds-hls-load/verify-planned.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { readFile, stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + EVIDENCE_KIND, + EVIDENCE_SCHEMA_VERSION, + assertRedactedEvidence, + sha256, + validateProfile, +} from './src/contracts.mjs'; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => ( + `${JSON.stringify(key)}:${canonicalJson(value[key])}` + )).join(',')}}`; + } + return JSON.stringify(value); +} + +function sameValue(left, right) { + return sha256(canonicalJson(left)) === sha256(canonicalJson(right)); +} + +function emptyMeasurements(measurements) { + return measurements?.clients?.started === 0 + && measurements.clients.completed === 0 + && measurements.clients.generatorScheduleMisses === 0 + && measurements.requests?.total === 0 + && measurements.requests.successful === 0 + && measurements.requests.failed === 0 + && measurements.bytes?.total === 0 + && measurements.fetchContinuity?.opportunities === 0 + && measurements.fetchContinuity.successfulMediaFetches === 0 + && measurements.fetchContinuity.misses === 0 + && measurements.manifest?.samples === 0; +} + +function validatePlannedEvidence(evidence) { + assert(evidence?.schemaVersion === EVIDENCE_SCHEMA_VERSION, 'unexpected evidence schema'); + assert(evidence.kind === EVIDENCE_KIND, 'unexpected evidence kind'); + assert(evidence.status === 'PLANNED', 'planned verification accepts only PLANNED evidence'); + assertRedactedEvidence(evidence); + assert(evidence.generator?.role === 'dry-run', 'planned evidence generator role must be dry-run'); + assert(evidence.generator.networkRequestsMade === false, + 'planned evidence must attest zero network requests'); + assert(/^[a-f0-9]{64}$/.test(evidence.generator.hostFingerprintSha256 ?? ''), + 'generator fingerprint is invalid'); + assert(evidence.target?.origin === null && evidence.target?.manifestPathSha256 === null, + 'planned evidence cannot identify a manifest target'); + + const { planHash, deterministicSchedule, ...publicGlobalPlan } = evidence.plan ?? {}; + assert(deterministicSchedule === 'utc-start-plus-global-client-ramp-ordinal', + 'deterministic schedule identifier differs'); + assert(/^[a-f0-9]{64}$/.test(planHash ?? ''), 'planHash is invalid'); + assert(sha256(canonicalJson({ runId: evidence.runId, ...publicGlobalPlan })) === planHash, + 'planHash does not match the public deterministic plan'); + const normalizedProfile = validateProfile(evidence.plan.profile, 'evidence plan profile'); + assert(sameValue(evidence.plan.profile, normalizedProfile), + 'evidence plan profile is not normalized'); + + assert(Number.isSafeInteger(evidence.shard?.index) && evidence.shard.index >= 0, + 'shard index is invalid'); + assert(evidence.shard.count === evidence.plan.shardCount, + 'shard count differs from the global plan'); + assert(Number.isSafeInteger(evidence.shard.localClients) && evidence.shard.localClients > 0, + 'local client count is invalid'); + assert(/^[a-f0-9]{64}$/.test(evidence.shard.clientOrdinalsSha256 ?? ''), + 'client ordinal hash is invalid'); + assert(evidence.measurements?.clients?.planned === evidence.shard.localClients, + 'planned clients differ from the shard'); + assert(emptyMeasurements(evidence.measurements), + 'PLANNED evidence contains runtime measurements or network activity'); + assert(evidence.redactionChecked === true, 'redaction check is not attested'); + return evidence; +} + +async function readEvidence(path) { + const resolved = resolve(path); + const details = await stat(resolved); + assert(details.isFile(), 'evidence source must be a regular file'); + assert((details.mode & 0o777) === 0o600, 'evidence source mode must be exactly 0600'); + const bytes = await readFile(resolved); + let evidence; + try { + evidence = JSON.parse(bytes.toString('utf8')); + } catch { + throw new Error('evidence source is not valid JSON'); + } + return { + sha256: createHash('sha256').update(bytes).digest('hex'), + evidence: validatePlannedEvidence(evidence), + }; +} + +async function main() { + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h')) { + process.stdout.write( + 'Usage: node verify-planned.mjs [--min-generators N] SHARD_EVIDENCE...\n', + ); + return; + } + const minimumIndex = args.indexOf('--min-generators'); + let minimumGenerators = 1; + if (minimumIndex >= 0) { + minimumGenerators = Number(args[minimumIndex + 1]); + assert(Number.isSafeInteger(minimumGenerators) && minimumGenerators > 0, + '--min-generators must be a positive integer'); + } + const sources = args.filter((_, index) => ( + index !== minimumIndex && index !== minimumIndex + 1 + )); + assert(sources.length > 0 && sources.every((source) => !source.startsWith('--')), + 'one exact PLANNED evidence path per shard is required'); + + const entries = await Promise.all(sources.map(readEvidence)); + const first = entries[0].evidence; + const shardCount = first.plan.shardCount; + assert(entries.length === shardCount, 'evidence count must equal shardCount'); + const indices = new Set(); + const ordinalHashes = new Set(); + const generatorFingerprints = new Set(); + let plannedClients = 0; + for (const { evidence } of entries) { + assert(evidence.runId === first.runId && evidence.plan.planHash === first.plan.planHash, + 'shard evidence does not share one deterministic plan'); + assert(sameValue(evidence.plan, first.plan), 'public plan differs between shards'); + assert(sameValue(evidence.target, first.target), 'planned target differs between shards'); + assert(sameValue(evidence.thresholds, first.thresholds), 'thresholds differ between shards'); + assert(sameValue(evidence.inputs, first.inputs), 'input hashes differ between shards'); + assert(!indices.has(evidence.shard.index), 'shard indices must be unique'); + assert(!ordinalHashes.has(evidence.shard.clientOrdinalsSha256), + 'client ordinal hashes must be unique'); + indices.add(evidence.shard.index); + ordinalHashes.add(evidence.shard.clientOrdinalsSha256); + generatorFingerprints.add(evidence.generator.hostFingerprintSha256); + plannedClients += evidence.shard.localClients; + } + assert([...indices].sort((a, b) => a - b).every((index, offset) => index === offset), + 'shard indices must be complete from zero'); + assert(plannedClients === first.plan.profile.clients, + 'shard clients do not sum to the global client count'); + assert(generatorFingerprints.size >= minimumGenerators, + `planned evidence requires at least ${minimumGenerators} distinct generators`); + + const summary = assertRedactedEvidence({ + schemaVersion: 1, + kind: `${EVIDENCE_KIND}-planned-verification`, + status: 'PLANNED', + runId: first.runId, + profileName: first.plan.profileName, + planHash: first.plan.planHash, + clients: plannedClients, + shardCount, + distinctGenerators: generatorFingerprints.size, + networkRequestsMade: false, + modeChecked: '0600', + redactionChecked: true, + sourceShards: entries + .map(({ sha256: digest, evidence }) => ({ + index: evidence.shard.index, + localClients: evidence.shard.localClients, + sha256: digest, + })) + .sort((left, right) => left.index - right.index), + }); + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write( + `Planned evidence verification refused: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +}); From dc890a4ab90192514b0af07db8c804fa785e6e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 18:48:09 -0300 Subject: [PATCH 111/376] ops(listener): add atomic public disable command --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 30 ++++ ops/early-birds-preview/package.json | 2 +- .../test/disable-public.test.mjs | 144 ++++++++++++++++++ .../test/preview-contract.test.mjs | 14 ++ scripts/early-birds-preview/disable-public.sh | 115 ++++++++++++++ 5 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 ops/early-birds-preview/test/disable-public.test.mjs create mode 100755 scripts/early-birds-preview/disable-public.sh diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 81d69f12..29a19b62 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -374,6 +374,36 @@ 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: diff --git a/ops/early-birds-preview/package.json b/ops/early-birds-preview/package.json index b6be2527..20627b2a 100644 --- a/ops/early-birds-preview/package.json +++ b/ops/early-birds-preview/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "check": "node --check ../../scripts/early-birds-preview/validate.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/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", + "check": "node --check ../../scripts/early-birds-preview/validate.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/test/disable-public.test.mjs b/ops/early-birds-preview/test/disable-public.test.mjs new file mode 100644 index 00000000..d6f0841e --- /dev/null +++ b/ops/early-birds-preview/test/disable-public.test.mjs @@ -0,0 +1,144 @@ +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' } = {}) { + 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}' ;;`, + 'esac', + '', + ].join('\n')); + return { directory, envFile, bin, commandLog, source }; +} + +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('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)/); +}); diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 29ed2c0e..8a9a11ae 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -249,6 +249,20 @@ test('smoke and rollback contracts cover both probes without deleting state', as const stop = await readRepository('scripts/early-birds-preview/stop.sh'); assert.match(stop, /stop listener 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 () => { diff --git a/scripts/early-birds-preview/disable-public.sh b/scripts/early-birds-preview/disable-public.sh new file mode 100755 index 00000000..afd145b3 --- /dev/null +++ b/scripts/early-birds-preview/disable-public.sh @@ -0,0 +1,115 @@ +#!/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 +} + +(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") +curl --fail --silent --show-error --max-time 10 \ + "http://127.0.0.1:${app_port}/api/health" >/dev/null || fail_closed +curl --fail --silent --show-error --max-time 10 \ + "http://127.0.0.1:${app_port}/api/health/ready" >/dev/null || 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" From 905b5e0d3651a3921a6857c2d783f1ac69315861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 19:32:01 -0300 Subject: [PATCH 112/376] feat(listener): cut over non-media runtime namespace --- .../LISTENER_NAMESPACE_MIGRATION.md | 31 ++++++- e2e/tests/early-birds-boundary.spec.ts | 12 +-- ...s-staging.harmonicbeacon.com.conf.template | 93 ++++++++++++++++++- .../listen.harmonicbeacon.com.conf.template | 76 ++++++++++++++- .../test/preview-contract.test.mjs | 23 ++++- .../auth/[...all]/__tests__/route.test.ts | 14 ++- .../api/early-birds/auth/[...all]/route.ts | 14 ++- .../free/redeem/__tests__/route.test.ts | 22 ++++- src/app/api/early-birds/free/redeem/route.ts | 6 +- src/app/early-birds/home/page.tsx | 4 +- src/app/early-birds/redeem/page.tsx | 7 +- .../early-birds/AccessBoundarySync.tsx | 4 +- src/components/early-birds/EarlyBirdHome.tsx | 5 +- .../early-birds/EarlyBirdLanding.tsx | 15 +-- .../early-birds/EarlyBirdUnavailable.tsx | 3 +- .../early-birds/FreeInvitationRedeemer.tsx | 7 +- .../early-birds/FreeWindowSetup.tsx | 3 +- .../early-birds/SyntheticTeamEntryForm.tsx | 3 +- .../early-birds/WelcomeAccessAction.tsx | 3 +- .../__tests__/AccessBoundarySync.test.tsx | 4 + .../__tests__/EarlyBirdLanding.test.tsx | 14 +-- .../__tests__/FreeInvitationRedeemer.test.tsx | 5 +- .../__tests__/FreeWindowSetup.test.tsx | 1 + .../__tests__/SyntheticTeamEntryForm.test.tsx | 2 +- .../listener/__tests__/media-boundary.test.ts | 56 +++++++++++ src/lib/listener/__tests__/namespace.test.ts | 2 + src/lib/listener/namespace.ts | 2 + 27 files changed, 371 insertions(+), 60 deletions(-) create mode 100644 src/lib/listener/__tests__/media-boundary.test.ts diff --git a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md index 44b4a7d5..570d7c03 100644 --- a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md +++ b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md @@ -1,8 +1,9 @@ # EarlyBird to Listener namespace migration -Status: phase 1 implemented on `feat/listener-namespace-compat`; later phases are -design only. This migration is deliberately additive. `EarlyBird` is an offer -and cohort name; `Listener` is the durable product and technical namespace. +Status: phase 1 is integrated; phase 2A is an undeployed candidate on +`feat/listener-namespace-runtime`. This migration is deliberately additive. +`EarlyBird` is an offer and cohort name; `Listener` is the durable product and +technical namespace. ## Invariants @@ -55,7 +56,7 @@ rewrites `/` internally to that route, and redirecting it before nginx and clien callbacks move would create unnecessary hops and could expose deployment internals. -## Phase 2: browser client cutover +## 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`. @@ -65,6 +66,28 @@ internals. 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 staging invitation redemption. Better Auth continues to use +its legacy base path and cookies. The public edge continues to exclude +invitation redemption and synthetic entry; the staging edge exposes only the +four exact canonical non-media APIs. 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. diff --git a/e2e/tests/early-birds-boundary.spec.ts b/e2e/tests/early-birds-boundary.spec.ts index b9a2d2c3..a31348fa 100644 --- a/e2e/tests/early-birds-boundary.spec.ts +++ b/e2e/tests/early-birds-boundary.spec.ts @@ -69,7 +69,7 @@ test.describe('Listener scheduled Free browser boundary', () => { let leavingStateRequests = 0; let enteringLeaseRequests = 0; let failEnteringOnce = true; - await enteringPage.route('**/api/early-birds/access-state', async (route) => { + await enteringPage.route('**/api/listener/access-state', async (route) => { enteringStateRequests += 1; if (failEnteringOnce) { failEnteringOnce = false; @@ -82,14 +82,14 @@ test.describe('Listener scheduled Free browser boundary', () => { enteringLeaseRequests += 1; await route.continue(); }); - await leavingPage.route('**/api/early-birds/access-state', async (route) => { + await leavingPage.route('**/api/listener/access-state', async (route) => { leavingStateRequests += 1; await route.continue(); }); await Promise.all([ - enteringPage.goto('/early-birds'), - leavingPage.goto('/early-birds'), + enteringPage.goto('/listener'), + leavingPage.goto('/listener'), ]); await expect(enteringPage.locator('.listener-shell--public')).toBeVisible(); await expect(enteringPage.locator('.listener-experience')).toHaveCount(0); @@ -97,8 +97,8 @@ test.describe('Listener scheduled Free browser boundary', () => { const enteringEpoch = await documentEpoch(enteringPage); const leavingEpoch = await documentEpoch(leavingPage); - const enteringState = await enteringPage.request.get('/api/early-birds/access-state'); - const leavingState = await leavingPage.request.get('/api/early-birds/access-state'); + 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 } }; 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 index 44f44a92..bfc06f8e 100644 --- a/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -25,6 +25,20 @@ server { return 302 https://earlybirds-staging.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://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://earlybirds-staging.harmonicbeacon.com$request_uri; + } + location / { return 301 https://earlybirds-staging.harmonicbeacon.com$request_uri; } @@ -86,12 +100,19 @@ server { 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; + } + # The preview hostname exists only for this product, so keep its public - # address canonical at `/`. The application still receives its stable - # internal route, preserving production compatibility and auth callbacks. + # address canonical at `/`. The application receives the canonical + # Listener route while legacy aliases and auth callbacks remain available. location = / { access_log off; - rewrite ^ /early-birds break; + rewrite ^ /listener break; proxy_pass http://127.0.0.1:13000; proxy_http_version 1.1; proxy_set_header Host $host; @@ -122,6 +143,72 @@ server { proxy_read_timeout 60s; } + location = /listener/redeem { + access_log off; + 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/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; + } + + 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/listener/free/redeem { + 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 ^~ /early-birds/ { proxy_pass http://127.0.0.1:13000; proxy_http_version 1.1; diff --git a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template index 9cfc82ee..b0e679c3 100644 --- a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -93,8 +93,12 @@ server { return 302 /; } + location = /listener { + return 302 /; + } + location = / { - rewrite ^ /early-birds break; + rewrite ^ /listener break; proxy_pass http://127.0.0.1:13000; proxy_http_version 1.1; proxy_set_header Host $host; @@ -133,8 +137,48 @@ server { proxy_read_timeout 60s; } - # Authenticated ordinary-Free scheduling. This is account-local access - # state, not a commerce membership or a staging synthetic seam. + # 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; + } + + 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; @@ -148,6 +192,32 @@ server { 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; + } + # 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. diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 8a9a11ae..8f6ef19d 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -176,7 +176,7 @@ test('nginx templates isolate staging, stream and the constrained public Listene assert.match(app, /location \^~ \/api\/early-birds\//); assert.equal((combined.match(/X-Harmonic-Beacon-Environment "early-birds-staging"/g) ?? []).length, 2); assert.equal((listener.match(/X-Harmonic-Beacon-Environment "listener-public-free"/g) ?? []).length, 1); - assert.match(app, /location = \/ \{[^}]*access_log off;[^}]*rewrite \^ \/early-birds break;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;/s); + assert.match(app, /location = \/ \{[^}]*access_log off;[^}]*rewrite \^ \/listener break;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;/s); 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)/); @@ -184,23 +184,36 @@ test('nginx templates isolate staging, stream and the constrained public Listene 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\//); + assert.match(listener, /location = \/listener \{\s*return 302 \/;/); + assert.match(listener, /location = \/ \{[^}]*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\//); assert.doesNotMatch(listener, /api\/early-birds\/(test-login|free\/|membership)/); + assert.doesNotMatch(listener, /api\/listener\/(test-login|free\/|membership)/); assert.doesNotMatch(listener, /location \^~ \/early-birds\//); const invitationEntryLocations = [...app.matchAll( - /location = \/early-birds(?:\/redeem)? \{([^}]*)\}/g, + /location = \/(?:listener|early-birds)(?:\/redeem)? \{([^}]*)\}/g, )]; - assert.equal(invitationEntryLocations.length, 4, 'HTTP and HTTPS must both protect both legacy invitation entries'); + 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]))); - assert.equal((app.match(/add_header Referrer-Policy "no-referrer" always;/g) ?? []).length, 3); - assert.equal((app.match(/add_header Cache-Control "private, no-store" always;/g) ?? []).length, 3); + assert.equal((app.match(/add_header Referrer-Policy "no-referrer" always;/g) ?? []).length, 6); + assert.equal((app.match(/add_header Cache-Control "private, no-store" always;/g) ?? []).length, 6); + for (const path of ['access-state', 'free-window', 'free/redeem', '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 () => { 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 index 69920546..09037650 100644 --- a/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts +++ b/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts @@ -66,7 +66,12 @@ describe('EarlyBird public auth route', () => { expect(handler).toHaveBeenCalledOnce(); }); - it('accepts only fixed Listener callbacks and constrained locale metadata for magic links', async () => { + it.each([ + ['/listener', '/listener?authError=1'], + ['/listener/redeem', '/listener?authError=1'], + ['/early-birds', '/early-birds?authError=1'], + ['/early-birds/redeem', '/early-birds?authError=1'], + ])('accepts exact Listener callback %s with constrained locale metadata', async (callbackURL, errorCallbackURL) => { const request = new NextRequest( 'https://listen.example.test/api/early-birds/auth/sign-in/magic-link', { @@ -77,8 +82,8 @@ describe('EarlyBird public auth route', () => { }, body: JSON.stringify({ email: 'listener@example.test', - callbackURL: '/early-birds', - errorCallbackURL: '/early-birds?authError=1', + callbackURL, + errorCallbackURL, metadata: { locale: 'es' }, }), }, @@ -92,6 +97,9 @@ describe('EarlyBird public auth route', () => { {}, { callbackURL: 'https://attacker.invalid/collect' }, { callbackURL: '/ops' }, + { callbackURL: '/listener-other' }, + { callbackURL: '/listener/redeem/extra' }, + { callbackURL: '/listener', errorCallbackURL: '/listener?authError=2' }, { callbackURL: '/early-birds', metadata: { locale: 'en', token: 'leak' } }, ])('rejects unsafe magic-link request fields: %j', async (body) => { const request = new NextRequest( diff --git a/src/app/api/early-birds/auth/[...all]/route.ts b/src/app/api/early-birds/auth/[...all]/route.ts index 2d9bae77..0587ca3e 100644 --- a/src/app/api/early-birds/auth/[...all]/route.ts +++ b/src/app/api/early-birds/auth/[...all]/route.ts @@ -10,6 +10,7 @@ import { earlyBirdsEnabled, earlyBirdsUnavailableResponse, } from '@/lib/early-birds/enabled'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; export const dynamic = 'force-dynamic'; @@ -47,13 +48,22 @@ function magicLinkVerification(request: NextRequest): boolean { return request.nextUrl.pathname.endsWith(EARLY_BIRD_MAGIC_LINK_VERIFY_PATH); } -const LISTENER_CALLBACKS = new Set(['/early-birds', '/early-birds/redeem']); +const LISTENER_CALLBACKS: ReadonlySet = new Set([ + LISTENER_NAMESPACE.canonical.home, + LISTENER_NAMESPACE.canonical.redeem, + LISTENER_NAMESPACE.legacy.home, + LISTENER_NAMESPACE.legacy.redeem, +]); +const LISTENER_ERROR_CALLBACKS: ReadonlySet = new Set([ + LISTENER_NAMESPACE.canonical.authError, + LISTENER_NAMESPACE.legacy.authError, +]); function safeListenerCallback(value: unknown, kind: 'success' | 'error'): boolean { if (value === undefined) return true; if (typeof value !== 'string') return false; if (kind === 'success') return LISTENER_CALLBACKS.has(value); - return value === '/early-birds?authError=1'; + return LISTENER_ERROR_CALLBACKS.has(value); } async function safeMagicLinkRequest(request: NextRequest): Promise { 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 index caa47610..ea256f1a 100644 --- a/src/app/api/early-birds/free/redeem/__tests__/route.test.ts +++ b/src/app/api/early-birds/free/redeem/__tests__/route.test.ts @@ -18,10 +18,13 @@ import { POST } from '../route'; const TOKEN = `ebi_v1.${'a'.repeat(32)}.${'b'.repeat(32)}.${'c'.repeat(32)}`; -function request(token: string | null = TOKEN) { +function request(token: string | null = TOKEN, namespace: 'legacy' | 'canonical' = 'legacy') { const headers = new Headers(); if (token) headers.set('cookie', `${EARLY_BIRD_INVITATION_COOKIE}=${token}`); - return new NextRequest('https://live.example.test/api/early-birds/free/redeem', { + const pathname = namespace === 'canonical' + ? '/api/listener/free/redeem' + : '/api/early-birds/free/redeem'; + return new NextRequest(`https://live.example.test${pathname}`, { method: 'POST', headers, }); @@ -76,6 +79,21 @@ describe('EarlyBird Free redemption boundary', () => { }); }); + 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( diff --git a/src/app/api/early-birds/free/redeem/route.ts b/src/app/api/early-birds/free/redeem/route.ts index 89511a45..cdffccf5 100644 --- a/src/app/api/early-birds/free/redeem/route.ts +++ b/src/app/api/early-birds/free/redeem/route.ts @@ -11,6 +11,7 @@ import { EarlyBirdMembershipGatewayUnavailableError, redeemFreeThroughCanonicalGateway, } from '@/lib/early-birds/membership-gateway'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; export const dynamic = 'force-dynamic'; @@ -39,9 +40,12 @@ export async function POST(request: NextRequest): Promise { if (!result.ok) { return NextResponse.json({ error: 'Invitation unavailable.' }, { status: 409 }); } + const landing = request.nextUrl.pathname === LISTENER_NAMESPACE.canonical.api.freeRedeem + ? LISTENER_NAMESPACE.canonical.home + : LISTENER_NAMESPACE.legacy.home; const response = NextResponse.json({ ok: true, - landing: '/early-birds', + landing, replayed: result.replayed, alreadyEntitled: result.alreadyEntitled, }); diff --git a/src/app/early-birds/home/page.tsx b/src/app/early-birds/home/page.tsx index e6ea745d..5002b728 100644 --- a/src/app/early-birds/home/page.tsx +++ b/src/app/early-birds/home/page.tsx @@ -1,7 +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('/early-birds'); + redirect(LISTENER_NAMESPACE.canonical.home); } diff --git a/src/app/early-birds/redeem/page.tsx b/src/app/early-birds/redeem/page.tsx index 4be1b15f..991ecb82 100644 --- a/src/app/early-birds/redeem/page.tsx +++ b/src/app/early-birds/redeem/page.tsx @@ -8,20 +8,21 @@ import { canonicalEarlyBirdInvitation, EARLY_BIRD_INVITATION_COOKIE, } 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('/early-birds'); + if (!earlyBirdsEnabled()) redirect(LISTENER_NAMESPACE.canonical.home); const cookieStore = await cookies(); const token = canonicalEarlyBirdInvitation( cookieStore.get(EARLY_BIRD_INVITATION_COOKIE)?.value, ); - if (!token) redirect('/early-birds'); + if (!token) redirect(LISTENER_NAMESPACE.canonical.home); const session = await currentEarlyBirdSession().catch(() => null); - if (!session) redirect('/early-birds'); + if (!session) redirect(LISTENER_NAMESPACE.canonical.home); return ; } diff --git a/src/components/early-birds/AccessBoundarySync.tsx b/src/components/early-birds/AccessBoundarySync.tsx index db9ad87b..4eb8dc8a 100644 --- a/src/components/early-birds/AccessBoundarySync.tsx +++ b/src/components/early-birds/AccessBoundarySync.tsx @@ -3,6 +3,8 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; + type AccessKind = 'membership' | 'free-window' | 'welcome' | 'denied'; /** @@ -36,7 +38,7 @@ export default function AccessBoundarySync({ inFlight = true; if (timer !== null) window.clearTimeout(timer); try { - const response = await fetch('/api/early-birds/access-state', { + const response = await fetch(LISTENER_NAMESPACE.canonical.api.accessState, { cache: 'no-store', headers: { Accept: 'application/json' }, }); diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index 91df1b67..e7a7941b 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -6,6 +6,7 @@ import { earlyBirdAuthClient } from '@/lib/early-birds/auth-client'; import type { ListenerCampfireFixture } from '@/lib/early-birds/campfire-prototype'; import { earlyBirdCopy, earlyBirdHomeCopy, listenerMembershipPresentationCopy } from '@/lib/early-birds/copy'; import type { ListenerMembershipPresentation } from '@/lib/early-birds/membership-presentation'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; import ListenerPlayer from './ListenerPlayer'; import AccessBoundarySync from './AccessBoundarySync'; @@ -38,7 +39,7 @@ export default function EarlyBirdHome({ async function signOut() { await earlyBirdAuthClient.signOut(); - window.location.assign('/early-birds'); + window.location.assign(LISTENER_NAMESPACE.canonical.home); } return ( @@ -53,7 +54,7 @@ export default function EarlyBirdHome({ )}
- +
{!publicAccess &&
diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index d2e99bb2..ef382309 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -9,6 +9,7 @@ import { earlyBirdCopy, listenerMembershipPresentationCopy } from '@/lib/early-b import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; import type { SerializedEarlyBirdWelcomeAccessState } from '@/lib/early-birds/welcome-access'; import type { ListenerMembershipPresentation } from '@/lib/early-birds/membership-presentation'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; import AccessBoundarySync from './AccessBoundarySync'; import BeaconField from './BeaconField'; @@ -39,8 +40,8 @@ export default function EarlyBirdLanding(props: Props) { const [emailRequested, setEmailRequested] = useState(false); const membership = listenerMembershipPresentationCopy(copy, props.membership); const callbackURL = props.invitationAvailable - ? '/early-birds/redeem' - : '/early-birds'; + ? LISTENER_NAMESPACE.canonical.redeem + : LISTENER_NAMESPACE.canonical.home; async function signIn(provider: 'google' | 'apple') { if (busy || !props.providers[provider]) return; @@ -50,7 +51,7 @@ export default function EarlyBirdLanding(props: Props) { const result = await earlyBirdAuthClient.signIn.social({ provider, callbackURL, - errorCallbackURL: '/early-birds?authError=1', + errorCallbackURL: LISTENER_NAMESPACE.canonical.authError, requestSignUp: true, }); if (!result.error) return; @@ -61,7 +62,7 @@ export default function EarlyBirdLanding(props: Props) { async function signOut() { await earlyBirdAuthClient.signOut(); - window.location.assign('/early-birds'); + window.location.assign(LISTENER_NAMESPACE.canonical.home); } async function requestMagicLink(event: React.FormEvent) { @@ -73,7 +74,7 @@ export default function EarlyBirdLanding(props: Props) { await earlyBirdAuthClient.signIn.magicLink({ email, callbackURL, - errorCallbackURL: '/early-birds?authError=1', + errorCallbackURL: LISTENER_NAMESPACE.canonical.authError, metadata: { locale }, }); // The same response is intentionally shown for unknown accounts, @@ -99,7 +100,7 @@ export default function EarlyBirdLanding(props: Props) { )}
- +
@@ -139,7 +140,7 @@ export default function EarlyBirdLanding(props: Props) {

{copy.signedIn}

{props.entitled ? ( - + {copy.enter} ) : props.invitationAvailable ? ( diff --git a/src/components/early-birds/EarlyBirdUnavailable.tsx b/src/components/early-birds/EarlyBirdUnavailable.tsx index fc699b97..99b3c268 100644 --- a/src/components/early-birds/EarlyBirdUnavailable.tsx +++ b/src/components/early-birds/EarlyBirdUnavailable.tsx @@ -2,6 +2,7 @@ import BrandLockup from '@/components/brand/BrandLockup'; import { useLocale } from '@/context/LocaleContext'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; const copy = { es: { @@ -24,7 +25,7 @@ export default function EarlyBirdUnavailable() {
- +
diff --git a/src/components/early-birds/FreeInvitationRedeemer.tsx b/src/components/early-birds/FreeInvitationRedeemer.tsx index 0d98f537..c4695808 100644 --- a/src/components/early-birds/FreeInvitationRedeemer.tsx +++ b/src/components/early-birds/FreeInvitationRedeemer.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import BrandLockup from '@/components/brand/BrandLockup'; import { useLocale } from '@/context/LocaleContext'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; export default function FreeInvitationRedeemer() { const { locale } = useLocale(); @@ -30,11 +31,11 @@ export default function FreeInvitationRedeemer() { setBusy(true); setError(false); try { - const response = await fetch('/api/early-birds/free/redeem', { + const response = await fetch(LISTENER_NAMESPACE.canonical.api.freeRedeem, { method: 'POST', }); if (response.ok) { - window.location.assign('/early-birds'); + window.location.assign(LISTENER_NAMESPACE.canonical.home); return; } } catch {} @@ -46,7 +47,7 @@ export default function FreeInvitationRedeemer() {
- +

{copy.eyebrow}

diff --git a/src/components/early-birds/FreeWindowSetup.tsx b/src/components/early-birds/FreeWindowSetup.tsx index 14efc716..d9f9948b 100644 --- a/src/components/early-birds/FreeWindowSetup.tsx +++ b/src/components/early-birds/FreeWindowSetup.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { useLocale } from '@/context/LocaleContext'; import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; import { earlyBirdCopy } from '@/lib/early-birds/copy'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; function currentLocalTime(): string { const now = new Date(); @@ -63,7 +64,7 @@ export default function FreeWindowSetup({ state }: { state: SerializedEarlyBirdF setBusy(mode); setError(false); try { - const response = await fetch('/api/early-birds/free-window', { + const response = await fetch(LISTENER_NAMESPACE.canonical.api.freeWindow, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/src/components/early-birds/SyntheticTeamEntryForm.tsx b/src/components/early-birds/SyntheticTeamEntryForm.tsx index affa22a7..9185e0d8 100644 --- a/src/components/early-birds/SyntheticTeamEntryForm.tsx +++ b/src/components/early-birds/SyntheticTeamEntryForm.tsx @@ -4,10 +4,11 @@ import { useState, type FormEvent } from 'react'; import { useLocale } from '@/context/LocaleContext'; import { earlyBirdSyntheticEntryCopy } from '@/lib/early-birds/copy'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; export default function SyntheticTeamEntryForm({ authOnly = false, - postLoginPath = '/early-birds', + postLoginPath = LISTENER_NAMESPACE.canonical.home, }: { authOnly?: boolean; postLoginPath?: string; diff --git a/src/components/early-birds/WelcomeAccessAction.tsx b/src/components/early-birds/WelcomeAccessAction.tsx index cb0a4a8f..ce4fd02e 100644 --- a/src/components/early-birds/WelcomeAccessAction.tsx +++ b/src/components/early-birds/WelcomeAccessAction.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from 'react'; import { useLocale } from '@/context/LocaleContext'; import { earlyBirdCopy } from '@/lib/early-birds/copy'; +import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; export default function WelcomeAccessAction() { const { locale } = useLocale(); @@ -18,7 +19,7 @@ export default function WelcomeAccessAction() { setBusy(true); setError(false); try { - const response = await fetch('/api/early-birds/welcome-access', { + const response = await fetch(LISTENER_NAMESPACE.canonical.api.welcomeAccess, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ activationRequestId: activationRequestId.current }), diff --git a/src/components/early-birds/__tests__/AccessBoundarySync.test.tsx b/src/components/early-birds/__tests__/AccessBoundarySync.test.tsx index 2d5578e4..03210df1 100644 --- a/src/components/early-birds/__tests__/AccessBoundarySync.test.tsx +++ b/src/components/early-birds/__tests__/AccessBoundarySync.test.tsx @@ -35,6 +35,10 @@ describe('Listener access boundary synchronization', () => { await act(async () => { await vi.advanceTimersByTimeAsync(60_750); }); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/listener/access-state', { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }); expect(changed).toHaveBeenCalledTimes(1); }); diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index 76cfba78..74cf598e 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -14,7 +14,7 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) })); vi.mock('@/lib/early-birds/auth-client', () => ({ earlyBirdAuthClient: { signIn: { social: signInSocial, magicLink: signInMagicLink }, signOut }, })); -vi.mock('@/components/brand/BrandLockup', () => ({ default: () => Harmonic Beacon })); +vi.mock('@/components/brand/BrandLockup', () => ({ default: ({ href }: { href: string }) => Harmonic Beacon })); import EarlyBirdLanding from '../EarlyBirdLanding'; @@ -78,8 +78,8 @@ describe('EarlyBird public landing', () => { await userEvent.click(screen.getByRole('button', { name: 'Continue with Google' })); expect(signInSocial).toHaveBeenCalledWith({ provider: 'google', - callbackURL: '/early-birds/redeem', - errorCallbackURL: '/early-birds?authError=1', + callbackURL: '/listener/redeem', + errorCallbackURL: '/listener?authError=1', requestSignUp: true, }); }); @@ -124,8 +124,8 @@ describe('EarlyBird public landing', () => { expect(signInMagicLink).toHaveBeenCalledWith({ email: 'listener@example.test', - callbackURL: '/early-birds', - errorCallbackURL: '/early-birds?authError=1', + callbackURL: '/listener', + errorCallbackURL: '/listener?authError=1', metadata: { locale: 'en' }, }); expect(screen.getByRole('status')).toHaveTextContent('If this email can be used for access'); @@ -152,7 +152,7 @@ describe('EarlyBird public landing', () => { renderLanding({ signedIn: true, entitled: true }); expect(screen.getAllByRole('link', { name: 'Enter the Beacon' })) .toEqual(expect.arrayContaining([ - expect.objectContaining({ href: expect.stringMatching(/\/early-birds$/) }), + expect.objectContaining({ href: expect.stringMatching(/\/listener$/) }), ])); expect(screen.queryByRole('button', { name: 'Continue with Google' })).toBeNull(); expect(screen.getByRole('button', { name: 'Sign out' })).toBeEnabled(); @@ -176,7 +176,7 @@ describe('EarlyBird public landing', () => { expect(screen.getByRole('heading', { name: 'Your daily time · 2 hours' })).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Listen now' })); - expect(fetchMock).toHaveBeenCalledWith('/api/early-birds/welcome-access', expect.objectContaining({ + expect(fetchMock).toHaveBeenCalledWith('/api/listener/welcome-access', expect.objectContaining({ method: 'POST', })); expect(fetchMock.mock.calls[0]?.[1]?.body).toContain('activationRequestId'); diff --git a/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx b/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx index a288f255..314249bc 100644 --- a/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx +++ b/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; import { LocaleProvider } from '@/context/LocaleContext'; -vi.mock('@/components/brand/BrandLockup', () => ({ default: () => Harmonic Beacon })); +vi.mock('@/components/brand/BrandLockup', () => ({ default: ({ href }: { href: string }) => Harmonic Beacon })); import FreeInvitationRedeemer from '../FreeInvitationRedeemer'; @@ -40,7 +40,8 @@ describe('EarlyBird free invitation redeemer', () => { expect(await screen.findByRole('alert')).toHaveTextContent('This invitation is unavailable.'); expect(screen.getByRole('button', { name: 'Activate invitation' })).toBeEnabled(); - expect(request).toHaveBeenCalledWith('/api/early-birds/free/redeem', { method: 'POST' }); + expect(request).toHaveBeenCalledWith('/api/listener/free/redeem', { method: 'POST' }); + expect(screen.getByRole('link', { name: 'Harmonic Beacon' })).toHaveAttribute('href', '/listener'); expect(JSON.stringify(request.mock.calls)).not.toContain('invitation-token'); }); }); diff --git a/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx b/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx index 26194fbb..9d58f654 100644 --- a/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx +++ b/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx @@ -61,6 +61,7 @@ describe('Free listening schedule UI', () => { await userEvent.click(screen.getByRole('button', { name: 'Save my listening time' })); expect(fetch).toHaveBeenCalledOnce(); + expect(vi.mocked(fetch).mock.calls[0][0]).toBe('/api/listener/free-window'); const init = vi.mocked(fetch).mock.calls[0][1] as RequestInit; expect(JSON.parse(init.body as string)).toMatchObject({ mode: 'custom', diff --git a/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx b/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx index 54794932..09103c1a 100644 --- a/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx +++ b/src/components/early-birds/__tests__/SyntheticTeamEntryForm.test.tsx @@ -62,7 +62,7 @@ describe('EarlyBird staging team entry form', () => { , ); diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts new file mode 100644 index 00000000..b6d153b9 --- /dev/null +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -0,0 +1,56 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const MEDIA_FILE_SHA256 = { + 'src/components/early-birds/ListenerPlayer.tsx': '7b0f6f4e75e6b1c84ad65bb3ccbb08c32759dd591bcc51048a0cfe3b71dd8b78', + 'src/lib/early-birds/stream.ts': 'e386413874e5ad799e17607e2b030851cc7baadea48161191c7daecb45183bea', + 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', + 'src/app/api/early-birds/stream/lease/route.ts': 'd858affc655c6df6607e76470508a59c3ebd442744bc12e5da334729fcb5d660', + 'src/app/api/early-birds/stream/manifest/route.ts': 'f149e9ed081579d75c0d588f7e6a75663b449c4baf28658418014a8e0c87a0de', + 'src/app/api/early-birds/stream/heartbeat/route.ts': '316a2690db50472b67bd0251d995e6d3375c710030ccf30e460e92d9ade1a407', + 'src/app/api/early-birds/drop-ins/[language]/route.ts': 'c905ba0ce68aa22448a2f41c9ed9563473dc097f0284e245421dae787cd30d94', +} as const; + +function sha256(value: string | Buffer): string { + return createHash('sha256').update(value).digest('hex'); +} + +function nginxLocation(source: string, location: string): string { + const escaped = location.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replaceAll('/', '\\/'); + const match = source.match(new RegExp(` location \\^~ ${escaped} \\{[\\s\\S]*?\\n \\}`)); + if (!match) throw new Error(`Missing nginx media location ${location}`); + return match[0]; +} + +describe('Listener namespace media boundary', () => { + it('keeps every player and media handler byte-identical to the accepted Phase 1 baseline', () => { + for (const [path, expected] of Object.entries(MEDIA_FILE_SHA256)) { + expect(sha256(readFileSync(resolve(process.cwd(), path))), path).toBe(expected); + } + }); + + it('keeps public stream and drop-in proxy blocks byte-identical', () => { + const source = readFileSync(resolve( + process.cwd(), + 'ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template', + ), 'utf8'); + + expect(sha256(nginxLocation(source, '/api/early-birds/stream/'))) + .toBe('877f937d145631a55f41b71fddb2142ad6f9b2833be1c57731eb772ed040fdb9'); + expect(sha256(nginxLocation(source, '/api/early-birds/drop-ins/'))) + .toBe('6738ce8a66d53fab790452e1b62bd74a3ff9439d7addc5c2a739cef00f8c17d4'); + }); + + it('keeps the staging legacy API proxy block byte-identical', () => { + const source = readFileSync(resolve( + process.cwd(), + 'ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template', + ), 'utf8'); + + expect(sha256(nginxLocation(source, '/api/early-birds/'))) + .toBe('31f610cfd1d4d5778d6d3a2c10879790a5c2dcb8e39563738b2b39aa9cdc14c8'); + }); +}); diff --git a/src/lib/listener/__tests__/namespace.test.ts b/src/lib/listener/__tests__/namespace.test.ts index ebd71106..54c67020 100644 --- a/src/lib/listener/__tests__/namespace.test.ts +++ b/src/lib/listener/__tests__/namespace.test.ts @@ -10,6 +10,7 @@ describe('Listener namespace compatibility contract', () => { expect(LISTENER_NAMESPACE.canonical).toEqual({ home: '/listener', redeem: '/listener/redeem', + authError: '/listener?authError=1', api: { accessState: '/api/listener/access-state', freeWindow: '/api/listener/free-window', @@ -19,6 +20,7 @@ describe('Listener namespace compatibility contract', () => { }); expect(LISTENER_NAMESPACE.legacy.home).toBe('/early-birds'); expect(LISTENER_NAMESPACE.legacy.redeem).toBe('/early-birds/redeem'); + expect(LISTENER_NAMESPACE.legacy.authError).toBe('/early-birds?authError=1'); }); it.each([ diff --git a/src/lib/listener/namespace.ts b/src/lib/listener/namespace.ts index 6b8a6b7b..f86570eb 100644 --- a/src/lib/listener/namespace.ts +++ b/src/lib/listener/namespace.ts @@ -9,6 +9,7 @@ export const LISTENER_NAMESPACE = { canonical: { home: '/listener', redeem: '/listener/redeem', + authError: '/listener?authError=1', api: { accessState: '/api/listener/access-state', freeWindow: '/api/listener/free-window', @@ -19,6 +20,7 @@ export const LISTENER_NAMESPACE = { legacy: { home: '/early-birds', redeem: '/early-birds/redeem', + authError: '/early-birds?authError=1', api: { accessState: '/api/early-birds/access-state', freeWindow: '/api/early-birds/free-window', From 3e4e64a3948cb5c91c48a98b5c660beded0ca05e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 19:47:07 -0300 Subject: [PATCH 113/376] fix(listener): contain invitation redemption to staging --- .../LISTENER_NAMESPACE_MIGRATION.md | 9 ++- e2e/tests/listener-namespace-compat.spec.ts | 77 +++++++++++++++++++ middleware.test.ts | 48 +++++++++++- ...s-staging.harmonicbeacon.com.conf.template | 7 ++ .../listen.harmonicbeacon.com.conf.template | 65 +++++++++++++++- .../test/preview-contract.test.mjs | 33 +++++++- src/middleware.ts | 19 +++-- 7 files changed, 241 insertions(+), 17 deletions(-) create mode 100644 e2e/tests/listener-namespace-compat.spec.ts diff --git a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md index 570d7c03..ea5b0441 100644 --- a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md +++ b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md @@ -69,9 +69,12 @@ internals. The phase 2A candidate changes only account-local access-state, Free-window, welcome-access and staging invitation redemption. Better Auth continues to use its legacy base path and cookies. The public edge continues to exclude -invitation redemption and synthetic entry; the staging edge exposes only the -four exact canonical non-media APIs. Stream, heartbeat, manifest, drop-in and -player storage paths remain on their accepted legacy URLs. +invitation redemption and synthetic entry: invitation queries are accepted only +on the exact staging hostname, while the public edge suppresses access logs, +scrubs the bearer with no-store/no-referrer and never mints its cookie. The +staging edge exposes only the four exact canonical non-media APIs. 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: diff --git a/e2e/tests/listener-namespace-compat.spec.ts b/e2e/tests/listener-namespace-compat.spec.ts new file mode 100644 index 00000000..e684263a --- /dev/null +++ b/e2e/tests/listener-namespace-compat.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; + +import { EARLY_BIRD_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 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/middleware.test.ts b/middleware.test.ts index c11d9aa3..e8d4427f 100644 --- a/middleware.test.ts +++ b/middleware.test.ts @@ -24,12 +24,18 @@ const INVITATION = `ebi_v1.${'a'.repeat(32)}.${'b'.repeat(32)}.${'c'.repeat(32)} * 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 { @@ -44,7 +50,11 @@ describe('middleware', () => { ['/early-birds', 'invite'], ['/early-birds/redeem', 'token'], ])('moves the canonical %s query token to a short HttpOnly cookie', (pathname, queryName) => { - const response = middleware(request(`${pathname}?${queryName}=${INVITATION}&locale=en`)); + const response = middleware(request( + `${pathname}?${queryName}=${INVITATION}&locale=en`, + undefined, + 'earlybirds-staging.harmonicbeacon.com', + )); expect(response.status).toBe(307); const target = location(response); @@ -63,6 +73,38 @@ describe('middleware', () => { }); }); + 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'], + ['live.harmonicbeacon.com', '/early-birds', '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(); + }); + + it('does not trust a forwarded staging host on the public Listener URL', () => { + const response = middleware(request( + `/listener?invite=${INVITATION}`, + undefined, + 'listen.harmonicbeacon.com', + { 'x-forwarded-host': 'earlybirds-staging.harmonicbeacon.com' }, + )); + + expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); + }); + it('scrubs malformed or ambiguous query values without persisting them', () => { for (const pathname of [ '/early-birds?invite=not-canonical', 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 index bfc06f8e..e04cbec5 100644 --- a/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -9,6 +9,13 @@ server { 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 { diff --git a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template index b0e679c3..fbd1c1eb 100644 --- a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -10,6 +10,44 @@ server { 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 = /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 / { return 301 https://listen.harmonicbeacon.com$request_uri; } @@ -90,14 +128,39 @@ server { } location = /early-birds { - return 302 /; + 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; + } + + # Invitation redemption is staging-only. Clean either redeem alias without + # proxying or retaining its bearer query on the public Listener host. + location = /early-birds/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; + return 302 /; + } + + location = /listener/redeem { + access_log off; + add_header Cache-Control "private, no-store" always; + add_header Referrer-Policy "no-referrer" always; return 302 /; } 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; diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 8f6ef19d..68d01e34 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -184,8 +184,19 @@ test('nginx templates isolate staging, stream and the constrained public Listene 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\//); - assert.match(listener, /location = \/listener \{\s*return 302 \/;/); - assert.match(listener, /location = \/ \{[^}]*rewrite \^ \/listener break;[^}]*proxy_pass http:\/\/127\.0\.0\.1:13000;/s); + 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"[^}]*return 302 /;`, + 's', + )); + } + 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/); @@ -203,13 +214,27 @@ test('nginx templates isolate staging, stream and the constrained public Listene assert.doesNotMatch(listener, /api\/listener\/(test-login|free\/|membership)/); assert.doesNotMatch(listener, /location \^~ \/early-birds\//); + 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]))); - assert.equal((app.match(/add_header Referrer-Policy "no-referrer" always;/g) ?? []).length, 6); - assert.equal((app.match(/add_header Cache-Control "private, no-store" always;/g) ?? []).length, 6); + assert.equal((app.match(/add_header Referrer-Policy "no-referrer" always;/g) ?? []).length, 7); + assert.equal((app.match(/add_header Cache-Control "private, no-store" always;/g) ?? []).length, 7); + 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', 'free/redeem', 'welcome-access']) { assert.match(app, new RegExp(`location = /api/listener/${path.replace('/', '\\/')}`)); } diff --git a/src/middleware.ts b/src/middleware.ts index 5814dfba..e8defd9d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -16,11 +16,12 @@ import { listenerInvitationQuery } from '@/lib/listener/namespace'; * as authorization would mean a revoked ticket kept its access simply because * the browser still held the cookie. * - * It performs two edge-local navigation chores: it exchanges a canonical - * EarlyBird invitation query for a short browser-inaccessible cookie before any - * page renders, and it sends visitors with no session cookie to the relevant - * login surface. Every protected page and API route still resolves the principal - * itself through `@/lib/auth`, and none of them may assume this file ran. + * It performs two edge-local navigation chores: on the exact invitation + * staging host it exchanges a canonical Listener invitation query for a short + * browser-inaccessible cookie, while every other host only scrubs the bearer; + * it also sends visitors with no session cookie to the relevant login surface. + * Every protected page and API route still resolves the principal itself + * through `@/lib/auth`, and none of them may assume this file ran. */ /** @@ -36,6 +37,7 @@ const ATTENDEE_PREFIXES = ['/session']; /** Staff surfaces: the operator console. */ const STAFF_PREFIXES = ['/ops']; +const LISTENER_INVITATION_HOST = 'earlybirds-staging.harmonicbeacon.com'; function matches(pathname: string, prefixes: string[]): boolean { return prefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); @@ -54,7 +56,12 @@ function scrubEarlyBirdInvitation(request: NextRequest): NextResponse | null { const response = NextResponse.redirect(target); response.headers.set('Cache-Control', 'private, no-store'); response.headers.set('Referrer-Policy', 'no-referrer'); - if (token) response.cookies.set(earlyBirdInvitationCookie(token)); + // Invitation redemption remains a staging-only surface. The canonical + // public Listener edge must still remove a bearer query without turning it + // into a durable browser credential or a dead-end redemption state. + if (token && request.nextUrl.hostname === LISTENER_INVITATION_HOST) { + response.cookies.set(earlyBirdInvitationCookie(token)); + } return response; } From b843c7d91f94c3233e4e71c130a94ca89d2bd4ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 20:12:45 -0300 Subject: [PATCH 114/376] fix(listener): make public access failures truthful --- e2e/tests/early-birds-responsive.spec.ts | 103 ++++++++++-------- e2e/tests/early-birds.spec.ts | 47 ++++---- src/app/__tests__/layout-locale.test.tsx | 58 ++++++++++ src/app/early-birds/__tests__/page.test.tsx | 54 +++++++++ src/app/early-birds/page.tsx | 44 +++++--- src/app/globals.css | 21 ++++ src/app/layout.tsx | 10 +- .../early-birds/EarlyBirdLanding.tsx | 31 +++++- .../__tests__/EarlyBirdLanding.test.tsx | 20 ++++ src/lib/early-birds/copy.ts | 8 ++ .../__tests__/public-discovery.test.ts | 11 ++ src/lib/listener/public-discovery.ts | 6 +- 12 files changed, 326 insertions(+), 87 deletions(-) create mode 100644 src/app/__tests__/layout-locale.test.tsx diff --git a/e2e/tests/early-birds-responsive.spec.ts b/e2e/tests/early-birds-responsive.spec.ts index d7af0faa..8aba7ab9 100644 --- a/e2e/tests/early-birds-responsive.spec.ts +++ b/e2e/tests/early-birds-responsive.spec.ts @@ -1,77 +1,84 @@ -import { expect, test } from '@playwright/test'; +import { AxeBuilder } from '@axe-core/playwright'; +import { expect, test, type Locator, type Page, type TestInfo } from '@playwright/test'; -async function expectNoHorizontalScroll(page: import('@playwright/test').Page): Promise { +const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; +const MEDIA_PATH = /\/api\/(?:early-birds\/(?:stream|drop-ins)|listener\/(?:stream|drop-ins))|\.(?:m3u8|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, 'EarlyBirds page has horizontal overflow').toBeLessThanOrEqual(1); + 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([]); } -test.describe('EarlyBirds responsive bilingual boundary', () => { +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('landing remains reachable and complete in ES and EN', async ({ page }) => { - await page.goto('/early-birds'); - await expectNoHorizontalScroll(page); - - const viewport = page.viewportSize(); - expect(viewport).not.toBeNull(); - for (const name of ['Nombre de prueba', 'Cuenta sintética', 'Código de acceso temporal']) { - const control = page.getByLabel(name); - await expect(control).toBeVisible(); - const box = await control.boundingBox(); - expect(box, `${name} has no layout box`).not.toBeNull(); - expect(box!.x).toBeGreaterThanOrEqual(0); - expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1); - } + 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.getByRole('button', { name: 'EN', exact: true }).click(); - await expect(page.locator('html')).toHaveAttribute('lang', 'en'); - await expect(page.getByRole('heading', { name: 'Remember your harmonic center.' })).toBeVisible(); - await expect(page.getByLabel('Test name')).toBeVisible(); - await expect(page.getByLabel('Synthetic account')).toBeVisible(); - await expect(page.getByLabel('Temporary access code')).toBeVisible(); + 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); + await expect(page.locator('audio, video')).toHaveCount(0); + expect(mediaRequests).toEqual([]); + await expectAccessible(page, testInfo, 'listener-public'); }); - test('private Listener remains in bounds without requesting event media', async ({ page }) => { - await page.addInitScript(() => { - Object.defineProperty(window, '__earlyBirdMediaRequests', { - value: 0, - writable: true, - }); - const mediaDevices = navigator.mediaDevices; - if (!mediaDevices?.getUserMedia) return; - const original = mediaDevices.getUserMedia.bind(mediaDevices); - mediaDevices.getUserMedia = (...constraints: Parameters) => { - (window as typeof window & { __earlyBirdMediaRequests: number }).__earlyBirdMediaRequests += 1; - return original(...constraints); - }; - }); + 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: { - // Reuse one synthetic identity across viewport projects. This - // exercises sign-up followed by sign-in and stays below Better - // Auth's intentional account-creation rate limit. - email: 'responsive-listener@e2e.invalid', + email: `responsive-listener-${testInfo.project.name}@e2e.invalid`, name: 'Responsive Listener', }, }); expect(response.status()).toBe(200); - await page.goto('/early-birds'); + await page.goto('/listener'); await expect(page.getByRole('heading', { name: 'Beacon' })).toBeAttached(); - await expect(page.getByRole('button', { name: 'Account' })).toHaveText('R'); - await expect(page.getByRole('button', { name: 'Listen' })).toBeVisible(); await expectNoHorizontalScroll(page); - await expect.poll(() => page.evaluate( - () => (window as typeof window & { __earlyBirdMediaRequests: number }).__earlyBirdMediaRequests, - )).toBe(0); + const account = page.locator('.listener-account > summary'); + await expect(account).toHaveAttribute('aria-label', 'Cuenta'); + await expectTouchTarget(account, 'Cuenta'); + await expectAccessible(page, testInfo, 'listener-authorized'); }); }); diff --git a/e2e/tests/early-birds.spec.ts b/e2e/tests/early-birds.spec.ts index 72f845f0..d9945323 100644 --- a/e2e/tests/early-birds.spec.ts +++ b/e2e/tests/early-birds.spec.ts @@ -1,23 +1,29 @@ import { expect, test } from '@playwright/test'; -test.describe('EarlyBird Listener boundary', () => { - test('serves the bilingual public landing without exposing test access', async ({ page }) => { - await page.goto('/early-birds'); - await expect(page.getByRole('heading', { name: 'El Beacon, siempre presente.' })).toBeVisible(); - await expect(page.getByRole('button', { name: /Continuar con Google/ })).toBeVisible(); - await expect(page.getByRole('button', { name: /Continuar con Apple/ })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Entrar con acceso de prueba' })).toHaveCount(0); +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 compatibility redirect', async ({ page }) => { + test('keeps the legacy private-home URL as a canonical compatibility redirect', async ({ page }) => { await page.goto('/early-birds/home'); - await expect(page).toHaveURL(/\/early-birds$/); - await expect(page.getByRole('heading', { name: 'El Beacon, siempre presente.' })).toBeVisible(); + + await expect(page).toHaveURL(/\/listener$/); + await expect(page.getByRole('heading', { name: 'Recuerda tu centro armónico.' })).toBeVisible(); }); - test('shows the team form only on the exact HTTPS staging host and never persists its code', async ({ page }) => { + test('submits the staging team credential once and never persists it', async ({ page }) => { const accessCode = 'browser-entered-staging-code-000000000001'; - await page.setExtraHTTPHeaders({ 'x-forwarded-proto': 'https' }); let authorization = ''; await page.route('**/api/early-birds/test-login', async (route) => { authorization = route.request().headers().authorization ?? ''; @@ -28,8 +34,7 @@ test.describe('EarlyBird Listener boundary', () => { }); }); - await page.goto('/early-birds'); - await expect(page.getByText('Acceso de equipo · staging')).toBeVisible(); + 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); @@ -44,7 +49,7 @@ test.describe('EarlyBird Listener boundary', () => { }))).not.toContain(accessCode); }); - test('creates an isolated synthetic session and reaches the private Listener home', async ({ page }) => { + 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', @@ -61,11 +66,13 @@ test.describe('EarlyBird Listener boundary', () => { landing: '/early-birds', }); - await page.goto('/early-birds'); - await expect(page.getByText('Synthetic Listener')).toBeVisible(); + await page.goto('/listener'); await expect(page.getByRole('heading', { name: 'Beacon' })).toBeAttached(); - await expect(page.getByText(/Membresía activa · TEST/)).toBeVisible(); - await expect(page.getByRole('button', { name: 'Play con intro' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Play · solo Beacon' })).toBeVisible(); + 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/src/app/__tests__/layout-locale.test.tsx b/src/app/__tests__/layout-locale.test.tsx new file mode 100644 index 00000000..58e4d950 --- /dev/null +++ b/src/app/__tests__/layout-locale.test.tsx @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + headers: vi.fn(), + requestLocale: vi.fn(), +})); + +vi.mock('next/headers', () => ({ headers: mocks.headers })); +vi.mock('@/lib/i18n-server', () => ({ requestLocale: mocks.requestLocale })); +vi.mock('next/font/google', () => ({ + Cormorant_Garamond: () => ({ variable: 'cormorant' }), + Syne: () => ({ variable: 'syne' }), + Space_Mono: () => ({ variable: 'mono' }), +})); +vi.mock('@/context/LocaleContext', () => ({ + LocaleProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock('sonner', () => ({ Toaster: () => null })); + +import RootLayout from '../layout'; + +function requestHeaders(host: string, acceptLanguage: string): Headers { + return new Headers({ host, 'accept-language': acceptLanguage }); +} + +describe('root document locale boundary', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + 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'); + + const result = await RootLayout({ children:
}); + + expect(result.props.lang).toBe('en'); + expect(result.props['data-lang']).toBe('en'); + expect(mocks.requestLocale).not.toHaveBeenCalled(); + }); + + 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(mocks.requestLocale).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx index d8182dfa..76515681 100644 --- a/src/app/early-birds/__tests__/page.test.tsx +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -210,4 +210,58 @@ describe('EarlyBird Listener page', () => { accessUntil: '2026-08-07T16: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.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); + 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', + freeWindow: null, + welcome: 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.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); + 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', + freeWindow: null, + welcome: 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.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); + 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'); + }); }); diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index e3441728..9d0ca825 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -15,14 +15,14 @@ import { } from '@/lib/early-birds/invitation-cookie'; import { syntheticTeamEntryAllowed } from '@/lib/early-birds/synthetic-team-entry'; import { configuredEarlyBirdDropIn } from '@/lib/early-birds/drop-ins'; -import { freeWindowState, serializeFreeWindowState } from '@/lib/early-birds/free-window'; -import { serializeWelcomeAccessState, welcomeAccessState } from '@/lib/early-birds/welcome-access'; +import { serializeFreeWindowState } from '@/lib/early-birds/free-window'; +import { serializeWelcomeAccessState } from '@/lib/early-birds/welcome-access'; import { earlyBirdMagicLinkAvailable } from '@/lib/early-birds/magic-link'; import { listenerCampfirePrototypeConfig } from '@/lib/early-birds/campfire-prototype'; import { listenerMembershipPresentation } from '@/lib/early-birds/membership-presentation'; -import { localeForBrowserLanguage } from '@/lib/i18n'; import { isCanonicalListenerHost, + listenerLocaleForHeaders, listenerPreviewMetadata, listenerPublicMetadata, } from '@/lib/listener/public-discovery'; @@ -34,7 +34,7 @@ export async function generateMetadata() { if (!isCanonicalListenerHost(incomingHeaders)) return listenerPreviewMetadata(); return listenerPublicMetadata( - localeForBrowserLanguage(incomingHeaders.get('accept-language')), + listenerLocaleForHeaders(incomingHeaders), ); } @@ -66,10 +66,16 @@ export default async function EarlyBirdsPage({ const serverNow = new Date().toISOString(); const incomingHeaders = new Headers(await requestHeaders()); const cookieStore = await cookies(); - const session = await currentEarlyBirdSession().catch(() => null); - const access = session - ? await getEarlyBirdListeningAccess(session.user.id).catch(() => null) - : null; + 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 = canonicalEarlyBirdInvitation( cookieStore.get(EARLY_BIRD_INVITATION_COOKIE)?.value, ) !== null; @@ -94,17 +100,29 @@ export default async function EarlyBirdsPage({ ); } + const providers = earlyBirdOAuthAvailability(); + const emailMagicLinkAvailable = earlyBirdMagicLinkAvailable(); + const syntheticTeamEntryAvailable = syntheticTeamEntryAllowed({ headers: incomingHeaders }); + const identityUnavailable = sessionResolution.unavailable || ( + !session + && !providers.google + && !providers.apple + && !emailMagicLinkAvailable + && !syntheticTeamEntryAvailable + ); + return ( diff --git a/src/app/globals.css b/src/app/globals.css index f3680857..7bffa615 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1718,6 +1718,27 @@ body { background: rgba(255, 214, 112, 0.06); } +.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(--paper); + 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(--paper); font-size: 0.88rem; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e23d7520..17e8bd24 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata, Viewport } from "next"; import { Cormorant_Garamond, Syne, Space_Mono } from "next/font/google"; +import { headers } from "next/headers"; import { LocaleProvider } from "@/context/LocaleContext"; import { requestLocale } from "@/lib/i18n-server"; +import { isCanonicalListenerHost, listenerLocaleForHeaders } from "@/lib/listener/public-discovery"; import "./globals.css"; import { Toaster } from "sonner"; @@ -52,7 +54,13 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const locale = await requestLocale(); + const incomingHeaders = await headers(); + // Listener content and metadata use the browser's primary language. Bind + // that behavior to the exact public host so event-language defaults on the + // rest of the application remain untouched. + const locale = isCanonicalListenerHost(incomingHeaders) + ? listenerLocaleForHeaders(incomingHeaders) + : await requestLocale(); return ( diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index ef382309..b076f351 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -20,13 +20,14 @@ import WelcomeAccessAction from './WelcomeAccessAction'; type Props = { signedIn: boolean; entitled: boolean; + serviceUnavailable: 'identity' | 'access' | null; invitationAvailable: boolean; authError: boolean; providers: { google: boolean; apple: boolean }; emailMagicLinkAvailable: boolean; syntheticTeamEntryAvailable: boolean; - freeWindow: SerializedEarlyBirdFreeWindowState; - welcome: SerializedEarlyBirdWelcomeAccessState; + freeWindow: SerializedEarlyBirdFreeWindowState | null; + welcome: SerializedEarlyBirdWelcomeAccessState | null; membership: ListenerMembershipPresentation; serverNow: string; }; @@ -91,7 +92,7 @@ export default function EarlyBirdLanding(props: Props) { return (
- {props.signedIn && props.freeWindow.nextStart && ( + {props.signedIn && props.freeWindow?.nextStart && ( )} - {props.signedIn ? ( + {props.serviceUnavailable ? ( +
+ {copy.serviceUnavailableTitle} +

{props.serviceUnavailable === 'identity' + ? copy.identityUnavailable + : copy.accessUnavailable}

+ + {copy.retryAccess} + + {props.signedIn && ( + + )} +
+ ) : props.signedIn && props.freeWindow && props.welcome ? (

{copy.signedIn}

{props.entitled ? ( diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index 74cf598e..78cd28e4 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -24,6 +24,7 @@ function renderLanding(overrides: Partial { expect(screen.queryByLabelText('Email address')).not.toBeInTheDocument(); }); + it.each([ + ['identity', 'The identity service is not responding.'], + ['access', 'We could not check your schedule or membership.'], + ] as const)('fails truthfully and retryably when %s resolution is unavailable', (kind, detail) => { + renderLanding({ + signedIn: kind === 'access', + serviceUnavailable: kind, + freeWindow: null, + welcome: null, + }); + + expect(screen.getByRole('alert')).toHaveTextContent(detail); + expect(screen.getByRole('link', { name: 'Try again' })).toHaveAttribute('href', '/listener'); + expect(screen.queryByRole('heading', { name: 'Your first listen · 30 minutes' })).toBeNull(); + expect(screen.queryByRole('heading', { name: 'Your daily time · 2 hours' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Continue with Google' })).toBeNull(); + expect(screen.queryByLabelText('Email address')).toBeNull(); + }); + it('takes an entitled signed-in listener directly to the private home', () => { renderLanding({ signedIn: true, entitled: true }); expect(screen.getAllByRole('link', { name: 'Enter the Beacon' })) diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 186d587b..629d02b4 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -44,6 +44,10 @@ export const earlyBirdCopy = { welcomeDescription: 'Tu primera escucha no fija todavía el horario diario.', welcomeError: 'No pudimos abrir esta primera escucha. Inténtalo nuevamente.', authError: 'No pudimos completar el acceso. Usa el mismo proveedor con el que creaste tu cuenta o contacta a soporte.', + serviceUnavailableTitle: 'No podemos confirmar tu acceso ahora.', + identityUnavailable: 'El servicio de identidad no está respondiendo. Tus datos y tu acceso no cambiaron.', + accessUnavailable: 'No pudimos consultar tu horario o membresía. No mostraremos un estado estimado.', + retryAccess: 'Intentar nuevamente', privacy: 'Tu cuenta y membresía administran el acceso. Durante la escucha sólo compartimos una presencia regional amplia y efímera; nunca tu ubicación exacta ni un historial personal de escucha.', membershipInvitation: 'Acceso por invitación', membershipPreview: 'Acceso de prueba', @@ -100,6 +104,10 @@ export const earlyBirdCopy = { welcomeDescription: 'Your first listen does not set your daily time yet.', welcomeError: 'We could not open this first listen. Please try again.', authError: 'We could not complete sign-in. Use the provider that created your account, or contact support.', + serviceUnavailableTitle: 'We cannot confirm your access right now.', + identityUnavailable: 'The identity service is not responding. Your data and access have not changed.', + accessUnavailable: 'We could not check your schedule or membership. We will not show an estimated state.', + retryAccess: 'Try again', privacy: 'Your account and membership manage access. While you listen, we share only broad, ephemeral regional presence—never your exact location or a personal listening history.', membershipInvitation: 'Invitation access', membershipPreview: 'Preview access', diff --git a/src/lib/listener/__tests__/public-discovery.test.ts b/src/lib/listener/__tests__/public-discovery.test.ts index 61e6a6ea..809dcbd8 100644 --- a/src/lib/listener/__tests__/public-discovery.test.ts +++ b/src/lib/listener/__tests__/public-discovery.test.ts @@ -5,6 +5,7 @@ import { LISTENER_CANONICAL_URL, listenerPreviewMetadata, listenerPublicMetadata, + listenerLocaleForHeaders, listenerRobotsText, listenerSitemapXml, } from '../public-discovery'; @@ -27,6 +28,16 @@ describe('Listener public discovery', () => { expect(isCanonicalListenerHost(requestHeaders(null))).toBe(false); }); + it('uses the same browser-language decision for public content and document metadata', () => { + const spanish = requestHeaders('listen.harmonicbeacon.com'); + spanish.set('accept-language', 'es-MX,es;q=0.9,en;q=0.7'); + const english = requestHeaders('listen.harmonicbeacon.com'); + english.set('accept-language', 'fr-FR,fr;q=0.9,en;q=0.8'); + + expect(listenerLocaleForHeaders(spanish)).toBe('es'); + expect(listenerLocaleForHeaders(english)).toBe('en'); + }); + it.each([ ['es', 'Harmonic Beacon · Recuerda tu centro armónico.', 'Un campo armónico continuo, compartido alrededor del mundo.'], ['en', 'Harmonic Beacon · Remember your harmonic center.', 'A continuous harmonic field, shared across the world.'], diff --git a/src/lib/listener/public-discovery.ts b/src/lib/listener/public-discovery.ts index 78b7ed31..664336b7 100644 --- a/src/lib/listener/public-discovery.ts +++ b/src/lib/listener/public-discovery.ts @@ -1,6 +1,6 @@ import type { Metadata } from 'next'; -import type { UiLocale } from '@/lib/i18n'; +import { localeForBrowserLanguage, type UiLocale } from '@/lib/i18n'; export const LISTENER_PUBLIC_HOST = 'listen.harmonicbeacon.com'; export const LISTENER_PUBLIC_ORIGIN = `https://${LISTENER_PUBLIC_HOST}`; @@ -35,6 +35,10 @@ export function isCanonicalListenerHost(headers: Pick): boolean return normalizedHost(headers.get('host')) === LISTENER_PUBLIC_HOST; } +export function listenerLocaleForHeaders(headers: Pick): UiLocale { + return localeForBrowserLanguage(headers.get('accept-language')); +} + export function listenerPublicMetadata(locale: UiLocale): Metadata { const copy = localizedMetadata[locale]; From 563bebf327f743e2ad8bc968fffef3ab2e111a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 20:58:19 -0300 Subject: [PATCH 115/376] feat(listener): enable secure public invitations --- docs/architecture/EARLY_BIRDS_LISTENER.md | 33 ++++- .../LISTENER_NAMESPACE_MIGRATION.md | 23 ++-- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 14 +- e2e/tests/listener-namespace-compat.spec.ts | 49 +++++++ middleware.test.ts | 75 +++++++++-- ...s-staging.harmonicbeacon.com.conf.template | 113 +++++++++++----- .../listen.harmonicbeacon.com.conf.template | 123 +++++++++++++++++- .../test/preview-contract.test.mjs | 84 ++++++++++-- .../free/redeem/__tests__/route.test.ts | 87 ++++++++++++- src/app/api/early-birds/free/redeem/route.ts | 52 ++++++-- .../__tests__/invitation-cookie.test.ts | 15 +++ src/lib/early-birds/invitation-cookie.ts | 22 ++++ src/middleware.ts | 31 +++-- 13 files changed, 620 insertions(+), 101 deletions(-) diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 1520ef85..9fae1e89 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -23,8 +23,8 @@ 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 while continuing to block synthetic login, invitations and -internal membership routes. +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 @@ -78,6 +78,35 @@ Byte-exact copies live in `contracts/early-bird-authority/v1` and - `ACTIVE`, time-valid `GRACE`, and time-valid `CANCELLED_PENDING_END` allow access. Every missing, expired, revoked, refunded 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, places it in a 30-minute `__Host-`, Secure, HttpOnly, SameSite=Lax cookie +and redirects to the clean URL. Neither the event host nor a forwarded-host +header can mint this cookie. + +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. A terminal authority +rejection clears the cookie; a transient 503 preserves it for a safe retry. 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. + ## Ordinary Free listening window Registration does not fabricate a commerce membership. A signed-in account diff --git a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md index ea5b0441..82be3ebe 100644 --- a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md +++ b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md @@ -45,9 +45,10 @@ Implemented in this branch: 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 and placed in the - existing HttpOnly cookie. The canonical route therefore accepts old cookies - without copying sensitive values into JavaScript-visible storage. +- 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. @@ -67,14 +68,14 @@ internals. 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 staging invitation redemption. Better Auth continues to use -its legacy base path and cookies. The public edge continues to exclude -invitation redemption and synthetic entry: invitation queries are accepted only -on the exact staging hostname, while the public edge suppresses access logs, -scrubs the bearer with no-store/no-referrer and never mints its cookie. The -staging edge exposes only the four exact canonical non-media APIs. Stream, -heartbeat, manifest, drop-in and player storage paths remain on their accepted -legacy URLs. +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: diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 29a19b62..fb9f6ed4 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -356,10 +356,16 @@ and returns 404 for the image's weekend, staff, event and checkout surfaces. The `listen.harmonicbeacon.com` vhost is narrower: it exposes only `/`, Next static assets, health, the dedicated Listener OAuth/session namespace, the exact ordinary-Free schedule endpoint, stream leases/manifests and configured -drop-ins. Synthetic login, invitation, -membership projection and all other app routes remain unreachable from that -host. The application additionally returns a hidden 404 for Better Auth's -email/password endpoints, so the public namespace offers only 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. A host operator must review certificate/DNS ownership, provision each named diff --git a/e2e/tests/listener-namespace-compat.spec.ts b/e2e/tests/listener-namespace-compat.spec.ts index e684263a..a502884e 100644 --- a/e2e/tests/listener-namespace-compat.spec.ts +++ b/e2e/tests/listener-namespace-compat.spec.ts @@ -13,6 +13,55 @@ const INVITATION = `ebi_v1.${'a'.repeat(32)}.${'b'.repeat(32)}.${'c'.repeat(32)} 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: EARLY_BIRD_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 === EARLY_BIRD_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); diff --git a/middleware.test.ts b/middleware.test.ts index e8d4427f..9ffe5d0b 100644 --- a/middleware.test.ts +++ b/middleware.test.ts @@ -49,7 +49,7 @@ describe('middleware', () => { ['/listener/redeem', 'token'], ['/early-birds', 'invite'], ['/early-birds/redeem', 'token'], - ])('moves the canonical %s query token to a short HttpOnly cookie', (pathname, queryName) => { + ])('forwards staging %s bearer once to the canonical redeem host', (pathname, queryName) => { const response = middleware(request( `${pathname}?${queryName}=${INVITATION}&locale=en`, undefined, @@ -58,27 +58,68 @@ describe('middleware', () => { expect(response.status).toBe(307); const target = location(response); - expect(target.pathname).toBe(pathname); - expect(target.searchParams.has(queryName)).toBe(false); - expect(target.searchParams.get('locale')).toBe('en'); + 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(); + }); + + 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', + }); + }); + + 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(); + + 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, }); }); 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'], ['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`, @@ -94,12 +135,12 @@ describe('middleware', () => { expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); }); - it('does not trust a forwarded staging host on the public Listener URL', () => { + it('does not trust a forwarded Listener host on an off-surface URL', () => { const response = middleware(request( - `/listener?invite=${INVITATION}`, + `/early-birds?invite=${INVITATION}`, undefined, - 'listen.harmonicbeacon.com', - { 'x-forwarded-host': 'earlybirds-staging.harmonicbeacon.com' }, + 'live.harmonicbeacon.com', + { 'x-forwarded-host': 'listen.harmonicbeacon.com' }, )); expect(response.cookies.get(EARLY_BIRD_INVITATION_COOKIE)).toBeUndefined(); @@ -116,6 +157,16 @@ describe('middleware', () => { 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', () => { 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 index e04cbec5..9b2dc831 100644 --- a/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -29,7 +29,7 @@ server { 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; + return 302 https://listen.harmonicbeacon.com/listener/redeem$is_args$args; } location = /listener { @@ -43,7 +43,31 @@ server { 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; + return 302 https://listen.harmonicbeacon.com/listener/redeem$is_args$args; + } + + 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 / { @@ -138,46 +162,65 @@ server { location = /early-birds/redeem { access_log off; - 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; + 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; - 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; + 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 = /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; + # 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; } - location = /api/listener/free-window { + # 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:13000; proxy_http_version 1.1; proxy_set_header Host $host; @@ -190,7 +233,7 @@ server { proxy_read_timeout 30s; } - location = /api/listener/free/redeem { + location = /api/listener/free-window { proxy_pass http://127.0.0.1:13000; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template index fbd1c1eb..da88e048 100644 --- a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -1,6 +1,9 @@ # Public Listener edge. It shares the isolated Listener runtime and exposes the -# Listener's dedicated OAuth/session boundary, but never staging synthetic -# entry, invitations, membership projection, staff or event routes. +# 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; + server { listen 80; listen [::]:80; @@ -48,6 +51,15 @@ server { 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; } @@ -141,20 +153,47 @@ server { return 302 /$is_args$args; } - # Invitation redemption is staging-only. Clean either redeem alias without - # proxying or retaining its bearer query on the public Listener host. + # 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; - return 302 /; + 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; - return 302 /; + 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 = / { @@ -281,6 +320,76 @@ server { 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. @@ -297,7 +406,7 @@ server { proxy_read_timeout 60s; } - # Synthetic entry, invitations, projection, staff and event routes are + # 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/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 68d01e34..ce3de397 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -174,8 +174,16 @@ test('nginx templates isolate staging, stream and the constrained public Listene assert.match(listener, /letsencrypt\/live\/listen\.harmonicbeacon\.com/); assert.match(app, /location \^~ \/api\/internal\//); assert.match(app, /location \^~ \/api\/early-birds\//); - assert.equal((combined.match(/X-Harmonic-Beacon-Environment "early-birds-staging"/g) ?? []).length, 2); - assert.equal((listener.match(/X-Harmonic-Beacon-Environment "listener-public-free"/g) ?? []).length, 1); + assert.equal( + (app.match(/X-Harmonic-Beacon-Environment "early-birds-staging"/g) ?? []).length, + 6, + 'server plus five 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, + 6, + 'server plus five 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:13000;/s); assert.match(app, /location = \/early-birds\/home \{\s*return 302 \/;/); assert.match(app, /location \/ \{\s*return 404;/); @@ -192,7 +200,7 @@ test('nginx templates isolate staging, stream and the constrained public Listene } 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"[^}]*return 302 /;`, + `location = /${path} \\{[^}]*access_log off;[^}]*Cache-Control "private, no-store"[^}]*Referrer-Policy "no-referrer"[^}]*proxy_pass http://127\\.0\\.0\\.1:13000;`, 's', )); } @@ -210,10 +218,27 @@ test('nginx templates isolate staging, stream and the constrained public Listene assert.equal((listener.match(/location = \/sitemap\.xml/g) ?? []).length, 1); assert.doesNotMatch(listener, /location \^~ \/api\/listener\/public-discovery\//); assert.doesNotMatch(listener, /location \^~ \/api\/listener\//); - assert.doesNotMatch(listener, /api\/early-birds\/(test-login|free\/|membership)/); - assert.doesNotMatch(listener, /api\/listener\/(test-login|free\/|membership)/); + assert.doesNotMatch(listener, /api\/early-birds\/(test-login|membership)/); + assert.doesNotMatch(listener, /api\/listener\/(test-login|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, )]; @@ -229,13 +254,54 @@ test('nginx templates isolate staging, stream and the constrained public Listene /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]))); - assert.equal((app.match(/add_header Referrer-Policy "no-referrer" always;/g) ?? []).length, 7); - assert.equal((app.match(/add_header Cache-Control "private, no-store" always;/g) ?? []).length, 7); + 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', 'free/redeem', 'welcome-access']) { + 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\//); 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 index ea256f1a..14da3534 100644 --- a/src/app/api/early-birds/free/redeem/__tests__/route.test.ts +++ b/src/app/api/early-birds/free/redeem/__tests__/route.test.ts @@ -18,13 +18,21 @@ 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') { +function request( + token: string | null = TOKEN, + namespace: 'legacy' | 'canonical' = 'legacy', + origin = 'https://listen.harmonicbeacon.com', + hostname = 'listen.harmonicbeacon.com', +) { const headers = new Headers(); 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://live.example.test${pathname}`, { + return new NextRequest(`https://${hostname}${pathname}`, { method: 'POST', headers, }); @@ -52,6 +60,52 @@ describe('EarlyBird Free redemption boundary', () => { 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 () => { @@ -77,6 +131,8 @@ describe('EarlyBird Free redemption boundary', () => { sameSite: 'lax', path: '/', }); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); }); it('returns the canonical landing only to the canonical alias', async () => { @@ -97,10 +153,15 @@ describe('EarlyBird Free redemption boundary', () => { 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://live.example.test/api/early-birds/free/redeem', + 'https://listen.harmonicbeacon.com/api/early-birds/free/redeem', { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + origin: 'https://listen.harmonicbeacon.com', + host: 'listen.harmonicbeacon.com', + 'x-forwarded-proto': 'https', + 'content-type': 'application/json', + }, body: JSON.stringify({ token: TOKEN }), }, )); @@ -109,11 +170,27 @@ describe('EarlyBird Free redemption boundary', () => { expect(redeemFreeThroughCanonicalGateway).not.toHaveBeenCalled(); }); - it('fails closed without leaking whether a token exists', async () => { + 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, + }); + }); + + 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.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 index cdffccf5..8d474223 100644 --- a/src/app/api/early-birds/free/redeem/route.ts +++ b/src/app/api/early-birds/free/redeem/route.ts @@ -5,6 +5,7 @@ import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-bi import { canonicalEarlyBirdInvitation, clearedEarlyBirdInvitationCookie, + earlyBirdInvitationCookieHost, EARLY_BIRD_INVITATION_COOKIE, } from '@/lib/early-birds/invitation-cookie'; import { @@ -15,17 +16,52 @@ 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); + response.cookies.set(clearedEarlyBirdInvitationCookie()); + 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 earlyBirdsUnavailableResponse(); + 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 NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); + if (!session) return json({ error: 'Sign in required.' }, 401); const token = canonicalEarlyBirdInvitation( request.cookies.get(EARLY_BIRD_INVITATION_COOKIE)?.value, ); if (!token) { - return NextResponse.json({ error: 'Invitation unavailable.' }, { status: 409 }); + return terminalInvitationUnavailable(); } let result; @@ -33,22 +69,22 @@ export async function POST(request: NextRequest): Promise { result = await redeemFreeThroughCanonicalGateway(session.user.id, token); } catch (error) { if (error instanceof EarlyBirdMembershipGatewayUnavailableError) { - return NextResponse.json({ error: 'Membership service unavailable.' }, { status: 503 }); + return json({ error: 'Membership service unavailable.' }, 503); } - return NextResponse.json({ error: 'Membership service unavailable.' }, { status: 503 }); + return json({ error: 'Membership service unavailable.' }, 503); } if (!result.ok) { - return NextResponse.json({ error: 'Invitation unavailable.' }, { status: 409 }); + return terminalInvitationUnavailable(); } const landing = request.nextUrl.pathname === LISTENER_NAMESPACE.canonical.api.freeRedeem ? LISTENER_NAMESPACE.canonical.home : LISTENER_NAMESPACE.legacy.home; - const response = NextResponse.json({ + const response = sensitive(NextResponse.json({ ok: true, landing, replayed: result.replayed, alreadyEntitled: result.alreadyEntitled, - }); + })); response.cookies.set(clearedEarlyBirdInvitationCookie()); return response; } diff --git a/src/lib/early-birds/__tests__/invitation-cookie.test.ts b/src/lib/early-birds/__tests__/invitation-cookie.test.ts index 53b1d494..bc424bc6 100644 --- a/src/lib/early-birds/__tests__/invitation-cookie.test.ts +++ b/src/lib/early-birds/__tests__/invitation-cookie.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from 'vitest'; import { canonicalEarlyBirdInvitation, clearedEarlyBirdInvitationCookie, + earlyBirdInvitationCookieHost, + earlyBirdInvitationHost, earlyBirdInvitationCookie, + earlyBirdInvitationStagingHost, EARLY_BIRD_INVITATION_COOKIE, } from '@/lib/early-birds/invitation-cookie'; @@ -33,4 +36,16 @@ describe('EarlyBird invitation handoff cookie', () => { path: '/', }); }); + + it('accepts invitation entry only on the exact Listener product and staging hosts', () => { + expect(earlyBirdInvitationHost('listen.harmonicbeacon.com')).toBe(true); + expect(earlyBirdInvitationHost('earlybirds-staging.harmonicbeacon.com')).toBe(true); + expect(earlyBirdInvitationHost('LISTEN.HARMONICBEACON.COM')).toBe(true); + expect(earlyBirdInvitationHost('live.harmonicbeacon.com')).toBe(false); + expect(earlyBirdInvitationHost('listen.harmonicbeacon.com.attacker.invalid')).toBe(false); + expect(earlyBirdInvitationCookieHost('listen.harmonicbeacon.com')).toBe(true); + expect(earlyBirdInvitationCookieHost('earlybirds-staging.harmonicbeacon.com')).toBe(false); + expect(earlyBirdInvitationStagingHost('earlybirds-staging.harmonicbeacon.com')).toBe(true); + expect(earlyBirdInvitationStagingHost('listen.harmonicbeacon.com')).toBe(false); + }); }); diff --git a/src/lib/early-birds/invitation-cookie.ts b/src/lib/early-birds/invitation-cookie.ts index 4df51aa8..f72464e5 100644 --- a/src/lib/early-birds/invitation-cookie.ts +++ b/src/lib/early-birds/invitation-cookie.ts @@ -1,8 +1,30 @@ const CANONICAL_INVITATION_TOKEN = /^ebi_v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; +const LISTENER_INVITATION_HOSTS = new Set([ + 'listen.harmonicbeacon.com', + 'earlybirds-staging.harmonicbeacon.com', +]); +const LISTENER_INVITATION_COOKIE_HOST = 'listen.harmonicbeacon.com'; +const LISTENER_INVITATION_STAGING_HOST = 'earlybirds-staging.harmonicbeacon.com'; + +export const LISTENER_INVITATION_CANONICAL_ORIGIN = 'https://listen.harmonicbeacon.com'; + export const EARLY_BIRD_INVITATION_COOKIE = '__Host-hb_early_bird_invitation'; export const EARLY_BIRD_INVITATION_MAX_AGE_SECONDS = 30 * 60; +/** Public product and isolated preview are the only invitation entry hosts. */ +export function earlyBirdInvitationHost(hostname: string): boolean { + return LISTENER_INVITATION_HOSTS.has(hostname.toLowerCase()); +} + +export function earlyBirdInvitationCookieHost(hostname: string): boolean { + return hostname.toLowerCase() === LISTENER_INVITATION_COOKIE_HOST; +} + +export function earlyBirdInvitationStagingHost(hostname: string): boolean { + return hostname.toLowerCase() === LISTENER_INVITATION_STAGING_HOST; +} + export function canonicalEarlyBirdInvitation(value: unknown): string | null { if (typeof value !== 'string' || value.length < 32 || value.length > 512) return null; return CANONICAL_INVITATION_TOKEN.test(value) ? value : null; diff --git a/src/middleware.ts b/src/middleware.ts index e8defd9d..fb6ffa8a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,7 +3,10 @@ import type { NextRequest } from 'next/server'; import { canonicalEarlyBirdInvitation, + earlyBirdInvitationCookieHost, earlyBirdInvitationCookie, + earlyBirdInvitationStagingHost, + LISTENER_INVITATION_CANONICAL_ORIGIN, } from '@/lib/early-birds/invitation-cookie'; import { listenerInvitationQuery } from '@/lib/listener/namespace'; @@ -16,8 +19,8 @@ import { listenerInvitationQuery } from '@/lib/listener/namespace'; * as authorization would mean a revoked ticket kept its access simply because * the browser still held the cookie. * - * It performs two edge-local navigation chores: on the exact invitation - * staging host it exchanges a canonical Listener invitation query for a short + * It performs two edge-local navigation chores: staging forwards a canonical + * invitation once to the Listener product host, which exchanges it for a short * browser-inaccessible cookie, while every other host only scrubs the bearer; * it also sends visitors with no session cookie to the relevant login surface. * Every protected page and API route still resolves the principal itself @@ -37,8 +40,6 @@ const ATTENDEE_PREFIXES = ['/session']; /** Staff surfaces: the operator console. */ const STAFF_PREFIXES = ['/ops']; -const LISTENER_INVITATION_HOST = 'earlybirds-staging.harmonicbeacon.com'; - function matches(pathname: string, prefixes: string[]): boolean { return prefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); } @@ -51,15 +52,29 @@ function scrubEarlyBirdInvitation(request: NextRequest): NextResponse | null { const token = candidates.length === 1 ? canonicalEarlyBirdInvitation(candidates[0]) : null; + const hostname = request.nextUrl.hostname; + if (earlyBirdInvitationStagingHost(hostname)) { + // OAuth/session authority lives on the canonical Listener host. Carry + // the bearer through exactly one unlogged/no-store redirect so the + // canonical edge can scrub it into its own host-only __Host- cookie. + const canonical = new URL( + token ? '/listener/redeem' : '/listener', + LISTENER_INVITATION_CANONICAL_ORIGIN, + ); + if (token) canonical.searchParams.set('token', token); + const response = NextResponse.redirect(canonical); + response.headers.set('Cache-Control', 'private, no-store'); + response.headers.set('Referrer-Policy', 'no-referrer'); + return response; + } const target = request.nextUrl.clone(); target.searchParams.delete(queryName); const response = NextResponse.redirect(target); response.headers.set('Cache-Control', 'private, no-store'); response.headers.set('Referrer-Policy', 'no-referrer'); - // Invitation redemption remains a staging-only surface. The canonical - // public Listener edge must still remove a bearer query without turning it - // into a durable browser credential or a dead-end redemption state. - if (token && request.nextUrl.hostname === LISTENER_INVITATION_HOST) { + // Host is taken from the request URL populated by the exact nginx vhost; + // forwarded host headers are deliberately not trusted. + if (token && earlyBirdInvitationCookieHost(hostname)) { response.cookies.set(earlyBirdInvitationCookie(token)); } return response; From c7145a1a0cb9e776684546acf440df17a2fde28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 21:02:31 -0300 Subject: [PATCH 116/376] feat(listener): add runtime env compatibility --- .env.example | 8 +- .../LISTENER_NAMESPACE_MIGRATION.md | 22 +- .../auth/[...all]/__tests__/route.test.ts | 17 ++ .../api/early-birds/auth/[...all]/route.ts | 16 +- src/app/api/early-birds/free-window/route.ts | 16 +- src/app/api/early-birds/test-login/route.ts | 9 +- .../api/early-birds/welcome-access/route.ts | 16 +- .../api/health/__tests__/ready-route.test.ts | 34 ++- src/app/api/health/ready/route.ts | 28 ++- src/lib/early-birds/__tests__/auth.test.ts | 36 +++ src/lib/early-birds/__tests__/enabled.test.ts | 10 + .../early-birds/__tests__/magic-link.test.ts | 15 ++ .../__tests__/synthetic-team-entry.test.ts | 17 ++ src/lib/early-birds/auth.ts | 108 ++++++--- src/lib/early-birds/enabled.ts | 14 +- src/lib/early-birds/magic-link.ts | 25 ++- src/lib/early-birds/synthetic-team-entry.ts | 16 +- .../listener/__tests__/runtime-env.test.ts | 130 +++++++++++ src/lib/listener/runtime-env.ts | 209 ++++++++++++++++++ 19 files changed, 663 insertions(+), 83 deletions(-) create mode 100644 src/lib/listener/__tests__/runtime-env.test.ts create mode 100644 src/lib/listener/runtime-env.ts diff --git a/.env.example b/.env.example index 56af2478..1c827c5a 100644 --- a/.env.example +++ b/.env.example @@ -45,8 +45,14 @@ BEACON_COMMERCE_SERVICE_KEY_CURRENT=replace-with-at-least-43-random-characters SESSION_COOKIE_TTL_SECONDS=604800 # =================== -# EarlyBird Listener identity, membership and private stream +# Listener identity, membership and private stream # =================== +# Identity/access settings below keep their EARLY_BIRDS_ deployment names for +# rollback compatibility. The application also accepts the stable +# BEACON_LISTENER_ alias for ENABLED, FREE_FOR_ALL, AUTH_BASE_URL, +# TRUSTED_ORIGINS, AUTH_SECRET, Google/Apple 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. diff --git a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md index 82be3ebe..e45877d2 100644 --- a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md +++ b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md @@ -32,7 +32,7 @@ under preview operations, 14 documentation files, 14 contract files, 10 scripts, | 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 1 continues to read and write it from both URL namespaces, so existing invitations 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 `LISTENER_*`-first/legacy-fallback readers in bounded groups; never rename deployment configuration before the binary accepts both. | +| 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. | @@ -114,13 +114,29 @@ same identity. Introduce a typed resolver for each bounded environment group: -1. `LISTENER_*` preferred, `EARLY_BIRDS_*` fallback; -2. fail startup when both are set to different values for security-sensitive +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 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 index 09037650..6b07bb79 100644 --- a/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts +++ b/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts @@ -57,6 +57,23 @@ describe('EarlyBird public auth route', () => { expect(handler).toHaveBeenCalledOnce(); }); + it('accepts canonical Listener origin config and rejects conflicting aliases', async () => { + vi.stubEnv('EARLY_BIRDS_AUTH_BASE_URL', ''); + vi.stubEnv('EARLY_BIRDS_TRUSTED_ORIGINS', ''); + vi.stubEnv('BEACON_LISTENER_AUTH_BASE_URL', 'https://listen.example.test'); + vi.stubEnv('BEACON_LISTENER_TRUSTED_ORIGINS', 'https://listen.example.test'); + const request = () => new NextRequest( + 'https://listen.example.test/api/early-birds/auth/sign-in/social', + { method: 'POST', headers: { origin: 'https://listen.example.test' }, body: '{}' }, + ); + expect((await POST(request())).status).toBe(204); + + handler.mockClear(); + vi.stubEnv('EARLY_BIRDS_AUTH_BASE_URL', 'https://other.example.test'); + expect((await POST(request())).status).toBe(403); + expect(handler).not.toHaveBeenCalled(); + }); + it('lets the provider callback reach its one-time state verifier', async () => { const request = new NextRequest( 'https://listen.example.test/api/early-birds/auth/callback/apple', diff --git a/src/app/api/early-birds/auth/[...all]/route.ts b/src/app/api/early-birds/auth/[...all]/route.ts index 0587ca3e..25ac71c6 100644 --- a/src/app/api/early-birds/auth/[...all]/route.ts +++ b/src/app/api/early-birds/auth/[...all]/route.ts @@ -11,6 +11,7 @@ import { earlyBirdsUnavailableResponse, } from '@/lib/early-birds/enabled'; import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; +import { listenerRuntimeTrustedOrigins } from '@/lib/listener/runtime-env'; export const dynamic = 'force-dynamic'; @@ -26,14 +27,13 @@ function hiddenSyntheticEmailEndpoint(request: NextRequest): Response | null { function trustedMutationOrigin(request: NextRequest): boolean { const origin = request.headers.get('origin'); if (!origin) return false; - const configured = [ - process.env.EARLY_BIRDS_AUTH_BASE_URL, - ...(process.env.EARLY_BIRDS_TRUSTED_ORIGINS ?? '').split(','), - ] - .map((value) => value?.trim()) - .filter((value): value is string => Boolean(value)); - const allowed = configured.length > 0 ? configured : [request.nextUrl.origin]; - return allowed.includes(origin); + try { + const configured = listenerRuntimeTrustedOrigins(); + const allowed = configured.length > 0 ? configured : [request.nextUrl.origin]; + return allowed.includes(origin); + } catch { + return false; + } } function oauthCallback(request: NextRequest): boolean { diff --git a/src/app/api/early-birds/free-window/route.ts b/src/app/api/early-birds/free-window/route.ts index 95d8be9f..35e0b3ef 100644 --- a/src/app/api/early-birds/free-window/route.ts +++ b/src/app/api/early-birds/free-window/route.ts @@ -9,6 +9,7 @@ import { selectEarlyBirdFreeWindow, serializeFreeWindowState, } from '@/lib/early-birds/free-window'; +import { listenerRuntimeTrustedOrigins } from '@/lib/listener/runtime-env'; export const dynamic = 'force-dynamic'; @@ -17,14 +18,13 @@ const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }; function sameOriginMutation(request: NextRequest): boolean { const origin = request.headers.get('origin'); if (!origin) return false; - const configured = [ - process.env.EARLY_BIRDS_AUTH_BASE_URL, - ...(process.env.EARLY_BIRDS_TRUSTED_ORIGINS ?? '').split(','), - ] - .map((value) => value?.trim()) - .filter((value): value is string => Boolean(value)); - const allowed = configured.length > 0 ? configured : [request.nextUrl.origin]; - return allowed.includes(origin); + try { + const configured = listenerRuntimeTrustedOrigins(); + const allowed = configured.length > 0 ? configured : [request.nextUrl.origin]; + return allowed.includes(origin); + } catch { + return false; + } } async function authenticatedAccount(request: NextRequest) { diff --git a/src/app/api/early-birds/test-login/route.ts b/src/app/api/early-birds/test-login/route.ts index 25c301c3..af140280 100644 --- a/src/app/api/early-birds/test-login/route.ts +++ b/src/app/api/early-birds/test-login/route.ts @@ -6,6 +6,7 @@ import { EARLY_BIRD_AUTH_BASE_PATH, earlyBirdAuth, earlyBirdTestAuthEnabled, + earlyBirdTestLoginSecret, } from '@/lib/early-birds/auth'; import { issueSyntheticMembership } from '@/lib/early-birds/membership'; import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; @@ -22,17 +23,19 @@ function digest(value: string): Buffer { } function authorizedSyntheticLogin(request: NextRequest): boolean { - if (!earlyBirdTestAuthEnabled()) return false; + const expected = earlyBirdTestLoginSecret(); + if (!expected || !earlyBirdTestAuthEnabled()) return false; const authorization = request.headers.get('authorization'); const presented = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : ''; - const expected = process.env.EARLY_BIRDS_TEST_LOGIN_SECRET ?? ''; return timingSafeEqual(digest(presented), digest(expected)); } function testPassword(email: string): string { - return createHmac('sha256', process.env.EARLY_BIRDS_TEST_LOGIN_SECRET!) + const secret = earlyBirdTestLoginSecret(); + if (!secret) throw new Error('Listener synthetic login is not configured'); + return createHmac('sha256', secret) .update(`early-birds-test-login:v1:${email}`) .digest('base64url'); } diff --git a/src/app/api/early-birds/welcome-access/route.ts b/src/app/api/early-birds/welcome-access/route.ts index ef1432af..110fab0f 100644 --- a/src/app/api/early-birds/welcome-access/route.ts +++ b/src/app/api/early-birds/welcome-access/route.ts @@ -13,6 +13,7 @@ import { serializeWelcomeAccessState, startEarlyBirdWelcomeAccess, } from '@/lib/early-birds/welcome-access'; +import { listenerRuntimeTrustedOrigins } from '@/lib/listener/runtime-env'; export const dynamic = 'force-dynamic'; @@ -21,14 +22,13 @@ const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }; function sameOriginMutation(request: NextRequest): boolean { const origin = request.headers.get('origin'); if (!origin) return false; - const configured = [ - process.env.EARLY_BIRDS_AUTH_BASE_URL, - ...(process.env.EARLY_BIRDS_TRUSTED_ORIGINS ?? '').split(','), - ] - .map((value) => value?.trim()) - .filter((value): value is string => Boolean(value)); - const allowed = configured.length > 0 ? configured : [request.nextUrl.origin]; - return allowed.includes(origin); + try { + const configured = listenerRuntimeTrustedOrigins(); + const allowed = configured.length > 0 ? configured : [request.nextUrl.origin]; + return allowed.includes(origin); + } catch { + return false; + } } export async function GET(request: NextRequest): Promise { diff --git a/src/app/api/health/__tests__/ready-route.test.ts b/src/app/api/health/__tests__/ready-route.test.ts index 94655f2a..20259607 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,37 @@ 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('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..94bee5b1 100644 --- a/src/app/api/health/ready/route.ts +++ b/src/app/api/health/ready/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { redactError } from '@/lib/redact'; +import { + ListenerRuntimeEnvironmentError, + validateListenerRuntimeEnvironment, +} from '@/lib/listener/runtime-env'; import { OperationTimeoutError, withTimeout } from '@/lib/with-timeout'; export const dynamic = 'force-dynamic'; @@ -17,10 +21,32 @@ const NO_STORE_HEADERS = { 'Cache-Control': 'no-store' }; * body distinguishes only 'timeout' from 'unreachable', nothing more. */ export async function GET() { + let listenerRuntimeConfigured = false; + try { + listenerRuntimeConfigured = validateListenerRuntimeEnvironment(); + } catch (error) { + const diagnostic = error instanceof ListenerRuntimeEnvironmentError + ? 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 { 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' } : {}), + }, + }, { headers: NO_STORE_HEADERS }, ); } catch (error) { diff --git a/src/lib/early-birds/__tests__/auth.test.ts b/src/lib/early-birds/__tests__/auth.test.ts index b6650ad9..43108df6 100644 --- a/src/lib/early-birds/__tests__/auth.test.ts +++ b/src/lib/early-birds/__tests__/auth.test.ts @@ -5,6 +5,8 @@ import { earlyBirdAuth, earlyBirdOAuthAvailability, earlyBirdSocialProviders, + earlyBirdTestLoginSecret, + earlyBirdTrustedOrigins, } from '../auth'; describe('EarlyBird Better Auth isolation', () => { @@ -44,6 +46,40 @@ describe('EarlyBird Better Auth isolation', () => { expect(Object.keys(earlyBirdSocialProviders(environment))).toEqual(['google']); }); + it('accepts canonical auth config without mixing credential generations', () => { + const canonical = { + BEACON_LISTENER_GOOGLE_CLIENT_ID: 'canonical-google-id', + BEACON_LISTENER_GOOGLE_CLIENT_SECRET: 'canonical-google-secret', + BEACON_LISTENER_AUTH_BASE_URL: 'https://listen.example.test', + BEACON_LISTENER_TRUSTED_ORIGINS: 'https://staging.example.test', + } as unknown as NodeJS.ProcessEnv; + expect(earlyBirdOAuthAvailability(canonical)).toEqual({ google: true, apple: false }); + expect(earlyBirdSocialProviders(canonical)).toMatchObject({ + google: { clientId: 'canonical-google-id', clientSecret: 'canonical-google-secret' }, + }); + expect(earlyBirdTrustedOrigins(canonical)).toEqual([ + 'https://listen.example.test', + 'https://staging.example.test', + ]); + + expect(earlyBirdOAuthAvailability({ + BEACON_LISTENER_GOOGLE_CLIENT_ID: 'new-id', + EARLY_BIRDS_GOOGLE_CLIENT_SECRET: 'old-secret', + } as unknown as NodeJS.ProcessEnv).google).toBe(false); + }); + + it('keeps the synthetic-login gate and secret in one generation', () => { + const secret = 's'.repeat(32); + expect(earlyBirdTestLoginSecret({ + BEACON_LISTENER_TEST_ACCESS_ENABLED: '1', + BEACON_LISTENER_TEST_LOGIN_SECRET: secret, + } as unknown as NodeJS.ProcessEnv)).toBe(secret); + expect(earlyBirdTestLoginSecret({ + BEACON_LISTENER_TEST_ACCESS_ENABLED: '1', + EARLY_BIRDS_TEST_LOGIN_SECRET: secret, + } as unknown as NodeJS.ProcessEnv)).toBeNull(); + }); + it('scrubs provider token material before create and update reach Prisma', async () => { const hooks = earlyBirdAuth().options.databaseHooks?.account; const providerPayload = { diff --git a/src/lib/early-birds/__tests__/enabled.test.ts b/src/lib/early-birds/__tests__/enabled.test.ts index ca86b599..8d826e31 100644 --- a/src/lib/early-birds/__tests__/enabled.test.ts +++ b/src/lib/early-birds/__tests__/enabled.test.ts @@ -24,6 +24,16 @@ describe('EarlyBird public kill switch', () => { expect(earlyBirdsEnabled()).toBe(true); }); + it('prefers the canonical gate and fails closed when aliases disagree', () => { + expect(earlyBirdsEnabled({ + BEACON_LISTENER_ENABLED: '1', + } as unknown as NodeJS.ProcessEnv)).toBe(true); + expect(earlyBirdsEnabled({ + BEACON_LISTENER_ENABLED: '1', + EARLY_BIRDS_ENABLED: '0', + } as unknown as NodeJS.ProcessEnv)).toBe(false); + }); + it('enables the Free for All override only for the explicit value 1', () => { for (const value of ['', 'true', 'yes', '0', ' 1 ']) { vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', value); diff --git a/src/lib/early-birds/__tests__/magic-link.test.ts b/src/lib/early-birds/__tests__/magic-link.test.ts index cfc69fa7..49048a9d 100644 --- a/src/lib/early-birds/__tests__/magic-link.test.ts +++ b/src/lib/early-birds/__tests__/magic-link.test.ts @@ -104,6 +104,21 @@ describe('EarlyBird email magic link', () => { })).toBe(false); }); + it('accepts a complete canonical delivery bundle and rejects mixed generations', () => { + expect(earlyBirdMagicLinkAvailable({ + BEACON_LISTENER_MAGIC_LINK_DELIVERY_URL: + `https://mail.example.test${EARLY_BIRD_MAGIC_LINK_DELIVERY_PATH}`, + BEACON_LISTENER_MAGIC_LINK_DELIVERY_TOKEN: 'canonical-token-with-at-least-32-characters', + BEACON_LISTENER_MAGIC_LINK_RATE_SECRET: 'canonical-rate-secret-with-at-least-32-characters', + })).toBe(true); + expect(earlyBirdMagicLinkAvailable({ + BEACON_LISTENER_MAGIC_LINK_DELIVERY_URL: + `https://mail.example.test${EARLY_BIRD_MAGIC_LINK_DELIVERY_PATH}`, + EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN: 'legacy-token-with-at-least-32-characters', + EARLY_BIRDS_MAGIC_LINK_RATE_SECRET: 'legacy-rate-secret-with-at-least-32-characters', + })).toBe(false); + }); + it('stores a one-way verifier rather than the raw token', () => { const token = 'raw-token-that-must-not-be-persisted'; const verifier = hashEarlyBirdMagicLinkToken(token); diff --git a/src/lib/early-birds/__tests__/synthetic-team-entry.test.ts b/src/lib/early-birds/__tests__/synthetic-team-entry.test.ts index 8b6c93d4..0a77539c 100644 --- a/src/lib/early-birds/__tests__/synthetic-team-entry.test.ts +++ b/src/lib/early-birds/__tests__/synthetic-team-entry.test.ts @@ -47,4 +47,21 @@ describe('EarlyBird synthetic team entry staging gate', () => { EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS: 'earlybirds-staging.example.test,bad/value', })).toBe(false); }); + + it('accepts a complete canonical staging gate', () => { + const canonical = { + NODE_ENV: 'production', + BEACON_LISTENER_ENABLED: '1', + BEACON_LISTENER_TEST_ACCESS_ENABLED: '1', + BEACON_LISTENER_TEST_LOGIN_SECRET: 's'.repeat(32), + BEACON_LISTENER_STAGING_TEAM_ENTRY_ENABLED: '1', + BEACON_LISTENER_STAGING_TEAM_ENTRY_HOSTS: 'earlybirds-staging.example.test', + } as NodeJS.ProcessEnv; + expect(syntheticTeamEntryAllowed({ + headers: new Headers({ + host: 'earlybirds-staging.example.test', + 'x-forwarded-proto': 'https', + }), + }, canonical)).toBe(true); + }); }); diff --git a/src/lib/early-birds/auth.ts b/src/lib/early-birds/auth.ts index 4ef428b2..98ca9f0c 100644 --- a/src/lib/early-birds/auth.ts +++ b/src/lib/early-birds/auth.ts @@ -4,6 +4,12 @@ import { prismaAdapter } from 'better-auth/adapters/prisma'; import { magicLink } from 'better-auth/plugins'; import { prisma } from '@/lib/db'; +import { + listenerRuntimeBundle, + listenerRuntimeFlag, + listenerRuntimeTrustedOrigins, + listenerRuntimeValue, +} from '@/lib/listener/runtime-env'; import { deliverEarlyBirdMagicLink, EARLY_BIRD_MAGIC_LINK_TTL_SECONDS, @@ -16,66 +22,96 @@ export const EARLY_BIRD_AUTH_BASE_PATH = '/api/early-birds/auth'; export const EARLY_BIRD_COOKIE_PREFIX = 'hb_earlybird'; export const EARLY_BIRD_SESSION_COOKIE = 'hb_earlybird_session'; -function nonEmpty(value: string | undefined): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; +export function earlyBirdTestAuthEnabled(environment: NodeJS.ProcessEnv = process.env): boolean { + return earlyBirdTestLoginSecret(environment) !== null; } -export function earlyBirdTestAuthEnabled(environment: NodeJS.ProcessEnv = process.env): boolean { - const secret = nonEmpty(environment.EARLY_BIRDS_TEST_LOGIN_SECRET); - return ( - environment.EARLY_BIRDS_TEST_ACCESS_ENABLED === '1' && - Boolean(secret && secret.length >= 32) - ); +export function earlyBirdTestLoginSecret( + environment: NodeJS.ProcessEnv = process.env, +): string | null { + try { + const configuration = listenerRuntimeBundle( + ['TEST_ACCESS_ENABLED', 'TEST_LOGIN_SECRET'], + environment, + ); + return configuration && listenerRuntimeFlag('TEST_ACCESS_ENABLED', environment) && + configuration.TEST_LOGIN_SECRET.length >= 32 + ? configuration.TEST_LOGIN_SECRET + : null; + } catch { + return null; + } } export function earlyBirdOAuthAvailability(environment: NodeJS.ProcessEnv = process.env) { + let google = null; + let apple = null; + try { + google = listenerRuntimeBundle( + ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'], + environment, + ); + } catch { /* An invalid pair is unavailable. */ } + try { + apple = listenerRuntimeBundle( + ['APPLE_CLIENT_ID', 'APPLE_CLIENT_SECRET'], + environment, + ); + } catch { /* An invalid pair is unavailable. */ } return { - google: Boolean( - nonEmpty(environment.EARLY_BIRDS_GOOGLE_CLIENT_ID) && - nonEmpty(environment.EARLY_BIRDS_GOOGLE_CLIENT_SECRET), - ), - apple: Boolean( - nonEmpty(environment.EARLY_BIRDS_APPLE_CLIENT_ID) && - nonEmpty(environment.EARLY_BIRDS_APPLE_CLIENT_SECRET), - ), + google: google !== null, + apple: apple !== null, } as const; } export function earlyBirdSocialProviders(environment: NodeJS.ProcessEnv = process.env) { - const googleId = nonEmpty(environment.EARLY_BIRDS_GOOGLE_CLIENT_ID); - const googleSecret = nonEmpty(environment.EARLY_BIRDS_GOOGLE_CLIENT_SECRET); - const appleId = nonEmpty(environment.EARLY_BIRDS_APPLE_CLIENT_ID); - const appleSecret = nonEmpty(environment.EARLY_BIRDS_APPLE_CLIENT_SECRET); + let google = null; + let apple = null; + try { + google = listenerRuntimeBundle( + ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'], + environment, + ); + } catch { /* Do not expose a provider with mixed credentials. */ } + try { + apple = listenerRuntimeBundle( + ['APPLE_CLIENT_ID', 'APPLE_CLIENT_SECRET'], + environment, + ); + } catch { /* Do not expose a provider with mixed credentials. */ } return { - ...(googleId && googleSecret ? { - google: { clientId: googleId, clientSecret: googleSecret, accessType: 'online' as const }, + ...(google ? { + google: { + clientId: google.GOOGLE_CLIENT_ID, + clientSecret: google.GOOGLE_CLIENT_SECRET, + accessType: 'online' as const, + }, } : {}), - ...(appleId && appleSecret ? { - apple: { clientId: appleId, clientSecret: appleSecret }, + ...(apple ? { + apple: { + clientId: apple.APPLE_CLIENT_ID, + clientSecret: apple.APPLE_CLIENT_SECRET, + }, } : {}), }; } function authSecret(): string { - const configured = nonEmpty(process.env.EARLY_BIRDS_AUTH_SECRET); + const configured = listenerRuntimeValue('AUTH_SECRET'); if (configured) return configured; if (process.env.NODE_ENV === 'production') { - throw new Error('EARLY_BIRDS_AUTH_SECRET is required at runtime'); + throw new Error('BEACON_LISTENER_AUTH_SECRET or its legacy alias is required at runtime'); } // Local/test fallback only. Production never reaches this value. return 'early-birds-local-only-secret-change-before-deploy'; } -function trustedOrigins(): string[] { - const configured = (process.env.EARLY_BIRDS_TRUSTED_ORIGINS ?? '') - .split(',') - .map((origin) => origin.trim()) - .filter(Boolean); - const baseURL = nonEmpty(process.env.EARLY_BIRDS_AUTH_BASE_URL); - return baseURL ? [...new Set([baseURL, ...configured])] : configured; +export function earlyBirdTrustedOrigins( + environment: NodeJS.ProcessEnv = process.env, +): string[] { + return listenerRuntimeTrustedOrigins(environment); } function scrubOAuthTokens>(account: T): T { @@ -100,7 +136,7 @@ function scrubSessionMetadata>(session: T): T function buildEarlyBirdAuth() { const testAuth = earlyBirdTestAuthEnabled(); - const baseURL = nonEmpty(process.env.EARLY_BIRDS_AUTH_BASE_URL); + const baseURL = listenerRuntimeValue('AUTH_BASE_URL'); const magicLinkEnabled = earlyBirdMagicLinkAvailable(); return betterAuth({ @@ -108,7 +144,7 @@ function buildEarlyBirdAuth() { ...(baseURL ? { baseURL } : {}), basePath: EARLY_BIRD_AUTH_BASE_PATH, secret: authSecret(), - trustedOrigins: trustedOrigins(), + trustedOrigins: earlyBirdTrustedOrigins(), database: prismaAdapter(prisma, { provider: 'postgresql' }), socialProviders: earlyBirdSocialProviders(), plugins: magicLinkEnabled ? [magicLink({ diff --git a/src/lib/early-birds/enabled.ts b/src/lib/early-birds/enabled.ts index 1c6e3c11..40610dda 100644 --- a/src/lib/early-birds/enabled.ts +++ b/src/lib/early-birds/enabled.ts @@ -1,12 +1,18 @@ import { NextResponse } from 'next/server'; +import { listenerRuntimeFlag } from '@/lib/listener/runtime-env'; + /** * Public EarlyBird entry is fail-closed. Internal membership projection routes * deliberately do not use this switch so reconciliation can continue while * the customer-facing experience is paused. */ export function earlyBirdsEnabled(environment: NodeJS.ProcessEnv = process.env): boolean { - return environment.EARLY_BIRDS_ENABLED === '1'; + try { + return listenerRuntimeFlag('ENABLED', environment); + } catch { + return false; + } } /** @@ -15,7 +21,11 @@ export function earlyBirdsEnabled(environment: NodeJS.ProcessEnv = process.env): * drop-in authorization stops on the next request/manifest refresh. */ export function earlyBirdsFreeForAll(environment: NodeJS.ProcessEnv = process.env): boolean { - return environment.EARLY_BIRDS_FREE_FOR_ALL === '1'; + try { + return listenerRuntimeFlag('FREE_FOR_ALL', environment); + } catch { + return false; + } } export function earlyBirdsUnavailableResponse(): NextResponse { diff --git a/src/lib/early-birds/magic-link.ts b/src/lib/early-birds/magic-link.ts index 9b89f2f2..f48a6f82 100644 --- a/src/lib/early-birds/magic-link.ts +++ b/src/lib/early-birds/magic-link.ts @@ -3,6 +3,7 @@ import { createHash, createHmac } from 'node:crypto'; import { Prisma } from '@prisma/client'; import { prisma } from '@/lib/db'; +import { listenerRuntimeBundle } from '@/lib/listener/runtime-env'; export const EARLY_BIRD_MAGIC_LINK_PATH = '/sign-in/magic-link'; export const EARLY_BIRD_MAGIC_LINK_VERIFY_PATH = '/magic-link/verify'; @@ -30,18 +31,24 @@ type DeliveryConfiguration = { type ThrottleClient = Pick; -function nonEmpty(value: string | undefined): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - export function earlyBirdMagicLinkConfiguration( environment: MagicLinkEnvironment = process.env, ): DeliveryConfiguration | null { - const rawURL = nonEmpty(environment.EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL); - const token = nonEmpty(environment.EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN); - const rateSecret = nonEmpty(environment.EARLY_BIRDS_MAGIC_LINK_RATE_SECRET); - if (!rawURL || !token || token.length < 32 || !rateSecret || rateSecret.length < 32) return null; + let configuration; + try { + configuration = listenerRuntimeBundle([ + 'MAGIC_LINK_DELIVERY_URL', + 'MAGIC_LINK_DELIVERY_TOKEN', + 'MAGIC_LINK_RATE_SECRET', + ], environment); + } catch { + return null; + } + if (!configuration) return null; + const rawURL = configuration.MAGIC_LINK_DELIVERY_URL; + const token = configuration.MAGIC_LINK_DELIVERY_TOKEN; + const rateSecret = configuration.MAGIC_LINK_RATE_SECRET; + if (token.length < 32 || rateSecret.length < 32) return null; try { const url = new URL(rawURL); diff --git a/src/lib/early-birds/synthetic-team-entry.ts b/src/lib/early-birds/synthetic-team-entry.ts index dd6ff8cd..156dc1c3 100644 --- a/src/lib/early-birds/synthetic-team-entry.ts +++ b/src/lib/early-birds/synthetic-team-entry.ts @@ -1,3 +1,5 @@ +import { listenerRuntimeBundle, listenerRuntimeFlag } from '@/lib/listener/runtime-env'; + import { earlyBirdTestAuthEnabled } from './auth'; import { earlyBirdsEnabled } from './enabled'; @@ -15,7 +17,17 @@ function canonicalHost(value: string): string | null { } function allowedHosts(environment: NodeJS.ProcessEnv): string[] | null { - const raw = environment.EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS ?? ''; + let configuration; + try { + configuration = listenerRuntimeBundle([ + 'STAGING_TEAM_ENTRY_ENABLED', + 'STAGING_TEAM_ENTRY_HOSTS', + ], environment); + } catch { + return null; + } + if (!configuration || !listenerRuntimeFlag('STAGING_TEAM_ENTRY_ENABLED', environment)) return null; + const raw = configuration.STAGING_TEAM_ENTRY_HOSTS; const entries = raw.split(',').map((entry) => canonicalHost(entry)).filter(Boolean); if (entries.length === 0 || entries.length !== raw.split(',').length) return null; return [...new Set(entries)] as string[]; @@ -31,8 +43,6 @@ export function syntheticTeamEntryAllowed( ): boolean { if (environment.NODE_ENV !== 'production') return false; if (!earlyBirdsEnabled(environment) || !earlyBirdTestAuthEnabled(environment)) return false; - if (environment.EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED !== '1') return false; - const host = canonicalHost(input.headers.get('host') ?? ''); const hosts = allowedHosts(environment); if (!host || !hosts?.includes(host)) return false; diff --git a/src/lib/listener/__tests__/runtime-env.test.ts b/src/lib/listener/__tests__/runtime-env.test.ts new file mode 100644 index 00000000..9400d8c4 --- /dev/null +++ b/src/lib/listener/__tests__/runtime-env.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; + +import { + ListenerRuntimeEnvironmentError, + listenerRuntimeBundle, + listenerRuntimeFlag, + listenerRuntimeValue, + validateListenerRuntimeEnvironment, +} from '../runtime-env'; + +describe('Listener runtime environment compatibility', () => { + it('reads canonical-only, legacy-only and matching dual values', () => { + expect(listenerRuntimeValue('ENABLED', { BEACON_LISTENER_ENABLED: ' 1 ' })).toBe('1'); + expect(listenerRuntimeValue('ENABLED', { EARLY_BIRDS_ENABLED: '1' })).toBe('1'); + expect(listenerRuntimeValue('ENABLED', { + BEACON_LISTENER_ENABLED: '1', + EARLY_BIRDS_ENABLED: ' 1 ', + })).toBe('1'); + expect(listenerRuntimeValue('ENABLED', { + BEACON_LISTENER_ENABLED: ' ', + EARLY_BIRDS_ENABLED: '', + })).toBeUndefined(); + }); + + it('keeps feature gates exact even though ordinary values are trimmed', () => { + expect(listenerRuntimeFlag('ENABLED', { BEACON_LISTENER_ENABLED: '1' })).toBe(true); + expect(listenerRuntimeFlag('ENABLED', { BEACON_LISTENER_ENABLED: ' 1 ' })).toBe(false); + expect(listenerRuntimeFlag('ENABLED', { EARLY_BIRDS_ENABLED: '1' })).toBe(true); + expect(listenerRuntimeFlag('ENABLED', { EARLY_BIRDS_ENABLED: 'true' })).toBe(false); + }); + + it('fails closed on conflicting generations without including values', () => { + const canonicalSecret = 'canonical-secret-value'; + const legacySecret = 'legacy-secret-value'; + expect(() => listenerRuntimeValue('AUTH_SECRET', { + BEACON_LISTENER_AUTH_SECRET: canonicalSecret, + EARLY_BIRDS_AUTH_SECRET: legacySecret, + })).toThrow(ListenerRuntimeEnvironmentError); + try { + listenerRuntimeValue('AUTH_SECRET', { + BEACON_LISTENER_AUTH_SECRET: canonicalSecret, + EARLY_BIRDS_AUTH_SECRET: legacySecret, + }); + } catch (error) { + expect(String(error)).toContain('BEACON_LISTENER_AUTH_SECRET'); + expect(String(error)).toContain('EARLY_BIRDS_AUTH_SECRET'); + expect(String(error)).not.toContain(canonicalSecret); + expect(String(error)).not.toContain(legacySecret); + } + }); + + it('keeps OAuth pairs within one complete generation', () => { + expect(listenerRuntimeBundle(['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'], { + BEACON_LISTENER_GOOGLE_CLIENT_ID: 'id', + BEACON_LISTENER_GOOGLE_CLIENT_SECRET: 'secret', + })).toEqual({ GOOGLE_CLIENT_ID: 'id', GOOGLE_CLIENT_SECRET: 'secret' }); + expect(listenerRuntimeBundle(['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'], { + EARLY_BIRDS_GOOGLE_CLIENT_ID: 'old-id', + EARLY_BIRDS_GOOGLE_CLIENT_SECRET: 'old-secret', + })).toEqual({ GOOGLE_CLIENT_ID: 'old-id', GOOGLE_CLIENT_SECRET: 'old-secret' }); + expect(() => listenerRuntimeBundle(['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'], { + BEACON_LISTENER_GOOGLE_CLIENT_ID: 'new-id', + EARLY_BIRDS_GOOGLE_CLIENT_SECRET: 'old-secret', + })).toThrow(/Incomplete Listener runtime bundle/); + }); + + it('accepts matching dual bundles and rejects a mismatched member', () => { + const matching = { + BEACON_LISTENER_MAGIC_LINK_DELIVERY_URL: 'https://mail.example.test/deliver', + BEACON_LISTENER_MAGIC_LINK_DELIVERY_TOKEN: 'token', + BEACON_LISTENER_MAGIC_LINK_RATE_SECRET: 'rate', + EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL: 'https://mail.example.test/deliver', + EARLY_BIRDS_MAGIC_LINK_DELIVERY_TOKEN: 'token', + EARLY_BIRDS_MAGIC_LINK_RATE_SECRET: 'rate', + }; + expect(listenerRuntimeBundle([ + 'MAGIC_LINK_DELIVERY_URL', + 'MAGIC_LINK_DELIVERY_TOKEN', + 'MAGIC_LINK_RATE_SECRET', + ], matching)).toEqual({ + MAGIC_LINK_DELIVERY_URL: 'https://mail.example.test/deliver', + MAGIC_LINK_DELIVERY_TOKEN: 'token', + MAGIC_LINK_RATE_SECRET: 'rate', + }); + expect(() => listenerRuntimeBundle([ + 'MAGIC_LINK_DELIVERY_URL', + 'MAGIC_LINK_DELIVERY_TOKEN', + 'MAGIC_LINK_RATE_SECRET', + ], { + ...matching, + EARLY_BIRDS_MAGIC_LINK_RATE_SECRET: 'different-rate', + })).toThrow(/BEACON_LISTENER_MAGIC_LINK_RATE_SECRET, EARLY_BIRDS_MAGIC_LINK_RATE_SECRET/); + }); + + it('validates the deployed legacy shape and catches a dead dual-env rollout', () => { + const legacy = { + EARLY_BIRDS_ENABLED: '1', + EARLY_BIRDS_FREE_FOR_ALL: '0', + EARLY_BIRDS_AUTH_BASE_URL: 'https://listen.example.test', + EARLY_BIRDS_TRUSTED_ORIGINS: 'https://listen.example.test', + EARLY_BIRDS_AUTH_SECRET: 'a'.repeat(32), + EARLY_BIRDS_GOOGLE_CLIENT_ID: 'google-id', + EARLY_BIRDS_GOOGLE_CLIENT_SECRET: 'google-secret', + EARLY_BIRDS_TEST_ACCESS_ENABLED: '0', + EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED: '0', + }; + expect(validateListenerRuntimeEnvironment(legacy)).toBe(true); + expect(validateListenerRuntimeEnvironment({})).toBe(false); + expect(() => validateListenerRuntimeEnvironment({ + ...legacy, + BEACON_LISTENER_AUTH_BASE_URL: 'https://different.example.test', + })).toThrow(/BEACON_LISTENER_AUTH_BASE_URL, EARLY_BIRDS_AUTH_BASE_URL/); + }); + + it('rejects ambiguous flags and incomplete enabled credential bundles', () => { + expect(() => validateListenerRuntimeEnvironment({ + BEACON_LISTENER_ENABLED: ' 1 ', + BEACON_LISTENER_AUTH_BASE_URL: 'https://listen.example.test', + BEACON_LISTENER_AUTH_SECRET: 'a'.repeat(32), + })).toThrow(/Invalid Listener runtime flag/); + expect(() => validateListenerRuntimeEnvironment({ + BEACON_LISTENER_ENABLED: '1', + BEACON_LISTENER_AUTH_BASE_URL: 'https://listen.example.test', + })).toThrow(/Enabled Listener requires/); + expect(() => validateListenerRuntimeEnvironment({ + BEACON_LISTENER_ENABLED: '0', + BEACON_LISTENER_GOOGLE_CLIENT_ID: 'id-only', + })).toThrow(/Incomplete Listener runtime bundle/); + }); +}); diff --git a/src/lib/listener/runtime-env.ts b/src/lib/listener/runtime-env.ts new file mode 100644 index 00000000..3bdcadc2 --- /dev/null +++ b/src/lib/listener/runtime-env.ts @@ -0,0 +1,209 @@ +const CANONICAL_PREFIX = 'BEACON_LISTENER_'; +const LEGACY_PREFIX = 'EARLY_BIRDS_'; + +type Environment = Record; + +const BOUNDED_SUFFIXES = [ + 'ENABLED', + 'FREE_FOR_ALL', + 'AUTH_BASE_URL', + 'TRUSTED_ORIGINS', + 'AUTH_SECRET', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'APPLE_CLIENT_ID', + 'APPLE_CLIENT_SECRET', + 'MAGIC_LINK_DELIVERY_URL', + 'MAGIC_LINK_DELIVERY_TOKEN', + 'MAGIC_LINK_RATE_SECRET', + 'TEST_ACCESS_ENABLED', + 'TEST_LOGIN_SECRET', + 'STAGING_TEAM_ENTRY_ENABLED', + 'STAGING_TEAM_ENTRY_HOSTS', +] as const; + +function normalized(value: string | undefined): string | undefined { + const result = value?.trim(); + return result ? result : undefined; +} + +function names(suffix: string): { canonical: string; legacy: string } { + return { + canonical: `${CANONICAL_PREFIX}${suffix}`, + legacy: `${LEGACY_PREFIX}${suffix}`, + }; +} + +/** + * Configuration errors intentionally include variable names only. Runtime + * values may be credentials and must never be copied into logs or responses. + */ +export class ListenerRuntimeEnvironmentError extends Error { + constructor(message: string) { + super(message); + this.name = 'ListenerRuntimeEnvironmentError'; + } +} + +/** + * Prefer the stable Listener namespace while accepting the previous name for + * rollback compatibility. Two populated generations must agree exactly after + * trimming; disagreement fails closed instead of choosing an arbitrary value. + */ +export function listenerRuntimeValue( + suffix: string, + environment: Environment = process.env, +): string | undefined { + const variable = names(suffix); + const canonical = normalized(environment[variable.canonical]); + const legacy = normalized(environment[variable.legacy]); + if (canonical && legacy && canonical !== legacy) { + throw new ListenerRuntimeEnvironmentError( + `Conflicting Listener runtime variables: ${variable.canonical}, ${variable.legacy}`, + ); + } + return canonical ?? legacy; +} + +/** Feature gates remain deliberately stricter than ordinary string config. */ +export function listenerRuntimeFlag( + suffix: string, + environment: Environment = process.env, +): boolean { + // Resolve first so conflicting generations still fail closed. + listenerRuntimeValue(suffix, environment); + const variable = names(suffix); + const canonical = environment[variable.canonical]; + const legacy = environment[variable.legacy]; + const selected = normalized(canonical) ? canonical : legacy; + return selected === '1'; +} + +/** + * Resolve credentials that must come from one complete configuration + * generation. This prevents, for example, pairing a new OAuth client id with + * an old secret during a gradual cutover. + */ +export function listenerRuntimeBundle( + suffixes: readonly Suffix[], + environment: Environment = process.env, +): Record | null { + const canonical = suffixes.map((suffix) => { + const variable = names(suffix); + return { suffix, name: variable.canonical, value: normalized(environment[variable.canonical]) }; + }); + const legacy = suffixes.map((suffix) => { + const variable = names(suffix); + return { suffix, name: variable.legacy, value: normalized(environment[variable.legacy]) }; + }); + const canonicalPresent = canonical.filter((entry) => entry.value !== undefined); + const legacyPresent = legacy.filter((entry) => entry.value !== undefined); + + if (canonicalPresent.length > 0 && canonicalPresent.length !== canonical.length) { + throw new ListenerRuntimeEnvironmentError( + `Incomplete Listener runtime bundle: ${canonical.map((entry) => entry.name).join(', ')}`, + ); + } + if (canonicalPresent.length === 0 && legacyPresent.length > 0 && legacyPresent.length !== legacy.length) { + throw new ListenerRuntimeEnvironmentError( + `Incomplete Listener runtime bundle: ${legacy.map((entry) => entry.name).join(', ')}`, + ); + } + if (canonicalPresent.length === 0 && legacyPresent.length === 0) return null; + + if (canonicalPresent.length === canonical.length) { + canonical.forEach((entry, index) => { + const previous = legacy[index]; + if (previous.value && previous.value !== entry.value) { + throw new ListenerRuntimeEnvironmentError( + `Conflicting Listener runtime variables: ${entry.name}, ${previous.name}`, + ); + } + }); + return Object.fromEntries(canonical.map((entry) => [entry.suffix, entry.value])) as Record; + } + + return Object.fromEntries(legacy.map((entry) => [entry.suffix, entry.value])) as Record; +} + +export function listenerRuntimeTrustedOrigins( + environment: Environment = process.env, +): string[] { + const configured = (listenerRuntimeValue('TRUSTED_ORIGINS', environment) ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + const baseURL = listenerRuntimeValue('AUTH_BASE_URL', environment); + return baseURL ? [...new Set([baseURL, ...configured])] : configured; +} + +function selectedRawValue(suffix: string, environment: Environment): string | undefined { + const variable = names(suffix); + return normalized(environment[variable.canonical]) + ? environment[variable.canonical] + : environment[variable.legacy]; +} + +function validateFlag(suffix: string, environment: Environment): boolean { + const value = listenerRuntimeValue(suffix, environment); + if (value === undefined) return false; + const selected = selectedRawValue(suffix, environment); + if (selected !== '0' && selected !== '1') { + const variable = names(suffix); + throw new ListenerRuntimeEnvironmentError( + `Invalid Listener runtime flag: ${variable.canonical}, ${variable.legacy}`, + ); + } + return selected === '1'; +} + +function anyBoundedValue(environment: Environment): boolean { + return BOUNDED_SUFFIXES.some((suffix) => { + const variable = names(suffix); + return normalized(environment[variable.canonical]) !== undefined || + normalized(environment[variable.legacy]) !== undefined; + }); +} + +/** + * Readiness-time validation for the bounded compatibility slice. It is inert + * in event/runtime processes that do not carry Listener configuration. + */ +export function validateListenerRuntimeEnvironment( + environment: Environment = process.env, +): boolean { + if (!anyBoundedValue(environment)) return false; + + const enabled = validateFlag('ENABLED', environment); + validateFlag('FREE_FOR_ALL', environment); + const baseURL = listenerRuntimeValue('AUTH_BASE_URL', environment); + listenerRuntimeTrustedOrigins(environment); + const authSecret = listenerRuntimeValue('AUTH_SECRET', environment); + + listenerRuntimeBundle(['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'], environment); + listenerRuntimeBundle(['APPLE_CLIENT_ID', 'APPLE_CLIENT_SECRET'], environment); + listenerRuntimeBundle([ + 'MAGIC_LINK_DELIVERY_URL', + 'MAGIC_LINK_DELIVERY_TOKEN', + 'MAGIC_LINK_RATE_SECRET', + ], environment); + + const testAccess = validateFlag('TEST_ACCESS_ENABLED', environment); + if (testAccess) { + listenerRuntimeBundle(['TEST_ACCESS_ENABLED', 'TEST_LOGIN_SECRET'], environment); + } + const stagingEntry = validateFlag('STAGING_TEAM_ENTRY_ENABLED', environment); + if (stagingEntry) { + listenerRuntimeBundle([ + 'STAGING_TEAM_ENTRY_ENABLED', + 'STAGING_TEAM_ENTRY_HOSTS', + ], environment); + } + + if (enabled && (!baseURL || !authSecret)) { + throw new ListenerRuntimeEnvironmentError( + 'Enabled Listener requires BEACON_LISTENER_AUTH_BASE_URL and BEACON_LISTENER_AUTH_SECRET or matching legacy aliases', + ); + } + return true; +} From 497772c6648373a4fd3c59c98f27a78abcff7e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 21:33:19 -0300 Subject: [PATCH 117/376] fix(listener): retry kill-switch readiness smoke --- .../test/disable-public.test.mjs | 20 +++++++++++++++++-- scripts/early-birds-preview/disable-public.sh | 20 +++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/ops/early-birds-preview/test/disable-public.test.mjs b/ops/early-birds-preview/test/disable-public.test.mjs index d6f0841e..638628ed 100644 --- a/ops/early-birds-preview/test/disable-public.test.mjs +++ b/ops/early-birds-preview/test/disable-public.test.mjs @@ -16,7 +16,7 @@ async function executable(pathname, content) { await fs.chmod(pathname, 0o700); } -async function fixture(t, { denialStatus = '503' } = {}) { +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'); @@ -48,10 +48,15 @@ async function fixture(t, { denialStatus = '503' } = {}) { '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 }; + return { directory, envFile, bin, commandLog, source, healthFailures }; } function run(mode, current) { @@ -61,6 +66,7 @@ function run(mode, current) { ...process.env, PATH: `${current.bin}:${process.env.PATH}`, TEST_COMMAND_LOG: current.commandLog, + TEST_HEALTH_FAILURES: String(current.healthFailures), }, }); } @@ -142,3 +148,13 @@ test('failed denial smoke leaves flags disabled and stops only Listener', async 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/scripts/early-birds-preview/disable-public.sh b/scripts/early-birds-preview/disable-public.sh index afd145b3..d8f86f25 100755 --- a/scripts/early-birds-preview/disable-public.sh +++ b/scripts/early-birds-preview/disable-public.sh @@ -95,14 +95,26 @@ fail_closed() { 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") -curl --fail --silent --show-error --max-time 10 \ - "http://127.0.0.1:${app_port}/api/health" >/dev/null || fail_closed -curl --fail --silent --show-error --max-time 10 \ - "http://127.0.0.1:${app_port}/api/health/ready" >/dev/null || fail_closed +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' \ From 45fec2b3bda449300bb5aaa6973f32193e075835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 21:46:16 -0300 Subject: [PATCH 118/376] docs(listener): record deployed release candidate --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 36 ++++++++++- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 64 +++++++++++-------- 2 files changed, 73 insertions(+), 27 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index fb9f6ed4..1d98d038 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,8 +1,42 @@ # EarlyBirds isolated staging runtime -## 2026-08-07 first-listen access and boundary synchronization +## 2026-08-08 public Listener convergence The isolated Listener runs application SHA +`b8a04fe94fb853a0276bb549773c74eb27e410d5`, image +`harmonic-beacon/earlybirds-preview-listener:b8a04fe` 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. +- 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. +- Immediate rollback retains schema/media and selects image `2344b10`; image + `16a15d1` 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. 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 diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index a0ebf2f5..f59f337d 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -16,13 +16,13 @@ an event-stack deployment or an acoustic change. | Artifact | Exact value | |---|---| -| Deployed Listener application | `dad29d4dc5010603a5bbc7ed309c8f78e7c0f384` | -| Branch/documentation head | `4cd9968` (documentation may advance independently) | -| Listener database schema | `20260807100000_early_bird_welcome_access` | +| Deployed Listener application | `b8a04fe94fb853a0276bb549773c74eb27e410d5` | +| Branch/documentation head | `b8a04fe` (documentation may advance independently) | +| Listener database schema | `20260807200000_listener_regional_presence` | | Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | | Public mode | Free for All OFF during coordinated registered-Free acceptance | -| Immediate Listener rollback | `55bf282` | -| Additional Listener rollback | `575b75a` | +| Immediate Listener rollback | `2344b10` | +| Additional Listener rollback | `16a15d1` | Health must attest the deployed application SHA, not the later documentation or test-only branch head. @@ -35,7 +35,8 @@ test-only branch head. | 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, invitation/internal/event/staff surfaces. | +| 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 | Listener ready; authority blocked | #221 is merged and hidden/fail-closed until the existing Gmail authority implements `SairaAsua/proyecciones-mito#44` and protected delivery values are installed. | | Two-hour recurring Free window | Proven | Unit/integration matrix plus deployed registered-Free smoke for custom and Listen now. | @@ -50,13 +51,13 @@ test-only branch head. | 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. | | 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 proven; physical pending | Chromium 390x844 has no overflow; mode targets are 52 px and primary action 56 px. Physical keyboard/screen-reader/touch review remains. | +| 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; this registration slice changed no asset, codec, rate, channel, gain, fade, buffer, routing or event audio. | | 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`; root is 51% used/93 GB free and secondary volume is 6% used. | +| 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,124 tests, ESLint, TypeScript, build, Prisma, preview, origin and observability checks are green. | +| Full gates | Proven | 1,222 tests with 19 standard skips, ESLint, TypeScript, build, Prisma, preview, origin, nginx and observability checks are green. PR #203 is clean and all required checks are green. | ## Delivered commits @@ -68,7 +69,11 @@ test-only branch head. - `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. +- `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. PR #203 remains draft and mergeable. The exact application SHA above is the deployed image; later documentation-only commits do not require rebuilding it. @@ -76,14 +81,17 @@ 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 temporarily OFF for the coordinated registered-Free human - acceptance. Restore it ON after this test before resuming anonymous sharing. +- Free for All is OFF so ordinary public access requires canonical identity and + server-authorized Free, welcome, 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/rollback images, every active image, containers, volumes, databases - and approved media remain. Removed historical images are reproducible from - their Git commits. +- Current image `b8a04fe` and rollback images `2344b10` and `16a15d1`, every + active image, container, volume, database and approved media remain. +- 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. @@ -103,20 +111,23 @@ deployed image; later documentation-only commits do not require rebuilding it. - #218 is closed/Done with deployed runtime evidence. - #219 is closed/Done after positive physical iPhone acceptance of the deployed gesture-safe handoff. -- #213 is Todo; #211/#212 and the larger campaign/cosmic-campfire journey are - post-MVP and do not block this bounded test. +- #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 remains feature-flagged pending visual acceptance and + does not block the minimal public Listener. ## Remaining human sequence Use `docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md` as the authoritative worksheet. -1. Finish playback/reconnect checks in the already active FFA-OFF interval. -2. Complete the remaining physical Chrome, Firefox and Android Chrome rows; +1. Complete one real Google sign-in through a valid synthetic invitation. +2. Confirm one scheduled Free start and end while the page remains open. +3. Complete the remaining physical Chrome, Firefox and Android Chrome rows; retain the accepted iPhone result. -3. Run one 60-minute physical listen with intro, handoff, background/foreground, +4. Run one 60-minute physical listen with intro, handoff, background/foreground, network transition and Stop/rejoin. -4. Restore FFA ON and verify anonymous playback before public sharing. 5. Run stepwise load from external generators before claiming measured scale. Do not select a user's Google account, provision Apple, charge a provider, @@ -124,11 +135,12 @@ alter audio or merge/promote the branch as part of an automated test. ## Rollback -Restore root-only -`/etc/harmonic-beacon/earlybirds-preview.env.pre-dad29d4`, select Listener image -`55bf282`, retain the preview database, additive welcome table and origin media, -recreate only the isolated Listener and run the preview health smoke. Nginx was -not changed by this deployment. +Run the fixed disable command from release `b8a04fe` first. Restore the exact +root-only environment/nginx backups created before `b8a04fe` (or the earlier +`2344b10` deployment), select Listener image `2344b10` or `16a15d1`, retain the +preview database and origin media, recreate only the isolated Listener, run +`nginx -t` before any reload and execute the complete preview health/access +smoke. Do not roll back the additive database schema. 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 From c0a09f6d4774d04c696c062e6b5e4d40874b2847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 21:50:05 -0300 Subject: [PATCH 119/376] docs(listener): distinguish runtime and docs heads --- docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index f59f337d..7d0f4855 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -17,7 +17,7 @@ an event-stack deployment or an acoustic change. | Artifact | Exact value | |---|---| | Deployed Listener application | `b8a04fe94fb853a0276bb549773c74eb27e410d5` | -| Branch/documentation head | `b8a04fe` (documentation may advance independently) | +| Last runtime-changing branch head | `b8a04fe` (documentation may advance independently) | | Listener database schema | `20260807200000_listener_regional_presence` | | Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | | Public mode | Free for All OFF during coordinated registered-Free acceptance | From 200242d5886644fec2606ceee83cc1325983a125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Fri, 7 Aug 2026 22:06:40 -0300 Subject: [PATCH 120/376] feat(listener): migrate invitation cookie safely --- docs/architecture/EARLY_BIRDS_LISTENER.md | 14 ++-- .../LISTENER_NAMESPACE_MIGRATION.md | 13 ++- e2e/tests/listener-namespace-compat.spec.ts | 9 +- middleware.test.ts | 16 ++++ .../free/redeem/__tests__/route.test.ts | 51 +++++++++++- src/app/api/early-birds/free/redeem/route.ts | 13 ++- src/app/early-birds/__tests__/page.test.tsx | 48 +++++++++-- src/app/early-birds/page.tsx | 10 +-- .../redeem/__tests__/page.test.tsx | 83 +++++++++++++++++++ src/app/early-birds/redeem/page.tsx | 11 +-- .../__tests__/invitation-cookie.test.ts | 44 ++++++++++ src/lib/early-birds/invitation-cookie.ts | 56 ++++++++++++- src/middleware.ts | 4 +- 13 files changed, 328 insertions(+), 44 deletions(-) create mode 100644 src/app/early-birds/redeem/__tests__/page.test.tsx diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 9fae1e89..4a6f84f3 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -85,9 +85,14 @@ 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, places it in a 30-minute `__Host-`, Secure, HttpOnly, SameSite=Lax cookie -and redirects to the clean URL. Neither the event host nor a forwarded-host -header can mint this cookie. +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 @@ -100,8 +105,7 @@ 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. A terminal authority -rejection clears the cookie; a transient 503 preserves it for a safe retry. All +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 diff --git a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md index e45877d2..64e1deda 100644 --- a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md +++ b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md @@ -30,7 +30,7 @@ under preview operations, 14 documentation files, 14 contract files, 10 scripts, | 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 1 continues to read and write it from both URL namespaces, so existing invitations survive. | +| 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. | @@ -103,6 +103,17 @@ 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, diff --git a/e2e/tests/listener-namespace-compat.spec.ts b/e2e/tests/listener-namespace-compat.spec.ts index a502884e..4d6a209b 100644 --- a/e2e/tests/listener-namespace-compat.spec.ts +++ b/e2e/tests/listener-namespace-compat.spec.ts @@ -1,6 +1,9 @@ import { expect, test } from '@playwright/test'; -import { EARLY_BIRD_INVITATION_COOKIE } from '../../src/lib/early-birds/invitation-cookie'; +import { + EARLY_BIRD_INVITATION_COOKIE, + LISTENER_INVITATION_COOKIE, +} from '../../src/lib/early-birds/invitation-cookie'; import { deleteSyntheticListenerEmails, signInSyntheticListener, @@ -31,7 +34,7 @@ test.describe('Listener namespace compatibility', () => { // same callback cookie contract; their exact callback is locked by // the landing/auth route unit suites. await context.addCookies([{ - name: EARLY_BIRD_INVITATION_COOKIE, + name: LISTENER_INVITATION_COOKIE, value: INVITATION, domain: baseURL.hostname, path: '/', @@ -48,7 +51,7 @@ test.describe('Listener namespace compatibility', () => { await context.addCookies(identityState.cookies.filter((cookie) => cookie.name.includes('session'))); const cookiesAfterIdentity = await context.cookies(); - expect(cookiesAfterIdentity.find((cookie) => cookie.name === EARLY_BIRD_INVITATION_COOKIE)) + 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); diff --git a/middleware.test.ts b/middleware.test.ts index 9ffe5d0b..7b7719f3 100644 --- a/middleware.test.ts +++ b/middleware.test.ts @@ -5,6 +5,7 @@ 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 @@ -66,6 +67,7 @@ describe('middleware', () => { 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([ @@ -88,6 +90,12 @@ describe('middleware', () => { 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', () => { @@ -97,6 +105,7 @@ describe('middleware', () => { '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( @@ -115,6 +124,11 @@ describe('middleware', () => { 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([ @@ -133,6 +147,7 @@ describe('middleware', () => { 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', () => { @@ -144,6 +159,7 @@ describe('middleware', () => { )); 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', () => { 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 index 14da3534..47d74aad 100644 --- a/src/app/api/early-birds/free/redeem/__tests__/route.test.ts +++ b/src/app/api/early-birds/free/redeem/__tests__/route.test.ts @@ -3,6 +3,7 @@ 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()); @@ -23,9 +24,11 @@ function request( namespace: 'legacy' | 'canonical' = 'legacy', origin = 'https://listen.harmonicbeacon.com', hostname = 'listen.harmonicbeacon.com', + cookieHeader?: string, ) { const headers = new Headers(); - if (token) headers.set('cookie', `${EARLY_BIRD_INVITATION_COOKIE}=${token}`); + 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'); @@ -131,10 +134,51 @@ describe('EarlyBird Free redemption boundary', () => { 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({ @@ -180,6 +224,10 @@ describe('EarlyBird Free redemption boundary', () => { 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 () => { @@ -190,6 +238,7 @@ describe('EarlyBird Free redemption boundary', () => { 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 index 8d474223..23adc532 100644 --- a/src/app/api/early-birds/free/redeem/route.ts +++ b/src/app/api/early-birds/free/redeem/route.ts @@ -3,10 +3,9 @@ import { NextResponse, type NextRequest } from 'next/server'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; import { earlyBirdsEnabled, earlyBirdsUnavailableResponse } from '@/lib/early-birds/enabled'; import { - canonicalEarlyBirdInvitation, - clearedEarlyBirdInvitationCookie, + clearedListenerInvitationCookies, earlyBirdInvitationCookieHost, - EARLY_BIRD_INVITATION_COOKIE, + listenerInvitationFromCookieHeader, } from '@/lib/early-birds/invitation-cookie'; import { EarlyBirdMembershipGatewayUnavailableError, @@ -28,7 +27,7 @@ function json(body: Record, status: number): NextResponse { function terminalInvitationUnavailable(): NextResponse { const response = json({ error: 'Invitation unavailable.' }, 409); - response.cookies.set(clearedEarlyBirdInvitationCookie()); + for (const cookie of clearedListenerInvitationCookies()) response.cookies.set(cookie); return response; } @@ -57,9 +56,7 @@ export async function POST(request: NextRequest): Promise { const session = await currentEarlyBirdSession(request.headers).catch(() => null); if (!session) return json({ error: 'Sign in required.' }, 401); - const token = canonicalEarlyBirdInvitation( - request.cookies.get(EARLY_BIRD_INVITATION_COOKIE)?.value, - ); + const token = listenerInvitationFromCookieHeader(request.headers.get('cookie')); if (!token) { return terminalInvitationUnavailable(); } @@ -85,6 +82,6 @@ export async function POST(request: NextRequest): Promise { replayed: result.replayed, alreadyEntitled: result.alreadyEntitled, })); - response.cookies.set(clearedEarlyBirdInvitationCookie()); + for (const cookie of clearedListenerInvitationCookies()) response.cookies.set(cookie); return response; } diff --git a/src/app/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx index 76515681..d4bd0c7c 100644 --- a/src/app/early-birds/__tests__/page.test.tsx +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -5,12 +5,10 @@ const mocks = vi.hoisted(() => ({ earlyBirdOAuthAvailability: vi.fn(), earlyBirdMagicLinkAvailable: vi.fn(), getEarlyBirdListeningAccess: vi.fn(), - cookies: vi.fn(), headers: vi.fn(), })); vi.mock('next/headers', () => ({ - cookies: mocks.cookies, headers: mocks.headers, })); vi.mock('@/lib/early-birds/auth', () => ({ @@ -25,8 +23,14 @@ vi.mock('@/lib/early-birds/access', () => ({ })); 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(); @@ -91,7 +95,6 @@ describe('EarlyBird Listener page', () => { it('renders an authenticated Listener during an active Free window', async () => { vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); - mocks.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); mocks.headers.mockResolvedValue(new Headers()); mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); mocks.getEarlyBirdListeningAccess.mockResolvedValue({ @@ -116,7 +119,6 @@ describe('EarlyBird Listener page', () => { it('derives a sanitized Founder presentation on the server', async () => { vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); - mocks.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); mocks.headers.mockResolvedValue(new Headers()); mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); mocks.getEarlyBirdListeningAccess.mockResolvedValue({ @@ -152,7 +154,6 @@ describe('EarlyBird Listener page', () => { it('shows the saved schedule rather than fabricating membership outside the window', async () => { vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); - mocks.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); mocks.headers.mockResolvedValue(new Headers()); mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); mocks.earlyBirdOAuthAvailability.mockReturnValue({ google: true, apple: false }); @@ -184,7 +185,6 @@ describe('EarlyBird Listener page', () => { it('renders the Listener during the one-time welcome session', async () => { vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); - mocks.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); mocks.headers.mockResolvedValue(new Headers()); mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); mocks.getEarlyBirdListeningAccess.mockResolvedValue({ @@ -214,7 +214,6 @@ describe('EarlyBird Listener page', () => { 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.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); mocks.headers.mockResolvedValue(new Headers()); mocks.currentEarlyBirdSession.mockRejectedValue(new Error('identity unavailable')); mocks.earlyBirdOAuthAvailability.mockReturnValue({ google: true, apple: false }); @@ -234,7 +233,6 @@ describe('EarlyBird Listener page', () => { 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.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); mocks.headers.mockResolvedValue(new Headers()); mocks.currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1', name: 'Nico' } }); mocks.getEarlyBirdListeningAccess.mockRejectedValue(new Error('database unavailable')); @@ -254,7 +252,6 @@ describe('EarlyBird Listener page', () => { 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.cookies.mockResolvedValue({ get: vi.fn().mockReturnValue(undefined) }); mocks.headers.mockResolvedValue(new Headers()); mocks.currentEarlyBirdSession.mockResolvedValue(null); mocks.earlyBirdOAuthAvailability.mockReturnValue({ google: false, apple: false }); @@ -264,4 +261,37 @@ describe('EarlyBird Listener page', () => { 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/page.tsx b/src/app/early-birds/page.tsx index 9d0ca825..956022e4 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -1,4 +1,4 @@ -import { cookies, headers as requestHeaders } from 'next/headers'; +import { headers as requestHeaders } from 'next/headers'; import EarlyBirdLanding from '@/components/early-birds/EarlyBirdLanding'; import EarlyBirdHome from '@/components/early-birds/EarlyBirdHome'; @@ -10,8 +10,7 @@ import { import { getEarlyBirdListeningAccess } from '@/lib/early-birds/access'; import { earlyBirdsEnabled, earlyBirdsFreeForAll } from '@/lib/early-birds/enabled'; import { - canonicalEarlyBirdInvitation, - EARLY_BIRD_INVITATION_COOKIE, + listenerInvitationFromCookieHeader, } from '@/lib/early-birds/invitation-cookie'; import { syntheticTeamEntryAllowed } from '@/lib/early-birds/synthetic-team-entry'; import { configuredEarlyBirdDropIn } from '@/lib/early-birds/drop-ins'; @@ -65,7 +64,6 @@ export default async function EarlyBirdsPage({ const params = await searchParams; const serverNow = new Date().toISOString(); const incomingHeaders = new Headers(await requestHeaders()); - const cookieStore = await cookies(); const sessionResolution = await currentEarlyBirdSession() .then((session) => ({ session, unavailable: false as const })) .catch(() => ({ session: null, unavailable: true as const })); @@ -76,8 +74,8 @@ export default async function EarlyBirdsPage({ .catch(() => ({ access: null, unavailable: true as const })) : { access: null, unavailable: false as const }; const access = accessResolution.access; - const invitationAvailable = canonicalEarlyBirdInvitation( - cookieStore.get(EARLY_BIRD_INVITATION_COOKIE)?.value, + const invitationAvailable = listenerInvitationFromCookieHeader( + incomingHeaders.get('cookie'), ) !== null; if (session && access?.allowed === true) { 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 index 991ecb82..b28191aa 100644 --- a/src/app/early-birds/redeem/page.tsx +++ b/src/app/early-birds/redeem/page.tsx @@ -1,12 +1,11 @@ import { redirect } from 'next/navigation'; -import { cookies } from 'next/headers'; +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 { - canonicalEarlyBirdInvitation, - EARLY_BIRD_INVITATION_COOKIE, + listenerInvitationFromCookieHeader, } from '@/lib/early-birds/invitation-cookie'; import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; @@ -15,10 +14,8 @@ export const dynamic = 'force-dynamic'; export default async function EarlyBirdRedeemPage() { if (!earlyBirdsEnabled()) redirect(LISTENER_NAMESPACE.canonical.home); - const cookieStore = await cookies(); - const token = canonicalEarlyBirdInvitation( - cookieStore.get(EARLY_BIRD_INVITATION_COOKIE)?.value, - ); + const incomingHeaders = await headers(); + const token = listenerInvitationFromCookieHeader(incomingHeaders.get('cookie')); if (!token) redirect(LISTENER_NAMESPACE.canonical.home); const session = await currentEarlyBirdSession().catch(() => null); diff --git a/src/lib/early-birds/__tests__/invitation-cookie.test.ts b/src/lib/early-birds/__tests__/invitation-cookie.test.ts index bc424bc6..99870a0b 100644 --- a/src/lib/early-birds/__tests__/invitation-cookie.test.ts +++ b/src/lib/early-birds/__tests__/invitation-cookie.test.ts @@ -8,6 +8,11 @@ import { earlyBirdInvitationCookie, earlyBirdInvitationStagingHost, EARLY_BIRD_INVITATION_COOKIE, + EARLY_BIRD_INVITATION_MAX_AGE_SECONDS, + LISTENER_INVITATION_COOKIE, + listenerInvitationCookies, + listenerInvitationFromCookieHeader, + clearedListenerInvitationCookies, } from '@/lib/early-birds/invitation-cookie'; const TOKEN = `ebi_v1.${'a'.repeat(32)}.${'b'.repeat(32)}.${'c'.repeat(32)}`; @@ -35,6 +40,45 @@ describe('EarlyBird invitation handoff cookie', () => { maxAge: 0, path: '/', }); + const dualCookies = listenerInvitationCookies(TOKEN); + expect(dualCookies.map(({ name }) => name)).toEqual([ + LISTENER_INVITATION_COOKIE, + EARLY_BIRD_INVITATION_COOKIE, + ]); + for (const cookie of dualCookies) { + expect(cookie).toMatchObject({ + value: TOKEN, + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: EARLY_BIRD_INVITATION_MAX_AGE_SECONDS, + }); + expect(cookie).not.toHaveProperty('domain'); + } + expect(clearedListenerInvitationCookies()).toHaveLength(2); + }); + + it('reads equal dual cookies and accepts a valid legacy-only fallback', () => { + const other = `ebi_v1.${'d'.repeat(32)}.${'e'.repeat(32)}.${'f'.repeat(32)}`; + expect(listenerInvitationFromCookieHeader( + `${EARLY_BIRD_INVITATION_COOKIE}=${TOKEN}`, + )).toBe(TOKEN); + expect(listenerInvitationFromCookieHeader( + `${LISTENER_INVITATION_COOKIE}=${TOKEN}; ${EARLY_BIRD_INVITATION_COOKIE}=${TOKEN}`, + )).toBe(TOKEN); + expect(listenerInvitationFromCookieHeader( + `${LISTENER_INVITATION_COOKIE}=${other}; ${EARLY_BIRD_INVITATION_COOKIE}=${TOKEN}`, + )).toBeNull(); + expect(listenerInvitationFromCookieHeader( + `${LISTENER_INVITATION_COOKIE}=invalid; ${EARLY_BIRD_INVITATION_COOKIE}=${TOKEN}`, + )).toBeNull(); + }); + + it('fails closed on duplicate same-name cookies before considering either generation', () => { + expect(listenerInvitationFromCookieHeader( + `${LISTENER_INVITATION_COOKIE}=${TOKEN}; ${LISTENER_INVITATION_COOKIE}=${TOKEN}; ${EARLY_BIRD_INVITATION_COOKIE}=${TOKEN}`, + )).toBeNull(); }); it('accepts invitation entry only on the exact Listener product and staging hosts', () => { diff --git a/src/lib/early-birds/invitation-cookie.ts b/src/lib/early-birds/invitation-cookie.ts index f72464e5..dd55b958 100644 --- a/src/lib/early-birds/invitation-cookie.ts +++ b/src/lib/early-birds/invitation-cookie.ts @@ -9,9 +9,15 @@ const LISTENER_INVITATION_STAGING_HOST = 'earlybirds-staging.harmonicbeacon.com' export const LISTENER_INVITATION_CANONICAL_ORIGIN = 'https://listen.harmonicbeacon.com'; +export const LISTENER_INVITATION_COOKIE = '__Host-hb_listener_invitation'; export const EARLY_BIRD_INVITATION_COOKIE = '__Host-hb_early_bird_invitation'; export const EARLY_BIRD_INVITATION_MAX_AGE_SECONDS = 30 * 60; +const LISTENER_INVITATION_COOKIE_NAMES = [ + LISTENER_INVITATION_COOKIE, + EARLY_BIRD_INVITATION_COOKIE, +] as const; + /** Public product and isolated preview are the only invitation entry hosts. */ export function earlyBirdInvitationHost(hostname: string): boolean { return LISTENER_INVITATION_HOSTS.has(hostname.toLowerCase()); @@ -30,9 +36,9 @@ export function canonicalEarlyBirdInvitation(value: unknown): string | null { return CANONICAL_INVITATION_TOKEN.test(value) ? value : null; } -export function earlyBirdInvitationCookie(value: string) { +function invitationCookie(name: string, value: string) { return { - name: EARLY_BIRD_INVITATION_COOKIE, + name, value, httpOnly: true, secure: true, @@ -42,6 +48,44 @@ export function earlyBirdInvitationCookie(value: string) { }; } +/** Legacy single-cookie constructor retained for rollback-compatible callers. */ +export function earlyBirdInvitationCookie(value: string) { + return invitationCookie(EARLY_BIRD_INVITATION_COOKIE, value); +} + +function cookieHeaderValues(header: string | null, name: string): string[] { + if (!header) return []; + return header.split(';').flatMap((part) => { + const separator = part.indexOf('='); + if (separator < 1 || part.slice(0, separator).trim() !== name) return []; + return [part.slice(separator + 1).trim()]; + }); +} + +/** Canonical-first raw-header read that rejects duplicate or conflicting cookies. */ +export function listenerInvitationFromCookieHeader(header: string | null): string | null { + const values = LISTENER_INVITATION_COOKIE_NAMES.map((name) => ( + cookieHeaderValues(header, name) + )); + const [canonicalValues, legacyValues] = values; + if (canonicalValues.length > 1 || legacyValues.length > 1) return null; + + const canonicalValue = canonicalValues[0]; + const legacyValue = legacyValues[0]; + if (canonicalValue !== undefined) { + const canonical = canonicalEarlyBirdInvitation(canonicalValue); + if (!canonical) return null; + if (legacyValue !== undefined && legacyValue !== canonical) return null; + return canonical; + } + return canonicalEarlyBirdInvitation(legacyValue); +} + +/** Dual-write keeps in-flight legacy pages and rollback images compatible. */ +export function listenerInvitationCookies(value: string) { + return LISTENER_INVITATION_COOKIE_NAMES.map((name) => invitationCookie(name, value)); +} + export function clearedEarlyBirdInvitationCookie() { return { ...earlyBirdInvitationCookie(''), @@ -49,3 +93,11 @@ export function clearedEarlyBirdInvitationCookie() { expires: new Date(0), }; } + +export function clearedListenerInvitationCookies() { + return LISTENER_INVITATION_COOKIE_NAMES.map((name) => ({ + ...invitationCookie(name, ''), + maxAge: 0, + expires: new Date(0), + })); +} diff --git a/src/middleware.ts b/src/middleware.ts index fb6ffa8a..818f878e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,8 +4,8 @@ import type { NextRequest } from 'next/server'; import { canonicalEarlyBirdInvitation, earlyBirdInvitationCookieHost, - earlyBirdInvitationCookie, earlyBirdInvitationStagingHost, + listenerInvitationCookies, LISTENER_INVITATION_CANONICAL_ORIGIN, } from '@/lib/early-birds/invitation-cookie'; import { listenerInvitationQuery } from '@/lib/listener/namespace'; @@ -75,7 +75,7 @@ function scrubEarlyBirdInvitation(request: NextRequest): NextResponse | null { // Host is taken from the request URL populated by the exact nginx vhost; // forwarded host headers are deliberately not trusted. if (token && earlyBirdInvitationCookieHost(hostname)) { - response.cookies.set(earlyBirdInvitationCookie(token)); + for (const cookie of listenerInvitationCookies(token)) response.cookies.set(cookie); } return response; } From 50b3d5bda6b98ec8de01c0389c5b5480016e4494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 03:30:48 -0300 Subject: [PATCH 121/376] docs(listener): record deployed cookie compatibility release --- .../LISTENER_NAMESPACE_MIGRATION.md | 4 ++-- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 14 ++++++++++---- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 19 +++++++++++-------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md index 64e1deda..5f9e773e 100644 --- a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md +++ b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md @@ -1,7 +1,7 @@ # EarlyBird to Listener namespace migration -Status: phase 1 is integrated; phase 2A is an undeployed candidate on -`feat/listener-namespace-runtime`. This migration is deliberately additive. +Status: phases 1, 2A and the invitation-cookie phase 2B are integrated and +deployed on the isolated Listener at `20406da`. This migration is deliberately additive. `EarlyBird` is an offer and cohort name; `Listener` is the durable product and technical namespace. diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 1d98d038..7a3a6dac 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -3,8 +3,8 @@ ## 2026-08-08 public Listener convergence The isolated Listener runs application SHA -`b8a04fe94fb853a0276bb549773c74eb27e410d5`, image -`harmonic-beacon/earlybirds-preview-listener:b8a04fe` and schema +`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. @@ -21,6 +21,12 @@ Free, invitation or Founder authority. 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 @@ -29,8 +35,8 @@ Free, invitation or Founder authority. 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. -- Immediate rollback retains schema/media and selects image `2344b10`; image - `16a15d1` is the additional retained fallback. Use the exact root-only env and +- Immediate 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. Never roll back the additive schema. diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index 7d0f4855..364fc5e1 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -16,13 +16,13 @@ an event-stack deployment or an acoustic change. | Artifact | Exact value | |---|---| -| Deployed Listener application | `b8a04fe94fb853a0276bb549773c74eb27e410d5` | -| Last runtime-changing branch head | `b8a04fe` (documentation may advance independently) | +| Deployed Listener application | `20406dae49e8cbabba38d0cb099d8f400276113e` | +| Last runtime-changing branch head | `20406da` (documentation may advance independently) | | Listener database schema | `20260807200000_listener_regional_presence` | | Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | | Public mode | Free for All OFF during coordinated registered-Free acceptance | -| Immediate Listener rollback | `2344b10` | -| Additional Listener rollback | `16a15d1` | +| Immediate Listener rollback | `b8a04fe` | +| Additional Listener rollback | `2344b10` | Health must attest the deployed application SHA, not the later documentation or test-only branch head. @@ -74,6 +74,8 @@ test-only branch head. - `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. @@ -87,7 +89,7 @@ deployed image; later documentation-only commits do not require rebuilding it. - 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 `b8a04fe` and rollback images `2344b10` and `16a15d1`, every +- Current image `20406da` and rollback images `b8a04fe` and `2344b10`, every active image, container, volume, database and approved media remain. - The fixed public-disable command was exercised after deployment. Its first health probe observed the normal Next.js startup connection reset, retried, @@ -135,9 +137,10 @@ alter audio or merge/promote the branch as part of an automated test. ## Rollback -Run the fixed disable command from release `b8a04fe` first. Restore the exact -root-only environment/nginx backups created before `b8a04fe` (or the earlier -`2344b10` deployment), select Listener image `2344b10` or `16a15d1`, retain the +Run the fixed disable command from release `20406da` first. Restore the exact +root-only environment backup `/etc/harmonic-beacon/earlybirds-preview.env.pre-20406da`, +select Listener image `b8a04fe` (or `2344b10` for the preceding compatibility +release), retain the preview database and origin media, recreate only the isolated Listener, run `nginx -t` before any reload and execute the complete preview health/access smoke. Do not roll back the additive database schema. From 687117cf6335485de46a57d3af32759265f362d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 03:55:51 -0300 Subject: [PATCH 122/376] feat(early-birds): sync authority v1 checkout hardening from canonical backend Copy contracts/early-bird-authority/v1 byte-exactly from the canonical backend at f90ffb92bca34952f3bb41c835014783b6187162: approval_url is now a bounded (2048 chars) credential-free HTTPS URI. SHA256SUMS regenerated with sha256sum and verified by scripts/verify-early-bird-contracts.py. Both shared v1 contract directories are byte-identical to the backend. --- contracts/early-bird-authority/v1/README.md | 5 ++++- contracts/early-bird-authority/v1/SHA256SUMS | 4 ++-- contracts/early-bird-authority/v1/checkout.schema.json | 8 +++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/contracts/early-bird-authority/v1/README.md b/contracts/early-bird-authority/v1/README.md index 35f65a07..6d852f33 100644 --- a/contracts/early-bird-authority/v1/README.md +++ b/contracts/early-bird-authority/v1/README.md @@ -17,7 +17,10 @@ Las llamadas son server-to-server por red privada. Exigen `Authorization: Bearer - `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; - - persiste la unión cuenta↔suscripción externa antes de devolver la URL sandbox; + - 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}` diff --git a/contracts/early-bird-authority/v1/SHA256SUMS b/contracts/early-bird-authority/v1/SHA256SUMS index 19720fa4..f2c91baf 100644 --- a/contracts/early-bird-authority/v1/SHA256SUMS +++ b/contracts/early-bird-authority/v1/SHA256SUMS @@ -1,8 +1,8 @@ -f452841e487b02f233ab8f8633e17c5625eeab5dc3620fbe2fa993cf87a8daaf README.md +b68d7933d72985709a94b2710d7cb57d8aec0b3581f7bc6aed9f940785fbc54e README.md 46ebfa406c3e17e7913122f5cb6fe16084fccef2baada373017bf411fe1908bf checkout-create.fixture.json 2d464d210c61e98489059a5828c83bbaf31370eb0ebd980edf1271e785d1d97a checkout-create.schema.json 6e73b4b0099afdaf4ce6d5894d845220b0828b9125ea9b574d8e8c7535120e4d checkout.fixture.json -b9595f3238a7750c2dc442b1d988290c62787afb03e84bfb662360453673274a checkout.schema.json +d7865fe63b59ddb82285d4df30145a7c3cb59cf8a0923f93eabac8370dba3c9f checkout.schema.json 47c937f3f93ed94b9eff6750bd430131af0c18ace5c2a6684ba36b5f5b3d41f2 invitation-redeem.fixture.json 085d6b6bbf0e88e974ed63e0585d2e52601e859cd283d13dbaf4191a3ae30fd2 invitation-redeem.schema.json d9c86c455ff006225aaeeaa66787650f690b485185a1f821ed55fdde5e5acbd1 membership.fixture.json diff --git a/contracts/early-bird-authority/v1/checkout.schema.json b/contracts/early-bird-authority/v1/checkout.schema.json index 629d5d01..098c9f3b 100644 --- a/contracts/early-bird-authority/v1/checkout.schema.json +++ b/contracts/early-bird-authority/v1/checkout.schema.json @@ -24,7 +24,13 @@ }, "provider": {"enum": ["paypal", "mercado_pago"]}, "external_subscription_id": {"type": "string", "minLength": 1, "maxLength": 255}, - "approval_url": {"type": "string", "minLength": 1, "maxLength": 2048}, + "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"} From f76ea80522d25f141726476183f4ae65dd1215ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 03:56:57 -0300 Subject: [PATCH 123/376] test(early-birds): pin hardened checkout approval_url contract Exercise the authority v1 checkout schema boundary: reject http, relative, credential-bearing, whitespace/control-character and oversized approval_url values; accept representative PayPal and Mercado Pago HTTPS sandbox approval URLs. Also assert SHA256SUMS consistency and, when the canonical backend checkout is available, byte-equivalence with commit f90ffb92bca34952f3bb41c835014783b6187162. --- .../__tests__/checkout-contract.test.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/lib/early-birds/__tests__/checkout-contract.test.ts diff --git a/src/lib/early-birds/__tests__/checkout-contract.test.ts b/src/lib/early-birds/__tests__/checkout-contract.test.ts new file mode 100644 index 00000000..9d03ec92 --- /dev/null +++ b/src/lib/early-birds/__tests__/checkout-contract.test.ts @@ -0,0 +1,147 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = fileURLToPath(new URL('../../../../', import.meta.url)); +const CONTRACT_DIRS = [ + 'contracts/early-bird-authority/v1', + 'contracts/early-bird-membership/v1', +] as const; +const AUTHORITY_DIR = `${REPO_ROOT}${CONTRACT_DIRS[0]}`; + +const checkoutSchema = JSON.parse( + readFileSync(`${AUTHORITY_DIR}/checkout.schema.json`, 'utf8'), +) as { + properties: { + approval_url: { + type: string; + format?: string; + minLength: number; + maxLength: number; + pattern: string; + }; + }; +}; + +// RFC 3986 `uri` format guard for the characters the schema pattern alone cannot +// exclude: whitespace and control characters are never valid inside a URI. +function isUriFormat(value: string): boolean { + return !/[\x00-\x20\x7f]/.test(value); +} + +const approvalUrlRules = checkoutSchema.properties.approval_url; +const approvalUrlPattern = new RegExp(approvalUrlRules.pattern); + +function approvalUrlValid(value: unknown): boolean { + return typeof value === 'string' && + value.length >= approvalUrlRules.minLength && + value.length <= approvalUrlRules.maxLength && + approvalUrlPattern.test(value) && + isUriFormat(value); +} + +describe('early-bird-authority v1 checkout approval_url hardening', () => { + it('declares a bounded credential-free HTTPS URI in the shipped schema', () => { + expect(approvalUrlRules.type).toBe('string'); + expect(approvalUrlRules.format).toBe('uri'); + expect(approvalUrlRules.minLength).toBe(9); + expect(approvalUrlRules.maxLength).toBe(2048); + expect(approvalUrlRules.pattern).toBe('^https://[^\\s/@]+(?:[/?#][^\\s]*)?$'); + }); + + it.each([ + ['http URL', 'http://www.sandbox.paypal.com/checkoutnow?token=5O190127TN364715T'], + ['protocol-relative URL', '//www.sandbox.paypal.com/checkoutnow?token=ABC'], + ['relative URL', '/checkout/v1/redirect?pref_id=123'], + ['bare path', 'checkout/v1/redirect'], + ['embedded credentials', 'https://user:pass@www.sandbox.paypal.com/checkoutnow?token=ABC'], + ['embedded username only', 'https://user@www.sandbox.paypal.com/checkoutnow?token=ABC'], + ['whitespace in path', 'https://www.sandbox.paypal.com/checkout now?token=ABC'], + ['tab in path', 'https://www.sandbox.paypal.com/checkout\tnow?token=ABC'], + ['newline in query', 'https://www.sandbox.paypal.com/checkoutnow?token=ABC\nDEF'], + ['control character in path', 'https://www.sandbox.paypal.com/\x01checkout'], + ['missing host', 'https://'], + ['over 2048 characters', `https://www.sandbox.paypal.com/${'a'.repeat(2048)}`], + ])('rejects %s', (_label, value) => { + expect(approvalUrlValid(value)).toBe(false); + }); + + it.each([ + [ + 'PayPal sandbox approval URL', + 'https://www.sandbox.paypal.com/checkoutnow?token=5O190127TN364715T', + ], + [ + 'PayPal sandbox subscription approval URL', + 'https://www.sandbox.paypal.com/webapps/billing/subscriptions?ba_token=BA-8A023366GG255991N', + ], + [ + 'Mercado Pago sandbox approval URL', + 'https://sandbox.mercadopago.com.ar/checkout/v1/redirect?pref_id=123456789-0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d', + ], + [ + 'Mercado Pago Argentina sandbox preapproval URL', + 'https://sandbox.mercadopago.com.ar/subscriptions/checkout?preapproval_id=2c938084726fca120172710000000000', + ], + ])('accepts %s', (_label, value) => { + expect(approvalUrlValid(value)).toBe(true); + }); + + it('accepts exactly 2048 characters', () => { + const value = `https://www.sandbox.paypal.com/${'a'.repeat(2048 - 31)}`; + expect(value).toHaveLength(2048); + expect(approvalUrlValid(value)).toBe(true); + }); + + it('ships a checkout fixture whose approval_url passes the hardened rules', () => { + const fixture = JSON.parse(readFileSync(`${AUTHORITY_DIR}/checkout.fixture.json`, 'utf8')); + expect(fixture.schema_version).toBe('early-bird-authority.checkout.v1'); + expect(approvalUrlValid(fixture.approval_url)).toBe(true); + }); +}); + +describe('EarlyBird v1 contract manifests', () => { + it.each(CONTRACT_DIRS)('%s SHA256SUMS matches the shipped bytes', (dir) => { + const manifest = readFileSync(`${REPO_ROOT}${dir}/SHA256SUMS`, 'utf8'); + for (const line of manifest.split('\n').filter(Boolean)) { + const [expected, filename] = line.split(' '); + const actual = createHash('sha256') + .update(readFileSync(`${REPO_ROOT}${dir}/${filename}`)) + .digest('hex'); + expect(actual, `${dir}/${filename}`).toBe(expected); + } + }); +}); + +const BACKEND_REPO = process.env.EARLY_BIRDS_BACKEND_REPO ?? '/home/nicolas/Projects/proyecciones-mito'; +const BACKEND_COMMIT = 'f90ffb92bca34952f3bb41c835014783b6187162'; +const backendAvailable = existsSync(`${BACKEND_REPO}/.git`); + +// Git hooks (e.g. the pre-commit suite run) export GIT_DIR and friends, which +// would override the `-C` target repo; scrub them so the canonical read is +// always the pinned backend commit. +function canonicalGitEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith('GIT_')) delete env[key]; + } + return env; +} + +describe.skipIf(!backendAvailable)('byte-equivalence with canonical backend f90ffb9', () => { + for (const dir of CONTRACT_DIRS) { + const files = readdirSync(`${REPO_ROOT}${dir}`).sort(); + it.each(files)(`${dir}/%s is byte-identical`, (filename) => { + const canonical = execFileSync( + 'git', + ['-C', BACKEND_REPO, 'show', `${BACKEND_COMMIT}:${dir}/${filename}`], + { env: canonicalGitEnv() }, + ); + const local = readFileSync(`${REPO_ROOT}${dir}/${filename}`); + expect(local.equals(canonical)).toBe(true); + }); + } +}); From b6fbac33e487b03a156fc454bb4fd4bde8ba3ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 04:42:51 -0300 Subject: [PATCH 124/376] feat(listener): bridge canonical session cookie fail-closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Better Auth stays the sole session authority on the legacy EarlyBird base path. A strict wrapper around the shared handler and every direct getSession path mirrors each emitted legacy session Set-Cookie onto the canonical hb_listener_session name byte-identically, while inbound only no-cookie, legacy-only, or byte-identical dual requests may proceed. Canonical-only, duplicate, conflicting, malformed or oversized session cookies terminate with a generic 400/401 before Better Auth can mint, rotate or clear anything, and ambiguous Set-Cookie output fails closed with a generic 500 carrying no cookies. Every rejection also expires BOTH exact session cookie names with Max-Age=0 and the scope Better Auth resolved (Path=/, HttpOnly, SameSite=Lax, Secure for __Secure- names; no Domain is ever invented), so a deploy/rollback/redeploy sequence that strands a stale canonical cookie — old sign-out clears only legacy, old re-login leaves canonical A plus legacy B — stays recoverable: the dual expiry logs the client out and the next clean sign-in mints a fresh dual pair. Rejected values are never echoed, parsed or logged, and direct getSession paths still fail closed to null without response cookies. --- .../LISTENER_NAMESPACE_MIGRATION.md | 65 +++ .../auth/[...all]/__tests__/route.test.ts | 2 +- .../api/early-birds/auth/[...all]/route.ts | 6 +- .../test-login/__tests__/route.test.ts | 82 +++- src/app/api/early-birds/test-login/route.ts | 30 +- src/lib/early-birds/auth.ts | 49 ++ .../session-cookie-bridge.integration.test.ts | 453 ++++++++++++++++++ .../__tests__/session-cookie-bridge.test.ts | 335 +++++++++++++ src/lib/listener/session-cookie-bridge.ts | 314 ++++++++++++ 9 files changed, 1322 insertions(+), 14 deletions(-) create mode 100644 src/lib/listener/__tests__/session-cookie-bridge.integration.test.ts create mode 100644 src/lib/listener/__tests__/session-cookie-bridge.test.ts create mode 100644 src/lib/listener/session-cookie-bridge.ts diff --git a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md index 5f9e773e..6bce55d9 100644 --- a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md +++ b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md @@ -121,6 +121,71 @@ 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. + ## Phase 4: environment and operations Introduce a typed resolver for each bounded environment group: 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 index 6b07bb79..b75311e3 100644 --- a/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts +++ b/src/app/api/early-birds/auth/[...all]/__tests__/route.test.ts @@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const handler = vi.hoisted(() => vi.fn()); -vi.mock('@/lib/early-birds/auth', () => ({ earlyBirdAuth: () => ({ handler }) })); +vi.mock('@/lib/early-birds/auth', () => ({ earlyBirdAuthHandler: handler })); import { GET, POST } from '../route'; diff --git a/src/app/api/early-birds/auth/[...all]/route.ts b/src/app/api/early-birds/auth/[...all]/route.ts index 25ac71c6..3485ba6e 100644 --- a/src/app/api/early-birds/auth/[...all]/route.ts +++ b/src/app/api/early-birds/auth/[...all]/route.ts @@ -1,6 +1,6 @@ import type { NextRequest } from 'next/server'; -import { earlyBirdAuth } from '@/lib/early-birds/auth'; +import { earlyBirdAuthHandler } from '@/lib/early-birds/auth'; import { EARLY_BIRD_MAGIC_LINK_PATH, EARLY_BIRD_MAGIC_LINK_VERIFY_PATH, @@ -111,7 +111,7 @@ export function GET(request: NextRequest): Promise | Response { if (!earlyBirdMagicLinkAvailable()) return hiddenMagicLinkResponse(); if (!safeMagicLinkVerification(request)) return invalidMagicLinkResponse(); } - return earlyBirdAuth().handler(request); + return earlyBirdAuthHandler(request); } export async function POST(request: NextRequest): Promise { @@ -131,5 +131,5 @@ export async function POST(request: NextRequest): Promise { headers: { 'Cache-Control': 'private, no-store' }, }); } - return earlyBirdAuth().handler(request); + return earlyBirdAuthHandler(request); } 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 index eb922342..f7eaab7e 100644 --- a/src/app/api/early-birds/test-login/__tests__/route.test.ts +++ b/src/app/api/early-birds/test-login/__tests__/route.test.ts @@ -2,10 +2,19 @@ import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ handler: vi.fn(), issueMembership: vi.fn() })); -vi.mock('@/lib/early-birds/auth', async (importOriginal) => ({ - ...await importOriginal(), - earlyBirdAuth: () => ({ handler: mocks.handler }), -})); +vi.mock('@/lib/early-birds/auth', async (importOriginal) => { + const bridge = await import('@/lib/listener/session-cookie-bridge'); + // The route crosses the real bridge; only Better Auth itself is stubbed, + // so rejection/mirroring behavior under test is the production wrapper. + const names = bridge.listenerSessionCookieNames('__Secure-hb_earlybird_session'); + return { + ...await importOriginal(), + earlyBirdAuth: () => ({ handler: mocks.handler }), + earlyBirdSessionCookieNames: () => names, + earlyBirdAuthHandler: (request: Request) => + bridge.listenerSessionAuthHandler(mocks.handler, names)(request), + }; +}); vi.mock('@/lib/early-birds/membership', () => ({ issueSyntheticMembership: mocks.issueMembership, })); @@ -13,8 +22,13 @@ vi.mock('@/lib/early-birds/membership', () => ({ import { POST } from '../route'; const URL = 'https://app.example.test/api/early-birds/test-login'; +const LEGACY_SESSION = '__Secure-hb_earlybird_session'; +const CANONICAL_SESSION = '__Secure-hb_listener_session'; +const SYNTHETIC_MINT = `${LEGACY_SESSION}=synthetic; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax; Secure`; +const LEGACY_CLEAR = `${LEGACY_SESSION}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`; +const CANONICAL_CLEAR = `${CANONICAL_SESSION}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`; -function request(authorization?: string, authOnly = false): NextRequest { +function request(authorization?: string, authOnly = false, cookie?: string): NextRequest { return new NextRequest(URL, { method: 'POST', headers: { @@ -22,6 +36,7 @@ function request(authorization?: string, authOnly = false): NextRequest { 'x-forwarded-proto': 'https', 'content-type': 'application/json', ...(authorization ? { authorization } : {}), + ...(cookie ? { cookie } : {}), }, body: JSON.stringify({ email: 'listener@e2e.invalid', @@ -44,7 +59,7 @@ describe('EarlyBird synthetic login seam', () => { user: { id: 'listener-synthetic-1' }, }), { status: 200, - headers: { 'set-cookie': 'hb_earlybird_session=synthetic; HttpOnly; Secure' }, + headers: { 'set-cookie': SYNTHETIC_MINT }, })); mocks.issueMembership.mockResolvedValue({}); }); @@ -106,7 +121,7 @@ describe('EarlyBird synthetic login seam', () => { user: { id: 'listener-synthetic-new' }, }), { status: 200, - headers: { 'set-cookie': 'hb_earlybird_session=synthetic; HttpOnly; Secure' }, + headers: { 'set-cookie': SYNTHETIC_MINT }, })); const response = await POST(request(`Bearer ${'s'.repeat(32)}`)); @@ -127,4 +142,57 @@ describe('EarlyBird synthetic login seam', () => { expect(mocks.handler).toHaveBeenCalledOnce(); expect(mocks.issueMembership).not.toHaveBeenCalled(); }); + + it('preserves the actual dual session Set-Cookie pair on a successful login', async () => { + const response = await POST(request(`Bearer ${'s'.repeat(32)}`)); + + expect(response.status).toBe(200); + // Not a mocked bridge: the real wrapper mirrored the legacy mint onto + // the canonical name byte-identically apart from the name itself. + expect(response.headers.getSetCookie()).toEqual([ + SYNTHETIC_MINT, + `${CANONICAL_SESSION}${SYNTHETIC_MINT.slice(LEGACY_SESSION.length)}`, + ]); + }); + + it('rejects invalid inbound session cookies before Better Auth and mints nothing', async () => { + const conflict = `${CANONICAL_SESSION}=stale.token%3D; ${LEGACY_SESSION}=fresh.token%3D`; + const response = await POST(request(`Bearer ${'s'.repeat(32)}`, false, conflict)); + + // Both internal sign-in and sign-up terminate at the bridge; the + // wrapped Better Auth handler is never invoked and no session exists. + expect(mocks.handler).not.toHaveBeenCalled(); + expect(mocks.issueMembership).not.toHaveBeenCalled(); + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.getSetCookie()).toEqual([LEGACY_CLEAR, CANONICAL_CLEAR]); + + // Once the browser honours the two expiries, a clean retry reaches + // Better Auth and mints a fresh byte-identical dual pair. + const retry = await POST(request(`Bearer ${'s'.repeat(32)}`)); + expect(retry.status).toBe(200); + expect(mocks.handler).toHaveBeenCalledOnce(); + expect(retry.headers.getSetCookie()).toEqual([ + SYNTHETIC_MINT, + `${CANONICAL_SESSION}${SYNTHETIC_MINT.slice(LEGACY_SESSION.length)}`, + ]); + }); + + it('never forwards malformed, incomplete or unrelated internal cookies on failure', async () => { + mocks.handler.mockResolvedValue(new Response(JSON.stringify({ error: 'nope' }), { + status: 401, + headers: [ + ['set-cookie', 'unrelated=secret; Path=/; HttpOnly'], + ['set-cookie', LEGACY_CLEAR], + ], + })); + + const response = await POST(request(`Bearer ${'s'.repeat(32)}`)); + + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.getSetCookie()).toEqual([]); + expect(mocks.handler).toHaveBeenCalledTimes(2); + expect(mocks.issueMembership).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/early-birds/test-login/route.ts b/src/app/api/early-birds/test-login/route.ts index af140280..400921de 100644 --- a/src/app/api/early-birds/test-login/route.ts +++ b/src/app/api/early-birds/test-login/route.ts @@ -4,13 +4,15 @@ import { NextResponse, type NextRequest } from 'next/server'; import { EARLY_BIRD_AUTH_BASE_PATH, - earlyBirdAuth, + earlyBirdAuthHandler, + earlyBirdSessionCookieNames, 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 { listenerSessionClearCookies } from '@/lib/listener/session-cookie-bridge'; export const dynamic = 'force-dynamic'; @@ -40,6 +42,25 @@ function testPassword(email: string): string { .digest('base64url'); } +/** + * Preserve only the bridge's exact, empty dual-cookie recovery response. + * Better Auth bodies, token values, unrelated cookies and other internal + * headers never cross the synthetic-login boundary. + */ +function recoveryHeaders(authResponse: Response): Headers { + const headers = new Headers({ 'cache-control': 'private, no-store' }); + const expected = listenerSessionClearCookies(earlyBirdSessionCookieNames()); + const actual = authResponse.headers.getSetCookie(); + if ( + actual.length !== expected.length || + new Set(actual).size !== actual.length || + !expected.every((entry) => actual.includes(entry)) + ) return headers; + + for (const entry of expected) headers.append('set-cookie', entry); + return headers; +} + async function authRequest( request: NextRequest, operation: 'sign-up' | 'sign-in', @@ -51,7 +72,7 @@ async function authRequest( // of Better Auth's request and never enters a cookie or client response. headers.delete('authorization'); headers.set('content-type', 'application/json'); - return earlyBirdAuth().handler(new Request(url, { + return earlyBirdAuthHandler(new Request(url, { method: 'POST', headers, body: JSON.stringify(body), @@ -99,7 +120,10 @@ export async function POST(request: NextRequest): Promise { }); } if (!authResponse.ok) { - return NextResponse.json({ error: 'Synthetic login failed.' }, { status: 503 }); + return NextResponse.json( + { error: 'Synthetic login failed.' }, + { status: 503, headers: recoveryHeaders(authResponse) }, + ); } const payload = await authResponse.clone().json() as { user?: { id?: unknown } }; diff --git a/src/lib/early-birds/auth.ts b/src/lib/early-birds/auth.ts index 98ca9f0c..937e4fab 100644 --- a/src/lib/early-birds/auth.ts +++ b/src/lib/early-birds/auth.ts @@ -1,9 +1,17 @@ import { headers as requestHeaders } from 'next/headers'; import { betterAuth } from 'better-auth/minimal'; import { prismaAdapter } from 'better-auth/adapters/prisma'; +import { getCookies } from 'better-auth/cookies'; import { magicLink } from 'better-auth/plugins'; import { prisma } from '@/lib/db'; +import { + LISTENER_SESSION_COOKIE, + listenerSessionAuthHandler, + listenerSessionCookieNames, + resolveListenerSessionCookie, + type ListenerSessionCookieNames, +} from '@/lib/listener/session-cookie-bridge'; import { listenerRuntimeBundle, listenerRuntimeFlag, @@ -21,6 +29,7 @@ import { export const EARLY_BIRD_AUTH_BASE_PATH = '/api/early-birds/auth'; export const EARLY_BIRD_COOKIE_PREFIX = 'hb_earlybird'; export const EARLY_BIRD_SESSION_COOKIE = 'hb_earlybird_session'; +export { LISTENER_SESSION_COOKIE }; export function earlyBirdTestAuthEnabled(environment: NodeJS.ProcessEnv = process.env): boolean { return earlyBirdTestLoginSecret(environment) !== null; @@ -235,6 +244,39 @@ export function earlyBirdAuth() { return singleton; } +/** + * The session cookie pair as Better Auth actually resolved it, including its + * `__Secure-` convention, plus the canonical Listener mirror name and the + * resolved cookie scope. Passing the resolved attributes through keeps every + * bridge-side expiry on exactly the scope Better Auth minted into; Better + * Auth never resolves a Domain here, and the bridge never invents one. + */ +export function earlyBirdSessionCookieNames(): ListenerSessionCookieNames { + const sessionToken = getCookies(earlyBirdAuth().options).sessionToken; + return listenerSessionCookieNames(sessionToken.name, { + path: sessionToken.attributes.path, + httpOnly: sessionToken.attributes.httpOnly, + // Better Auth resolves lowercase; emit the canonical wire casing. + sameSite: sessionToken.attributes.sameSite.charAt(0).toUpperCase() + + sessionToken.attributes.sameSite.slice(1), + secure: sessionToken.attributes.secure, + }); +} + +/** + * Shared Better Auth route handler with the Listener session-cookie bridge: + * invalid inbound session-cookie states are rejected generically before + * Better Auth runs, and every emitted session cookie (sign-in, rotation, + * sign-out) is mirrored on the way out. Better Auth's own base path, cookie + * and verification are unchanged. + */ +export function earlyBirdAuthHandler(request: Request): Promise { + return listenerSessionAuthHandler( + (bridged) => earlyBirdAuth().handler(bridged), + earlyBirdSessionCookieNames(), + )(request); +} + export type EarlyBirdSession = { user: { id: string; @@ -253,6 +295,13 @@ export async function currentEarlyBirdSession( suppliedHeaders?: Headers, ): Promise { const resolvedHeaders = suppliedHeaders ?? new Headers(await requestHeaders()); + // The same strict inbound policy as the route handler: ambiguous + // session-cookie states never reach Better Auth, they fail closed here. + const resolution = resolveListenerSessionCookie( + resolvedHeaders.get('cookie'), + earlyBirdSessionCookieNames(), + ); + if (resolution.kind === 'reject') return null; const result = await earlyBirdAuth().api.getSession({ headers: resolvedHeaders }); if (!result) return null; diff --git a/src/lib/listener/__tests__/session-cookie-bridge.integration.test.ts b/src/lib/listener/__tests__/session-cookie-bridge.integration.test.ts new file mode 100644 index 00000000..52dc2f5d --- /dev/null +++ b/src/lib/listener/__tests__/session-cookie-bridge.integration.test.ts @@ -0,0 +1,453 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { memoryAdapter } from 'better-auth/adapters/memory'; +import { betterAuth } from 'better-auth/minimal'; +import { getCookies } from 'better-auth/cookies'; + +import { + listenerSessionAuthHandler, + listenerSessionClearCookies, + listenerSessionCookieNames, + type ListenerSessionCookieNames, +} from '@/lib/listener/session-cookie-bridge'; + +type MemoryRow = Record; +type BridgedHandler = (request: Request) => Promise; + +const BASE_URL = 'https://listen.example.test'; + +function bridge(updateAge = 60 * 60 * 24) { + const database: Record = { + user: [], + session: [], + account: [], + verification: [], + }; + const auth = betterAuth({ + baseURL: BASE_URL, + basePath: '/api/early-birds/auth', + secret: 'test-auth-secret-with-at-least-32-characters', + trustedOrigins: [BASE_URL], + database: memoryAdapter(database), + rateLimit: { enabled: false }, + emailAndPassword: { enabled: true }, + session: { + expiresIn: 60 * 60 * 24 * 30, + updateAge, + cookieCache: { enabled: false }, + }, + advanced: { + cookiePrefix: 'hb_earlybird', + cookies: { session_token: { name: 'hb_earlybird_session' } }, + useSecureCookies: true, + }, + }); + const names = listenerSessionCookieNames(getCookies(auth.options).sessionToken.name); + expect(names).toEqual({ + legacy: '__Secure-hb_earlybird_session', + canonical: '__Secure-hb_listener_session', + scope: { path: '/', httpOnly: true, sameSite: 'Lax', secure: true }, + }); + return { + auth, + database, + names, + handler: listenerSessionAuthHandler((request) => auth.handler(request), names), + }; +} + +function signUp(handler: BridgedHandler, email: string, cookie?: string): Promise { + return handler(new Request(`${BASE_URL}/api/early-birds/auth/sign-up/email`, { + method: 'POST', + headers: { + origin: BASE_URL, + 'content-type': 'application/json', + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify({ email, name: 'Listener', password: 'listener-password-1' }), + })); +} + +function signIn(handler: BridgedHandler, email: string, cookie?: string): Promise { + return handler(new Request(`${BASE_URL}/api/early-birds/auth/sign-in/email`, { + method: 'POST', + headers: { + origin: BASE_URL, + 'content-type': 'application/json', + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify({ email, password: 'listener-password-1' }), + })); +} + +function setCookieEntry(response: Response, name: string): string { + const entry = response.headers.getSetCookie() + .find((cookie) => cookie.startsWith(`${name}=`)); + expect(entry, `expected a Set-Cookie for ${name}`).toBeDefined(); + return entry as string; +} + +function sessionCookieValue(response: Response, name: string): string { + const entry = setCookieEntry(response, name); + return entry.slice(name.length + 1, entry.indexOf(';')); +} + +async function getSession(handler: BridgedHandler, cookie: string | null) { + const response = await handler(new Request(`${BASE_URL}/api/early-birds/auth/get-session`, { + headers: cookie ? { cookie } : {}, + })); + expect(response.status).toBe(200); + return { response, body: await response.json() as { user?: { email?: string } } | null }; +} + +/** + * A rejection must come from the bridge itself: generic body, and the only + * Set-Cookie output is the dual expiry of both exact session names — never + * an auth token, never the rejected value. + */ +async function expectBridgeRejection( + response: Response, + status: 400 | 401, + names: ListenerSessionCookieNames, +) { + expect(response.status).toBe(status); + const clears = response.headers.getSetCookie(); + expect(clears).toEqual(listenerSessionClearCookies(names)); + for (const entry of clears) { + expect(entry).toContain('Max-Age=0'); + expect(entry).toContain('Path=/'); + expect(entry).toContain('HttpOnly'); + expect(entry).toContain('SameSite=Lax'); + expect(entry).toContain('Secure'); + expect(entry).not.toContain('Domain='); + } + await expect(response.json()).resolves.toEqual({ error: 'invalid session credentials' }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('Listener session-cookie bridge over a real Better Auth pipeline', () => { + it('mints a byte-identical dual pair with matching security attributes on sign-in', async () => { + const state = bridge(); + const response = await signUp(state.handler, 'first@example.test'); + expect(response.status).toBe(200); + + const legacy = setCookieEntry(response, state.names.legacy); + const canonical = setCookieEntry(response, state.names.canonical); + expect(canonical).toBe(`${state.names.canonical}${legacy.slice(state.names.legacy.length)}`); + for (const entry of [legacy, canonical]) { + expect(entry).toContain('HttpOnly'); + expect(entry).toContain('Secure'); + expect(entry).toContain('SameSite=Lax'); + expect(entry).toContain('Path=/'); + expect(entry).toContain('Max-Age='); + expect(entry).not.toContain('Domain='); + } + expect(state.database.session).toHaveLength(1); + }); + + it('accepts legacy-only and identical dual sessions, rejects canonical-only', async () => { + const state = bridge(); + const response = await signUp(state.handler, 'continuity@example.test'); + const legacy = sessionCookieValue(response, state.names.legacy); + const canonical = sessionCookieValue(response, state.names.canonical); + expect(canonical).toBe(legacy); + + // Legacy-only is the rollback window: existing clients keep working. + const legacyOnly = await getSession(state.handler, `${state.names.legacy}=${legacy}`); + expect(legacyOnly.body?.user?.email).toBe('continuity@example.test'); + + const dual = await getSession( + state.handler, + `${state.names.canonical}=${canonical}; ${state.names.legacy}=${legacy}`, + ); + expect(dual.body?.user?.email).toBe('continuity@example.test'); + + // Canonical-only is NOT rollback-compatible: rejected before Better Auth. + const canonicalOnly = await state.handler( + new Request(`${BASE_URL}/api/early-birds/auth/get-session`, { + headers: { cookie: `${state.names.canonical}=${canonical}` }, + }), + ); + await expectBridgeRejection(canonicalOnly, 401, state.names); + }); + + it('rejects conflicts, duplicates and malformed cookies before Better Auth', async () => { + const state = bridge(); + const response = await signUp(state.handler, 'guarded@example.test'); + const legacy = sessionCookieValue(response, state.names.legacy); + const other = (await signUp(state.handler, 'other@example.test')) + .headers.getSetCookie().map((entry) => entry); + const otherLegacy = other.find((entry) => entry.startsWith(`${state.names.legacy}=`))!; + const otherValue = otherLegacy.slice( + state.names.legacy.length + 1, + otherLegacy.indexOf(';'), + ); + + const rejected: [string, 400 | 401][] = [ + // Conflicting canonical/legacy values are never arbitrated. + [`${state.names.canonical}=${otherValue}; ${state.names.legacy}=${legacy}`, 400], + // Duplicate same-name cookies are never silently selected. + [`${state.names.canonical}=${legacy}; ${state.names.canonical}=${legacy}`, 400], + [`${state.names.legacy}=${legacy}; ${state.names.legacy}=${legacy}`, 400], + // Malformed percent encoding must not downgrade to the valid legacy cookie. + [`${state.names.canonical}=${legacy.slice(0, 12)}%zz; ${state.names.legacy}=${legacy}`, 400], + // Empty and oversized values. + [`${state.names.canonical}=; ${state.names.legacy}=${legacy}`, 400], + [`${state.names.legacy}=${'a'.repeat(513)}`, 400], + ]; + for (const [cookie, status] of rejected) { + const attempt = await state.handler( + new Request(`${BASE_URL}/api/early-birds/auth/get-session`, { + headers: { cookie }, + }), + ); + await expectBridgeRejection(attempt, status, state.names); + } + expect(state.database.session).toHaveLength(2); + + // A well-formed but unsigned value still fails Better Auth's own + // verification, with the bridge passing it through untouched. + const forged = await getSession( + state.handler, + `${state.names.legacy}=forged-token-without-signature`, + ); + expect(forged.body).toBeNull(); + }); + + it('never mints a session when sign-in or callback paths carry invalid cookies', async () => { + const state = bridge(); + const duplicate = `${state.names.legacy}=aB3d.tokensig%3D; ${state.names.legacy}=aB3d.tokensig%3D`; + + // Sign-in: the minting path terminates before Better Auth runs. + const blockedSignUp = await signUp(state.handler, 'blocked@example.test', duplicate); + await expectBridgeRejection(blockedSignUp, 400, state.names); + expect(state.database.user).toHaveLength(0); + expect(state.database.session).toHaveLength(0); + + // OAuth callback: a GET minting path terminates the same way. + const callback = await state.handler( + new Request(`${BASE_URL}/api/early-birds/auth/callback/google?state=abc&code=def`, { + headers: { origin: BASE_URL, cookie: duplicate }, + }), + ); + await expectBridgeRejection(callback, 400, state.names); + expect(state.database.session).toHaveLength(0); + + // Sign-in recovery: once the client honours the dual expiry and + // retries clean, the very next sign-up mints an exact dual pair. + const recovered = await signUp(state.handler, 'blocked@example.test'); + expect(recovered.status).toBe(200); + const legacy = setCookieEntry(recovered, state.names.legacy); + expect(setCookieEntry(recovered, state.names.canonical)) + .toBe(`${state.names.canonical}${legacy.slice(state.names.legacy.length)}`); + expect(state.database.session).toHaveLength(1); + }); + + it('keeps two sessions of one account isolated and rejects crossed pairs', async () => { + const state = bridge(); + // Two devices, ONE account: sign-up mints the first session, a real + // sign-in of the same account mints an independent second one. + const first = sessionCookieValue( + await signUp(state.handler, 'listener@example.test'), + state.names.legacy, + ); + const second = sessionCookieValue( + await signIn(state.handler, 'listener@example.test'), + state.names.legacy, + ); + expect(second).not.toBe(first); + expect(state.database.user).toHaveLength(1); + expect(state.database.session).toHaveLength(2); + + // Both independent tokens validate for the same account. + const deviceA = await getSession( + state.handler, + `${state.names.canonical}=${first}; ${state.names.legacy}=${first}`, + ); + expect(deviceA.body?.user?.email).toBe('listener@example.test'); + const deviceB = await getSession(state.handler, `${state.names.legacy}=${second}`); + expect(deviceB.body?.user?.email).toBe('listener@example.test'); + + // Crossing one device's canonical with the other's legacy rejects. + const crossed = await state.handler( + new Request(`${BASE_URL}/api/early-birds/auth/get-session`, { + headers: { + cookie: `${state.names.legacy}=${first}; ${state.names.canonical}=${second}`, + }, + }), + ); + await expectBridgeRejection(crossed, 400, state.names); + // Rejection is not revocation: both sessions remain valid afterwards. + expect((await getSession(state.handler, `${state.names.legacy}=${first}`)) + .body?.user?.email).toBe('listener@example.test'); + expect((await getSession(state.handler, `${state.names.legacy}=${second}`)) + .body?.user?.email).toBe('listener@example.test'); + }); + + it('clears both cookies with matching scope on sign-out and revokes the session', async () => { + const state = bridge(); + const response = await signUp(state.handler, 'leaving@example.test'); + const legacy = sessionCookieValue(response, state.names.legacy); + + const signOut = await state.handler(new Request(`${BASE_URL}/api/early-birds/auth/sign-out`, { + method: 'POST', + headers: { + origin: BASE_URL, + 'content-type': 'application/json', + cookie: `${state.names.canonical}=${legacy}; ${state.names.legacy}=${legacy}`, + }, + body: '{}', + })); + expect(signOut.status).toBe(200); + for (const name of [state.names.legacy, state.names.canonical]) { + const cleared = setCookieEntry(signOut, name); + expect(cleared).toContain(`${name}=;`); + expect(cleared).toContain('Max-Age=0'); + expect(cleared).toContain('Path=/'); + expect(cleared).toContain('HttpOnly'); + expect(cleared).toContain('SameSite=Lax'); + expect(cleared).toContain('Secure'); + } + expect(state.database.session).toHaveLength(0); + + const after = await getSession(state.handler, `${state.names.legacy}=${legacy}`); + expect(after.body).toBeNull(); + }); + + it('clears both cookies when sign-out arrives legacy-only (rollback retention)', async () => { + const state = bridge(); + const response = await signUp(state.handler, 'rollback-leaving@example.test'); + const legacy = sessionCookieValue(response, state.names.legacy); + + const signOut = await state.handler(new Request(`${BASE_URL}/api/early-birds/auth/sign-out`, { + method: 'POST', + headers: { + origin: BASE_URL, + 'content-type': 'application/json', + cookie: `${state.names.legacy}=${legacy}`, + }, + body: '{}', + })); + expect(signOut.status).toBe(200); + expect(state.database.session).toHaveLength(0); + for (const name of [state.names.legacy, state.names.canonical]) { + expect(setCookieEntry(signOut, name)).toContain('Max-Age=0'); + } + }); + + it('rejects a canonical-only sign-out without revoking the session', async () => { + const state = bridge(); + const response = await signUp(state.handler, 'canonical-leaving@example.test'); + const canonical = sessionCookieValue(response, state.names.canonical); + + const signOut = await state.handler(new Request(`${BASE_URL}/api/early-birds/auth/sign-out`, { + method: 'POST', + headers: { + origin: BASE_URL, + 'content-type': 'application/json', + cookie: `${state.names.canonical}=${canonical}`, + }, + body: '{}', + })); + await expectBridgeRejection(signOut, 401, state.names); + expect(state.database.session).toHaveLength(1); + }); + + it('recovers a stale canonical cookie stranded by a deploy/rollback/redeploy sequence', async () => { + const state = bridge(); + // The bare handler simulates the pre-bridge rollback image (20406): + // the same Better Auth pipeline with no bridge and legacy name only. + const legacyHandler: BridgedHandler = (request) => state.auth.handler(request); + const signOutRequest = (handler: BridgedHandler, cookie: string) => + handler(new Request(`${BASE_URL}/api/early-birds/auth/sign-out`, { + method: 'POST', + headers: { origin: BASE_URL, 'content-type': 'application/json', cookie }, + body: '{}', + })); + + // Bridge deploy: sign-up mints the byte-identical dual pair A. + const deployed = await signUp(state.handler, 'rollback@example.test'); + expect(deployed.status).toBe(200); + const tokenA = sessionCookieValue(deployed, state.names.legacy); + expect(state.database.session).toHaveLength(1); + + // Rollback: the unwrapped legacy handler keeps accepting legacy-only. + const rollbackSession = await getSession(legacyHandler, `${state.names.legacy}=${tokenA}`); + expect(rollbackSession.body?.user?.email).toBe('rollback@example.test'); + + // Old sign-out clears ONLY the legacy name; the canonical cookie + // survives in the browser jar untouched. + const oldSignOut = await signOutRequest(legacyHandler, `${state.names.legacy}=${tokenA}`); + expect(oldSignOut.status).toBe(200); + const oldSignOutCookies = oldSignOut.headers.getSetCookie(); + expect(oldSignOutCookies.some((entry) => entry.startsWith(`${state.names.legacy}=`))).toBe(true); + expect(oldSignOutCookies.some((entry) => entry.startsWith(`${state.names.canonical}=`))).toBe(false); + expect(state.database.session).toHaveLength(0); + + // Alternate timeline probe: redeploying the bridge right here would + // face canonical-only A and answer 401 with the dual expiry, instead + // of the unrecoverable 401-without-clear the first bridge revision + // produced. (No server mutation: Better Auth never runs.) + const canonicalOnly = await state.handler( + new Request(`${BASE_URL}/api/early-birds/auth/get-session`, { + headers: { cookie: `${state.names.canonical}=${tokenA}` }, + }), + ); + await expectBridgeRejection(canonicalOnly, 401, state.names); + + // Old re-login: the rollback image ignores the canonical name, so it + // mints a fresh legacy-only session B while stale canonical A persists. + const oldRelogin = await signIn( + legacyHandler, + 'rollback@example.test', + `${state.names.canonical}=${tokenA}`, + ); + expect(oldRelogin.status).toBe(200); + const tokenB = sessionCookieValue(oldRelogin, state.names.legacy); + expect(tokenB).not.toBe(tokenA); + expect(oldRelogin.headers.getSetCookie() + .some((entry) => entry.startsWith(`${state.names.canonical}=`))).toBe(false); + + // Redeploy the bridge: stale canonical A plus fresh legacy B is a + // conflict. It fails closed and expires BOTH names; session B is not + // revoked because Better Auth never ran. + const stranded = await state.handler( + new Request(`${BASE_URL}/api/early-birds/auth/get-session`, { + headers: { + cookie: `${state.names.canonical}=${tokenA}; ${state.names.legacy}=${tokenB}`, + }, + }), + ); + await expectBridgeRejection(stranded, 400, state.names); + expect(state.database.session).toHaveLength(1); + + // The browser honours the dual expiry: the next clean sign-in + // succeeds and emits the exact byte-identical dual pair again. + const recovered = await signIn(state.handler, 'rollback@example.test'); + expect(recovered.status).toBe(200); + const legacy = setCookieEntry(recovered, state.names.legacy); + expect(setCookieEntry(recovered, state.names.canonical)) + .toBe(`${state.names.canonical}${legacy.slice(state.names.legacy.length)}`); + }); + + it('rotates both cookies coherently when the refresh window elapses', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-08T10:00:00.000Z')); + const state = bridge(1); + const response = await signUp(state.handler, 'rotating@example.test'); + const initial = sessionCookieValue(response, state.names.legacy); + + vi.setSystemTime(new Date('2026-08-08T10:00:05.000Z')); + const rotated = await getSession(state.handler, `${state.names.legacy}=${initial}`); + expect(rotated.body?.user?.email).toBe('rotating@example.test'); + + const legacy = setCookieEntry(rotated.response, state.names.legacy); + const canonical = setCookieEntry(rotated.response, state.names.canonical); + // Rotation re-signs the same token deterministically: values stay + // byte-identical and both cookies move together. + expect(sessionCookieValue(rotated.response, state.names.legacy)).toBe(initial); + expect(canonical).toBe(`${state.names.canonical}${legacy.slice(state.names.legacy.length)}`); + }); +}); diff --git a/src/lib/listener/__tests__/session-cookie-bridge.test.ts b/src/lib/listener/__tests__/session-cookie-bridge.test.ts new file mode 100644 index 00000000..d72fd993 --- /dev/null +++ b/src/lib/listener/__tests__/session-cookie-bridge.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + LISTENER_SESSION_COOKIE, + listenerSessionAuthHandler, + listenerSessionClearCookies, + listenerSessionCookieNames, + listenerSessionSetCookieMirrors, + mirrorListenerSessionResponse, + resolveListenerSessionCookie, +} from '@/lib/listener/session-cookie-bridge'; + +const NAMES = listenerSessionCookieNames('hb_earlybird_session'); +const SECURE_NAMES = listenerSessionCookieNames('__Secure-hb_earlybird_session'); + +// Shape of a Better Auth signed session value on the wire: +// encodeURIComponent(`${token}.${base64 HMAC}`). +const VALUE = 'm1V0k2NlR3JlVG9rZW4.x%2B9ab%2Fcd%3D'; +const OTHER_VALUE = '90mV0k2NlR3JlVG9rZW4.qwert%2Fyuiop%3D'; + +function setCookiesOf(response: Response): string[] { + return response.headers.getSetCookie(); +} + +describe('Listener session-cookie bridge names', () => { + it('derives the canonical name from the resolved legacy name and its security posture', () => { + expect(LISTENER_SESSION_COOKIE).toBe('hb_listener_session'); + expect(NAMES).toEqual({ + legacy: 'hb_earlybird_session', + canonical: 'hb_listener_session', + scope: { path: '/', httpOnly: true, sameSite: 'Lax', secure: false }, + }); + expect(SECURE_NAMES).toEqual({ + legacy: '__Secure-hb_earlybird_session', + canonical: '__Secure-hb_listener_session', + scope: { path: '/', httpOnly: true, sameSite: 'Lax', secure: true }, + }); + }); + + it('honours the scope Better Auth resolved instead of re-deriving it', () => { + const resolved = listenerSessionCookieNames('hb_earlybird_session', { + path: '/', + httpOnly: true, + sameSite: 'Lax', + secure: true, + }); + expect(resolved.scope).toEqual({ + path: '/', + httpOnly: true, + sameSite: 'Lax', + secure: true, + }); + }); +}); + +describe('listenerSessionClearCookies', () => { + it('expires both exact names with the resolved scope and no value or Domain', () => { + expect(listenerSessionClearCookies(SECURE_NAMES)).toEqual([ + `${SECURE_NAMES.legacy}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`, + `${SECURE_NAMES.canonical}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`, + ]); + }); + + it('matches the non-secure scope when Better Auth resolved plain names', () => { + expect(listenerSessionClearCookies(NAMES)).toEqual([ + `${NAMES.legacy}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`, + `${NAMES.canonical}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`, + ]); + }); +}); + +describe('resolveListenerSessionCookie', () => { + it('forwards requests without any session cookie', () => { + expect(resolveListenerSessionCookie(null, NAMES)) + .toEqual({ kind: 'forward', header: null }); + const header = 'other=1; hb_earlybird_device_id=abc'; + expect(resolveListenerSessionCookie(header, NAMES)) + .toEqual({ kind: 'forward', header }); + }); + + it('forwards a legacy-only session unchanged for rollback compatibility', () => { + const header = `cart=1; ${NAMES.legacy}=${VALUE}`; + expect(resolveListenerSessionCookie(header, NAMES)) + .toEqual({ kind: 'forward', header }); + }); + + it('forwards a byte-identical dual pair unchanged', () => { + const header = `${NAMES.canonical}=${VALUE}; ${NAMES.legacy}=${VALUE}`; + expect(resolveListenerSessionCookie(header, NAMES)) + .toEqual({ kind: 'forward', header }); + }); + + it('rejects a canonical-only session instead of relabelling it as legacy', () => { + expect(resolveListenerSessionCookie( + `cart=1; ${NAMES.canonical}=${VALUE}; theme=dark`, + NAMES, + )).toEqual({ kind: 'reject', status: 401 }); + }); + + it('rejects conflicting canonical/legacy values instead of arbitrating', () => { + const header = `cart=1; ${NAMES.canonical}=${OTHER_VALUE}; ${NAMES.legacy}=${VALUE}`; + expect(resolveListenerSessionCookie(header, NAMES)) + .toEqual({ kind: 'reject', status: 400 }); + }); + + it('rejects duplicate cookies of either name instead of letting first-wins decide', () => { + for (const header of [ + `${NAMES.canonical}=${VALUE}; ${NAMES.canonical}=${VALUE}`, + `${NAMES.canonical}=${VALUE}; ${NAMES.canonical}=${OTHER_VALUE}; ${NAMES.legacy}=${VALUE}`, + `${NAMES.legacy}=${VALUE}; ${NAMES.legacy}=${VALUE}`, + `${NAMES.legacy}=${VALUE}; ${NAMES.legacy}=${OTHER_VALUE}`, + ]) { + expect(resolveListenerSessionCookie(header, NAMES), header) + .toEqual({ kind: 'reject', status: 400 }); + } + }); + + it('rejects malformed percent encoding even alongside a valid cookie', () => { + for (const header of [ + `${NAMES.canonical}=${VALUE.slice(0, 10)}%zz; ${NAMES.legacy}=${VALUE}`, + `${NAMES.canonical}=${VALUE}; ${NAMES.legacy}=${VALUE.slice(0, 10)}%2g`, + `${NAMES.legacy}=${VALUE.slice(0, 10)}%`, + ]) { + expect(resolveListenerSessionCookie(header, NAMES), header) + .toEqual({ kind: 'reject', status: 400 }); + } + }); + + it('rejects empty, non-wire, control-character or oversized session values', () => { + for (const header of [ + `${NAMES.canonical}=; ${NAMES.legacy}=${VALUE}`, + `${NAMES.legacy}==${VALUE}`, + `${NAMES.legacy}=${'a'.repeat(513)}`, + `${NAMES.canonical}=${VALUE}, ${NAMES.canonical}=${OTHER_VALUE}`, + // Control characters embedded in a value never match the wire charset. + `${NAMES.legacy}=${VALUE.slice(0, 5)}\t${VALUE.slice(5)}`, + `${NAMES.canonical}=${VALUE.slice(0, 5)} ${VALUE.slice(5)}; ${NAMES.legacy}=${VALUE}`, + ]) { + const resolution = resolveListenerSessionCookie(header, NAMES); + expect(resolution.kind, header).toBe('reject'); + } + }); + + it('rejects an oversized header carrying a session cookie entirely', () => { + const filler = `filler=${'f'.repeat(9000)}`; + const header = `${NAMES.legacy}=${VALUE}; ${filler}`; + expect(resolveListenerSessionCookie(header, NAMES)) + .toEqual({ kind: 'reject', status: 400 }); + }); + + it('ignores cookies that merely share a prefix with the session names', () => { + const header = `${NAMES.legacy}_backup=${VALUE}; ${NAMES.legacy}=${VALUE}`; + expect(resolveListenerSessionCookie(header, NAMES)) + .toEqual({ kind: 'forward', header }); + }); +}); + +describe('listenerSessionSetCookieMirrors', () => { + it('mirrors a single legacy mint with byte-identical value and attributes', () => { + const emitted = `${SECURE_NAMES.legacy}=${VALUE}; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax; Secure`; + expect(listenerSessionSetCookieMirrors([emitted], SECURE_NAMES)).toEqual([ + `${SECURE_NAMES.canonical}=${VALUE}; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax; Secure`, + ]); + }); + + it('mirrors a single legacy clear as a dual clear with matching scope', () => { + const expired = `${SECURE_NAMES.legacy}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`; + expect(listenerSessionSetCookieMirrors([expired], SECURE_NAMES)).toEqual([ + `${SECURE_NAMES.canonical}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`, + ]); + }); + + it('leaves an already byte-identical dual output untouched', () => { + const emitted = [ + `${SECURE_NAMES.legacy}=${VALUE}; Max-Age=2592000; Path=/; HttpOnly; Secure`, + `${SECURE_NAMES.canonical}=${VALUE}; Max-Age=2592000; Path=/; HttpOnly; Secure`, + ]; + expect(listenerSessionSetCookieMirrors(emitted, SECURE_NAMES)).toEqual([]); + }); + + it('never mirrors OAuth state, PKCE, CSRF or other cookies', () => { + expect(listenerSessionSetCookieMirrors([ + `hb_earlybird.state=${VALUE}; Max-Age=300; Path=/; HttpOnly`, + `hb_earlybird.pkce=${VALUE}; Max-Age=300; Path=/; HttpOnly`, + `${NAMES.legacy}_backup=${VALUE}; Path=/`, + 'unrelated=1; Path=/', + ], NAMES)).toEqual([]); + }); + + it('fails ambiguous output instead of appending another canonical', () => { + const mint = `${NAMES.legacy}=${VALUE}; Path=/; HttpOnly`; + const ambiguous: string[][] = [ + // More than one same-name mutation. + [mint, `${NAMES.legacy}=${OTHER_VALUE}; Path=/; HttpOnly`], + [mint, `${NAMES.canonical}=${VALUE}; Path=/; HttpOnly`, `${NAMES.canonical}=${VALUE}; Path=/; HttpOnly`], + // A canonical mutation without a legacy counterpart. + [`${NAMES.canonical}=${VALUE}; Path=/; HttpOnly`], + // A pair whose values or security attributes differ. + [mint, `${NAMES.canonical}=${OTHER_VALUE}; Path=/; HttpOnly`], + [mint, `${NAMES.canonical}=${VALUE}; Path=/; HttpOnly; Secure`], + ]; + for (const entries of ambiguous) { + expect(listenerSessionSetCookieMirrors(entries, NAMES), entries.join(' | ')) + .toBeNull(); + } + }); +}); + +describe('bridge response wrapper', () => { + it('appends the canonical mirror while preserving status, body and other headers', async () => { + const headers = new Headers({ 'content-type': 'application/json' }); + headers.append('set-cookie', `${NAMES.legacy}=${VALUE}; Path=/; HttpOnly`); + headers.append('set-cookie', 'hb_earlybird.state=abc; Path=/; HttpOnly'); + const response = new Response(JSON.stringify({ ok: true }), { status: 201, headers }); + + const mirrored = mirrorListenerSessionResponse(response, NAMES); + expect(mirrored.status).toBe(201); + expect(mirrored.headers.get('content-type')).toBe('application/json'); + expect(setCookiesOf(mirrored)).toEqual([ + `${NAMES.legacy}=${VALUE}; Path=/; HttpOnly`, + 'hb_earlybird.state=abc; Path=/; HttpOnly', + `${NAMES.canonical}=${VALUE}; Path=/; HttpOnly`, + ]); + await expect(mirrored.json()).resolves.toEqual({ ok: true }); + }); + + it('returns the original response when nothing needs mirroring', () => { + const response = new Response(null, { status: 204 }); + expect(mirrorListenerSessionResponse(response, NAMES)).toBe(response); + }); + + it('replaces ambiguous Better Auth output with a generic 500 and no Set-Cookie', async () => { + const headers = new Headers(); + headers.append('set-cookie', `${NAMES.legacy}=${VALUE}; Path=/; HttpOnly`); + headers.append('set-cookie', `${NAMES.legacy}=${OTHER_VALUE}; Path=/; HttpOnly`); + const response = new Response('secret session body', { status: 200, headers }); + + const mirrored = mirrorListenerSessionResponse(response, NAMES); + expect(mirrored.status).toBe(500); + expect(setCookiesOf(mirrored)).toEqual([]); + await expect(mirrored.text()).resolves.not.toContain('secret session body'); + }); +}); + +describe('listenerSessionAuthHandler', () => { + function mintingHandler() { + return vi.fn(async () => { + const headers = new Headers(); + headers.append('set-cookie', `${NAMES.legacy}=${VALUE}; Path=/; HttpOnly`); + return new Response('ok', { headers }); + }); + } + + it('terminates invalid states before the handler, expiring both names generically', async () => { + const inner = mintingHandler(); + const handler = listenerSessionAuthHandler(inner, NAMES); + + for (const [cookie, status] of [ + [`${NAMES.canonical}=${VALUE}`, 401], + [`${NAMES.legacy}=${VALUE}; ${NAMES.legacy}=${VALUE}`, 400], + [`${NAMES.canonical}=${OTHER_VALUE}; ${NAMES.legacy}=${VALUE}`, 400], + [`${NAMES.legacy}=${VALUE.slice(0, 10)}%zz`, 400], + ] as const) { + const response = await handler(new Request('https://listen.example.test/x', { + headers: { cookie }, + })); + expect(response.status, cookie).toBe(status); + // Exactly the two expiry cookies, never an auth token, and the + // rejected value is never echoed back. + expect(setCookiesOf(response), cookie).toEqual( + listenerSessionClearCookies(NAMES), + ); + const body = await response.text(); + expect(body).not.toContain(VALUE); + expect(body).not.toContain(OTHER_VALUE); + } + expect(inner).not.toHaveBeenCalled(); + }); + + it('expires both names with Secure when the resolved scope is secure', async () => { + const handler = listenerSessionAuthHandler(mintingHandler(), SECURE_NAMES); + const response = await handler(new Request('https://listen.example.test/x', { + headers: { cookie: `${SECURE_NAMES.canonical}=${VALUE}` }, + })); + expect(response.status).toBe(401); + expect(setCookiesOf(response)).toEqual([ + `${SECURE_NAMES.legacy}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`, + `${SECURE_NAMES.canonical}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`, + ]); + }); + + it('stays recoverable: an accepted state after a rejection still reaches the handler', async () => { + const inner = mintingHandler(); + const handler = listenerSessionAuthHandler(inner, NAMES); + + const rejected = await handler(new Request('https://listen.example.test/x', { + headers: { cookie: `${NAMES.canonical}=${VALUE}` }, + })); + expect(rejected.status).toBe(401); + expect(inner).not.toHaveBeenCalled(); + + // The client honoured the dual expiry and retries with no session + // cookie: the minting path runs and mirrors the fresh dual pair. + const response = await handler(new Request('https://listen.example.test/x')); + expect(response.status).toBe(200); + expect(setCookiesOf(response)).toEqual([ + `${NAMES.legacy}=${VALUE}; Path=/; HttpOnly`, + `${NAMES.canonical}=${VALUE}; Path=/; HttpOnly`, + ]); + expect(inner).toHaveBeenCalledOnce(); + }); + + it('forwards accepted states untouched and mirrors the minted pair', async () => { + const seenCookies: (string | null)[] = []; + const handler = listenerSessionAuthHandler(async (request) => { + seenCookies.push(request.headers.get('cookie')); + const headers = new Headers(); + headers.append('set-cookie', `${NAMES.legacy}=${VALUE}; Path=/; HttpOnly`); + return new Response('ok', { headers }); + }, NAMES); + + const dual = `${NAMES.canonical}=${VALUE}; ${NAMES.legacy}=${VALUE}`; + for (const cookie of [null, `${NAMES.legacy}=${VALUE}`, dual]) { + const response = await handler(new Request('https://listen.example.test/x', { + headers: cookie ? { cookie } : {}, + })); + expect(response.status).toBe(200); + expect(setCookiesOf(response)).toEqual([ + `${NAMES.legacy}=${VALUE}; Path=/; HttpOnly`, + `${NAMES.canonical}=${VALUE}; Path=/; HttpOnly`, + ]); + } + expect(seenCookies).toEqual([null, `${NAMES.legacy}=${VALUE}`, dual]); + }); +}); diff --git a/src/lib/listener/session-cookie-bridge.ts b/src/lib/listener/session-cookie-bridge.ts new file mode 100644 index 00000000..e0471c81 --- /dev/null +++ b/src/lib/listener/session-cookie-bridge.ts @@ -0,0 +1,314 @@ +/** + * Canonical Listener session-cookie bridge. + * + * Better Auth remains the sole session authority: it alone mints, signs, + * verifies, rotates and revokes session tokens on the legacy EarlyBird base + * path. The signed cookie value Better Auth emits is opaque and its HMAC does + * not cover the cookie name, so the value is portable verbatim under a second + * name. This module only: + * + * - mirrors every legacy session `Set-Cookie` Better Auth emits onto the + * canonical Listener name with byte-identical value and attributes, and + * - enforces which inbound session-cookie states may reach Better Auth at + * all during the rollback-compatible phase. + * + * Inbound, exactly three states are accepted: no session cookie, exactly one + * legacy-only session cookie, or exactly one canonical plus one byte-identical + * legacy cookie. Everything else — canonical-only, duplicate same-name + * cookies, conflicting pairs, malformed percent encoding or control + * characters, oversized values or an oversized header — terminates with a + * generic 400/401 BEFORE Better Auth is invoked, so no ambiguous request can + * be silently repaired by better-call's first-wins parser or mint a fresh + * session on sign-in/OAuth callback paths. + * + * Every such rejection also expires BOTH exact session cookie names with + * `Max-Age=0` and the resolved secure scope. That is what keeps a + * deploy/rollback/redeploy sequence recoverable: a rollback image's sign-out + * clears only the legacy name, so a stale canonical cookie would otherwise + * 401 forever (or a stale canonical plus a fresh legacy would 400) and no + * accepted auth mutation could ever repair it, because every ambiguous state + * stops before Better Auth. Clearing both names logs the client out but lets + * the next clean sign-in mint a fresh dual pair. The rejected values are + * never echoed, parsed or logged — the expiry cookies carry empty values. + * + * Outbound, mirroring happens only for unambiguous output: exactly one legacy + * session mutation and either no canonical mutation or one byte-identical + * canonical mutation with identical security attributes. Any other shape + * (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. + * + * The value is lexically bounded and validated, but its token contents are + * never decoded, interpreted, re-signed or logged here. OAuth state, PKCE and + * any other non-session cookies pass through untouched in both directions. + */ + +export const LISTENER_SESSION_COOKIE = 'hb_listener_session'; + +const SECURE_COOKIE_PREFIX = '__Secure-'; + +/** Bound cookie-header smuggling without rejecting legitimate other cookies. */ +const MAX_COOKIE_HEADER_LENGTH = 8192; +/** + * A Better Auth signed session value is `encodeURIComponent(token + "." + + * base64 HMAC)` and stays well under 200 characters; 512 leaves headroom for + * token-format changes without accepting junk. + */ +const MAX_SESSION_COOKIE_VALUE_LENGTH = 512; +/** Characters `encodeURIComponent` can leave on the wire; excludes controls. */ +const WIRE_VALUE_CHARSET = /^[A-Za-z0-9\-_.!~*'()%]+$/; +const INVALID_PERCENT_ESCAPE = /%(?![0-9A-Fa-f]{2})/; + +/** + * The cookie scope the bridge must match when it expires session cookies + * itself (rejections). Derived from the session cookie attributes Better + * Auth actually resolved (`getCookies(auth.options).sessionToken.attributes`) + * so an expiry lands on exactly the scope Better Auth minted into. A Domain + * is never invented: only an attribute Better Auth itself resolved is copied. + */ +export type ListenerSessionCookieScope = { + readonly path: string; + readonly httpOnly: boolean; + readonly sameSite: string; + readonly secure: boolean; +}; + +export type ListenerSessionCookieNames = { + /** Resolved session cookie name Better Auth reads and writes. */ + readonly legacy: string; + /** Canonical Listener name carrying the same opaque value. */ + readonly canonical: string; + /** Resolved scope shared by both names; used for bridge-side expiries. */ + readonly scope: ListenerSessionCookieScope; +}; + +/** + * Derives the bridge pair from the session cookie name Better Auth actually + * resolved (via `getCookies(auth.options)`), inheriting its `__Secure-` + * convention so both names always share one security posture. `scope` should + * come from the resolved Better Auth session-cookie attributes; without it + * the secure posture follows the name and Better Auth's documented defaults. + */ +export function listenerSessionCookieNames( + resolvedLegacyName: string, + scope?: Partial, +): ListenerSessionCookieNames { + const secure = resolvedLegacyName.startsWith(SECURE_COOKIE_PREFIX); + return { + legacy: resolvedLegacyName, + canonical: `${secure ? SECURE_COOKIE_PREFIX : ''}${LISTENER_SESSION_COOKIE}`, + scope: { + path: scope?.path ?? '/', + httpOnly: scope?.httpOnly ?? true, + sameSite: scope?.sameSite ?? 'Lax', + secure: scope?.secure ?? secure, + }, + }; +} + +/** + * Builds the dual session-cookie expiry for a bridge-side rejection: both + * exact names with empty values, `Max-Age=0` and the resolved scope, so the + * client is logged out on whichever names it holds and the next clean + * sign-in can mint a fresh dual pair. Never carries a session value and + * never adds a Domain attribute. + */ +export function listenerSessionClearCookies( + names: ListenerSessionCookieNames, +): string[] { + const { path, httpOnly, sameSite, secure } = names.scope; + const attributes = [ + 'Max-Age=0', + `Path=${path}`, + ...(httpOnly ? ['HttpOnly'] : []), + `SameSite=${sameSite}`, + ...(secure ? ['Secure'] : []), + ].join('; '); + return [names.legacy, names.canonical].map((name) => `${name}=; ${attributes}`); +} + +/** + * Strict inbound verdict. `forward` carries the Cookie header to present to + * Better Auth (null when the request had none). `reject` terminates the + * request before Better Auth with a generic status: 401 for a well-formed + * canonical-only credential that is not accepted during this phase, 400 for + * every structurally invalid state. + */ +export type ListenerSessionCookieResolution = + | { readonly kind: 'forward'; readonly header: string | null } + | { readonly kind: 'reject'; readonly status: 400 | 401 }; + +type CookiePart = { + readonly name: string; + readonly value: string; +}; + +function parseCookieParts(header: string): CookiePart[] { + return header.split(';').map((raw) => { + const separator = raw.indexOf('='); + if (separator < 1) return { name: '', value: '' }; + return { + name: raw.slice(0, separator).trim(), + value: raw.slice(separator + 1).trim(), + }; + }); +} + +function wellFormedSessionValue(value: string): boolean { + return value.length > 0 && + value.length <= MAX_SESSION_COOKIE_VALUE_LENGTH && + WIRE_VALUE_CHARSET.test(value) && + !INVALID_PERCENT_ESCAPE.test(value); +} + +/** + * Resolves whether an inbound Cookie header may reach Better Auth under the + * bridge policy. Accepted states are forwarded byte-for-byte (Better Auth + * ignores the canonical name and reads the legacy one); no state is ever + * rewritten or stripped, because repairing an ambiguous header is exactly + * what better-call's first-wins parser would do silently. + */ +export function resolveListenerSessionCookie( + header: string | null, + names: ListenerSessionCookieNames, +): ListenerSessionCookieResolution { + const forward: ListenerSessionCookieResolution = { kind: 'forward', header }; + if (!header) return forward; + + const parts = parseCookieParts(header); + const legacyParts = parts.filter((part) => part.name === names.legacy); + const canonicalParts = parts.filter((part) => part.name === names.canonical); + if (legacyParts.length === 0 && canonicalParts.length === 0) return forward; + + const reject = (status: 400 | 401): ListenerSessionCookieResolution => + ({ kind: 'reject', status }); + + if ( + header.length > MAX_COOKIE_HEADER_LENGTH || + legacyParts.length > 1 || + canonicalParts.length > 1 || + legacyParts.some((part) => !wellFormedSessionValue(part.value)) || + canonicalParts.some((part) => !wellFormedSessionValue(part.value)) + ) return reject(400); + + const legacy = legacyParts[0]; + const canonical = canonicalParts[0]; + // A well-formed canonical credential without its legacy counterpart is + // not accepted during the rollback-compatible phase. + if (!legacy) return reject(401); + // Conflicting values are never arbitrated between. + if (canonical && canonical.value !== legacy.value) return reject(400); + return forward; +} + +/** + * Mirrors the legacy session `Set-Cookie` onto the canonical name with the + * value and all attributes byte-identical, under the strict output policy: + * + * - exactly one legacy mutation and no canonical mutation: one canonical + * mirror is appended (this covers mint, rotation and clear alike); + * - exactly one legacy and one canonical mutation that are byte-identical + * apart from the name: the output is already dual, nothing is appended; + * - no session mutation at all: nothing is appended. + * + * Returns null for every ambiguous shape — repeated same-name mutations, a + * canonical mutation without a legacy counterpart, or a pair whose value or + * security attributes differ. Callers must treat null as an internal failure + * and emit no session cookie at all. Cookies with any other name (OAuth + * state, PKCE, CSRF, unrelated) are never inspected beyond the name match. + */ +export function listenerSessionSetCookieMirrors( + setCookies: readonly string[], + names: ListenerSessionCookieNames, +): string[] | null { + const nameOf = (entry: string): string => { + const separator = entry.indexOf('='); + return separator < 1 ? '' : entry.slice(0, separator); + }; + const legacyMutations = setCookies.filter((entry) => nameOf(entry) === names.legacy); + const canonicalMutations = setCookies.filter((entry) => nameOf(entry) === names.canonical); + + if (legacyMutations.length > 1 || canonicalMutations.length > 1) return null; + const legacy = legacyMutations[0]; + const canonical = canonicalMutations[0]; + if (!legacy) return canonical ? null : []; + if (!canonical) return [`${names.canonical}${legacy.slice(names.legacy.length)}`]; + const identical = canonical.slice(names.canonical.length) === + legacy.slice(names.legacy.length); + return identical ? [] : null; +} + +/** + * Generic rejection: no token or cookie detail, but both exact session + * cookie names are expired so a stale canonical or legacy cookie left by a + * deploy/rollback/redeploy sequence cannot lock the client out of recovery. + */ +function rejectionResponse( + status: 400 | 401, + names: ListenerSessionCookieNames, +): Response { + const headers = new Headers({ + 'content-type': 'application/json', + 'cache-control': 'private, no-store', + }); + for (const clear of listenerSessionClearCookies(names)) { + headers.append('set-cookie', clear); + } + return new Response(JSON.stringify({ error: 'invalid session credentials' }), { + status, + headers, + }); +} + +/** Generic internal failure: ambiguous output must not reach the client. */ +function ambiguousOutputResponse(): Response { + return new Response(JSON.stringify({ error: 'internal error' }), { + status: 500, + headers: { + 'content-type': 'application/json', + 'cache-control': 'private, no-store', + }, + }); +} + +/** + * Applies the outbound bridge policy: appends the canonical mirror of a + * single legacy session `Set-Cookie`, or replaces the whole response with a + * generic 500 when Better Auth's session-cookie output is ambiguous. + */ +export function mirrorListenerSessionResponse( + response: Response, + names: ListenerSessionCookieNames, +): Response { + const mirrors = listenerSessionSetCookieMirrors(response.headers.getSetCookie(), names); + if (mirrors === null) return ambiguousOutputResponse(); + if (mirrors.length === 0) return response; + const headers = new Headers(response.headers); + for (const mirror of mirrors) headers.append('set-cookie', mirror); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +/** + * Wraps a Better Auth handler so every request/response pair crosses the + * bridge. Invalid inbound session-cookie states terminate with a generic + * 400/401 (expiring both session cookie names) before the handler runs, so no + * ambiguous request can mint, rotate or clear a session; ambiguous outbound + * output fails closed with a generic 500 carrying no Set-Cookie at all. The + * wrapped handler stays the only code that touches sessions. + */ +export function listenerSessionAuthHandler( + handler: (request: Request) => Promise, + names: ListenerSessionCookieNames, +): (request: Request) => Promise { + return async (request) => { + const resolution = resolveListenerSessionCookie(request.headers.get('cookie'), names); + if (resolution.kind === 'reject') { + return rejectionResponse(resolution.status, names); + } + return mirrorListenerSessionResponse(await handler(request), names); + }; +} From 7f9d1655426848df1e6dc59876821eecad72fa5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 04:29:13 -0300 Subject: [PATCH 125/376] feat(load): add hardened ten-client external Listener smoke Import and harden the bounded external HLS smoke harness: a dedicated wrapper fixes the ten-client sixty-second staging-smoke plan against the isolated stream origin and refuses a network run without a fresh signed manifest (exact 0600 regular bounded file), a passing external decoded canary and a passing target monitor co-located on one external host. The target monitor evaluates direct scalar stop thresholds, checks Alertmanager and Prometheus independently, and latches a restart/OOM baseline for the exact isolated Listener and origin containers; every missing, ambiguous or non-finite series fails closed. A status guard polls both safety files during the run and aborts the load child exactly once on the first failing or stale check. --- .../external-decoded-canary.mjs | 99 ++++++ .../external-target-monitor.mjs | 64 ++++ tools/early-birds-hls-load/package.json | 2 +- .../policies/listener-staging-smoke-10.json | 30 ++ .../run-staging-smoke.mjs | 171 ++++++++++ tools/early-birds-hls-load/run.mjs | 9 +- .../early-birds-hls-load/src/smoke-guard.mjs | 39 +++ .../early-birds-hls-load/src/smoke-safety.mjs | 244 ++++++++++++++ .../early-birds-hls-load/src/target-probe.mjs | 299 ++++++++++++++++++ 9 files changed, 951 insertions(+), 6 deletions(-) create mode 100644 tools/early-birds-hls-load/external-decoded-canary.mjs create mode 100644 tools/early-birds-hls-load/external-target-monitor.mjs create mode 100644 tools/early-birds-hls-load/policies/listener-staging-smoke-10.json create mode 100644 tools/early-birds-hls-load/run-staging-smoke.mjs create mode 100644 tools/early-birds-hls-load/src/smoke-guard.mjs create mode 100644 tools/early-birds-hls-load/src/smoke-safety.mjs create mode 100644 tools/early-birds-hls-load/src/target-probe.mjs diff --git a/tools/early-birds-hls-load/external-decoded-canary.mjs b/tools/early-birds-hls-load/external-decoded-canary.mjs new file mode 100644 index 00000000..7f6784e6 --- /dev/null +++ b/tools/early-birds-hls-load/external-decoded-canary.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +import { setTimeout as delay } from 'node:timers/promises'; + +import { decodeManifest, parseManifest } from '../../ops/early-birds/canary/canary-exporter.mjs'; +import { assertAllowedUrl } from './src/contracts.mjs'; +import { + EXTERNAL_CANARY_KIND, + EXTERNAL_CANARY_ROLE, + SIGNED_MANIFEST_MAX_BYTES, + SMOKE_ORIGIN, + SMOKE_TARGET_ID, + assertExternalHost, + readPrivateFile, + writePrivateJsonAtomic, +} from './src/smoke-safety.mjs'; + +function option(args, name, fallback = null) { + const index = args.indexOf(name); + if (index < 0) return fallback; + if (!args[index + 1] || args[index + 1].startsWith('--')) throw new Error(`${name} requires a value`); + return args[index + 1]; +} + +async function probe(manifestPath, hostHash) { + const now = new Date(); + const base = { + schemaVersion: 1, + kind: EXTERNAL_CANARY_KIND, + role: EXTERNAL_CANARY_ROLE, + status: 'FAIL', + external: true, + targetId: SMOKE_TARGET_ID, + targetOrigin: SMOKE_ORIGIN, + hostHash, + observedAt: now.toISOString(), + decodedAudio: false, + decodedSeconds: 0, + manifestAgeSeconds: null, + }; + try { + const { text } = await readPrivateFile(manifestPath, 'signed manifest', SIGNED_MANIFEST_MAX_BYTES); + const manifestUrl = text.trim(); + assertAllowedUrl(manifestUrl, [SMOKE_ORIGIN]); + const response = await fetch(manifestUrl, { + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error('manifest unavailable'); + if (response.headers.get('x-harmonic-beacon-environment') !== 'early-birds-staging') { + throw new Error('staging attestation missing'); + } + const manifest = await response.text(); + const { manifestAgeSeconds } = parseManifest(manifest); + await decodeManifest(manifest); + return { + ...base, + status: manifestAgeSeconds <= 18 ? 'PASS' : 'FAIL', + observedAt: new Date().toISOString(), + decodedAudio: true, + decodedSeconds: 6, + manifestAgeSeconds, + }; + } catch { + return { ...base, observedAt: new Date().toISOString() }; + } +} + +async function main() { + const args = process.argv.slice(2); + const known = new Set(['--manifest-url-file', '--status-file', '--interval-ms', '--once']); + for (const argument of args.filter((value) => value.startsWith('--'))) { + if (!known.has(argument)) throw new Error(`unknown option ${argument}`); + } + const manifestPath = option(args, '--manifest-url-file'); + const statusPath = option(args, '--status-file'); + const intervalMs = Number(option(args, '--interval-ms', '30000')); + if (!manifestPath || !statusPath) throw new Error('--manifest-url-file and --status-file are required'); + if (!Number.isSafeInteger(intervalMs) || intervalMs < 5_000 || intervalMs > 30_000) { + throw new Error('--interval-ms must be between 5000 and 30000'); + } + const hostHash = assertExternalHost(); + let stopping = false; + process.once('SIGINT', () => { stopping = true; }); + process.once('SIGTERM', () => { stopping = true; }); + do { + const status = await probe(manifestPath, hostHash); + await writePrivateJsonAtomic(statusPath, status); + process.stdout.write(`External decoded canary: ${status.status}\n`); + if (args.includes('--once')) break; + await delay(intervalMs, undefined, { ref: true }); + } while (!stopping); +} + +main().catch((error) => { + process.stderr.write(`External decoded canary refused: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/tools/early-birds-hls-load/external-target-monitor.mjs b/tools/early-birds-hls-load/external-target-monitor.mjs new file mode 100644 index 00000000..3370b813 --- /dev/null +++ b/tools/early-birds-hls-load/external-target-monitor.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +import { setTimeout as delay } from 'node:timers/promises'; + +import { assertExternalHost, writePrivateJsonAtomic } from './src/smoke-safety.mjs'; +import { createContainerBaseline, loopbackTunnelOrigin, probeMonitor } from './src/target-probe.mjs'; + +function option(args, name, fallback = null) { + const index = args.indexOf(name); + if (index < 0) return fallback; + if (!args[index + 1] || args[index + 1].startsWith('--')) throw new Error(`${name} requires a value`); + return args[index + 1]; +} + +async function main() { + const args = process.argv.slice(2); + const known = new Set([ + '--prometheus-url', '--alertmanager-url', '--status-file', '--interval-ms', '--once', + ]); + for (const argument of args.filter((value) => value.startsWith('--'))) { + if (!known.has(argument)) throw new Error(`unknown option ${argument}`); + } + const statusPath = option(args, '--status-file'); + const prometheus = loopbackTunnelOrigin( + option(args, '--prometheus-url', 'http://127.0.0.1:19090'), + 'Prometheus', + ); + const alertmanager = loopbackTunnelOrigin( + option(args, '--alertmanager-url', 'http://127.0.0.1:19093'), + 'Alertmanager', + ); + const intervalMs = Number(option(args, '--interval-ms', '5000')); + if (!statusPath) throw new Error('--status-file is required'); + if (!Number.isSafeInteger(intervalMs) || intervalMs < 2_000 || intervalMs > 10_000) { + throw new Error('--interval-ms must be between 2000 and 10000'); + } + const hostHash = assertExternalHost(); + const baseline = createContainerBaseline(); + let stopping = false; + process.once('SIGINT', () => { stopping = true; }); + process.once('SIGTERM', () => { stopping = true; }); + // A passing status requires an established restart/OOM baseline verified by + // a later matching sample, so --once deliberately probes twice. + const minimumProbes = args.includes('--once') ? 2 : 1; + let probes = 0; + do { + const status = await probeMonitor({ + prometheusOrigin: prometheus, + alertmanagerOrigin: alertmanager, + hostHash, + baseline, + }); + await writePrivateJsonAtomic(statusPath, status); + process.stdout.write(`External target monitor: ${status.status}\n`); + probes += 1; + if (args.includes('--once') && probes >= minimumProbes) break; + await delay(intervalMs, undefined, { ref: true }); + } while (!stopping); +} + +main().catch((error) => { + process.stderr.write(`External target monitor refused: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/tools/early-birds-hls-load/package.json b/tools/early-birds-hls-load/package.json index 31986533..7bc38b9e 100644 --- a/tools/early-birds-hls-load/package.json +++ b/tools/early-birds-hls-load/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "External, bounded, format-neutral HLS load and soak evidence harness", "scripts": { - "check": "node --check run.mjs && node --check aggregate.mjs && node --check verify-planned.mjs && node --check src/contracts.mjs && node --check src/runner.mjs", + "check": "node --check run.mjs && node --check run-staging-smoke.mjs && node --check external-decoded-canary.mjs && node --check external-target-monitor.mjs && node --check aggregate.mjs && node --check verify-planned.mjs && node --check src/contracts.mjs && node --check src/runner.mjs && node --check src/smoke-safety.mjs && node --check src/target-probe.mjs && node --check src/smoke-guard.mjs", "test": "node --test test/*.test.mjs" }, "engines": { diff --git a/tools/early-birds-hls-load/policies/listener-staging-smoke-10.json b/tools/early-birds-hls-load/policies/listener-staging-smoke-10.json new file mode 100644 index 00000000..3632d9ad --- /dev/null +++ b/tools/early-birds-hls-load/policies/listener-staging-smoke-10.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "targets": [ + { + "id": "listener-staging-smoke-10", + "environment": "staging", + "production": false, + "attestation": { + "header": "x-harmonic-beacon-environment", + "value": "early-birds-staging" + }, + "origins": [ + "https://stream.harmonicbeacon.com" + ], + "limits": { + "maxClients": 10, + "maxRampPerSecond": 2, + "maxSoakSeconds": 60, + "maxShardCount": 1, + "minManifestIntervalMs": 3000, + "maxInflightPerShard": 16, + "maxSegmentsPerPoll": 6, + "maxRequestsPerSecond": 28, + "maxManifestBytes": 262144, + "maxSegmentBytes": 4194304, + "maxClockOffsetMs": 100 + } + } + ] +} diff --git a/tools/early-birds-hls-load/run-staging-smoke.mjs b/tools/early-birds-hls-load/run-staging-smoke.mjs new file mode 100644 index 00000000..73b04d0d --- /dev/null +++ b/tools/early-birds-hls-load/run-staging-smoke.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { buildPlan, selectTarget } from './src/contracts.mjs'; +import { startStatusGuard } from './src/smoke-guard.mjs'; +import { + SIGNED_MANIFEST_MAX_BYTES, + SMOKE_TARGET_ID, + readJsonStatus, + readPrivateFile, + validateNetworkSmokePreconditions, +} from './src/smoke-safety.mjs'; + +const toolRoot = dirname(fileURLToPath(import.meta.url)); +const policyPath = resolve(toolRoot, 'policies/listener-staging-smoke-10.json'); +const profilesPath = resolve(toolRoot, 'profiles.json'); + +function option(args, name, fallback = null) { + const index = args.indexOf(name); + if (index < 0) return fallback; + if (!args[index + 1] || args[index + 1].startsWith('--')) throw new Error(`${name} requires a value`); + return args[index + 1]; +} + +function printHelp() { + process.stdout.write(`Usage: + node tools/early-birds-hls-load/run-staging-smoke.mjs \\ + --run-id RUN_ID --start-at UTC --evidence PATH --dry-run + +For the exact ten-client network smoke, remove --dry-run and add: + --manifest-url-file FILE --canary-status-file FILE --monitor-status-file FILE \\ + --clock-offset-ms NUMBER --confirm "EXACT DRY-RUN CONFIRMATION" + +This wrapper fixes the target, origin, profile and shard count. It cannot run +more than ten clients or target any host other than the isolated stream origin. +`); +} + +async function readDocuments() { + const [policy, profiles] = await Promise.all([ + readFile(policyPath, 'utf8').then(JSON.parse), + readFile(profilesPath, 'utf8').then(JSON.parse), + ]); + return { policy, profiles }; +} + +async function readPreconditions({ + manifestPath, + canaryPath, + monitorPath, + plan, + target, + checkFileFreshness = false, +}) { + const [{ text, details }, canaryStatus, monitorStatus] = await Promise.all([ + readPrivateFile(manifestPath, 'signed manifest', SIGNED_MANIFEST_MAX_BYTES), + readJsonStatus(canaryPath, 'external decoded canary status'), + readJsonStatus(monitorPath, 'target monitor status'), + ]); + const values = { + plan, + target, + manifest: { + value: text.trim(), + writtenAtMs: checkFileFreshness ? details.mtimeMs : null, + }, + canaryStatus, + monitorStatus, + }; + validateNetworkSmokePreconditions(values); + return values; +} + +async function main() { + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h')) { + printHelp(); + return; + } + const known = new Set([ + '--run-id', '--start-at', '--evidence', '--dry-run', '--manifest-url-file', + '--canary-status-file', '--monitor-status-file', '--clock-offset-ms', '--confirm', + ]); + for (const argument of args.filter((value) => value.startsWith('--'))) { + if (!known.has(argument)) throw new Error(`unknown option ${argument}`); + } + const dryRun = args.includes('--dry-run'); + const runId = option(args, '--run-id'); + const startAt = option(args, '--start-at'); + const evidence = option(args, '--evidence'); + if (!runId || !startAt || !evidence) throw new Error('--run-id, --start-at and --evidence are required'); + + const { policy, profiles } = await readDocuments(); + const target = selectTarget(policy, SMOKE_TARGET_ID); + const profile = profiles.profiles?.['staging-smoke']; + const plan = buildPlan({ + runId, + profileName: 'staging-smoke', + profile, + target, + shardIndex: 0, + shardCount: 1, + startAt, + networkRun: !dryRun, + }); + + const childArgs = [ + resolve(toolRoot, 'run.mjs'), + '--profiles', profilesPath, + '--policy', policyPath, + '--target', SMOKE_TARGET_ID, + '--profile', 'staging-smoke', + '--run-id', runId, + '--start-at', startAt, + '--shard-index', '0', + '--shard-count', '1', + '--evidence', resolve(evidence), + ]; + let guard = null; + let child; + if (dryRun) { + childArgs.push('--dry-run'); + } else { + const manifestPath = option(args, '--manifest-url-file'); + const canaryPath = option(args, '--canary-status-file'); + const monitorPath = option(args, '--monitor-status-file'); + const clockOffset = option(args, '--clock-offset-ms'); + const confirmation = option(args, '--confirm'); + if (!manifestPath || !canaryPath || !monitorPath || clockOffset === null || !confirmation) { + throw new Error('network smoke requires signed manifest, external canary, target monitor, clock offset and confirmation'); + } + await readPreconditions({ + manifestPath, + canaryPath, + monitorPath, + plan, + target, + checkFileFreshness: true, + }); + childArgs.push( + '--manifest-url-file', resolve(manifestPath), + '--clock-offset-ms', clockOffset, + '--confirm', confirmation, + '--external-generator', + ); + guard = startStatusGuard({ + check: () => readPreconditions({ manifestPath, canaryPath, monitorPath, plan, target }), + onAbort: () => child?.kill('SIGINT'), + }); + } + + child = spawn(process.execPath, childArgs, { stdio: 'inherit', env: process.env }); + // A guard abort between precondition validation and spawn is delivered here, + // so the child can never run unguarded after a failed status. + if (guard?.aborted) child.kill('SIGINT'); + const exitCode = await new Promise((resolveExit, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolveExit(code ?? (signal ? 130 : 1))); + }); + if (guard) guard.stop(); + process.exitCode = exitCode; +} + +main().catch((error) => { + process.stderr.write(`Ten-client smoke refused: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/tools/early-birds-hls-load/run.mjs b/tools/early-birds-hls-load/run.mjs index 4642e155..1545d46b 100644 --- a/tools/early-birds-hls-load/run.mjs +++ b/tools/early-birds-hls-load/run.mjs @@ -1,12 +1,13 @@ #!/usr/bin/env node import { constants } from 'node:fs'; -import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { buildPlan, selectTarget, sha256 } from './src/contracts.mjs'; import { plannedEvidence, runShard } from './src/runner.mjs'; +import { SIGNED_MANIFEST_MAX_BYTES, readPrivateFile } from './src/smoke-safety.mjs'; const toolRoot = dirname(fileURLToPath(import.meta.url)); @@ -88,10 +89,8 @@ async function assertNewFile(path) { } async function readManifestUrl(path) { - const details = await stat(path); - if (!details.isFile()) throw new Error('manifest URL source must be a regular file'); - if ((details.mode & 0o077) !== 0) throw new Error('manifest URL source must not be group/world accessible'); - const value = (await readFile(path, 'utf8')).trim(); + const { text } = await readPrivateFile(path, 'manifest URL source', SIGNED_MANIFEST_MAX_BYTES); + const value = text.trim(); if (!value || value.includes('\n') || value.includes('\r')) { throw new Error('manifest URL source must contain exactly one URL'); } diff --git a/tools/early-birds-hls-load/src/smoke-guard.mjs b/tools/early-birds-hls-load/src/smoke-guard.mjs new file mode 100644 index 00000000..f8a3618d --- /dev/null +++ b/tools/early-birds-hls-load/src/smoke-guard.mjs @@ -0,0 +1,39 @@ +// Polls the external safety statuses while the load child runs. On the first +// failed or stale check it aborts exactly once, promptly and deterministically, +// and never prints status contents or error details. +export function startStatusGuard({ + check, + onAbort, + intervalMs = 2_000, + setIntervalImpl = setInterval, + clearIntervalImpl = clearInterval, + writeImpl = (line) => process.stderr.write(line), +}) { + let aborted = false; + let busy = false; + const abort = () => { + if (aborted) return; + aborted = true; + clearIntervalImpl(timer); + writeImpl('Smoke safety status became stale or failed; aborting without printing sensitive details.\n'); + onAbort(); + }; + const tick = () => { + if (aborted || busy) return; + busy = true; + Promise.resolve() + .then(check) + .catch(abort) + .finally(() => { busy = false; }); + }; + const timer = setIntervalImpl(tick, intervalMs); + timer.unref?.(); + return { + stop() { + clearIntervalImpl(timer); + }, + get aborted() { + return aborted; + }, + }; +} diff --git a/tools/early-birds-hls-load/src/smoke-safety.mjs b/tools/early-birds-hls-load/src/smoke-safety.mjs new file mode 100644 index 00000000..79ae0d10 --- /dev/null +++ b/tools/early-birds-hls-load/src/smoke-safety.mjs @@ -0,0 +1,244 @@ +import { constants } from 'node:fs'; +import { chmod, rename, writeFile, access, readFile, lstat } from 'node:fs/promises'; +import { hostname } from 'node:os'; + +import { assertAllowedUrl, sha256 } from './contracts.mjs'; + +export const SMOKE_TARGET_ID = 'listener-staging-smoke-10'; +export const SMOKE_ORIGIN = 'https://stream.harmonicbeacon.com'; +export const EXTERNAL_CANARY_KIND = 'harmonic-beacon-external-decoded-canary'; +export const TARGET_MONITOR_KIND = 'harmonic-beacon-listener-target-monitor'; +export const EXTERNAL_CANARY_ROLE = 'decoded-canary'; +export const TARGET_MONITOR_ROLE = 'target-monitor'; +export const CANARY_MAX_AGE_MS = 45_000; +export const MONITOR_MAX_AGE_MS = 15_000; +export const SIGNED_MANIFEST_MAX_BYTES = 4_096; +export const STATUS_MAX_BYTES = 16_384; + +// Immediate stop thresholds for the ten-client smoke. The monitor applies them +// and the wrapper re-verifies them, so neither side can weaken them silently. +export const MONITOR_THRESHOLDS = Object.freeze({ + maxCpuUsedRatio: 0.5, + maxMemoryUsedRatio: 0.7, + minRootFreeRatio: 0.3, + maxEgressBitsPerSecond: 1_500_000_000, + maxTcpRetransmitRatio: 0.01, + maxInterfaceErrorsDrops: 0, +}); + +const MAX_ALERT_COUNT = 10_000; +const MAX_RESTART_OBSERVATIONS = 1_000; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function parseTimestamp(value, field) { + const milliseconds = Date.parse(String(value ?? '')); + assert(Number.isFinite(milliseconds), `${field} must be a valid timestamp`); + return milliseconds; +} + +export async function readPrivateFile(path, label, maxBytes = STATUS_MAX_BYTES) { + assert(typeof path === 'string' && path.length > 0, `${label} file is required`); + assert(Number.isSafeInteger(maxBytes) && maxBytes > 0, `${label} size bound is invalid`); + try { + await access(path, constants.R_OK); + } catch { + throw new Error(`cannot read ${label} file`); + } + // lstat never follows a symlink, so a symlink, device or other non-regular + // source is refused instead of being silently dereferenced. + const details = await lstat(path); + assert(details.isFile(), `${label} source must be a regular file, not a symlink or device`); + assert((details.mode & 0o777) === 0o600, `${label} source mode must be exactly 0600`); + assert(details.size <= maxBytes, `${label} source exceeds the ${maxBytes} byte bound`); + return { text: await readFile(path, 'utf8'), details }; +} + +export function validateFreshSignedManifest({ + value, + allowedOrigins, + nowMs = Date.now(), + requiredThroughMs, + writtenAtMs = null, +}) { + assert(typeof value === 'string' && value.trim() === value && value.length > 0, + 'signed manifest file must contain exactly one URL'); + assert(!value.includes('\n') && !value.includes('\r'), + 'signed manifest file must contain exactly one URL'); + const url = assertAllowedUrl(value, allowedOrigins); + assert(/^\/v1\/hls\/[a-z0-9][a-z0-9._-]{0,127}\/live\.m3u8$/.test(url.pathname), + 'signed manifest path is not the canonical media-playlist path'); + const keys = [...url.searchParams.keys()].sort(); + assert(JSON.stringify(keys) === JSON.stringify(['exp', 'sig']), + 'signed manifest must contain only exp and sig'); + const expiresAtSeconds = Number(url.searchParams.get('exp')); + const signature = url.searchParams.get('sig') ?? ''; + assert(Number.isSafeInteger(expiresAtSeconds), 'signed manifest expiry is invalid'); + assert(/^[A-Za-z0-9_-]{32,}$/.test(signature), 'signed manifest signature shape is invalid'); + const expiresAtMs = expiresAtSeconds * 1000; + assert(expiresAtMs > nowMs, 'signed manifest is expired'); + assert(expiresAtMs >= requiredThroughMs, 'signed manifest expires before smoke recovery begins'); + assert(expiresAtMs <= nowMs + 130_000, 'signed manifest expiry exceeds the bounded origin TTL'); + if (writtenAtMs !== null) { + assert(writtenAtMs <= nowMs + 5_000 && nowMs - writtenAtMs <= 30_000, + 'signed manifest file was not refreshed in the last 30 seconds'); + } + return { origin: url.origin, expiresAtMs }; +} + +function validateCommonStatus(status, { kind, role, maxAgeMs, nowMs, label }) { + assert(status && typeof status === 'object', `${label} status is required`); + assert(status.schemaVersion === 1 && status.kind === kind, `${label} status schema is invalid`); + assert(status.role === role, `${label} status role is invalid`); + assert(status.status === 'PASS', `${label} is not passing`); + assert(status.external === true, `${label} must attest external execution`); + assert(status.targetId === SMOKE_TARGET_ID && status.targetOrigin === SMOKE_ORIGIN, + `${label} target does not match the ten-client smoke`); + assert(/^[a-f0-9]{12}$/.test(String(status.hostHash ?? '')), + `${label} host fingerprint is invalid`); + const observedAtMs = parseTimestamp(status.observedAt, `${label}.observedAt`); + assert(observedAtMs <= nowMs + 5_000, `${label} status is from the future`); + assert(nowMs - observedAtMs <= maxAgeMs, `${label} status is stale`); +} + +function boundedCount(value, field, maximum) { + assert(Number.isSafeInteger(value) && value >= 0 && value <= maximum, + `${field} must be a bounded count`); +} + +function boundedRatio(value, field, minimum, maximum) { + assert(typeof value === 'number' && Number.isFinite(value) + && value >= minimum && value <= maximum, + `${field} must be a finite number between ${minimum} and ${maximum}`); +} + +export function validateExternalCanaryStatus(status, nowMs = Date.now()) { + validateCommonStatus(status, { + kind: EXTERNAL_CANARY_KIND, + role: EXTERNAL_CANARY_ROLE, + maxAgeMs: CANARY_MAX_AGE_MS, + nowMs, + label: 'external decoded canary', + }); + assert(status.decodedAudio === true, 'external decoded canary did not decode audio'); + boundedCount(status.decodedSeconds, 'external decoded canary decoded seconds', 600); + assert(status.decodedSeconds >= 6, 'external decoded canary did not decode six seconds'); + assert(Number.isFinite(status.manifestAgeSeconds) + && status.manifestAgeSeconds >= 0 && status.manifestAgeSeconds <= 18, + 'external decoded canary manifest is stale'); + return status; +} + +export function validateTargetMonitorStatus(status, nowMs = Date.now()) { + validateCommonStatus(status, { + kind: TARGET_MONITOR_KIND, + role: TARGET_MONITOR_ROLE, + maxAgeMs: MONITOR_MAX_AGE_MS, + nowMs, + label: 'target monitor', + }); + assert(status.listenerReady === true && status.streamHealthy === true + && status.liveReady === true && status.originUp === true && status.canaryOk === true, + 'target monitor has a failed health check'); + assert(status.alertmanagerReady === true, 'target monitor Alertmanager is not ready'); + boundedCount(status.activeAlerts, 'target monitor active alerts', MAX_ALERT_COUNT); + boundedCount(status.prometheusFiringAlerts, 'target monitor firing alerts', MAX_ALERT_COUNT); + assert(status.activeAlerts === 0 && status.prometheusFiringAlerts === 0, + 'target monitor reports active alerts'); + // The declared thresholds must equal the fixed smoke policy exactly; a + // monitor running with weaker limits can never produce an accepted PASS. + const declared = status.thresholds; + assert(declared && typeof declared === 'object' + && Object.keys(declared).length === Object.keys(MONITOR_THRESHOLDS).length + && Object.entries(MONITOR_THRESHOLDS).every(([key, value]) => declared[key] === value), + 'target monitor thresholds differ from the fixed smoke policy'); + // Bounded direct telemetry at the immediate stop thresholds. A monitor PASS + // with an out-of-policy value is rejected here as well. + boundedRatio(status.cpuUsedRatio, 'target monitor CPU used ratio', 0, 1); + assert(status.cpuUsedRatio < MONITOR_THRESHOLDS.maxCpuUsedRatio, + 'target monitor CPU used ratio reached the immediate stop threshold'); + boundedRatio(status.memoryUsedRatio, 'target monitor memory used ratio', 0, 1); + assert(status.memoryUsedRatio < MONITOR_THRESHOLDS.maxMemoryUsedRatio, + 'target monitor memory used ratio reached the immediate stop threshold'); + boundedRatio(status.rootFreeRatio, 'target monitor root free ratio', 0, 1); + assert(status.rootFreeRatio > MONITOR_THRESHOLDS.minRootFreeRatio, + 'target monitor root free ratio reached the immediate stop threshold'); + boundedRatio(status.egressBitsPerSecond, 'target monitor egress bits/s', 0, 1e12); + assert(status.egressBitsPerSecond < MONITOR_THRESHOLDS.maxEgressBitsPerSecond, + 'target monitor egress reached the immediate stop threshold'); + boundedRatio(status.tcpRetransmitRatio, 'target monitor TCP retransmit ratio', 0, 1); + assert(status.tcpRetransmitRatio < MONITOR_THRESHOLDS.maxTcpRetransmitRatio, + 'target monitor TCP retransmit ratio reached the immediate stop threshold'); + boundedCount(status.interfaceErrorsDrops, 'target monitor interface errors/drops', 1e9); + assert(status.interfaceErrorsDrops === MONITOR_THRESHOLDS.maxInterfaceErrorsDrops, + 'target monitor reports interface errors or drops'); + // A status that has not established and verified a restart/OOM baseline can + // never be accepted; nor can one that observed a restart or OOM kill. + assert(status.restartBaselineEstablished === true, + 'target monitor has not established a restart/OOM baseline'); + boundedCount(status.containerRestartsObserved, 'target monitor container restarts', + MAX_RESTART_OBSERVATIONS); + boundedCount(status.oomEventsDelta, 'target monitor OOM events', MAX_RESTART_OBSERVATIONS); + assert(status.containerRestartsObserved === 0 && status.oomEventsDelta === 0, + 'target monitor observed a container restart or OOM event'); + return status; +} + +export function validateNetworkSmokePreconditions({ + plan, + target, + manifest, + canaryStatus, + monitorStatus, + nowMs = Date.now(), + expectedHostHash = assertExternalHost(), +}) { + assert(plan?.profileName === 'staging-smoke', 'dedicated smoke accepts only staging-smoke'); + assert(plan.profile?.clients === 10 && plan.profile?.soakSeconds === 60 + && plan.profile?.rampPerSecond === 2 && plan.shardCount === 1, + 'dedicated smoke plan must remain exactly ten clients for sixty seconds'); + assert(target?.id === SMOKE_TARGET_ID && target?.limits?.maxClients === 10, + 'dedicated smoke policy is not capped at ten clients'); + assert(target.origins.length === 1 && target.origins[0] === SMOKE_ORIGIN, + 'dedicated smoke origin allowlist differs'); + validateFreshSignedManifest({ + ...manifest, + allowedOrigins: target.origins, + nowMs, + requiredThroughMs: Date.parse(plan.endAt) + 10_000, + }); + validateExternalCanaryStatus(canaryStatus, nowMs); + validateTargetMonitorStatus(monitorStatus, nowMs); + // The canary and monitor status files are read from local disk, so both + // safety producers must be co-located with this wrapper on one external + // host. Identical fingerprints on a second host would fail this check too. + assert(/^[a-f0-9]{12}$/.test(expectedHostHash), 'local host fingerprint is invalid'); + assert(canaryStatus.hostHash === expectedHostHash + && monitorStatus.hostHash === expectedHostHash, + 'external canary and target monitor must run on this external host'); +} + +export async function readJsonStatus(path, label) { + const { text } = await readPrivateFile(path, label); + try { + return JSON.parse(text); + } catch { + throw new Error(`${label} file is not valid JSON`); + } +} + +export function assertExternalHost(host = hostname()) { + const labels = String(host).trim().toLowerCase().replace(/\.+$/, '').split('.'); + assert(!labels.some((label) => label === 'mona' || label.startsWith('mona-')), + 'external safety process is forbidden from mona'); + return sha256(String(host)).slice(0, 12); +} + +export async function writePrivateJsonAtomic(path, value) { + const temporaryPath = `${path}.tmp-${process.pid}`; + await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'w' }); + await chmod(temporaryPath, 0o600); + await rename(temporaryPath, path); +} diff --git a/tools/early-birds-hls-load/src/target-probe.mjs b/tools/early-birds-hls-load/src/target-probe.mjs new file mode 100644 index 00000000..8df11671 --- /dev/null +++ b/tools/early-birds-hls-load/src/target-probe.mjs @@ -0,0 +1,299 @@ +import { + MONITOR_THRESHOLDS, + SMOKE_ORIGIN, + SMOKE_TARGET_ID, + TARGET_MONITOR_KIND, + TARGET_MONITOR_ROLE, +} from './smoke-safety.mjs'; + +export const LISTENER_READY_URL = 'https://earlybirds-staging.harmonicbeacon.com/api/health/ready'; +export const STREAM_HEALTH_URL = `${SMOKE_ORIGIN}/healthz`; +export const LIVE_READY_URL = 'https://live.harmonicbeacon.com/api/health/ready'; +export const STAGING_ATTESTATION = 'early-birds-staging'; + +// Exact isolated staging containers on the target host, from the checked-in +// earlybirds-preview Compose project. Never participant or event containers. +export const LISTENER_CONTAINER = 'earlybirds-preview-listener-1'; +export const ORIGIN_CONTAINER = 'earlybirds-preview-beacon-stream-1'; + +// Instant Prometheus queries. Every query must yield exactly one vector +// element; an empty, duplicated or non-finite result fails the probe. Host +// scalars aggregate in PromQL so multi-device cardinality cannot silently +// pick one interface or core. +export const MONITOR_QUERIES = Object.freeze({ + cpuUsedRatio: '1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m]))', + memoryUsedRatio: '1 - (sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes))', + rootFreeRatio: 'sum(node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"})' + + ' / sum(node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"})', + egressBitsPerSecond: + 'sum(rate(node_network_transmit_bytes_total{device!~"lo|docker.*|veth.*"}[2m])) * 8', + tcpRetransmitRatio: 'sum(rate(node_netstat_Tcp_RetransSegs[2m]))' + + ' / clamp_min(sum(rate(node_netstat_Tcp_OutSegs[2m])), 1)', + interfaceErrorsDrops: + 'sum(node_network_transmit_errs_total{device!~"lo|docker.*|veth.*"})' + + ' + sum(node_network_receive_errs_total{device!~"lo|docker.*|veth.*"})' + + ' + sum(node_network_transmit_drop_total{device!~"lo|docker.*|veth.*"})' + + ' + sum(node_network_receive_drop_total{device!~"lo|docker.*|veth.*"})', + originUp: 'up{job="beacon-stream"}', + canaryOk: 'beacon_stream_canary_ok', + listenerStartSeconds: `container_start_time_seconds{name="${LISTENER_CONTAINER}"}`, + originStartSeconds: `container_start_time_seconds{name="${ORIGIN_CONTAINER}"}`, + listenerOomEvents: `container_oom_events_total{name="${LISTENER_CONTAINER}"}`, + originOomEvents: `container_oom_events_total{name="${ORIGIN_CONTAINER}"}`, +}); + +export function loopbackTunnelOrigin(value, label) { + let url; + try { + url = new URL(String(value)); + } catch { + throw new Error(`${label} must be an uncredentialed loopback SSH tunnel origin`); + } + if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) + || url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error(`${label} must be an uncredentialed loopback SSH tunnel origin`); + } + return url.origin; +} + +async function fetchJson(fetchImpl, url) { + const response = await fetchImpl(url, { + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) throw new Error('monitoring query failed'); + try { + return await response.json(); + } catch { + throw new Error('monitoring query returned malformed JSON'); + } +} + +// Exactly one unambiguous, finite vector element or a thrown refusal. Missing, +// duplicated, NaN and +/-Inf results can never become a passing scalar. +export async function queryPrometheusScalar(fetchImpl, prometheusOrigin, query) { + const url = `${prometheusOrigin}/api/v1/query?query=${encodeURIComponent(query)}`; + const body = await fetchJson(fetchImpl, url); + if (body?.status !== 'success' || body?.data?.resultType !== 'vector' + || !Array.isArray(body.data.result)) { + throw new Error('Prometheus query response is malformed'); + } + if (body.data.result.length !== 1) { + throw new Error('Prometheus query result is missing or ambiguous'); + } + const point = body.data.result[0]?.value; + if (!Array.isArray(point) || point.length !== 2) { + throw new Error('Prometheus query sample is malformed'); + } + const value = Number(point[1]); + if (!Number.isFinite(value)) throw new Error('Prometheus query sample is not finite'); + return value; +} + +export async function fetchPrometheusFiringAlerts(fetchImpl, prometheusOrigin) { + const body = await fetchJson(fetchImpl, `${prometheusOrigin}/api/v1/alerts`); + if (body?.status !== 'success' || !Array.isArray(body?.data?.alerts)) { + throw new Error('Prometheus alerts response is malformed'); + } + return body.data.alerts.filter((alert) => alert?.state === 'firing').length; +} + +// Alertmanager is queried directly; its health is never inferred from +// Prometheus. Any malformed, missing or failed response fails closed. +export async function fetchAlertmanagerState(fetchImpl, alertmanagerOrigin) { + const status = await fetchJson(fetchImpl, `${alertmanagerOrigin}/api/v2/status`); + const ready = status?.cluster?.status === 'ready'; + const alerts = await fetchJson( + fetchImpl, + `${alertmanagerOrigin}/api/v2/alerts?active=true&silenced=false&inhibited=false`, + ); + if (!Array.isArray(alerts) || alerts.length > 10_000) { + throw new Error('Alertmanager alerts response is malformed'); + } + let activeAlerts = 0; + for (const alert of alerts) { + const state = alert?.status?.state; + if (typeof state !== 'string') throw new Error('Alertmanager alert entry is malformed'); + const silenced = Array.isArray(alert.status.silencedBy) && alert.status.silencedBy.length > 0; + const inhibited = Array.isArray(alert.status.inhibitedBy) && alert.status.inhibitedBy.length > 0; + if (state === 'active' && !silenced && !inhibited) activeAlerts += 1; + } + return { ready, activeAlerts }; +} + +async function fetchHealth(fetchImpl, url, stagingAttestation) { + try { + const response = await fetchImpl(url, { + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) return false; + if (stagingAttestation + && response.headers.get('x-harmonic-beacon-environment') !== stagingAttestation) return false; + return true; + } catch { + return false; + } +} + +// In-process restart/OOM baseline for the exact isolated Listener and origin +// containers. The first sample only establishes the baseline; a PASS requires +// a later sample that still matches it. A changed start timestamp or an +// increased OOM counter is latched and fails every subsequent probe. +export function createContainerBaseline() { + return { + established: false, + verified: false, + startSeconds: null, + oomEvents: null, + restartsObserved: 0, + oomEventsDelta: 0, + }; +} + +export function observeContainerBaseline(baseline, sample) { + const starts = [sample.listenerStartSeconds, sample.originStartSeconds]; + const ooms = [sample.listenerOomEvents, sample.originOomEvents]; + if (!baseline.established) { + baseline.established = true; + baseline.startSeconds = starts; + baseline.oomEvents = ooms; + return; + } + starts.forEach((value, index) => { + if (value !== baseline.startSeconds[index]) { + baseline.restartsObserved += 1; + baseline.startSeconds[index] = value; + baseline.verified = false; + } + }); + ooms.forEach((value, index) => { + const delta = value - baseline.oomEvents[index]; + if (delta > 0) { + baseline.oomEventsDelta += delta; + baseline.verified = false; + } + if (delta !== 0) baseline.oomEvents[index] = value; + }); + if (baseline.restartsObserved === 0 && baseline.oomEventsDelta === 0) { + baseline.verified = true; + } +} + +function withinImmediateThresholds(scalars) { + return scalars.cpuUsedRatio < MONITOR_THRESHOLDS.maxCpuUsedRatio + && scalars.memoryUsedRatio < MONITOR_THRESHOLDS.maxMemoryUsedRatio + && scalars.rootFreeRatio > MONITOR_THRESHOLDS.minRootFreeRatio + && scalars.egressBitsPerSecond < MONITOR_THRESHOLDS.maxEgressBitsPerSecond + && scalars.tcpRetransmitRatio < MONITOR_THRESHOLDS.maxTcpRetransmitRatio + && scalars.interfaceErrorsDrops === MONITOR_THRESHOLDS.maxInterfaceErrorsDrops + && scalars.canaryOk === 1 + && scalars.originUp === 1; +} + +// One monitor sample. The returned status carries only bounded numerics, +// booleans, the hashed host fingerprint, timing and the fixed thresholds; +// never raw Prometheus/Alertmanager payloads, labels, URLs or hostnames. +export async function probeMonitor({ + fetchImpl = fetch, + prometheusOrigin, + alertmanagerOrigin, + hostHash, + baseline, + healthUrls = { + listenerReady: LISTENER_READY_URL, + streamHealthy: STREAM_HEALTH_URL, + liveReady: LIVE_READY_URL, + }, +}) { + const base = { + schemaVersion: 1, + kind: TARGET_MONITOR_KIND, + role: TARGET_MONITOR_ROLE, + status: 'FAIL', + external: true, + targetId: SMOKE_TARGET_ID, + targetOrigin: SMOKE_ORIGIN, + hostHash, + observedAt: new Date().toISOString(), + listenerReady: false, + streamHealthy: false, + liveReady: false, + originUp: false, + canaryOk: false, + prometheusFiringAlerts: null, + alertmanagerReady: false, + activeAlerts: null, + cpuUsedRatio: null, + memoryUsedRatio: null, + rootFreeRatio: null, + egressBitsPerSecond: null, + tcpRetransmitRatio: null, + interfaceErrorsDrops: null, + restartBaselineEstablished: baseline.verified, + containerRestartsObserved: baseline.restartsObserved, + oomEventsDelta: baseline.oomEventsDelta, + thresholds: { ...MONITOR_THRESHOLDS }, + }; + try { + const scalarNames = Object.keys(MONITOR_QUERIES); + const [listenerReady, streamHealthy, liveReady, alertmanager, firingAlerts, ...scalarValues] = ( + await Promise.all([ + fetchHealth(fetchImpl, healthUrls.listenerReady, STAGING_ATTESTATION), + fetchHealth(fetchImpl, healthUrls.streamHealthy, STAGING_ATTESTATION), + fetchHealth(fetchImpl, healthUrls.liveReady, null), + fetchAlertmanagerState(fetchImpl, alertmanagerOrigin), + fetchPrometheusFiringAlerts(fetchImpl, prometheusOrigin), + ...scalarNames.map((name) => queryPrometheusScalar( + fetchImpl, + prometheusOrigin, + MONITOR_QUERIES[name], + )), + ]) + ); + const scalars = Object.fromEntries(scalarNames.map((name, index) => [name, scalarValues[index]])); + observeContainerBaseline(baseline, { + listenerStartSeconds: scalars.listenerStartSeconds, + originStartSeconds: scalars.originStartSeconds, + listenerOomEvents: scalars.listenerOomEvents, + originOomEvents: scalars.originOomEvents, + }); + const passed = listenerReady && streamHealthy && liveReady + && alertmanager.ready && alertmanager.activeAlerts === 0 && firingAlerts === 0 + && withinImmediateThresholds(scalars) + && baseline.verified + && baseline.restartsObserved === 0 && baseline.oomEventsDelta === 0; + return { + ...base, + status: passed ? 'PASS' : 'FAIL', + observedAt: new Date().toISOString(), + listenerReady, + streamHealthy, + liveReady, + originUp: scalars.originUp === 1, + canaryOk: scalars.canaryOk === 1, + prometheusFiringAlerts: firingAlerts, + alertmanagerReady: alertmanager.ready, + activeAlerts: alertmanager.activeAlerts, + cpuUsedRatio: scalars.cpuUsedRatio, + memoryUsedRatio: scalars.memoryUsedRatio, + rootFreeRatio: scalars.rootFreeRatio, + egressBitsPerSecond: scalars.egressBitsPerSecond, + tcpRetransmitRatio: scalars.tcpRetransmitRatio, + interfaceErrorsDrops: scalars.interfaceErrorsDrops, + restartBaselineEstablished: baseline.verified, + containerRestartsObserved: baseline.restartsObserved, + oomEventsDelta: baseline.oomEventsDelta, + }; + } catch { + return { + ...base, + observedAt: new Date().toISOString(), + restartBaselineEstablished: baseline.verified, + containerRestartsObserved: baseline.restartsObserved, + oomEventsDelta: baseline.oomEventsDelta, + }; + } +} From 696735785f21310c4be5f61dd0b81e10041a6959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 04:29:13 -0300 Subject: [PATCH 126/376] test(load): cover monitor thresholds, Alertmanager, baseline and abort Local fake-HTTP coverage for missing, ambiguous and non-finite Prometheus series, Alertmanager and health failures, exact threshold edges, restart/OOM baseline latching, 0600 private-input enforcement, producer/consumer host-fingerprint consistency and the deterministic single abort. --- .../test/smoke-guard.test.mjs | 89 ++++++ .../test/smoke-safety.test.mjs | 290 ++++++++++++++++++ .../test/target-monitor.test.mjs | 269 ++++++++++++++++ 3 files changed, 648 insertions(+) create mode 100644 tools/early-birds-hls-load/test/smoke-guard.test.mjs create mode 100644 tools/early-birds-hls-load/test/smoke-safety.test.mjs create mode 100644 tools/early-birds-hls-load/test/target-monitor.test.mjs diff --git a/tools/early-birds-hls-load/test/smoke-guard.test.mjs b/tools/early-birds-hls-load/test/smoke-guard.test.mjs new file mode 100644 index 00000000..d450fb23 --- /dev/null +++ b/tools/early-birds-hls-load/test/smoke-guard.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { startStatusGuard } from '../src/smoke-guard.mjs'; + +function fakeTimer() { + const handles = []; + return { + setIntervalImpl: (fn) => { + const handle = { fn, cleared: false, unref() { return this; } }; + handles.push(handle); + return handle; + }, + clearIntervalImpl: (handle) => { handle.cleared = true; }, + tick: (index = 0) => { + if (!handles[index].cleared) handles[index].fn(); + }, + handles, + }; +} + +test('guard aborts exactly once on the first failed check', async () => { + const timers = fakeTimer(); + let aborts = 0; + let checks = 0; + const guard = startStatusGuard({ + check: async () => { + checks += 1; + throw new Error('stale'); + }, + onAbort: () => { aborts += 1; }, + ...timers, + writeImpl: () => {}, + }); + timers.tick(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + timers.tick(); + timers.tick(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(aborts, 1); + assert.equal(checks, 1); + assert.equal(guard.aborted, true); + assert.equal(timers.handles[0].cleared, true); +}); + +test('concurrent overlapping checks cannot double-abort', async () => { + const timers = fakeTimer(); + let aborts = 0; + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const guard = startStatusGuard({ + check: async () => { + await gate; + throw new Error('failed'); + }, + onAbort: () => { aborts += 1; }, + ...timers, + writeImpl: () => {}, + }); + timers.tick(); + timers.tick(); // ignored while the first check is still in flight + release(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(aborts, 1); + assert.equal(guard.aborted, true); +}); + +test('passing checks never abort and stop() leaves the guard quiet', async () => { + const timers = fakeTimer(); + let aborts = 0; + let checks = 0; + const guard = startStatusGuard({ + check: async () => { checks += 1; }, + onAbort: () => { aborts += 1; }, + ...timers, + writeImpl: () => {}, + }); + timers.tick(); + await new Promise((resolve) => setImmediate(resolve)); + timers.tick(); + await new Promise((resolve) => setImmediate(resolve)); + guard.stop(); + timers.tick(); + assert.equal(aborts, 0); + assert.equal(checks, 2); + assert.equal(guard.aborted, false); +}); diff --git a/tools/early-birds-hls-load/test/smoke-safety.test.mjs b/tools/early-birds-hls-load/test/smoke-safety.test.mjs new file mode 100644 index 00000000..8caebbcc --- /dev/null +++ b/tools/early-birds-hls-load/test/smoke-safety.test.mjs @@ -0,0 +1,290 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { chmod, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { buildPlan, selectTarget } from '../src/contracts.mjs'; +import { + EXTERNAL_CANARY_KIND, + EXTERNAL_CANARY_ROLE, + MONITOR_THRESHOLDS, + SMOKE_ORIGIN, + SMOKE_TARGET_ID, + TARGET_MONITOR_KIND, + TARGET_MONITOR_ROLE, + assertExternalHost, + readJsonStatus, + readPrivateFile, + validateFreshSignedManifest, + validateNetworkSmokePreconditions, +} from '../src/smoke-safety.mjs'; + +const toolRoot = new URL('../', import.meta.url); +const policy = JSON.parse(await readFile(new URL('policies/listener-staging-smoke-10.json', toolRoot))); +const profiles = JSON.parse(await readFile(new URL('profiles.json', toolRoot))); +const TEST_HOST_HASH = '0123456789ab'; + +function fixture(nowMs = Date.now()) { + const target = selectTarget(policy, SMOKE_TARGET_ID); + const plan = buildPlan({ + runId: 'smoke-safety-test', + profileName: 'staging-smoke', + profile: profiles.profiles['staging-smoke'], + target, + shardIndex: 0, + shardCount: 1, + startAt: new Date(nowMs + 20_000).toISOString(), + networkRun: true, + nowMs, + }); + const common = { + schemaVersion: 1, + status: 'PASS', + external: true, + targetId: SMOKE_TARGET_ID, + targetOrigin: SMOKE_ORIGIN, + hostHash: TEST_HOST_HASH, + observedAt: new Date(nowMs).toISOString(), + }; + return { + plan, + target, + manifest: { + value: `${SMOKE_ORIGIN}/v1/hls/approved-v2/live.m3u8?exp=${Math.floor((nowMs + 120_000) / 1000)}&sig=${'a'.repeat(43)}`, + writtenAtMs: nowMs, + }, + canaryStatus: { + ...common, + kind: EXTERNAL_CANARY_KIND, + role: EXTERNAL_CANARY_ROLE, + decodedAudio: true, + decodedSeconds: 6, + manifestAgeSeconds: 1, + }, + monitorStatus: { + ...common, + kind: TARGET_MONITOR_KIND, + role: TARGET_MONITOR_ROLE, + listenerReady: true, + streamHealthy: true, + liveReady: true, + originUp: true, + canaryOk: true, + prometheusFiringAlerts: 0, + alertmanagerReady: true, + activeAlerts: 0, + cpuUsedRatio: 0.2, + memoryUsedRatio: 0.4, + rootFreeRatio: 0.6, + egressBitsPerSecond: 500_000_000, + tcpRetransmitRatio: 0.001, + interfaceErrorsDrops: 0, + restartBaselineEstablished: true, + containerRestartsObserved: 0, + oomEventsDelta: 0, + thresholds: { ...MONITOR_THRESHOLDS }, + }, + nowMs, + expectedHostHash: TEST_HOST_HASH, + }; +} + +test('dedicated policy can authorize exactly ten clients and no more', () => { + const target = selectTarget(policy, SMOKE_TARGET_ID); + assert.deepEqual(target.origins, [SMOKE_ORIGIN]); + assert.equal(target.limits.maxClients, 10); + assert.equal(target.limits.maxShardCount, 1); + assert.equal(target.limits.maxSoakSeconds, 60); + assert.equal(target.limits.maxRequestsPerSecond, 28); + assert.throws(() => buildPlan({ + runId: 'smoke-eleven-refused', + profileName: 'unsafe-smoke', + profile: { ...profiles.profiles['staging-smoke'], clients: 11 }, + target, + shardIndex: 0, + shardCount: 1, + startAt: '2030-01-01T00:00:00.000Z', + networkRun: false, + }), /clients exceed the target policy/); +}); + +test('fresh signed manifest is exact-origin, canonical, recently written and long-lived enough', () => { + const values = fixture(); + assert.doesNotThrow(() => validateNetworkSmokePreconditions(values)); + assert.throws(() => validateFreshSignedManifest({ + ...values.manifest, + value: values.manifest.value.replace(SMOKE_ORIGIN, 'https://attacker.example'), + allowedOrigins: values.target.origins, + nowMs: values.nowMs, + requiredThroughMs: Date.parse(values.plan.endAt) + 10_000, + }), /escaped the exact target allowlist/); + assert.throws(() => validateFreshSignedManifest({ + ...values.manifest, + value: `${SMOKE_ORIGIN}/v1/hls/approved-v2/live.m3u8?exp=${Math.floor((values.nowMs - 1) / 1000)}&sig=${'a'.repeat(43)}`, + allowedOrigins: values.target.origins, + nowMs: values.nowMs, + requiredThroughMs: Date.parse(values.plan.endAt) + 10_000, + }), /expired/); + assert.throws(() => validateFreshSignedManifest({ + ...values.manifest, + writtenAtMs: values.nowMs - 31_000, + allowedOrigins: values.target.origins, + nowMs: values.nowMs, + requiredThroughMs: Date.parse(values.plan.endAt) + 10_000, + }), /not refreshed/); +}); + +test('network smoke cannot start or continue without fresh external canary and monitor', () => { + const values = fixture(); + assert.throws(() => validateNetworkSmokePreconditions({ ...values, canaryStatus: undefined }), + /external decoded canary status is required/); + assert.throws(() => validateNetworkSmokePreconditions({ ...values, monitorStatus: undefined }), + /target monitor status is required/); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + canaryStatus: { ...values.canaryStatus, observedAt: new Date(values.nowMs - 46_000).toISOString() }, + }), /canary status is stale/); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, activeAlerts: 1 }, + }), /active alerts/); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, liveReady: false }, + }), /failed health check/); +}); + +test('safety producers refuse execution on mona', () => { + assert.throws(() => assertExternalHost('mona'), /forbidden from mona/); + assert.throws(() => assertExternalHost('mona-01.example'), /forbidden from mona/); + assert.match(assertExternalHost('daimonmatrix'), /^[a-f0-9]{12}$/); +}); + +test('dedicated CLI refuses a network run before spawning load when safety files are absent', async () => { + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'listener-smoke-refusal-')); + const startAt = new Date(Date.now() + 20_000).toISOString(); + const child = spawn(process.execPath, [ + new URL('../run-staging-smoke.mjs', import.meta.url).pathname, + '--run-id', 'missing-safety-files', + '--start-at', startAt, + '--evidence', path.join(temporaryRoot, 'must-not-exist.json'), + '--clock-offset-ms', '1', + '--confirm', 'not-reached', + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', (chunk) => { output += chunk; }); + child.stderr.on('data', (chunk) => { output += chunk; }); + const [exitCode] = await once(child, 'exit'); + assert.equal(exitCode, 1); + assert.match(output, /requires signed manifest, external canary, target monitor/); + await assert.rejects(readFile(path.join(temporaryRoot, 'must-not-exist.json')), /ENOENT/); +}); + +test('wrapper rejects Alertmanager failures, firing alerts and threshold-breaching telemetry', () => { + const values = fixture(); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, alertmanagerReady: false }, + }), /Alertmanager is not ready/); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, activeAlerts: 1 }, + }), /active alerts/); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, prometheusFiringAlerts: 1 }, + }), /active alerts/); + for (const [field, value, pattern] of [ + ['cpuUsedRatio', MONITOR_THRESHOLDS.maxCpuUsedRatio, /CPU used ratio/], + ['memoryUsedRatio', MONITOR_THRESHOLDS.maxMemoryUsedRatio, /memory used ratio/], + ['rootFreeRatio', MONITOR_THRESHOLDS.minRootFreeRatio, /root free ratio/], + ['egressBitsPerSecond', MONITOR_THRESHOLDS.maxEgressBitsPerSecond, /egress/], + ['tcpRetransmitRatio', MONITOR_THRESHOLDS.maxTcpRetransmitRatio, /retransmit/], + ['interfaceErrorsDrops', 1, /interface errors or drops/], + ['cpuUsedRatio', Number.NaN, /finite number/], + ['egressBitsPerSecond', Number.POSITIVE_INFINITY, /finite number/], + ]) { + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, [field]: value }, + }), pattern, `${field}=${value} must be rejected`); + } + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { + ...values.monitorStatus, + thresholds: { ...MONITOR_THRESHOLDS, maxCpuUsedRatio: 0.9 }, + }, + }), /thresholds differ/); +}); + +test('wrapper rejects a monitor without a verified restart/OOM baseline or with observed restarts', () => { + const values = fixture(); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, restartBaselineEstablished: false }, + }), /has not established a restart\/OOM baseline/); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, containerRestartsObserved: 1 }, + }), /restart or OOM event/); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, oomEventsDelta: 2 }, + }), /restart or OOM event/); +}); + +test('status roles, target identity and co-located host fingerprints are enforced', () => { + const values = fixture(); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + canaryStatus: { ...values.canaryStatus, role: TARGET_MONITOR_ROLE }, + }), /canary status role is invalid/); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, targetOrigin: 'https://live.harmonicbeacon.com' }, + }), /target does not match/); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + canaryStatus: { ...values.canaryStatus, hostHash: 'ffffffffffff' }, + }), /must run on this external host/); + assert.throws(() => validateNetworkSmokePreconditions({ + ...values, + monitorStatus: { ...values.monitorStatus, hostHash: 'ffffffffffff' }, + }), /must run on this external host/); +}); + +test('private inputs require a regular file with exact mode 0600 and bounded size', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'listener-smoke-input-')); + const accepted = path.join(root, 'accepted.json'); + await writeFile(accepted, '{"ok":true}\n', { mode: 0o600 }); + const { text } = await readPrivateFile(accepted, 'test input'); + assert.equal(text, '{"ok":true}\n'); + + for (const mode of [0o640, 0o644, 0o400, 0o700]) { + const candidate = path.join(root, `mode-${mode.toString(8)}.json`); + await writeFile(candidate, '{}\n', { mode: 0o600 }); + await chmod(candidate, mode); + await assert.rejects(readPrivateFile(candidate, 'test input'), /exactly 0600/); + } + + const linked = path.join(root, 'linked.json'); + await symlink(accepted, linked); + await assert.rejects(readPrivateFile(linked, 'test input'), /regular file/); + + const directory = path.join(root, 'directory'); + await mkdir(directory, { mode: 0o700 }); + await assert.rejects(readPrivateFile(directory, 'test input'), /regular file/); + + const oversized = path.join(root, 'oversized.json'); + await writeFile(oversized, `${'x'.repeat(20_000)}\n`, { mode: 0o600 }); + await assert.rejects(readPrivateFile(oversized, 'test input'), /byte bound/); + await assert.rejects(readJsonStatus(oversized, 'test status'), /byte bound/); + await assert.rejects( + readPrivateFile(oversized, 'signed manifest', 4_096), + /byte bound/, + ); +}); diff --git a/tools/early-birds-hls-load/test/target-monitor.test.mjs b/tools/early-birds-hls-load/test/target-monitor.test.mjs new file mode 100644 index 00000000..c66d12c6 --- /dev/null +++ b/tools/early-birds-hls-load/test/target-monitor.test.mjs @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { MONITOR_THRESHOLDS, validateTargetMonitorStatus } from '../src/smoke-safety.mjs'; +import { + LISTENER_READY_URL, + LIVE_READY_URL, + MONITOR_QUERIES, + STREAM_HEALTH_URL, + createContainerBaseline, + loopbackTunnelOrigin, + probeMonitor, +} from '../src/target-probe.mjs'; + +const TEST_HOST_HASH = '0123456789ab'; +const PROMETHEUS = 'http://127.0.0.1:19090'; +const ALERTMANAGER = 'http://127.0.0.1:19093'; +const HEALTH_URLS = [LISTENER_READY_URL, STREAM_HEALTH_URL, LIVE_READY_URL]; + +const DEFAULT_SCALARS = { + cpuUsedRatio: 0.2, + memoryUsedRatio: 0.4, + rootFreeRatio: 0.6, + egressBitsPerSecond: 500_000_000, + tcpRetransmitRatio: 0.001, + interfaceErrorsDrops: 0, + originUp: 1, + canaryOk: 1, + listenerStartSeconds: 1_700_000_000, + originStartSeconds: 1_700_000_100, + listenerOomEvents: 0, + originOomEvents: 0, +}; + +function fakeJsonResponse(body, { ok = true, status = 200 } = {}) { + return { + ok, + status, + headers: { get: () => null }, + json: async () => body, + }; +} + +function vectorResult(values) { + return { + status: 'success', + data: { + resultType: 'vector', + result: values.map((value) => ({ metric: {}, value: [1_700_000_000, String(value)] })), + }, + }; +} + +// Fully in-process fake: routes by URL and reads `state` live so a test can +// change telemetry between probes. No socket is ever opened. +function makeFetch(state) { + return async (url) => { + const text = String(url); + if (state.failUrls?.(text)) throw new Error('injected connection failure'); + if (HEALTH_URLS.some((healthUrl) => text.startsWith(healthUrl))) { + const ok = state.healthOk !== false; + return { + ok, + status: ok ? 200 : 503, + headers: { + get: (name) => (name === 'x-harmonic-beacon-environment' && ok + ? 'early-birds-staging' + : null), + }, + }; + } + if (text.includes('/api/v2/status')) { + if (state.alertmanagerStatusError) return fakeJsonResponse({}, { ok: false, status: 500 }); + return fakeJsonResponse(state.alertmanagerStatus ?? { cluster: { status: 'ready' } }); + } + if (text.includes('/api/v2/alerts')) { + return fakeJsonResponse(state.alertmanagerAlerts ?? []); + } + if (text.includes('/api/v1/alerts')) { + return fakeJsonResponse({ + status: 'success', + data: { alerts: state.firingAlerts ?? [] }, + }); + } + if (text.includes('/api/v1/query')) { + const query = new URL(text).searchParams.get('query'); + const name = Object.keys(MONITOR_QUERIES).find((key) => MONITOR_QUERIES[key] === query); + assert.ok(name, `unexpected query ${query}`); + if (state.scalarResults && name in state.scalarResults) { + return fakeJsonResponse(state.scalarResults[name]); + } + const value = state.scalars && name in state.scalars + ? state.scalars[name] + : DEFAULT_SCALARS[name]; + return fakeJsonResponse(vectorResult([value])); + } + throw new Error(`unexpected URL ${text}`); + }; +} + +async function probeTwice(state = {}) { + const baseline = createContainerBaseline(); + const options = { + fetchImpl: makeFetch(state), + prometheusOrigin: PROMETHEUS, + alertmanagerOrigin: ALERTMANAGER, + hostHash: TEST_HOST_HASH, + baseline, + }; + const first = await probeMonitor(options); + const second = await probeMonitor(options); + return { first, second, baseline }; +} + +test('loopback tunnel origins are validated exactly', () => { + assert.equal(loopbackTunnelOrigin('http://127.0.0.1:19090', 'Prometheus'), 'http://127.0.0.1:19090'); + assert.equal(loopbackTunnelOrigin('http://[::1]:19093/', 'Alertmanager'), 'http://[::1]:19093'); + for (const bad of [ + 'https://127.0.0.1:19090', + 'http://user:pass@127.0.0.1:19090', + 'http://mona.example:9090', + 'http://127.0.0.1:19090/api', + 'http://127.0.0.1:19090?x=1', + 'not-a-url', + ]) { + assert.throws(() => loopbackTunnelOrigin(bad, 'Prometheus'), /loopback SSH tunnel origin/); + } +}); + +test('a healthy target passes only after the restart/OOM baseline is verified', async () => { + const { first, second } = await probeTwice(); + assert.equal(first.status, 'FAIL'); + assert.equal(first.restartBaselineEstablished, false); + assert.throws(() => validateTargetMonitorStatus(first), /not passing|baseline/); + assert.equal(second.status, 'PASS'); + assert.equal(second.restartBaselineEstablished, true); + assert.equal(second.containerRestartsObserved, 0); + assert.equal(second.oomEventsDelta, 0); + assert.deepEqual(second.thresholds, { ...MONITOR_THRESHOLDS }); + assert.doesNotThrow(() => validateTargetMonitorStatus(second)); + // The status must not leak hostnames, URLs, labels or raw payloads. + const serialized = JSON.stringify(second); + assert.ok(!/harmonicbeacon|127\.0\.0\.1|19090|19093|container_|node_/.test(serialized.replace( + /https:\/\/stream\.harmonicbeacon\.com/, + '', + ))); +}); + +test('Alertmanager unavailable, unready, firing or malformed fails closed', async () => { + const unavailable = await probeTwice({ failUrls: (url) => url.includes('/api/v2/') }); + assert.equal(unavailable.second.status, 'FAIL'); + assert.equal(unavailable.second.alertmanagerReady, false); + + const settling = await probeTwice({ alertmanagerStatus: { cluster: { status: 'settling' } } }); + assert.equal(settling.second.status, 'FAIL'); + assert.equal(settling.second.alertmanagerReady, false); + + const firing = await probeTwice({ + alertmanagerAlerts: [{ status: { state: 'active', silencedBy: [], inhibitedBy: [] } }], + }); + assert.equal(firing.second.status, 'FAIL'); + assert.equal(firing.second.activeAlerts, 1); + + const silenced = await probeTwice({ + alertmanagerAlerts: [{ status: { state: 'active', silencedBy: ['abc'], inhibitedBy: [] } }], + }); + assert.equal(silenced.second.status, 'PASS'); + assert.equal(silenced.second.activeAlerts, 0); + + const notAnArray = await probeTwice({ alertmanagerAlerts: { alerts: [] } }); + assert.equal(notAnArray.second.status, 'FAIL'); + + const malformedEntry = await probeTwice({ alertmanagerAlerts: [{ unexpected: true }] }); + assert.equal(malformedEntry.second.status, 'FAIL'); + + const failedStatus = await probeTwice({ alertmanagerStatusError: true }); + assert.equal(failedStatus.second.status, 'FAIL'); + + const malformedStatus = await probeTwice({ alertmanagerStatus: [1, 2, 3] }); + assert.equal(malformedStatus.second.status, 'FAIL'); +}); + +test('firing Prometheus rules fail even when Alertmanager is quiet', async () => { + const { second } = await probeTwice({ firingAlerts: [{ state: 'firing' }, { state: 'pending' }] }); + assert.equal(second.status, 'FAIL'); + assert.equal(second.prometheusFiringAlerts, 1); +}); + +test('missing, ambiguous or non-finite scalar results fail closed', async () => { + for (const name of Object.keys(DEFAULT_SCALARS)) { + const missing = await probeTwice({ scalarResults: { [name]: vectorResult([]) } }); + assert.equal(missing.second.status, 'FAIL', `${name} missing must fail`); + const ambiguous = await probeTwice({ scalarResults: { [name]: vectorResult([1, 1]) } }); + assert.equal(ambiguous.second.status, 'FAIL', `${name} ambiguous must fail`); + const notFinite = await probeTwice({ scalarResults: { [name]: vectorResult(['NaN']) } }); + assert.equal(notFinite.second.status, 'FAIL', `${name} NaN must fail`); + const infinite = await probeTwice({ scalarResults: { [name]: vectorResult(['+Inf']) } }); + assert.equal(infinite.second.status, 'FAIL', `${name} Inf must fail`); + } +}); + +test('immediate stop thresholds are enforced at their exact edges', async () => { + const edges = [ + ['cpuUsedRatio', MONITOR_THRESHOLDS.maxCpuUsedRatio, 0.499], + ['memoryUsedRatio', MONITOR_THRESHOLDS.maxMemoryUsedRatio, 0.699], + ['rootFreeRatio', MONITOR_THRESHOLDS.minRootFreeRatio, 0.301], + ['egressBitsPerSecond', MONITOR_THRESHOLDS.maxEgressBitsPerSecond, 1_499_999_999], + ['tcpRetransmitRatio', MONITOR_THRESHOLDS.maxTcpRetransmitRatio, 0.0099], + ['interfaceErrorsDrops', 1, 0], + ['canaryOk', 0, 1], + ['originUp', 0, 1], + ]; + for (const [name, failingValue, passingValue] of edges) { + const failing = await probeTwice({ scalars: { [name]: failingValue } }); + assert.equal(failing.second.status, 'FAIL', `${name}=${failingValue} must fail`); + const passing = await probeTwice({ scalars: { [name]: passingValue } }); + assert.equal(passing.second.status, 'PASS', `${name}=${passingValue} must pass`); + } +}); + +test('a changed container start timestamp is latched as a restart', async () => { + const state = { scalars: {} }; + const baseline = createContainerBaseline(); + const options = { + fetchImpl: makeFetch(state), + prometheusOrigin: PROMETHEUS, + alertmanagerOrigin: ALERTMANAGER, + hostHash: TEST_HOST_HASH, + baseline, + }; + await probeMonitor(options); + const clean = await probeMonitor(options); + assert.equal(clean.status, 'PASS'); + state.scalars.listenerStartSeconds = DEFAULT_SCALARS.listenerStartSeconds + 60; + const restarted = await probeMonitor(options); + assert.equal(restarted.status, 'FAIL'); + assert.equal(restarted.containerRestartsObserved, 1); + assert.throws(() => validateTargetMonitorStatus(restarted), /restart or OOM|not passing/); + // The latch persists: later samples cannot quietly re-pass. + const latched = await probeMonitor(options); + assert.equal(latched.status, 'FAIL'); + assert.equal(latched.containerRestartsObserved, 1); +}); + +test('an increased container OOM counter fails closed with a bounded delta', async () => { + const state = { scalars: {} }; + const baseline = createContainerBaseline(); + const options = { + fetchImpl: makeFetch(state), + prometheusOrigin: PROMETHEUS, + alertmanagerOrigin: ALERTMANAGER, + hostHash: TEST_HOST_HASH, + baseline, + }; + await probeMonitor(options); + assert.equal((await probeMonitor(options)).status, 'PASS'); + state.scalars.originOomEvents = 2; + const oomed = await probeMonitor(options); + assert.equal(oomed.status, 'FAIL'); + assert.equal(oomed.oomEventsDelta, 2); +}); + +test('a degraded health endpoint fails the probe without sensitive detail', async () => { + const { second } = await probeTwice({ healthOk: false }); + assert.equal(second.status, 'FAIL'); + assert.equal(second.listenerReady, false); + assert.equal(second.streamHealthy, false); + assert.equal(second.liveReady, false); +}); From a0071f492a32302f3c3696fbdd92d5a00c76cdbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 04:29:13 -0300 Subject: [PATCH 127/376] docs(ops): document first external Listener smoke and mona preflight Add the runbook for the only approved first network step, including the fail-closed restart/OOM preflight. Record the verified mona runtime state: only the root cgroup is exposed for the cAdvisor container metrics, so the exact Listener/origin queries return empty vectors and the monitor keeps reporting FAIL. The separately approved, cAdvisor-only recreate and exact per-container series verification are a hard blocker before any load. --- docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md | 259 ++++++++++++++++++ tools/early-birds-hls-load/README.md | 24 +- 2 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md 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..3d0d9beb --- /dev/null +++ b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md @@ -0,0 +1,259 @@ +# First external Listener HLS smoke + +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 +containers: + +```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 cAdvisor `container_start_time_seconds` and +`container_oom_events_total` for exactly +`earlybirds-preview-listener-1` and `earlybirds-preview-beacon-stream-1`, each +resolving to exactly one finite series. Before scheduling load, run the monitor +with `--once` (it probes twice: baseline plus verification) and confirm a +`PASS` status with `restartBaselineEstablished: true`, +`containerRestartsObserved: 0` and `oomEventsDelta: 0`. If those container +metrics are absent or ambiguous, the monitor keeps reporting `FAIL` — that is +the exact preflight blocker. Resolve it on the observability stack first; 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, and the wrapper rejects such a status. + +### Actual-mona preflight: per-container cAdvisor series + +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/.../mount-id`. The cause is that the +cAdvisor `/:/rootfs` bind lacked recursive slave propagation after the Docker +storage topology changed, so storage-driver mounts never became visible inside +the container. The checked-in fix mounts `/:/rootfs:ro,rslave` (the same +propagation node-exporter already uses); it changes only the isolated +observability cAdvisor container and no event or runtime container. + +Until that fix is running on `mona`, the ten-client smoke **cannot start**: the +monitor's exact container queries return empty vectors, every probe reports +`FAIL`, and the wrapper refuses the network run. There is no fallback — no +Docker CLI/API read, no inferred zero, no weakened restart/OOM evidence. + +Before any load, the operator must: + +1. Obtain explicit human approval and recreate **only** cAdvisor on `mona` + (see `ops/early-birds/runbook/README.md`, "cAdvisor mount propagation"). + Do not recreate Prometheus, Alertmanager, node-exporter, the canary or any + event/runtime container. +2. Through the loopback SSH tunnel, confirm each of the four exact queries + returns exactly one finite series, not an empty vector: + + ```bash + for query in \ + 'container_start_time_seconds{name="earlybirds-preview-listener-1"}' \ + 'container_start_time_seconds{name="earlybirds-preview-beacon-stream-1"}' \ + 'container_oom_events_total{name="earlybirds-preview-listener-1"}' \ + 'container_oom_events_total{name="earlybirds-preview-beacon-stream-1"}' + do + curl -fsS 'http://127.0.0.1:19090/api/v1/query' --get --data-urlencode "query=$query" + done + ``` +3. Only then run the monitor `--once` preflight above. An empty vector at any + step is a hard blocker: stop and resolve observability first; 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: + +```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/tools/early-birds-hls-load/README.md b/tools/early-birds-hls-load/README.md index d74fef80..bbec118d 100644 --- a/tools/early-birds-hls-load/README.md +++ b/tools/early-birds-hls-load/README.md @@ -46,7 +46,8 @@ runtime code. - Whole-run request-error and fetch-miss tolerances cannot exceed 10%; derived rolling-window gates are capped at 20% and in-run circuit breakers at 25%, regardless of a custom profile. -- The signed manifest URL lives only in a group/world-inaccessible file. That +- The signed manifest URL lives only in a regular file at exactly mode `0600` + (never a symlink or device), bounded in size. That file is refreshed from disk at least once per second so a separate approved control plane can rotate short-lived URLs without giving the harness a signing key. - Every staging shard records an externally measured UTC clock offset within @@ -103,6 +104,22 @@ contains runtime measurements or claims a network request. See [`docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md`](../../docs/ops/EARLY_BIRDS_HLS_LOAD_SOAK.md) for the distributed procedure, stop conditions and evidence interpretation. +The first real request is deliberately narrower than the general harness. Use +`run-staging-smoke.mjs` and the checked-in `listener-staging-smoke-10` policy; +that wrapper fixes the exact origin, ten clients, one shard and sixty-second +soak. A staging network invocation also requires a fresh private signed +manifest file plus continuously refreshed external decoded-canary and target +monitor status files, all exactly mode `0600` regular files produced on the +same external host as the wrapper. The target monitor queries Alertmanager +(`--alertmanager-url`, default `http://127.0.0.1:19093`) and Prometheus +(`--prometheus-url`, default `http://127.0.0.1:19090`) through separate +uncredentialed loopback SSH tunnels, evaluates the immediate host thresholds +directly and maintains an in-process restart/OOM baseline for the isolated +Listener and origin containers. The wrapper polls both status files every two +seconds and aborts the load child exactly once on the first failing or stale +check. See +[`LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md`](../../docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md). + ## Development verification ```bash @@ -115,5 +132,6 @@ ranges and declared gaps. It rejects master playlists, encrypted media and LL-HLS parts instead of silently undercounting them; this says nothing about the opaque media encoding. -The test suite uses only a tiny loopback synthetic origin. It never contacts -staging, production, DNS or external media. +The test suite uses only a tiny loopback synthetic origin plus in-process fake +HTTP responders for the Prometheus/Alertmanager monitor probes. It never +contacts staging, production, DNS or external media. From f1ad42666a608a5ee89792d244d2b0f758172786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 05:17:20 -0300 Subject: [PATCH 128/376] test(load): serialize bounded Listener smoke runs --- docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md | 104 +++++++++------ ops/early-birds/runbook/README.md | 57 +++++++++ tools/early-birds-hls-load/package.json | 2 +- .../run-staging-smoke.mjs | 119 ++++++++++++------ .../src/network-run-lock.mjs | 89 +++++++++++++ .../early-birds-hls-load/src/smoke-guard.mjs | 16 ++- .../early-birds-hls-load/src/smoke-safety.mjs | 10 ++ .../test/network-run-lock.test.mjs | 76 +++++++++++ .../test/run-staging-smoke.test.mjs | 117 +++++++++++++++++ .../test/smoke-guard.test.mjs | 26 ++++ 10 files changed, 536 insertions(+), 80 deletions(-) create mode 100644 tools/early-birds-hls-load/src/network-run-lock.mjs create mode 100644 tools/early-birds-hls-load/test/network-run-lock.test.mjs create mode 100644 tools/early-birds-hls-load/test/run-staging-smoke.test.mjs diff --git a/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md index 3d0d9beb..d6228080 100644 --- a/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md +++ b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md @@ -1,5 +1,11 @@ # First external Listener HLS smoke +**Status: code-complete but runtime-blocked. This smoke is not ready to +execute.** The fail-closed harness, monitor, canary and policy are complete and +tested, but the restart/OOM preflight blocker below is unresolved on `mona`: +no supported per-container restart/OOM observer exists yet. No monitored smoke +may run until one is implemented and verified (see "Runtime blocker" below). + 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 @@ -86,59 +92,74 @@ monitor never infers Alertmanager health from Prometheus. ### Restart/OOM preflight blocker -The restart/OOM baseline requires cAdvisor `container_start_time_seconds` and -`container_oom_events_total` for exactly +The restart/OOM baseline requires per-container `container_start_time_seconds` +and `container_oom_events_total` series (currently expected from cAdvisor) for +exactly `earlybirds-preview-listener-1` and `earlybirds-preview-beacon-stream-1`, each resolving to exactly one finite series. Before scheduling load, run the monitor with `--once` (it probes twice: baseline plus verification) and confirm a `PASS` status with `restartBaselineEstablished: true`, `containerRestartsObserved: 0` and `oomEventsDelta: 0`. If those container metrics are absent or ambiguous, the monitor keeps reporting `FAIL` — that is -the exact preflight blocker. Resolve it on the observability stack first; the -monitor never silently claims zero restarts. Because the baseline is +the exact preflight blocker, and it is currently unresolved on `mona` (see +"Runtime blocker" below). 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, and the wrapper rejects such a status. -### Actual-mona preflight: per-container cAdvisor series +### Runtime blocker: no supported per-container restart/OOM 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/.../mount-id`. The cause is that the -cAdvisor `/:/rootfs` bind lacked recursive slave propagation after the Docker -storage topology changed, so storage-driver mounts never became visible inside -the container. The checked-in fix mounts `/:/rootfs:ro,rslave` (the same -propagation node-exporter already uses); it changes only the isolated -observability cAdvisor container and no event or runtime container. - -Until that fix is running on `mona`, the ten-client smoke **cannot start**: the +`/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. + +Therefore the ten-client smoke **cannot start** and stays blocked: the monitor's exact container queries return empty vectors, every probe reports `FAIL`, and the wrapper refuses the network run. There is no fallback — no -Docker CLI/API read, no inferred zero, no weakened restart/OOM evidence. - -Before any load, the operator must: - -1. Obtain explicit human approval and recreate **only** cAdvisor on `mona` - (see `ops/early-birds/runbook/README.md`, "cAdvisor mount propagation"). - Do not recreate Prometheus, Alertmanager, node-exporter, the canary or any - event/runtime container. -2. Through the loopback SSH tunnel, confirm each of the four exact queries - returns exactly one finite series, not an empty vector: - - ```bash - for query in \ - 'container_start_time_seconds{name="earlybirds-preview-listener-1"}' \ - 'container_start_time_seconds{name="earlybirds-preview-beacon-stream-1"}' \ - 'container_oom_events_total{name="earlybirds-preview-listener-1"}' \ - 'container_oom_events_total{name="earlybirds-preview-beacon-stream-1"}' - do - curl -fsS 'http://127.0.0.1:19090/api/v1/query' --get --data-urlencode "query=$query" - done - ``` -3. Only then run the monitor `--once` preflight above. An empty vector at any - step is a hard blocker: stop and resolve observability first; never treat - missing series as zero restarts or zero OOM events. +Docker CLI/API read, no inferred zero, no weakened restart/OOM evidence. No +monitored smoke may run until a supported, read-only per-container +restart/OOM observer is implemented and verified on `mona`. Candidate options +are listed in `ops/early-birds/runbook/README.md` ("Per-container restart/OOM +observability blocker"); none may be implemented, restarted or deployed +without explicit operational approval. + +Only after such an observer is deployed and verified, the operator confirms — +through the loopback SSH tunnel — that each of the four exact queries returns +exactly one finite series, not an empty vector: + +```bash +for query in \ + 'container_start_time_seconds{name="earlybirds-preview-listener-1"}' \ + 'container_start_time_seconds{name="earlybirds-preview-beacon-stream-1"}' \ + 'container_oom_events_total{name="earlybirds-preview-listener-1"}' \ + 'container_oom_events_total{name="earlybirds-preview-beacon-stream-1"}' +do + curl -fsS 'http://127.0.0.1:19090/api/v1/query' --get --data-urlencode "query=$query" +done +``` + +Only then run the monitor `--once` preflight above. An empty vector at any +step is a hard blocker: stop and resolve observability first; never treat +missing series as zero restarts or zero OOM events. ## Five-minute baseline @@ -202,6 +223,15 @@ 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 \ diff --git a/ops/early-birds/runbook/README.md b/ops/early-birds/runbook/README.md index 4aa26a13..e513a061 100644 --- a/ops/early-birds/runbook/README.md +++ b/ops/early-birds/runbook/README.md @@ -58,6 +58,63 @@ 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. +## Per-container restart/OOM observability blocker + +Per-container `container_start_time_seconds` and `container_oom_events_total` +series for the isolated Listener and origin containers are a hard prerequisite +for the Listener external smoke (see +`docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md`). On `mona`, Prometheus +currently exposes only the root cgroup for these series and the exact +per-container queries return empty vectors; 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. No monitored smoke +may run until a supported, read-only per-container restart/OOM observer is +implemented and verified on `mona`. Until then the smoke stays +runtime-blocked (its harness is code-complete and fails closed on the missing +series). + +Candidate future options, for evaluation only — none may be implemented, +restarted or deployed without explicit operational approval: + +1. A minimal read-only Docker Engine observer that watches the Engine event + stream and container state (restart counts, OOM-killed status) for exactly + the isolated Listener and origin containers and exports the required + Prometheus series. This needs read-only access to the Docker socket, which + is root-equivalent on the host, so it is acceptable only with an explicit + threat model: dedicated least-privilege observer, read-only socket mount, + no write API calls. +2. A proven containerd-compatible per-container collector — for example a + cAdvisor release verified against the containerd image store, or a + containerd-native metrics source — validated read-only in a throwaway + container on `mona` before any change to the checked-in observability + stack. +3. Any other observer only if it keeps the same fail-closed contract: exactly + one finite series per exact container query, no inferred zeros, no + weakened restart/OOM evidence. + +Whatever is chosen, verification is unchanged: the four exact per-container +queries must each return exactly one finite series before any load, and empty +vectors remain a hard blocker, never a reason to proceed. + +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: diff --git a/tools/early-birds-hls-load/package.json b/tools/early-birds-hls-load/package.json index 7bc38b9e..a044bb63 100644 --- a/tools/early-birds-hls-load/package.json +++ b/tools/early-birds-hls-load/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "External, bounded, format-neutral HLS load and soak evidence harness", "scripts": { - "check": "node --check run.mjs && node --check run-staging-smoke.mjs && node --check external-decoded-canary.mjs && node --check external-target-monitor.mjs && node --check aggregate.mjs && node --check verify-planned.mjs && node --check src/contracts.mjs && node --check src/runner.mjs && node --check src/smoke-safety.mjs && node --check src/target-probe.mjs && node --check src/smoke-guard.mjs", + "check": "node --check run.mjs && node --check run-staging-smoke.mjs && node --check external-decoded-canary.mjs && node --check external-target-monitor.mjs && node --check aggregate.mjs && node --check verify-planned.mjs && node --check src/contracts.mjs && node --check src/runner.mjs && node --check src/smoke-safety.mjs && node --check src/target-probe.mjs && node --check src/smoke-guard.mjs && node --check src/network-run-lock.mjs", "test": "node --test test/*.test.mjs" }, "engines": { diff --git a/tools/early-birds-hls-load/run-staging-smoke.mjs b/tools/early-birds-hls-load/run-staging-smoke.mjs index 73b04d0d..681c1cd0 100644 --- a/tools/early-birds-hls-load/run-staging-smoke.mjs +++ b/tools/early-birds-hls-load/run-staging-smoke.mjs @@ -6,6 +6,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { buildPlan, selectTarget } from './src/contracts.mjs'; +import { acquireNetworkRunLock, NETWORK_RUN_LOCK_PATH } from './src/network-run-lock.mjs'; import { startStatusGuard } from './src/smoke-guard.mjs'; import { SIGNED_MANIFEST_MAX_BYTES, @@ -37,6 +38,12 @@ For the exact ten-client network smoke, remove --dry-run and add: This wrapper fixes the target, origin, profile and shard count. It cannot run more than ten clients or target any host other than the isolated stream origin. +A network run first takes an exclusive local lock on this generator host, so two +wrappers on the same host and Unix account cannot overlap. Its one absolute +path is not configurable; the lock is refused — never deleted — when it is +already held or stale. Do not manually remove or replace it during a run. +The lock is local to one host: a single trusted authorized generator is an +operational precondition that this code cannot enforce across hosts. `); } @@ -121,48 +128,82 @@ async function main() { '--evidence', resolve(evidence), ]; let guard = null; - let child; - if (dryRun) { - childArgs.push('--dry-run'); - } else { - const manifestPath = option(args, '--manifest-url-file'); - const canaryPath = option(args, '--canary-status-file'); - const monitorPath = option(args, '--monitor-status-file'); - const clockOffset = option(args, '--clock-offset-ms'); - const confirmation = option(args, '--confirm'); - if (!manifestPath || !canaryPath || !monitorPath || clockOffset === null || !confirmation) { - throw new Error('network smoke requires signed manifest, external canary, target monitor, clock offset and confirmation'); + let child = null; + let lock = null; + let signalName = null; + const onSignal = (name) => { + if (signalName) return; + signalName = name; + // Stop the guard first so an in-flight status check can never abort or + // kill during shutdown; then forward the interrupt to the load child. + guard?.stop(); + child?.kill('SIGINT'); + }; + try { + if (dryRun) { + childArgs.push('--dry-run'); + } else { + const manifestPath = option(args, '--manifest-url-file'); + const canaryPath = option(args, '--canary-status-file'); + const monitorPath = option(args, '--monitor-status-file'); + const clockOffset = option(args, '--clock-offset-ms'); + const confirmation = option(args, '--confirm'); + if (!manifestPath || !canaryPath || !monitorPath || clockOffset === null || !confirmation) { + throw new Error('network smoke requires signed manifest, external canary, target monitor, clock offset and confirmation'); + } + // The exclusive local lock is taken before any precondition read or + // child spawn and is held through child exit and guard stop, so two + // wrappers on this host can never overlap a network run. A held, stale + // or ambiguous lock refuses the run; it is never deleted by a non-owner. + lock = await acquireNetworkRunLock({ + path: NETWORK_RUN_LOCK_PATH, + runId, + }); + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + await readPreconditions({ + manifestPath, + canaryPath, + monitorPath, + plan, + target, + checkFileFreshness: true, + }); + childArgs.push( + '--manifest-url-file', resolve(manifestPath), + '--clock-offset-ms', clockOffset, + '--confirm', confirmation, + '--external-generator', + ); + guard = startStatusGuard({ + check: () => readPreconditions({ manifestPath, canaryPath, monitorPath, plan, target }), + onAbort: () => child?.kill('SIGINT'), + }); } - await readPreconditions({ - manifestPath, - canaryPath, - monitorPath, - plan, - target, - checkFileFreshness: true, - }); - childArgs.push( - '--manifest-url-file', resolve(manifestPath), - '--clock-offset-ms', clockOffset, - '--confirm', confirmation, - '--external-generator', - ); - guard = startStatusGuard({ - check: () => readPreconditions({ manifestPath, canaryPath, monitorPath, plan, target }), - onAbort: () => child?.kill('SIGINT'), + if (signalName) { + // Interrupted between lock acquisition and spawn: no load ever started. + process.exitCode = 130; + return; + } + child = spawn(process.execPath, childArgs, { stdio: 'inherit', env: process.env }); + // A guard abort between precondition validation and spawn is delivered here, + // so the child can never run unguarded after a failed status. + if (guard?.aborted) child.kill('SIGINT'); + const exitCode = await new Promise((resolveExit, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolveExit(code ?? (signal ? 130 : 1))); }); + if (guard) guard.stop(); + process.exitCode = signalName ? 130 : exitCode; + } finally { + // Deterministic cleanup on every path — validation error, spawn error, + // signal/abort and normal exit: park the guard, drop the signal handlers + // and release only the lock this process owns. + guard?.stop(); + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + if (lock) await lock.release(); } - - child = spawn(process.execPath, childArgs, { stdio: 'inherit', env: process.env }); - // A guard abort between precondition validation and spawn is delivered here, - // so the child can never run unguarded after a failed status. - if (guard?.aborted) child.kill('SIGINT'); - const exitCode = await new Promise((resolveExit, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => resolveExit(code ?? (signal ? 130 : 1))); - }); - if (guard) guard.stop(); - process.exitCode = exitCode; } main().catch((error) => { diff --git a/tools/early-birds-hls-load/src/network-run-lock.mjs b/tools/early-birds-hls-load/src/network-run-lock.mjs new file mode 100644 index 00000000..2228e327 --- /dev/null +++ b/tools/early-birds-hls-load/src/network-run-lock.mjs @@ -0,0 +1,89 @@ +// Exclusive local network-run lock for the ten-client Listener smoke. +// +// Scope and honest limits: this lock serializes wrapper processes on ONE +// generator host, so two concurrent wrappers started on the same trusted host +// can never drive more than the exact ten clients together. It is a local +// filesystem primitive only: it cannot see, let alone stop, a wrapper on a +// different host, so it does NOT cryptographically guarantee a global +// aggregate client limit. A single trusted, authorized generator host remains +// an operational precondition enforced by procedure and host inspection, not +// by this code. +// +// The primitive is atomic creation (O_EXCL), mode 0600, with no secrets in +// the file. A pre-existing lock — active, stale or ambiguous — is refused +// outright: the wrapper never inspects PIDs, never guesses staleness and +// never deletes a lock it did not create. Only the operator may remove a +// leftover lock after verifying no smoke is running. During a run, operators +// must not remove or replace it: release rechecks the exact bytes before +// unlinking, but Node has no portable FD-scoped unlink/flock that can make a +// human replacement between those operations impossible. + +import { chmod, open, readFile, unlink } from 'node:fs/promises'; + +export const NETWORK_RUN_LOCK_KIND = 'harmonic-beacon-listener-smoke-network-run-lock'; +/** One host-wide path: production callers cannot select a second lock domain. */ +export const NETWORK_RUN_LOCK_PATH = '/tmp/harmonic-beacon-listener-smoke-10-network-run.lock'; + +function lockContents({ runId, pid, acquiredAtMs }) { + return `${JSON.stringify({ + schemaVersion: 1, + kind: NETWORK_RUN_LOCK_KIND, + runId, + pid, + acquiredAt: new Date(acquiredAtMs).toISOString(), + })}\n`; +} + +export async function acquireNetworkRunLock({ + path, + runId, + pid = process.pid, + acquiredAtMs = Date.now(), +}) { + if (typeof path !== 'string' || path.length === 0) { + throw new Error('network run lock path is required'); + } + const contents = lockContents({ runId, pid, acquiredAtMs }); + let handle; + try { + handle = await open(path, 'wx', 0o600); + } catch (error) { + if (error?.code === 'EEXIST') { + // Stale or ambiguous state is refused, never resolved: deleting or + // reusing a foreign lock could put two concurrent network runs on the + // origin. The operator must verify no smoke is running and remove the + // file manually. + throw new Error( + 'network run lock already exists on this host; refusing to start: ' + + 'another smoke wrapper may be active. If none is running, verify ' + + 'the lock owner is gone and remove the lock file manually', + ); + } + throw new Error(`cannot create network run lock: ${error?.message ?? String(error)}`); + } + try { + await handle.writeFile(contents); + } finally { + await handle.close(); + } + await chmod(path, 0o600); + let released = false; + return { + path, + async release() { + if (released) return; + released = true; + let current; + try { + current = await readFile(path, 'utf8'); + } catch { + return; // Already gone; nothing deterministic left to clean up. + } + if (current !== contents) { + // Replaced or foreign lock: it is not ours to remove. + return; + } + await unlink(path); + }, + }; +} diff --git a/tools/early-birds-hls-load/src/smoke-guard.mjs b/tools/early-birds-hls-load/src/smoke-guard.mjs index f8a3618d..d67f5fec 100644 --- a/tools/early-birds-hls-load/src/smoke-guard.mjs +++ b/tools/early-birds-hls-load/src/smoke-guard.mjs @@ -1,6 +1,8 @@ // Polls the external safety statuses while the load child runs. On the first // failed or stale check it aborts exactly once, promptly and deterministically, -// and never prints status contents or error details. +// and never prints status contents or error details. stop() parks the guard: +// no further tick runs and an in-flight check that later rejects emits no +// abort and never invokes onAbort. export function startStatusGuard({ check, onAbort, @@ -10,16 +12,20 @@ export function startStatusGuard({ writeImpl = (line) => process.stderr.write(line), }) { let aborted = false; + let stopped = false; let busy = false; const abort = () => { - if (aborted) return; + // A check that was already in flight when stop() ran must never emit an + // abort or invoke onAbort: the load child has already exited by then and + // the wrapper is shutting down cleanly. + if (aborted || stopped) return; aborted = true; clearIntervalImpl(timer); writeImpl('Smoke safety status became stale or failed; aborting without printing sensitive details.\n'); onAbort(); }; const tick = () => { - if (aborted || busy) return; + if (aborted || stopped || busy) return; busy = true; Promise.resolve() .then(check) @@ -30,10 +36,14 @@ export function startStatusGuard({ timer.unref?.(); return { stop() { + stopped = true; clearIntervalImpl(timer); }, get aborted() { return aborted; }, + get stopped() { + return stopped; + }, }; } diff --git a/tools/early-birds-hls-load/src/smoke-safety.mjs b/tools/early-birds-hls-load/src/smoke-safety.mjs index 79ae0d10..b3f03092 100644 --- a/tools/early-birds-hls-load/src/smoke-safety.mjs +++ b/tools/early-birds-hls-load/src/smoke-safety.mjs @@ -214,6 +214,10 @@ export function validateNetworkSmokePreconditions({ // The canary and monitor status files are read from local disk, so both // safety producers must be co-located with this wrapper on one external // host. Identical fingerprints on a second host would fail this check too. + // The fingerprint is a truncated hostname hash, not a cryptographic + // attestation: it catches misplaced producers but cannot prove topology, so + // the runbook still requires a trusted operator and external inspection of + // the generator host. assert(/^[a-f0-9]{12}$/.test(expectedHostHash), 'local host fingerprint is invalid'); assert(canaryStatus.hostHash === expectedHostHash && monitorStatus.hostHash === expectedHostHash, @@ -230,6 +234,12 @@ export async function readJsonStatus(path, label) { } export function assertExternalHost(host = hostname()) { + // Defense-in-depth only, NOT cryptographic topology proof: this rejects + // mona-like hostnames and derives a non-cryptographic hash of the local + // hostname. It cannot prove where a safety producer really ran — a renamed + // or misconfigured host passes silently. A trusted operator plus external + // inspection of the generator host (documented in the runbook) remains + // required; do not weaken the rejection below. const labels = String(host).trim().toLowerCase().replace(/\.+$/, '').split('.'); assert(!labels.some((label) => label === 'mona' || label.startsWith('mona-')), 'external safety process is forbidden from mona'); diff --git a/tools/early-birds-hls-load/test/network-run-lock.test.mjs b/tools/early-birds-hls-load/test/network-run-lock.test.mjs new file mode 100644 index 00000000..c1bc7e89 --- /dev/null +++ b/tools/early-birds-hls-load/test/network-run-lock.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { access, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { NETWORK_RUN_LOCK_KIND, acquireNetworkRunLock } from '../src/network-run-lock.mjs'; + +async function tempLockPath() { + const directory = await mkdtemp(path.join(os.tmpdir(), 'network-run-lock-')); + return path.join(directory, 'smoke.lock'); +} + +test('acquire creates a mode-0600 lock and a concurrent second acquire is refused', async () => { + const lockPath = await tempLockPath(); + const first = await acquireNetworkRunLock({ path: lockPath, runId: 'run-a', pid: 4321 }); + const details = await stat(lockPath); + assert.equal(details.mode & 0o777, 0o600); + assert.equal(details.isFile(), true); + const contents = JSON.parse(await readFile(lockPath, 'utf8')); + assert.equal(contents.kind, NETWORK_RUN_LOCK_KIND); + assert.equal(contents.runId, 'run-a'); + assert.equal(contents.pid, 4321); + await assert.rejects( + acquireNetworkRunLock({ path: lockPath, runId: 'run-b' }), + /network run lock already exists/, + ); + // The refused second acquire leaves the original lock untouched. + assert.equal(JSON.parse(await readFile(lockPath, 'utf8')).runId, 'run-a'); + await first.release(); + await assert.rejects(access(lockPath), /ENOENT/); +}); + +test('a stale or ambiguous pre-existing lock is refused and never deleted', async () => { + const lockPath = await tempLockPath(); + const stale = '{"stale":true}\n'; + await writeFile(lockPath, stale, { mode: 0o600 }); + await assert.rejects( + acquireNetworkRunLock({ path: lockPath, runId: 'run-c' }), + /network run lock already exists/, + ); + assert.equal(await readFile(lockPath, 'utf8'), stale); +}); + +test('a later run proceeds after a clean release', async () => { + const lockPath = await tempLockPath(); + const first = await acquireNetworkRunLock({ path: lockPath, runId: 'run-d' }); + await first.release(); + const second = await acquireNetworkRunLock({ path: lockPath, runId: 'run-e' }); + assert.equal(JSON.parse(await readFile(lockPath, 'utf8')).runId, 'run-e'); + await second.release(); + await assert.rejects(access(lockPath), /ENOENT/); +}); + +test('release removes only the lock this process wrote', async () => { + const lockPath = await tempLockPath(); + const lock = await acquireNetworkRunLock({ path: lockPath, runId: 'run-f' }); + const replaced = '{"replaced":true}\n'; + await writeFile(lockPath, replaced); + await lock.release(); + assert.equal(await readFile(lockPath, 'utf8'), replaced); + await lock.release(); // Releasing twice stays a safe no-op. + assert.equal(await readFile(lockPath, 'utf8'), replaced); +}); + +test('release tolerates a lock that is already gone', async () => { + const lockPath = await tempLockPath(); + const lock = await acquireNetworkRunLock({ path: lockPath, runId: 'run-g' }); + await lock.release(); + await lock.release(); + await assert.rejects(access(lockPath), /ENOENT/); +}); + +test('a lock path is required', async () => { + await assert.rejects(acquireNetworkRunLock({ path: '', runId: 'run-h' }), /lock path is required/); +}); diff --git a/tools/early-birds-hls-load/test/run-staging-smoke.test.mjs b/tools/early-birds-hls-load/test/run-staging-smoke.test.mjs new file mode 100644 index 00000000..f06f45a9 --- /dev/null +++ b/tools/early-birds-hls-load/test/run-staging-smoke.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { access, mkdtemp } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + acquireNetworkRunLock, + NETWORK_RUN_LOCK_PATH, +} from '../src/network-run-lock.mjs'; + +const wrapperPath = fileURLToPath(new URL('../run-staging-smoke.mjs', import.meta.url)); + +function runWrapper(args) { + return new Promise((resolveRun) => { + const child = spawn(process.execPath, [wrapperPath, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', (error) => resolveRun({ code: -1, stdout, stderr: stderr + error.message })); + child.on('close', (code) => resolveRun({ code, stdout, stderr })); + }); +} + +function baseArgs(directory) { + return [ + '--run-id', 'lock-test-run', + '--start-at', new Date(Date.now() + 120_000).toISOString(), + '--evidence', path.join(directory, 'evidence.json'), + ]; +} + +function networkArgs(directory) { + return [ + ...baseArgs(directory), + '--manifest-url-file', path.join(directory, 'missing-manifest'), + '--canary-status-file', path.join(directory, 'missing-canary.json'), + '--monitor-status-file', path.join(directory, 'missing-monitor.json'), + '--clock-offset-ms', '0', + '--confirm', 'test-confirmation', + ]; +} + +// These tests never run network load: every network-mode invocation below is +// refused either at the lock or at the missing precondition files, before any +// load child is spawned. +test('a concurrent second wrapper is refused at the lock before spawning any load', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'staging-smoke-wrapper-')); + const held = await acquireNetworkRunLock({ path: NETWORK_RUN_LOCK_PATH, runId: 'first-run' }); + try { + const result = await runWrapper(networkArgs(directory)); + assert.equal(result.code, 1); + assert.match(result.stderr, /network run lock already exists/); + // The refusal precedes the precondition reads: the missing signed + // manifest is never even reported. + assert.doesNotMatch(result.stderr, /signed manifest/); + // No load child ever ran, so no evidence was produced. + await assert.rejects(access(path.join(directory, 'evidence.json')), /ENOENT/); + } finally { + await held.release(); + } +}); + +test('a later run passes the lock after a clean release and still fails closed', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'staging-smoke-wrapper-')); + const held = await acquireNetworkRunLock({ path: NETWORK_RUN_LOCK_PATH, runId: 'first-run' }); + await held.release(); + const result = await runWrapper(networkArgs(directory)); + assert.equal(result.code, 1); + // The lock was acquired; the run now fails closed on the missing + // precondition file instead of on the lock. + assert.match(result.stderr, /cannot read signed manifest file/); + assert.doesNotMatch(result.stderr, /network run lock/); + // The validation-error path released the lock deterministically. + await assert.rejects(access(NETWORK_RUN_LOCK_PATH), /ENOENT/); +}); + +test('a wrapper that acquires the lock itself refuses a second wrapper until it exits', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'staging-smoke-wrapper-')); + const first = await runWrapper(networkArgs(directory)); + // The first wrapper failed closed on preconditions and released its lock, + // so a following invocation is again refused only on preconditions. + assert.equal(first.code, 1); + assert.match(first.stderr, /cannot read signed manifest file/); + await assert.rejects(access(NETWORK_RUN_LOCK_PATH), /ENOENT/); +}); + +test('dry-run plans without the network lock even while another run holds it', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'staging-smoke-wrapper-')); + const held = await acquireNetworkRunLock({ path: NETWORK_RUN_LOCK_PATH, runId: 'other-run' }); + try { + const result = await runWrapper([...baseArgs(directory), '--dry-run']); + assert.equal(result.code, 0); + assert.match(result.stdout, /Status: PLANNED/); + await access(path.join(directory, 'evidence.json')); + } finally { + await held.release(); + } +}); + +test('the production CLI refuses attempts to select a second lock path', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'staging-smoke-wrapper-')); + const bypass = path.join(directory, 'bypass.lock'); + const result = await runWrapper([ + ...baseArgs(directory), + '--dry-run', + '--lock-file', bypass, + ]); + assert.equal(result.code, 1); + assert.match(result.stderr, /unknown option --lock-file/); + await assert.rejects(access(bypass), /ENOENT/); +}); diff --git a/tools/early-birds-hls-load/test/smoke-guard.test.mjs b/tools/early-birds-hls-load/test/smoke-guard.test.mjs index d450fb23..d2cc74b6 100644 --- a/tools/early-birds-hls-load/test/smoke-guard.test.mjs +++ b/tools/early-birds-hls-load/test/smoke-guard.test.mjs @@ -86,4 +86,30 @@ test('passing checks never abort and stop() leaves the guard quiet', async () => assert.equal(aborts, 0); assert.equal(checks, 2); assert.equal(guard.aborted, false); + assert.equal(guard.stopped, true); +}); + +test('an in-flight rejection after stop() emits no abort and never kills', async () => { + const timers = fakeTimer(); + let aborts = 0; + let writes = 0; + let rejectCheck; + const guard = startStatusGuard({ + check: () => new Promise((unused, reject) => { rejectCheck = reject; }), + onAbort: () => { aborts += 1; }, + ...timers, + writeImpl: () => { writes += 1; }, + }); + timers.tick(); // Starts one check that is still in flight. + await new Promise((resolve) => setImmediate(resolve)); + guard.stop(); // The load child has exited; the guard is parked. + rejectCheck(new Error('status failed after stop')); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + timers.tick(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(aborts, 0); + assert.equal(writes, 0); + assert.equal(guard.aborted, false); + assert.equal(guard.stopped, true); }); From 314878538fc452cb2e23f0e5b9796fe2e1be003a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 05:55:31 -0300 Subject: [PATCH 129/376] chore(listener): vendor founder membership read v2 contract --- contracts/early-bird-authority/v2/README.md | 32 +++++ contracts/early-bird-authority/v2/SHA256SUMS | 3 + .../v2/membership.fixture.json | 34 ++++++ .../v2/membership.schema.json | 113 ++++++++++++++++++ scripts/verify-early-bird-contracts.py | 3 +- 5 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 contracts/early-bird-authority/v2/README.md create mode 100644 contracts/early-bird-authority/v2/SHA256SUMS create mode 100644 contracts/early-bird-authority/v2/membership.fixture.json create mode 100644 contracts/early-bird-authority/v2/membership.schema.json diff --git a/contracts/early-bird-authority/v2/README.md b/contracts/early-bird-authority/v2/README.md new file mode 100644 index 00000000..91096df7 --- /dev/null +++ b/contracts/early-bird-authority/v2/README.md @@ -0,0 +1,32 @@ +# EarlyBird authority membership read contract v2 + +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 2/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..41b2e7e0 --- /dev/null +++ b/contracts/early-bird-authority/v2/SHA256SUMS @@ -0,0 +1,3 @@ +42a2dd7f1e74e295a073efd785e9eae05a27d74bf14cfcf44294f8eb83716d68 README.md +d7e19379aaa071ee34260e2e26cdab2fa0927e27765d7b52e22a9b403c864d4c membership.fixture.json +52bb0b8bb7a1545e6ae9c806d94b4e19b3e74c2bec4d58546987e74aa67afa82 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..4be149d3 --- /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": 200 + }, + "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": 200 + }, + "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..84c6f697 --- /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": 200} + } + }, + "billing_period": {"const": "MONTHLY"}, + "granted_at": {"type": "string", "format": "date-time"} + } + } + ] + } + } +} diff --git a/scripts/verify-early-bird-contracts.py b/scripts/verify-early-bird-contracts.py index 49a7f202..9beaae4b 100644 --- a/scripts/verify-early-bird-contracts.py +++ b/scripts/verify-early-bird-contracts.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Verify byte-exact copies of both canonical EarlyBird v1 contracts.""" +"""Verify byte-exact copies of the canonical EarlyBird contracts.""" import hashlib from pathlib import Path @@ -8,6 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] CONTRACTS = ( ROOT / "contracts/early-bird-authority/v1", + ROOT / "contracts/early-bird-authority/v2", ROOT / "contracts/early-bird-membership/v1", ) From bf373e6227382fe446c7b7392368ced308d0ff89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 06:20:11 -0300 Subject: [PATCH 130/376] feat(listener): project canonical founder eligibility --- .../LISTENER_FOUNDER_ELIGIBILITY.md | 61 +++++ .../migration.sql | 35 +++ prisma/schema.prisma | 34 ++- .../reconcile/__tests__/route.test.ts | 145 +++++++++++ .../[accountId]/reconcile/route.ts | 83 ++++++ .../founder-eligibility-contract.test.ts | 238 ++++++++++++++++++ .../founder-eligibility.postgres.test.ts | 65 +++++ .../__tests__/founder-eligibility.test.ts | 113 +++++++++ src/lib/early-birds/founder-eligibility.ts | 104 ++++++++ src/lib/early-birds/membership-contract.ts | 121 ++++++++- src/lib/early-birds/membership-gateway.ts | 119 ++++++++- 11 files changed, 1110 insertions(+), 8 deletions(-) create mode 100644 docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md create mode 100644 prisma/migrations/20260808090000_early_bird_founder_eligibility_projection/migration.sql create mode 100644 src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts create mode 100644 src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/route.ts create mode 100644 src/lib/early-birds/__tests__/founder-eligibility-contract.test.ts create mode 100644 src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts create mode 100644 src/lib/early-birds/__tests__/founder-eligibility.test.ts create mode 100644 src/lib/early-birds/founder-eligibility.ts diff --git a/docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md b/docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md new file mode 100644 index 00000000..b510b4a4 --- /dev/null +++ b/docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md @@ -0,0 +1,61 @@ +# Listener Founder price eligibility projection + +## Boundary + +PMP Myth Bot is the only authority that can grant the lifetime Founding Listener price. Its private +membership read v2 is versioned in `contracts/early-bird-authority/v2` and was introduced by backend +merge `3febc1d525adf150bfdd75fd2b98b04771cb79b7`. + +Listener stores a positive-only projection of that evidence in +`early_bird_founder_eligibility_projections`. This row is deliberately separate from the v1 +membership projection: + +- it has no membership revision and never participates in stream authorization; +- its hash covers only the canonical eligibility object; +- RFC 3339 timestamps are normalized to UTC millisecond precision before hashing and storage; +- the first positive value is immutable; +- `null` never deletes or downgrades an existing positive value; +- Free, welcome access, invitations and Free For All never create the row. + +The existing membership push route, public page, access resolver, leases and presentation remain +unchanged. Founder eligibility alone cannot grant listening access or produce a Purchase event. + +## Reconciliation + +An authenticated server-side caller may request: + +```text +POST /api/internal/v1/early-bird-founder-eligibilities/{account_id}/reconcile +Authorization: Bearer +X-HB-Service-Key-Id: +``` + +The route authenticates and verifies the local opaque account before contacting the authority. It +then performs a bounded, no-store GET of the private v2 membership document and applies only +`founder_price_eligibility` transactionally. + +Successful outcomes are: + +- `ABSENT`: the authority returned `null` and no local positive evidence exists; +- `APPLIED`: the first canonical positive evidence was stored; +- `REPLAYED`: the exact evidence already exists. + +Conflicting positive evidence, positive-to-null transitions and an authority 404 for an existing +local account return 409 and preserve local state. Authority timeout, authentication failure, 5xx, +oversized or malformed bodies and account mismatches return a generic 503 without mutation. +Unknown local accounts return 404 before outbound I/O. Unexpected database failures return a +generic 500 and roll back the transaction. + +The endpoint is not invoked by page rendering, OAuth, stream lease creation or the v1 push route. +Automation can be added later as an operations worker without changing the evidence semantics. +`observed_at` records the first successful local observation and is intentionally unchanged on replay. + +## Migration and rollback + +The migration is forward-only and additive. Deploying code before the migration is prohibited; +deploy applies the migration before selecting an image. Rolling the application image back is safe +because older code ignores the new table. The table is retained during rollback so positive +eligibility evidence is never destroyed. + +This subsystem belongs only to the isolated Listener product. It does not modify weekend events, +LiveKit, event tickets, tapestry, event audio or Proyección del Mito. 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/schema.prisma b/prisma/schema.prisma index 1755c4e1..2d0aba7d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -442,12 +442,13 @@ model EarlyBirdUser { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - identities EarlyBirdIdentity[] - authSessions EarlyBirdAuthSession[] - membership EarlyBirdMembershipProjection? - freeSchedule EarlyBirdFreeSchedule? - welcomeAccess EarlyBirdWelcomeAccess? - streamLeases EarlyBirdStreamLease[] + identities EarlyBirdIdentity[] + authSessions EarlyBirdAuthSession[] + membership EarlyBirdMembershipProjection? + founderEligibility EarlyBirdFounderEligibilityProjection? + freeSchedule EarlyBirdFreeSchedule? + welcomeAccess EarlyBirdWelcomeAccess? + streamLeases EarlyBirdStreamLease[] @@map("early_bird_users") } @@ -544,6 +545,27 @@ model EarlyBirdMembershipProjection { @@map("early_bird_membership_projections") } +// Positive-only projection of the canonical, lifetime Founder price evidence. +// It is deliberately separate from membership/access revisions and authorizes +// nothing by itself. +model EarlyBirdFounderEligibilityProjection { + accountId String @id @map("account_id") + account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) + offerCode String @map("offer_code") @db.VarChar(128) + offerRevision Int @map("offer_revision") + currency String @db.Char(3) + amountMinor Int @map("amount_minor") + billingPeriod String @map("billing_period") @db.VarChar(32) + grantedAt DateTime @map("granted_at") + eligibilityHash String @map("eligibility_hash") @db.Char(64) + observedAt DateTime @map("observed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([grantedAt]) + @@map("early_bird_founder_eligibility_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. diff --git a/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts b/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts new file mode 100644 index 00000000..bc2c45a4 --- /dev/null +++ b/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts @@ -0,0 +1,145 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + findAccount: vi.fn(), + readMembership: vi.fn(), + apply: vi.fn(), +})); + +vi.mock('@/lib/early-birds/service-auth', () => ({ + authorizeEarlyBirdMembershipService: mocks.authorize, +})); +vi.mock('@/lib/db', () => ({ + prisma: { earlyBirdUser: { findUnique: mocks.findAccount } }, +})); +vi.mock('@/lib/early-birds/membership-gateway', async (importOriginal) => ({ + ...await importOriginal(), + earlyBirdMembershipReader: () => ({ readMembership: mocks.readMembership }), +})); +vi.mock('@/lib/early-birds/founder-eligibility', async (importOriginal) => ({ + ...await importOriginal(), + applyFounderEligibilityProjection: mocks.apply, +})); + +import { FounderEligibilityConflictError } from '@/lib/early-birds/founder-eligibility'; +import { EarlyBirdMembershipGatewayUnavailableError } from '@/lib/early-birds/membership-gateway'; + +import { POST } from '../route'; + +const ACCOUNT = 'listener-1'; +const eligibility = { + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, + canonical_price: { currency: 'USD', amount_minor: 200 }, + billing_period: 'MONTHLY', + granted_at: '2026-08-06T12:00:00Z', +}; +const canonical = { + schema_version: 'early-bird-authority.membership.v2', + account_id: ACCOUNT, + 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: 200 }, + free_entitlement_consumed: true, + reason_code: 'SUBSCRIPTION_CANCELLED', + founder_price_eligibility: eligibility, +}; + +function request(headers: Record = {}) { + return new NextRequest( + `http://beacon-app:3000/api/internal/v1/early-bird-founder-eligibilities/${ACCOUNT}/reconcile`, + { + method: 'POST', + headers: { + authorization: 'Bearer secret-not-logged', + 'x-hb-service-key-id': 'current', + ...headers, + }, + }, + ); +} + +const params = { params: Promise.resolve({ accountId: ACCOUNT }) }; + +describe('private Founder eligibility reconciliation route', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authorize.mockReturnValue(true); + mocks.findAccount.mockResolvedValue({ id: ACCOUNT }); + mocks.readMembership.mockResolvedValue({ ok: true, membership: canonical }); + mocks.apply.mockResolvedValue('APPLIED'); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + it('authenticates before local lookup or outbound authority access', async () => { + mocks.authorize.mockReturnValue(false); + const response = await POST(request(), params); + expect(response.status).toBe(401); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(mocks.findAccount).not.toHaveBeenCalled(); + expect(mocks.readMembership).not.toHaveBeenCalled(); + expect(mocks.apply).not.toHaveBeenCalled(); + }); + + it('rejects an unknown local account without contacting the authority', async () => { + mocks.findAccount.mockResolvedValue(null); + const response = await POST(request(), params); + expect(response.status).toBe(404); + expect(mocks.readMembership).not.toHaveBeenCalled(); + }); + + it.each(['ABSENT', 'APPLIED', 'REPLAYED'] as const)('returns only the sanitized %s outcome', async (outcome) => { + mocks.apply.mockResolvedValue(outcome); + if (outcome === 'ABSENT') { + mocks.readMembership.mockResolvedValue({ + ok: true, + membership: { ...canonical, founder_price_eligibility: null }, + }); + } + const response = await POST(request(), params); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(await response.json()).toEqual({ + schema_version: 'listener-founder-eligibility-reconciliation.result.v1', + outcome, + founder_price_eligible: outcome !== 'ABSENT', + }); + expect(mocks.apply).toHaveBeenCalledWith( + ACCOUNT, + outcome === 'ABSENT' ? null : eligibility, + ); + }); + + it('maps authority absence and durable evidence conflict to generic 409', async () => { + mocks.readMembership.mockResolvedValue({ ok: false, reason: 'not-found' }); + const absent = await POST(request(), params); + expect(absent.status).toBe(409); + + mocks.readMembership.mockResolvedValue({ ok: true, membership: canonical }); + mocks.apply.mockRejectedValue(new FounderEligibilityConflictError()); + const conflict = await POST(request(), params); + expect(conflict.status).toBe(409); + expect(await conflict.json()).toEqual({ error: 'Founder eligibility reconciliation conflict.' }); + }); + + it('maps authority and database failures without leaking their material', async () => { + mocks.readMembership.mockRejectedValue(new EarlyBirdMembershipGatewayUnavailableError()); + const authority = await POST(request(), params); + expect(authority.status).toBe(503); + expect(await authority.json()).toEqual({ error: 'Founder eligibility authority unavailable.' }); + + mocks.readMembership.mockResolvedValue({ ok: true, membership: canonical }); + mocks.apply.mockRejectedValue(new Error('subscription secret provider body')); + const database = await POST(request(), params); + expect(database.status).toBe(500); + expect(JSON.stringify(await database.json())).not.toContain('subscription secret provider body'); + }); +}); diff --git a/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/route.ts b/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/route.ts new file mode 100644 index 00000000..a2cc607e --- /dev/null +++ b/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { prisma } from '@/lib/db'; +import { + applyFounderEligibilityProjection, + FounderEligibilityAccountNotFoundError, + FounderEligibilityConflictError, +} from '@/lib/early-birds/founder-eligibility'; +import { + earlyBirdMembershipReader, + EarlyBirdMembershipGatewayUnavailableError, +} from '@/lib/early-birds/membership-gateway'; +import { isEarlyBirdAccountId } from '@/lib/early-birds/account-id'; +import { authorizeEarlyBirdMembershipService } from '@/lib/early-birds/service-auth'; + +export const dynamic = 'force-dynamic'; + +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'), + ); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ accountId: string }> }, +): Promise { + if (!authorized(request)) return response({ error: 'Service authentication failed.' }, 401); + const { accountId } = await params; + if (!isEarlyBirdAccountId(accountId)) return response({ error: 'Resource not found.' }, 404); + + try { + const account = await prisma.earlyBirdUser.findUnique({ + where: { id: accountId }, + select: { id: true }, + }); + if (!account) return response({ error: 'Resource not found.' }, 404); + } catch { + console.error('[founder-eligibility] local account lookup failed'); + return response({ error: 'Founder eligibility reconciliation unavailable.' }, 500); + } + + let canonical; + try { + canonical = await earlyBirdMembershipReader().readMembership(accountId); + } catch (error) { + if (!(error instanceof EarlyBirdMembershipGatewayUnavailableError)) { + console.error('[founder-eligibility] unexpected authority read failure'); + } + return response({ error: 'Founder eligibility authority unavailable.' }, 503); + } + if (!canonical.ok) { + return response({ error: 'Founder eligibility reconciliation conflict.' }, 409); + } + + try { + const outcome = await applyFounderEligibilityProjection( + accountId, + canonical.membership.founder_price_eligibility, + ); + return response({ + schema_version: 'listener-founder-eligibility-reconciliation.result.v1', + outcome, + founder_price_eligible: canonical.membership.founder_price_eligibility !== null, + }); + } catch (error) { + if (error instanceof FounderEligibilityAccountNotFoundError) { + return response({ error: 'Resource not found.' }, 404); + } + if (error instanceof FounderEligibilityConflictError) { + return response({ error: 'Founder eligibility reconciliation conflict.' }, 409); + } + console.error('[founder-eligibility] projection failed without authority material'); + return response({ error: 'Founder eligibility reconciliation unavailable.' }, 500); + } +} diff --git a/src/lib/early-birds/__tests__/founder-eligibility-contract.test.ts b/src/lib/early-birds/__tests__/founder-eligibility-contract.test.ts new file mode 100644 index 00000000..0e1c73bd --- /dev/null +++ b/src/lib/early-birds/__tests__/founder-eligibility-contract.test.ts @@ -0,0 +1,238 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { + HttpEarlyBirdMembershipGateway, +} from '../membership-gateway'; +import { + EarlyBirdMembershipContractError, + parseCanonicalAuthorityMembershipV2, +} from '../membership-contract'; + +const CONTRACT = resolve(process.cwd(), 'contracts/early-bird-authority/v2'); +const fixture = JSON.parse(readFileSync(`${CONTRACT}/membership.fixture.json`, 'utf8')) as Record; +const active: Record = { + ...fixture, + account_id: 'listener-1', + state: 'ACTIVE', + access_allowed: true, + effective_at: '2026-08-08T12:00:00Z', + paid_through: '2027-08-08T12:00:00Z', + reason_code: 'PAYMENT_SUCCEEDED', +}; + +function cancellableResponse(status: number, headers: Record = {}) { + let cancelled = false; + const response = new Response(new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array([0x7b])); + }, + cancel() { + cancelled = true; + }, + }, { highWaterMark: 0 }), { status, headers }); + return { response, wasCancelled: () => cancelled }; +} + +describe('canonical Founder membership read v2', () => { + it('parses the byte-vendored positive fixture and a Free null eligibility', () => { + expect(parseCanonicalAuthorityMembershipV2(fixture).founder_price_eligibility) + .toEqual({ + ...(fixture.founder_price_eligibility as object), + granted_at: '2026-08-06T12:00:00.000Z', + }); + expect(parseCanonicalAuthorityMembershipV2({ + ...active, + source: 'FREE', + provider: null, + current_price: null, + founder_price_eligibility: null, + }).founder_price_eligibility).toBeNull(); + }); + + it.each([ + ['extra top-level field', { ...active, email: 'private@example.invalid' }], + ['missing eligibility', Object.fromEntries(Object.entries(active).filter(([key]) => key !== 'founder_price_eligibility'))], + ['wrong currency', { + ...active, + founder_price_eligibility: { + ...(active.founder_price_eligibility as object), + canonical_price: { currency: 'ARS', amount_minor: 200 }, + }, + }], + ['wrong amount', { + ...active, + founder_price_eligibility: { + ...(active.founder_price_eligibility as object), + canonical_price: { currency: 'USD', amount_minor: 201 }, + }, + }], + ['wrong period', { + ...active, + founder_price_eligibility: { + ...(active.founder_price_eligibility as object), + billing_period: 'YEARLY', + }, + }], + ['impossible calendar date', { + ...active, + founder_price_eligibility: { + ...(active.founder_price_eligibility as object), + granted_at: '2026-02-30T12:00:00Z', + }, + }], + ['invalid hour', { + ...active, + founder_price_eligibility: { + ...(active.founder_price_eligibility as object), + granted_at: '2026-08-06T24:00:00Z', + }, + }], + ['unsafe revision', { + ...active, + founder_price_eligibility: { + ...(active.founder_price_eligibility as object), + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: Number.MAX_SAFE_INTEGER + 1 }, + }, + }], + ['paid access without eligibility', { ...active, founder_price_eligibility: null }], + ])('rejects %s', (_label, payload) => { + expect(() => parseCanonicalAuthorityMembershipV2(payload)) + .toThrow(EarlyBirdMembershipContractError); + }); + + it('accepts RFC 3339 offsets and canonicalizes equivalent instants to UTC milliseconds', () => { + const withOffset = parseCanonicalAuthorityMembershipV2({ + ...active, + effective_at: '2026-08-08T09:00:00-03:00', + paid_through: '2027-08-08T09:00:00.000000000-03:00', + founder_price_eligibility: { + ...(active.founder_price_eligibility as object), + granted_at: '2026-08-06T09:00:00-03:00', + }, + }); + const withFraction = parseCanonicalAuthorityMembershipV2({ + ...active, + founder_price_eligibility: { + ...(active.founder_price_eligibility as object), + granted_at: '2026-08-06T12:00:00.000000000Z', + }, + }); + expect(withOffset.founder_price_eligibility?.granted_at) + .toBe('2026-08-06T12:00:00.000Z'); + expect(withOffset.effective_at).toBe('2026-08-08T12:00:00.000Z'); + expect(withOffset.paid_through).toBe('2027-08-08T12:00:00.000Z'); + expect(withFraction.founder_price_eligibility?.granted_at) + .toBe('2026-08-06T12:00:00.000Z'); + }); + + it.each([ + ['effective_at', { effective_at: '2026-02-30T12:00:00Z' }], + ['paid_through', { paid_through: '2027-02-30T12:00:00Z' }], + ['grace_until', { grace_until: '2026-08-08T12:00:00+24:00' }], + ])('rejects invalid v2 %s while leaving the legacy v1 parser unchanged', (_label, override) => { + expect(() => parseCanonicalAuthorityMembershipV2({ ...active, ...override })) + .toThrow(EarlyBirdMembershipContractError); + }); + + it('uses the exact private GET and rejects mismatches or oversized bodies', async () => { + const request = vi.fn().mockResolvedValue(new Response(JSON.stringify(active), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + const gateway = new HttpEarlyBirdMembershipGateway({ + baseUrl: 'http://pmp-myth-api:8765', + keyId: '2026-08-current', + token: 's'.repeat(43), + }, request); + + await expect(gateway.readMembership('listener-1')).resolves.toMatchObject({ + ok: true, + membership: { account_id: 'listener-1' }, + }); + expect(request).toHaveBeenCalledWith( + 'http://pmp-myth-api:8765/api/internal/v2/early-bird-memberships/listener-1', + expect.objectContaining({ + method: 'GET', + redirect: 'error', + cache: 'no-store', + headers: expect.objectContaining({ + authorization: `Bearer ${'s'.repeat(43)}`, + 'x-hb-service-key-id': '2026-08-current', + }), + }), + ); + + request.mockResolvedValueOnce(new Response(JSON.stringify({ ...active, account_id: 'other' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); + + const oversized = cancellableResponse(200, { + 'content-type': 'application/json', + 'content-length': String(65 * 1024), + }); + request.mockResolvedValueOnce(oversized.response); + await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); + expect(oversized.wasCancelled()).toBe(true); + }); + + it('distinguishes canonical not-found and fails closed on other statuses', async () => { + const notFound = cancellableResponse(404); + const request = vi.fn().mockResolvedValue(notFound.response); + const gateway = new HttpEarlyBirdMembershipGateway({ + baseUrl: 'https://authority.example.test', + keyId: 'current', + token: 's'.repeat(43), + }, request); + await expect(gateway.readMembership('listener-1')).resolves.toEqual({ + ok: false, + reason: 'not-found', + }); + expect(notFound.wasCancelled()).toBe(true); + + const unauthorized = cancellableResponse(401); + request.mockResolvedValueOnce(unauthorized.response); + await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); + expect(unauthorized.wasCancelled()).toBe(true); + + const wrongContentType = cancellableResponse(200, { 'content-type': 'text/html' }); + request.mockResolvedValueOnce(wrongContentType.response); + await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); + expect(wrongContentType.wasCancelled()).toBe(true); + }); + + it('cancels an undeclared chunked body as soon as it exceeds 64 KiB', async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + pulls += 1; + if (pulls <= 4) { + controller.enqueue(new Uint8Array(24 * 1024).fill(0x20)); + } else { + controller.close(); + } + }, + cancel() { + cancelled = true; + }, + }, { highWaterMark: 0 }); + const request = vi.fn().mockResolvedValue(new Response(body, { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + const gateway = new HttpEarlyBirdMembershipGateway({ + baseUrl: 'https://authority.example.test', + keyId: 'current', + token: 's'.repeat(43), + }, request); + + await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); + expect(cancelled).toBe(true); + expect(pulls).toBe(3); + }); +}); diff --git a/src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts b/src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts new file mode 100644 index 00000000..bdd26474 --- /dev/null +++ b/src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts @@ -0,0 +1,65 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { prisma } from '@/lib/db'; + +import { + applyFounderEligibilityProjection, + FounderEligibilityConflictError, +} from '../founder-eligibility'; +import type { FounderPriceEligibility } from '../membership-contract'; + +const postgres = process.env.LISTENER_TEST_DATABASE_URL ? describe : describe.skip; +const accountIds = ['listener-founder-pg-identical', 'listener-founder-pg-conflict']; +const eligibility: FounderPriceEligibility = { + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, + canonical_price: { currency: 'USD', amount_minor: 200 }, + billing_period: 'MONTHLY', + granted_at: '2026-08-06T12:00:00Z', +}; + +postgres('Founder eligibility PostgreSQL convergence', () => { + beforeAll(async () => { + await prisma.earlyBirdUser.createMany({ + data: accountIds.map((id, index) => ({ + id, + name: `Synthetic Listener ${index}`, + email: `founder-pg-${index}@example.invalid`, + emailVerified: true, + })), + skipDuplicates: true, + }); + }); + + afterAll(async () => { + await prisma.earlyBirdUser.deleteMany({ where: { id: { in: accountIds } } }); + await prisma.$disconnect(); + }); + + it('converges concurrent identical evidence to one positive row', async () => { + const outcomes = await Promise.all([ + applyFounderEligibilityProjection(accountIds[0], eligibility), + applyFounderEligibilityProjection(accountIds[0], eligibility), + ]); + expect(outcomes.sort()).toEqual(['APPLIED', 'REPLAYED']); + await expect(prisma.earlyBirdFounderEligibilityProjection.count({ + where: { accountId: accountIds[0] }, + })).resolves.toBe(1); + }); + + it('preserves one winner when concurrent positive evidence conflicts', async () => { + const conflicting: FounderPriceEligibility = { + ...eligibility, + offer: { ...eligibility.offer, revision: 2 }, + }; + const results = await Promise.allSettled([ + applyFounderEligibilityProjection(accountIds[1], eligibility), + applyFounderEligibilityProjection(accountIds[1], conflicting), + ]); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + const rejected = results.find((result) => result.status === 'rejected'); + expect(rejected).toMatchObject({ reason: expect.any(FounderEligibilityConflictError) }); + await expect(prisma.earlyBirdFounderEligibilityProjection.count({ + where: { accountId: accountIds[1] }, + })).resolves.toBe(1); + }); +}); diff --git a/src/lib/early-birds/__tests__/founder-eligibility.test.ts b/src/lib/early-birds/__tests__/founder-eligibility.test.ts new file mode 100644 index 00000000..b05ea5d3 --- /dev/null +++ b/src/lib/early-birds/__tests__/founder-eligibility.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const tx = vi.hoisted(() => ({ + $queryRaw: vi.fn(), + earlyBirdFounderEligibilityProjection: { + findUnique: vi.fn(), + create: vi.fn(), + }, +})); +const prisma = vi.hoisted(() => ({ + $transaction: vi.fn((callback: (client: typeof tx) => unknown) => callback(tx)), +})); + +vi.mock('@/lib/db', () => ({ prisma })); + +import { + applyFounderEligibilityProjection, + FounderEligibilityAccountNotFoundError, + FounderEligibilityConflictError, + founderEligibilityHash, +} from '../founder-eligibility'; +import type { FounderPriceEligibility } from '../membership-contract'; + +const NOW = new Date('2026-08-08T12:00:00Z'); +const eligibility: FounderPriceEligibility = { + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, + canonical_price: { currency: 'USD', amount_minor: 200 }, + billing_period: 'MONTHLY', + granted_at: '2026-08-06T12:00:00Z', +}; + +function row(overrides: Record = {}) { + return { + accountId: 'listener-1', + offerCode: eligibility.offer.code, + offerRevision: eligibility.offer.revision, + currency: eligibility.canonical_price.currency, + amountMinor: eligibility.canonical_price.amount_minor, + billingPeriod: eligibility.billing_period, + grantedAt: new Date(eligibility.granted_at), + eligibilityHash: founderEligibilityHash(eligibility), + observedAt: NOW, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + } as never; +} + +describe('positive-only Founder eligibility projection', () => { + beforeEach(() => { + vi.clearAllMocks(); + tx.$queryRaw.mockResolvedValue([{ id: 'listener-1' }]); + tx.earlyBirdFounderEligibilityProjection.findUnique.mockResolvedValue(null); + tx.earlyBirdFounderEligibilityProjection.create.mockResolvedValue(row()); + }); + + it('returns ABSENT without writing when canonical evidence is null', async () => { + await expect(applyFounderEligibilityProjection('listener-1', null, NOW)) + .resolves.toBe('ABSENT'); + expect(tx.earlyBirdFounderEligibilityProjection.create).not.toHaveBeenCalled(); + }); + + it('applies the first positive evidence and replays exact evidence', async () => { + await expect(applyFounderEligibilityProjection('listener-1', eligibility, NOW)) + .resolves.toBe('APPLIED'); + expect(tx.earlyBirdFounderEligibilityProjection.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + accountId: 'listener-1', + currency: 'USD', + amountMinor: 200, + billingPeriod: 'MONTHLY', + eligibilityHash: founderEligibilityHash(eligibility), + }), + }); + + tx.earlyBirdFounderEligibilityProjection.findUnique.mockResolvedValue(row()); + await expect(applyFounderEligibilityProjection('listener-1', eligibility, NOW)) + .resolves.toBe('REPLAYED'); + }); + + it('replays equivalent timestamp spellings after UTC millisecond normalization', async () => { + tx.earlyBirdFounderEligibilityProjection.findUnique.mockResolvedValue(row()); + const equivalents = [ + '2026-08-06T09:00:00-03:00', + '2026-08-06T12:00:00.000Z', + '2026-08-06T12:00:00.000000000Z', + ]; + for (const grantedAt of equivalents) { + await expect(applyFounderEligibilityProjection('listener-1', { + ...eligibility, + granted_at: grantedAt, + }, NOW)).resolves.toBe('REPLAYED'); + } + }); + + it('preserves positive evidence against null or a conflicting positive payload', async () => { + tx.earlyBirdFounderEligibilityProjection.findUnique.mockResolvedValue(row()); + await expect(applyFounderEligibilityProjection('listener-1', null, NOW)) + .rejects.toBeInstanceOf(FounderEligibilityConflictError); + await expect(applyFounderEligibilityProjection('listener-1', { + ...eligibility, + offer: { ...eligibility.offer, revision: 2 }, + }, NOW)).rejects.toBeInstanceOf(FounderEligibilityConflictError); + expect(tx.earlyBirdFounderEligibilityProjection.create).not.toHaveBeenCalled(); + }); + + it('fails before any evidence lookup when the local account is absent', async () => { + tx.$queryRaw.mockResolvedValue([]); + await expect(applyFounderEligibilityProjection('missing', eligibility, NOW)) + .rejects.toBeInstanceOf(FounderEligibilityAccountNotFoundError); + expect(tx.earlyBirdFounderEligibilityProjection.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/early-birds/founder-eligibility.ts b/src/lib/early-birds/founder-eligibility.ts new file mode 100644 index 00000000..954a452c --- /dev/null +++ b/src/lib/early-birds/founder-eligibility.ts @@ -0,0 +1,104 @@ +import { createHash } from 'node:crypto'; + +import { Prisma } from '@prisma/client'; + +import { prisma } from '@/lib/db'; + +import { + canonicalRfc3339Instant, + type FounderPriceEligibility, +} from './membership-contract'; +import { jcsCanonicalize } from './membership'; + +export type FounderEligibilityProjectionOutcome = 'ABSENT' | 'APPLIED' | 'REPLAYED'; + +export class FounderEligibilityConflictError extends Error { + constructor() { + super('Canonical Founder price eligibility conflicts with durable local evidence'); + this.name = 'FounderEligibilityConflictError'; + } +} + +export class FounderEligibilityAccountNotFoundError extends Error { + constructor() { + super('Listener account does not exist'); + this.name = 'FounderEligibilityAccountNotFoundError'; + } +} + +function normalizedEligibility(value: FounderPriceEligibility): FounderPriceEligibility { + if ( + value.offer.code !== 'EARLY_BIRDS_FOUNDERS_V1' + || !Number.isSafeInteger(value.offer.revision) + || value.offer.revision < 1 + || value.canonical_price.currency !== 'USD' + || value.canonical_price.amount_minor !== 200 + || value.billing_period !== 'MONTHLY' + ) { + throw new FounderEligibilityConflictError(); + } + try { + return { + ...value, + granted_at: canonicalRfc3339Instant(value.granted_at, 'Founder granted_at'), + }; + } catch { + throw new FounderEligibilityConflictError(); + } +} + +export function founderEligibilityHash(eligibility: FounderPriceEligibility): string { + return createHash('sha256') + .update(jcsCanonicalize(normalizedEligibility(eligibility))) + .digest('hex'); +} + +export async function applyFounderEligibilityProjection( + accountId: string, + rawEligibility: FounderPriceEligibility | null, + observedAt = new Date(), +): Promise { + return prisma.$transaction(async (tx) => { + const accounts = await tx.$queryRaw>( + Prisma.sql`SELECT "id" FROM "early_bird_users" WHERE "id" = ${accountId} FOR UPDATE`, + ); + if (accounts.length !== 1) throw new FounderEligibilityAccountNotFoundError(); + + const existing = await tx.earlyBirdFounderEligibilityProjection.findUnique({ + where: { accountId }, + }); + if (rawEligibility === null) { + if (existing) throw new FounderEligibilityConflictError(); + return 'ABSENT'; + } + + const eligibility = normalizedEligibility(rawEligibility); + const eligibilityHash = founderEligibilityHash(eligibility); + if (existing) { + const exact = existing.eligibilityHash === eligibilityHash + && existing.offerCode === eligibility.offer.code + && existing.offerRevision === eligibility.offer.revision + && existing.currency === eligibility.canonical_price.currency + && existing.amountMinor === eligibility.canonical_price.amount_minor + && existing.billingPeriod === eligibility.billing_period + && existing.grantedAt.toISOString() === new Date(eligibility.granted_at).toISOString(); + if (!exact) throw new FounderEligibilityConflictError(); + return 'REPLAYED'; + } + + await tx.earlyBirdFounderEligibilityProjection.create({ + data: { + accountId, + offerCode: eligibility.offer.code, + offerRevision: eligibility.offer.revision, + currency: eligibility.canonical_price.currency, + amountMinor: eligibility.canonical_price.amount_minor, + billingPeriod: eligibility.billing_period, + grantedAt: new Date(eligibility.granted_at), + eligibilityHash, + observedAt, + }, + }); + return 'APPLIED'; + }); +} diff --git a/src/lib/early-birds/membership-contract.ts b/src/lib/early-birds/membership-contract.ts index c05d3e84..2b7ff02d 100644 --- a/src/lib/early-birds/membership-contract.ts +++ b/src/lib/early-birds/membership-contract.ts @@ -10,6 +10,7 @@ const AUTHORITY_KEYS = [ 'grace_until', 'membership_revision', 'offer', 'paid_through', 'provider', 'reason_code', 'schema_version', 'source', 'state', ] as const; +const AUTHORITY_V2_KEYS = [...AUTHORITY_KEYS, 'founder_price_eligibility'] as const; const STATES = [ 'PENDING', 'ACTIVE', 'GRACE', 'CANCELLED_PENDING_END', 'EXPIRED', 'REFUNDED', 'REVOKED', ] as const; @@ -23,12 +24,25 @@ export class EarlyBirdMembershipContractError extends Error { } } -type CanonicalAuthorityMembership = Omit & { +export type CanonicalAuthorityMembership = Omit & { schema_version: 'early-bird-authority.membership.v1'; access_allowed: boolean; free_entitlement_consumed: boolean; }; +export type FounderPriceEligibility = { + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1'; revision: number }; + canonical_price: { currency: 'USD'; amount_minor: 200 }; + billing_period: 'MONTHLY'; + granted_at: string; +}; + +export type CanonicalAuthorityMembershipV2 = + Omit & { + schema_version: 'early-bird-authority.membership.v2'; + founder_price_eligibility: FounderPriceEligibility | null; + }; + function record(value: unknown, label: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new EarlyBirdMembershipContractError(`${label} must be an object`); @@ -58,6 +72,47 @@ function instant(value: unknown, field: string, nullable = false): string | null return value; } +export function canonicalRfc3339Instant(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new EarlyBirdMembershipContractError(`${field} must be an RFC 3339 date-time`); + } + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/.exec(value); + if (!match) { + throw new EarlyBirdMembershipContractError(`${field} must be an RFC 3339 date-time`); + } + const [, yearRaw, monthRaw, dayRaw, hourRaw, minuteRaw, secondRaw, , offset] = match; + const year = Number(yearRaw); + const month = Number(monthRaw); + const day = Number(dayRaw); + const hour = Number(hourRaw); + const minute = Number(minuteRaw); + const second = Number(secondRaw); + const offsetHour = offset === 'Z' ? 0 : Number(offset.slice(1, 3)); + const offsetMinute = offset === 'Z' ? 0 : Number(offset.slice(4, 6)); + const daysInMonth = month >= 1 && month <= 12 + ? new Date(Date.UTC(year, month, 0)).getUTCDate() + : 0; + if ( + day < 1 || day > daysInMonth + || hour > 23 || minute > 59 || second > 59 + || offsetHour > 23 || offsetMinute > 59 + ) { + throw new EarlyBirdMembershipContractError(`${field} must be an RFC 3339 date-time`); + } + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) { + throw new EarlyBirdMembershipContractError(`${field} must be an RFC 3339 date-time`); + } + // PostgreSQL/Prisma stores this projection at millisecond precision. One + // canonical UTC representation prevents equivalent wire forms from + // producing different durable evidence hashes. + return new Date(parsed).toISOString(); +} + +function canonicalNullableRfc3339Instant(value: unknown, field: string): string | null { + return value === null ? null : canonicalRfc3339Instant(value, field); +} + function offer(value: unknown): EarlyBirdMembershipProjectionCommand['offer'] { if (value === null) return null; const input = record(value, 'offer'); @@ -83,6 +138,32 @@ function price(value: unknown): EarlyBirdMembershipProjectionCommand['current_pr }; } +function founderPriceEligibility(value: unknown): FounderPriceEligibility | null { + if (value === null) return null; + const input = record(value, 'founder_price_eligibility'); + exactKeys(input, ['billing_period', 'canonical_price', 'granted_at', 'offer']); + + const parsedOffer = offer(input.offer); + if (parsedOffer === null) { + throw new EarlyBirdMembershipContractError('Founder price eligibility offer is invalid'); + } + const canonicalPrice = record(input.canonical_price, 'founder canonical_price'); + exactKeys(canonicalPrice, ['amount_minor', 'currency']); + if (canonicalPrice.currency !== 'USD' || canonicalPrice.amount_minor !== 200) { + throw new EarlyBirdMembershipContractError('Founder canonical_price is invalid'); + } + if (input.billing_period !== 'MONTHLY') { + throw new EarlyBirdMembershipContractError('Founder billing_period is invalid'); + } + const grantedAt = canonicalRfc3339Instant(input.granted_at, 'Founder granted_at'); + return { + offer: parsedOffer, + canonical_price: { currency: 'USD', amount_minor: 200 }, + billing_period: 'MONTHLY', + granted_at: grantedAt, + }; +} + function common(input: Record) { if (!isEarlyBirdAccountId(input.account_id)) { throw new EarlyBirdMembershipContractError('account_id is invalid'); @@ -159,6 +240,44 @@ export function parseCanonicalAuthorityMembership(value: unknown): CanonicalAuth return membership; } +export function parseCanonicalAuthorityMembershipV2(value: unknown): CanonicalAuthorityMembershipV2 { + const input = record(value, 'authority membership v2'); + exactKeys(input, AUTHORITY_V2_KEYS); + if (input.schema_version !== 'early-bird-authority.membership.v2') { + throw new EarlyBirdMembershipContractError('Unsupported authority membership schema'); + } + if (typeof input.access_allowed !== 'boolean' || typeof input.free_entitlement_consumed !== 'boolean') { + throw new EarlyBirdMembershipContractError('Authority membership booleans are invalid'); + } + const eligibility = founderPriceEligibility(input.founder_price_eligibility); + const shared = common(input); + const membership: CanonicalAuthorityMembershipV2 = { + schema_version: input.schema_version, + ...shared, + effective_at: canonicalRfc3339Instant(input.effective_at, 'effective_at'), + paid_through: canonicalNullableRfc3339Instant(input.paid_through, 'paid_through'), + grace_until: canonicalNullableRfc3339Instant(input.grace_until, 'grace_until'), + access_allowed: input.access_allowed, + free_entitlement_consumed: input.free_entitlement_consumed, + founder_price_eligibility: eligibility, + }; + if (membership.access_allowed !== canonicalAccessAllowed(membership)) { + throw new EarlyBirdMembershipContractError( + 'Authority access decision contradicts membership state or time bounds', + ); + } + if ( + membership.access_allowed + && (membership.source === 'PAYPAL' || membership.source === 'MERCADO_PAGO') + && eligibility === null + ) { + throw new EarlyBirdMembershipContractError( + 'Paid access is missing canonical Founder price eligibility', + ); + } + return membership; +} + export function authorityMembershipCommand( membership: CanonicalAuthorityMembership, ): EarlyBirdMembershipProjectionCommand { diff --git a/src/lib/early-birds/membership-gateway.ts b/src/lib/early-birds/membership-gateway.ts index 8a9ba2c9..05348b0d 100644 --- a/src/lib/early-birds/membership-gateway.ts +++ b/src/lib/early-birds/membership-gateway.ts @@ -4,6 +4,8 @@ import { isEarlyBirdAccountId } from './account-id'; import { authorityMembershipCommand, parseCanonicalAuthorityMembership, + parseCanonicalAuthorityMembershipV2, + type CanonicalAuthorityMembershipV2, } from './membership-contract'; import { applyMembershipProjection, @@ -12,6 +14,52 @@ import { const INVITATION_TOKEN = /^ebi_v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; const REQUEST_TIMEOUT_MS = 5_000; +const MAX_AUTHORITY_RESPONSE_BYTES = 64 * 1024; + +async function cancelResponseBody(response: Response): Promise { + await response.body?.cancel().catch(() => undefined); +} + +async function boundedResponseText(response: Response): Promise { + const declaredLength = response.headers.get('content-length'); + if (declaredLength !== null) { + if (!/^\d+$/.test(declaredLength) || Number(declaredLength) > MAX_AUTHORITY_RESPONSE_BYTES) { + await cancelResponseBody(response); + throw new EarlyBirdMembershipGatewayUnavailableError(); + } + } + + const reader = response.body?.getReader(); + if (!reader) return ''; + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_AUTHORITY_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + throw new EarlyBirdMembershipGatewayUnavailableError(); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const body = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(body); + } catch { + throw new EarlyBirdMembershipGatewayUnavailableError(); + } +} export type CanonicalFreeRedemptionResult = | { @@ -29,6 +77,14 @@ export interface EarlyBirdMembershipGateway { }): Promise; } +export type CanonicalMembershipReadResult = + | { ok: true; membership: CanonicalAuthorityMembershipV2 } + | { ok: false; reason: 'not-found' }; + +export interface EarlyBirdMembershipReader { + readMembership(accountId: string): Promise; +} + export class EarlyBirdMembershipGatewayUnavailableError extends Error { constructor() { super('Canonical EarlyBird membership gateway is unavailable'); @@ -62,7 +118,7 @@ function redemptionIdempotencyKey(accountId: string, invitation: string): string return `early-bird-invitation-redeem:${digest}`; } -export class HttpEarlyBirdMembershipGateway implements EarlyBirdMembershipGateway { +export class HttpEarlyBirdMembershipGateway implements EarlyBirdMembershipGateway, EarlyBirdMembershipReader { constructor( private readonly config: GatewayConfig = gatewayConfig(), private readonly request: typeof fetch = fetch, @@ -117,18 +173,79 @@ export class HttpEarlyBirdMembershipGateway implements EarlyBirdMembershipGatewa clearTimeout(timeout); } } + + async readMembership(accountId: string): Promise { + if (!isEarlyBirdAccountId(accountId)) throw new EarlyBirdMembershipGatewayUnavailableError(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await this.request( + `${this.config.baseUrl}/api/internal/v2/early-bird-memberships/${encodeURIComponent(accountId)}`, + { + method: 'GET', + redirect: 'error', + cache: 'no-store', + signal: controller.signal, + headers: { + accept: 'application/json', + authorization: `Bearer ${this.config.token}`, + 'x-hb-service-key-id': this.config.keyId, + }, + }, + ); + if (response.status === 404) { + await cancelResponseBody(response); + return { ok: false, reason: 'not-found' }; + } + if (!response.ok) { + await cancelResponseBody(response); + throw new EarlyBirdMembershipGatewayUnavailableError(); + } + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + if (contentType !== 'application/json') { + await cancelResponseBody(response); + throw new EarlyBirdMembershipGatewayUnavailableError(); + } + const raw = await boundedResponseText(response); + let body: unknown; + try { + body = JSON.parse(raw) as unknown; + } catch { + throw new EarlyBirdMembershipGatewayUnavailableError(); + } + const membership = parseCanonicalAuthorityMembershipV2(body); + if (membership.account_id !== accountId) { + throw new EarlyBirdMembershipGatewayUnavailableError(); + } + return { ok: true, membership }; + } catch (error) { + if (error instanceof EarlyBirdMembershipGatewayUnavailableError) throw error; + throw new EarlyBirdMembershipGatewayUnavailableError(); + } finally { + clearTimeout(timeout); + } + } } let gatewayOverride: EarlyBirdMembershipGateway | null = null; +let readerOverride: EarlyBirdMembershipReader | null = null; export function setEarlyBirdMembershipGatewayForTests(gateway: EarlyBirdMembershipGateway | null): void { gatewayOverride = gateway; } +export function setEarlyBirdMembershipReaderForTests(reader: EarlyBirdMembershipReader | null): void { + readerOverride = reader; +} + export function earlyBirdMembershipGateway(): EarlyBirdMembershipGateway { return gatewayOverride ?? new HttpEarlyBirdMembershipGateway(); } +export function earlyBirdMembershipReader(): EarlyBirdMembershipReader { + return readerOverride ?? new HttpEarlyBirdMembershipGateway(); +} + /** The browser's opaque invitation is consumed only by the canonical authority. */ export async function redeemFreeThroughCanonicalGateway( accountId: string, From 95eb60adf850c6dc66bf914141d68b28aaaab193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 06:34:24 -0300 Subject: [PATCH 131/376] fix(listener): support radio keyboard navigation --- src/components/early-birds/ListenerPlayer.tsx | 112 ++++++++++++++---- .../__tests__/ListenerTransport.test.tsx | 63 ++++++++++ 2 files changed, 155 insertions(+), 20 deletions(-) diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 327f4754..e7b76e34 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -98,6 +98,89 @@ function preferredDropLanguage( return englishDropIn ? 'en' : 'es'; } +function ListenerPlaybackModeSelector({ + value, + introLabel, + beaconLabel, + groupLabel, + introDisabled, + beaconDisabled, + onChange, +}: { + value: PlaybackMode; + introLabel: string; + beaconLabel: string; + groupLabel: string; + introDisabled: boolean; + beaconDisabled: boolean; + onChange: (mode: PlaybackMode) => void; +}) { + const introButton = useRef(null); + const beaconButton = useRef(null); + const modes: PlaybackMode[] = ['intro', 'beacon']; + const disabled = { intro: introDisabled, beacon: beaconDisabled }; + const buttons = { intro: introButton, beacon: beaconButton }; + const enabledModes = modes.filter((mode) => !disabled[mode]); + const tabStop = enabledModes.includes(value) ? value : enabledModes[0]; + + function selectAndFocus(mode: PlaybackMode) { + if (disabled[mode]) return; + onChange(mode); + buttons[mode].current?.focus(); + } + + function handleKeyDown(event: React.KeyboardEvent, current: PlaybackMode) { + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + if (enabledModes.length === 0) return; + + if (event.key === 'Home') { + selectAndFocus(enabledModes[0]); + return; + } + if (event.key === 'End') { + selectAndFocus(enabledModes[enabledModes.length - 1]); + return; + } + + const currentIndex = enabledModes.indexOf(current); + const direction = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1; + const nextIndex = currentIndex < 0 + ? 0 + : (currentIndex + direction + enabledModes.length) % enabledModes.length; + selectAndFocus(enabledModes[nextIndex]); + } + + return ( +
+ + +
+ ); +} + export default function ListenerPlayer({ dropIns, }: { @@ -1079,26 +1162,15 @@ export default function ListenerPlayer({ {phaseLabel}

} -
- - -
+
{availableDropCount > 1 && !transportActive && ( diff --git a/src/components/early-birds/__tests__/ListenerTransport.test.tsx b/src/components/early-birds/__tests__/ListenerTransport.test.tsx index 0ca50a35..9a9a4a42 100644 --- a/src/components/early-birds/__tests__/ListenerTransport.test.tsx +++ b/src/components/early-birds/__tests__/ListenerTransport.test.tsx @@ -130,6 +130,69 @@ describe('Listener one-action playlist transport', () => { .toHaveAttribute('aria-checked', 'true')); }); + it('uses one roving tab stop and selects playback modes with arrow keys', async () => { + prepareMedia(); + renderPlayer(); + await waitForListen(); + const intro = screen.getByRole('radio', { name: /With introduction/ }); + const beacon = screen.getByRole('radio', { name: /Beacon only/ }); + + expect(intro).toHaveAttribute('tabindex', '0'); + expect(beacon).toHaveAttribute('tabindex', '-1'); + intro.focus(); + + fireEvent.keyDown(intro, { key: 'ArrowRight' }); + expect(beacon).toHaveFocus(); + expect(beacon).toHaveAttribute('aria-checked', 'true'); + expect(beacon).toHaveAttribute('tabindex', '0'); + expect(intro).toHaveAttribute('tabindex', '-1'); + + fireEvent.keyDown(beacon, { key: 'ArrowDown' }); + expect(intro).toHaveFocus(); + expect(intro).toHaveAttribute('aria-checked', 'true'); + + fireEvent.keyDown(intro, { key: 'ArrowLeft' }); + expect(beacon).toHaveFocus(); + expect(beacon).toHaveAttribute('aria-checked', 'true'); + + fireEvent.keyDown(beacon, { key: 'ArrowUp' }); + expect(intro).toHaveFocus(); + expect(intro).toHaveAttribute('aria-checked', 'true'); + }); + + it('moves to the first and last playback mode with Home and End', async () => { + prepareMedia(); + renderPlayer(); + await waitForListen(); + const intro = screen.getByRole('radio', { name: /With introduction/ }); + const beacon = screen.getByRole('radio', { name: /Beacon only/ }); + + intro.focus(); + fireEvent.keyDown(intro, { key: 'End' }); + expect(beacon).toHaveFocus(); + expect(beacon).toHaveAttribute('aria-checked', 'true'); + + fireEvent.keyDown(beacon, { key: 'Home' }); + expect(intro).toHaveFocus(); + expect(intro).toHaveAttribute('aria-checked', 'true'); + }); + + it('keeps the available Beacon mode as the only tab stop when no intro exists', async () => { + prepareMedia(); + renderPlayer({ es: null, en: null }); + await waitForListen(); + const intro = screen.getByRole('radio', { name: /With introduction/ }); + const beacon = screen.getByRole('radio', { name: /Beacon only/ }); + + expect(intro).toBeDisabled(); + expect(intro).toHaveAttribute('tabindex', '-1'); + expect(beacon).toHaveAttribute('tabindex', '0'); + beacon.focus(); + fireEvent.keyDown(beacon, { key: 'ArrowRight' }); + expect(beacon).toHaveFocus(); + expect(beacon).toHaveAttribute('aria-checked', 'true'); + }); + it('pauses and resumes the intro at the same position', async () => { const { play, pause } = prepareMedia(); renderPlayer(); From 9fc4c5b09c0c3933dd9c136ac2790e24f8957a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 08:29:51 -0300 Subject: [PATCH 132/376] test(listener): re-pin approved accessibility baseline --- src/lib/listener/__tests__/media-boundary.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts index b6d153b9..978ee52e 100644 --- a/src/lib/listener/__tests__/media-boundary.test.ts +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -5,7 +5,9 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const MEDIA_FILE_SHA256 = { - 'src/components/early-birds/ListenerPlayer.tsx': '7b0f6f4e75e6b1c84ad65bb3ccbb08c32759dd591bcc51048a0cfe3b71dd8b78', + // Re-pinned after the accepted radio-keyboard accessibility-only refactor + // in PR #252. Playback, media sources and signal processing were untouched. + 'src/components/early-birds/ListenerPlayer.tsx': 'fdf787402e94cc97a3b29b9291dc63e52b7a859d1ff9e9c505c99fe3aba720b1', 'src/lib/early-birds/stream.ts': 'e386413874e5ad799e17607e2b030851cc7baadea48161191c7daecb45183bea', 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', 'src/app/api/early-birds/stream/lease/route.ts': 'd858affc655c6df6607e76470508a59c3ebd442744bc12e5da334729fcb5d660', From a66374bcbfdb92326b236ad394f532ded47619dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 08:30:16 -0300 Subject: [PATCH 133/376] refactor(listener): isolate public presentation from event UI --- docs/plans/EARLY_BIRDS.md | 20 ++ e2e/tests/early-birds-responsive.spec.ts | 85 +++++++- src/app/globals.css | 169 ++++++++++++++++ .../early-birds/EarlyBirdLanding.tsx | 10 +- .../early-birds/EarlyBirdUnavailable.tsx | 2 +- .../early-birds/FreeInvitationRedeemer.tsx | 6 +- .../early-birds/FreeWindowSetup.tsx | 10 +- .../early-birds/SyntheticTeamEntryForm.tsx | 10 +- .../early-birds/WelcomeAccessAction.tsx | 4 +- .../listener-visual-isolation.test.tsx | 186 ++++++++++++++++++ 10 files changed, 480 insertions(+), 22 deletions(-) create mode 100644 src/components/early-birds/__tests__/listener-visual-isolation.test.tsx diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 64d432b6..8e30b9b7 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -355,6 +355,26 @@ The media test ladder is mandatory and intentionally incremental: 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. diff --git a/e2e/tests/early-birds-responsive.spec.ts b/e2e/tests/early-birds-responsive.spec.ts index 8aba7ab9..7b656030 100644 --- a/e2e/tests/early-birds-responsive.spec.ts +++ b/e2e/tests/early-birds-responsive.spec.ts @@ -2,7 +2,7 @@ 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|m4a|aac|mp3|ogg|wav)(?:[?#]|$)/i; +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', @@ -54,11 +54,86 @@ test.describe('Listener responsive and accessibility boundary', () => { 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: { @@ -79,6 +154,14 @@ test.describe('Listener responsive and accessibility boundary', () => { 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/src/app/globals.css b/src/app/globals.css index 7bffa615..dce6beda 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1871,6 +1871,170 @@ body { line-height: 1.65; } +/* -------------------------------------------- + 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: var(--night); + 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(158, 114, 255, 0.05) 0%, transparent 50%), + radial-gradient(ellipse at 85% 80%, rgba(124, 234, 255, 0.04) 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(--gold) 0 1px, transparent 1.5px), + radial-gradient(circle at 72% 18%, var(--cyan) 0 1px, transparent 1.5px), + radial-gradient(circle at 84% 76%, var(--pink) 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(--font-space-mono), monospace; + font-size: 12px; + font-weight: 400; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; + transition: transform 0.3s ease, box-shadow 0.3s ease, background-color 0.3s 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(--night); + background: linear-gradient(105deg, var(--gold), var(--lime) 56%, var(--cyan)); + box-shadow: 0 8px 28px rgba(124, 234, 255, 0.12), 0 0 20px rgba(255, 216, 117, 0.15); + border-color: rgba(255, 255, 255, 0.2); +} + +.listener-button--primary:hover { + transform: translateY(-2px) scale(1.02); + box-shadow: 0 12px 36px rgba(124, 234, 255, 0.18), 0 0 28px rgba(255, 216, 117, 0.22); +} + +.listener-button--primary:active { + transform: translateY(0) scale(0.99); +} + +.listener-button--secondary { + color: var(--cream); + background: rgba(255, 255, 255, 0.04); + border-color: var(--border-subtle); +} + +.listener-button--secondary:hover { + background: rgba(255, 255, 255, 0.08); + border-color: var(--border-strong); +} + +.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(--cream); + background: rgba(255, 255, 255, 0.04); +} + +.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(7, 18, 15, 0.6); + color: var(--paper); + font-size: 15px; + font-family: var(--font-syne), system-ui, sans-serif; + 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(--pink); + box-shadow: 0 0 0 3px rgba(255, 143, 200, 0.1), 0 0 16px rgba(255, 143, 200, 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-campfire { opacity: 0.72; } .listener-shell__frame { padding-inline: 1rem; } @@ -1911,4 +2075,9 @@ body { .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/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index b076f351..fe3eebed 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -145,7 +145,7 @@ export default function EarlyBirdLanding(props: Props) { : copy.accessUnavailable}

{copy.retryAccess} @@ -163,11 +163,11 @@ export default function EarlyBirdLanding(props: Props) {

{copy.signedIn}

{props.entitled ? ( - + {copy.enter} ) : props.invitationAvailable ? ( - + {copy.redeem} ) : ( @@ -198,7 +198,7 @@ export default function EarlyBirdLanding(props: Props) { type="button" onClick={() => signIn(provider)} disabled={busy !== null} - className="event-button event-button--secondary w-full" + className="listener-button listener-button--secondary w-full" > {busy === provider ? copy.signingIn @@ -233,7 +233,7 @@ export default function EarlyBirdLanding(props: Props) { diff --git a/src/components/early-birds/EarlyBirdUnavailable.tsx b/src/components/early-birds/EarlyBirdUnavailable.tsx index 99b3c268..a00e766a 100644 --- a/src/components/early-birds/EarlyBirdUnavailable.tsx +++ b/src/components/early-birds/EarlyBirdUnavailable.tsx @@ -22,7 +22,7 @@ export default function EarlyBirdUnavailable() { const text = copy[locale]; return ( -
+
diff --git a/src/components/early-birds/FreeInvitationRedeemer.tsx b/src/components/early-birds/FreeInvitationRedeemer.tsx index c4695808..80abd8e7 100644 --- a/src/components/early-birds/FreeInvitationRedeemer.tsx +++ b/src/components/early-birds/FreeInvitationRedeemer.tsx @@ -44,7 +44,7 @@ export default function FreeInvitationRedeemer() { } return ( -
+
@@ -53,12 +53,12 @@ export default function FreeInvitationRedeemer() {

{copy.eyebrow}

{copy.heading}

{copy.body}

- {error &&

{copy.error}

} + {error &&

{copy.error}

} diff --git a/src/components/early-birds/FreeWindowSetup.tsx b/src/components/early-birds/FreeWindowSetup.tsx index d9f9948b..9f5aec36 100644 --- a/src/components/early-birds/FreeWindowSetup.tsx +++ b/src/components/early-birds/FreeWindowSetup.tsx @@ -119,7 +119,7 @@ export default function FreeWindowSetup({ state }: { state: SerializedEarlyBirdF
)} - {error &&

{copy.freeScheduleError}

} + {error &&

{copy.freeScheduleError}

}
); } diff --git a/src/components/early-birds/SyntheticTeamEntryForm.tsx b/src/components/early-birds/SyntheticTeamEntryForm.tsx index 9185e0d8..57bab75a 100644 --- a/src/components/early-birds/SyntheticTeamEntryForm.tsx +++ b/src/components/early-birds/SyntheticTeamEntryForm.tsx @@ -65,7 +65,7 @@ export default function SyntheticTeamEntryForm({ autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} - className="event-field mt-1" + className="listener-input mt-1" /> - {failed &&

{copy.failed}

} - diff --git a/src/components/early-birds/WelcomeAccessAction.tsx b/src/components/early-birds/WelcomeAccessAction.tsx index ce4fd02e..ce4c7027 100644 --- a/src/components/early-birds/WelcomeAccessAction.tsx +++ b/src/components/early-birds/WelcomeAccessAction.tsx @@ -39,13 +39,13 @@ export default function WelcomeAccessAction() {

{copy.welcomeDescription}

- {error &&

{copy.welcomeError}

} + {error &&

{copy.welcomeError}

}
); } diff --git a/src/components/early-birds/__tests__/listener-visual-isolation.test.tsx b/src/components/early-birds/__tests__/listener-visual-isolation.test.tsx new file mode 100644 index 00000000..ed669901 --- /dev/null +++ b/src/components/early-birds/__tests__/listener-visual-isolation.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; + +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocaleProvider } from '@/context/LocaleContext'; +import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; + +const refresh = vi.hoisted(() => vi.fn()); +const signInSocial = vi.hoisted(() => vi.fn()); +const signInMagicLink = vi.hoisted(() => vi.fn()); +const signOut = vi.hoisted(() => vi.fn()); +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) })); +vi.mock('@/lib/early-birds/auth-client', () => ({ + earlyBirdAuthClient: { signIn: { social: signInSocial, magicLink: signInMagicLink }, signOut }, +})); +vi.mock('@/components/brand/BrandLockup', () => ({ + default: ({ href }: { href: string }) => Harmonic Beacon, +})); + +import EarlyBirdLanding from '../EarlyBirdLanding'; +import EarlyBirdUnavailable from '../EarlyBirdUnavailable'; +import FreeInvitationRedeemer from '../FreeInvitationRedeemer'; +import FreeWindowSetup from '../FreeWindowSetup'; +import SyntheticTeamEntryForm from '../SyntheticTeamEntryForm'; +import WelcomeAccessAction from '../WelcomeAccessAction'; + +const EVENT_VISUAL_CLASS = /event-(shell|button|alert|field|card)/; + +const emptyFreeWindow: SerializedEarlyBirdFreeWindowState = { + configured: false, + active: false, + timeZone: null, + localStartMinute: null, + selectedAt: null, + changeAllowedAt: null, + canChange: true, + activeStart: null, + activeEnd: null, + nextStart: null, + nextEnd: null, +}; + +function renderLanding(overrides: Partial> = {}) { + return render( + + + , + ); +} + +describe('Listener visual isolation from event surfaces (issues #213, #198)', () => { + beforeEach(() => { + refresh.mockReset(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 400 }))); + }); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it('globals.css defines additive listener-scoped mirrors of the borrowed event primitives', () => { + const css = readFileSync('src/app/globals.css', 'utf8'); + for (const rule of [ + '.listener-page-shell', + '.listener-button--primary', + '.listener-button--secondary', + '.listener-button--ghost', + '.listener-alert--danger', + '.listener-alert--error', + '.listener-input', + ]) { + expect(css, `globals.css is missing ${rule}`).toContain(rule); + } + }); + + it('standalone Listener pages render the listener page shell, never the event shell', () => { + const unavailable = render( + , + ); + expect(unavailable.container.querySelector('main')).toHaveClass('listener-page-shell'); + expect(unavailable.container.innerHTML).not.toMatch(EVENT_VISUAL_CLASS); + unavailable.unmount(); + + const redeemer = render( + , + ); + expect(redeemer.container.querySelector('main')).toHaveClass('listener-page-shell'); + expect(redeemer.container.innerHTML).not.toMatch(EVENT_VISUAL_CLASS); + }); + + it('invitation redemption keeps one contextual primary listener action and a styled danger alert', async () => { + render(); + + const action = screen.getByRole('button', { name: 'Activate invitation' }); + expect(action).toHaveClass('listener-button', 'listener-button--primary'); + + await userEvent.click(action); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveClass('listener-alert', 'listener-alert--danger'); + }); + + it('Free schedule errors surface through the styled listener error alert variant', async () => { + render( + , + ); + + const primary = await waitFor(() => screen.getByRole('button', { name: 'Listen free now' })); + expect(primary).toHaveClass('listener-button', 'listener-button--primary'); + expect(screen.getByRole('button', { name: 'Choose another time' })) + .toHaveClass('listener-button', 'listener-button--secondary'); + + await userEvent.click(primary); + const alert = await screen.findByRole('alert'); + // `event-alert--error` never had a rule; the listener-scoped variant does. + expect(alert).toHaveClass('listener-alert', 'listener-alert--error'); + }); + + it('welcome access errors surface through the styled listener error alert variant', async () => { + render(); + + const action = screen.getByRole('button', { name: 'Escuchar ahora' }); + expect(action).toHaveClass('listener-button', 'listener-button--primary'); + + await userEvent.click(action); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveClass('listener-alert', 'listener-alert--error'); + }); + + it('the staging team entry form uses listener fields, alert and button classes only', async () => { + render(); + + expect(screen.getByLabelText('Test name')).toHaveClass('listener-input'); + expect(screen.getByLabelText('Synthetic account')).toHaveClass('listener-input'); + expect(screen.getByLabelText('Temporary access code')).toHaveClass('listener-input'); + expect(screen.getByRole('button', { name: 'Enter staging' })) + .toHaveClass('listener-button', 'listener-button--ghost'); + + await userEvent.type(screen.getByLabelText('Test name'), 'Team Listener'); + await userEvent.type(screen.getByLabelText('Synthetic account'), 'team.listener@e2e.invalid'); + await userEvent.type( + screen.getByLabelText('Temporary access code'), + 'team-staging-access-code-0000000000000001', + ); + await userEvent.click(screen.getByRole('button', { name: 'Enter staging' })); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveClass('listener-alert', 'listener-alert--danger'); + }); + + it('the public landing actions use listener button classes and keep one contextual primary action', () => { + const anonymous = renderLanding(); + expect(screen.getByRole('button', { name: 'Continue with Google' })) + .toHaveClass('listener-button', 'listener-button--secondary'); + expect(anonymous.container.innerHTML).not.toMatch(EVENT_VISUAL_CLASS); + // The anonymous surface offers no competing primary action inside the access card. + expect( + anonymous.container.querySelectorAll('.listener-access__card .listener-button--primary'), + ).toHaveLength(0); + anonymous.unmount(); + + const entitled = renderLanding({ signedIn: true, entitled: true }); + const primaries = entitled.container.querySelectorAll( + '.listener-access__card .listener-button--primary', + ); + expect(primaries).toHaveLength(1); + expect(primaries[0]).toHaveTextContent('Enter the Beacon'); + expect(entitled.container.innerHTML).not.toMatch(EVENT_VISUAL_CLASS); + }); +}); From cccfbbfd50e5303d703e38dd8849128058cddd70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 09:06:10 -0300 Subject: [PATCH 134/376] chore(listener): vendor Mercado Pago checkout v2 contract --- contracts/early-bird-checkout/v2/README.md | 18 ++++++++++++++++++ contracts/early-bird-checkout/v2/SHA256SUMS | 3 +++ .../v2/checkout-create.fixture.json | 8 ++++++++ .../v2/checkout-create.schema.json | 16 ++++++++++++++++ docs/plans/EARLY_BIRDS.md | 19 +++++++++++++++++++ scripts/verify-early-bird-contracts.py | 1 + 6 files changed, 65 insertions(+) create mode 100644 contracts/early-bird-checkout/v2/README.md create mode 100644 contracts/early-bird-checkout/v2/SHA256SUMS create mode 100644 contracts/early-bird-checkout/v2/checkout-create.fixture.json create mode 100644 contracts/early-bird-checkout/v2/checkout-create.schema.json 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..3362fb01 --- /dev/null +++ b/contracts/early-bird-checkout/v2/SHA256SUMS @@ -0,0 +1,3 @@ +a0008e822c60c4d8b7804da90c411c7f07a1b0457b74192260a5d8a14617a191 README.md +128a8b6e1e91604db276ba4b9e4bc8f592ddacadffec19b2d2f037f1dc8d9c87 checkout-create.fixture.json +1e3cbcc19b723ba5fb52ee9b4134533c8ae6f4626d9d884787f5d6585aa5156a 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..e72289b7 --- /dev/null +++ b/contracts/early-bird-checkout/v2/checkout-create.schema.json @@ -0,0 +1,16 @@ +{ + "$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", "pattern": "^acct_[A-Za-z0-9_-]{16,128}$"}, + "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/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 8e30b9b7..a5c13000 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -457,6 +457,25 @@ 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/v1`: the checkout, invitation-redeem and + membership authority family. +- `contracts/early-bird-authority/v2` and `contracts/early-bird-membership/v1`: + the canonical founder membership reads. +- `contracts/early-bird-checkout/v2`: the Mercado Pago checkout command for + `POST /api/internal/v2/early-bird-checkouts`, vendored byte-exact from + backend commit `f4b19a5dee8b2054a59284c719450f72d99af8aa`, merged to backend + main as `1d555aab9ca48d7374a577b40243fb9d43ccc067` + (SairaAsua/proyecciones-mito#54). The provider remains TEST-only and disabled + by default; no public checkout UI or sales activation is part of this slice. + Mandatory external gate before any paid activation: real Mercado Pago TEST + proof that `/preapproval/search?q=hb_` returns the exact + created preapproval. + ## 11. Fast Forward development lane The purpose of isolation is to make development fast, not to reproduce the diff --git a/scripts/verify-early-bird-contracts.py b/scripts/verify-early-bird-contracts.py index 9beaae4b..42cca7b1 100644 --- a/scripts/verify-early-bird-contracts.py +++ b/scripts/verify-early-bird-contracts.py @@ -9,6 +9,7 @@ CONTRACTS = ( ROOT / "contracts/early-bird-authority/v1", ROOT / "contracts/early-bird-authority/v2", + ROOT / "contracts/early-bird-checkout/v2", ROOT / "contracts/early-bird-membership/v1", ) From 45151aecf778b004606821418cfb2a306f5aba49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 09:49:45 -0300 Subject: [PATCH 135/376] feat(listener): observe session cookie migration states --- .../LISTENER_NAMESPACE_MIGRATION.md | 79 ++++++++- .../test/preview-contract.test.mjs | 5 + .../__tests__/route.test.ts | 99 +++++++++++ .../session-cookie-observations/route.ts | 38 +++++ .../auth-session-observation.test.ts | 114 +++++++++++++ src/lib/early-birds/auth.ts | 17 +- .../session-cookie-bridge.integration.test.ts | 59 ++++++- .../__tests__/session-cookie-bridge.test.ts | 157 +++++++++++++++++ .../session-cookie-observability.test.ts | 156 +++++++++++++++++ src/lib/listener/session-cookie-bridge.ts | 102 ++++++++--- .../listener/session-cookie-observability.ts | 158 ++++++++++++++++++ 11 files changed, 956 insertions(+), 28 deletions(-) create mode 100644 src/app/api/internal/v1/listener/session-cookie-observations/__tests__/route.test.ts create mode 100644 src/app/api/internal/v1/listener/session-cookie-observations/route.ts create mode 100644 src/lib/early-birds/__tests__/auth-session-observation.test.ts create mode 100644 src/lib/listener/__tests__/session-cookie-observability.test.ts create mode 100644 src/lib/listener/session-cookie-observability.ts diff --git a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md index 6bce55d9..49300d8b 100644 --- a/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md +++ b/docs/architecture/LISTENER_NAMESPACE_MIGRATION.md @@ -1,7 +1,10 @@ # 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`. This migration is deliberately additive. +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. @@ -186,6 +189,80 @@ 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: diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index ce3de397..cbfb5266 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -218,6 +218,10 @@ test('nginx templates isolate staging, stream and the constrained public Listene 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|membership)/); assert.doesNotMatch(listener, /location \^~ \/early-birds\//); @@ -322,6 +326,7 @@ test('public Listener certificate bootstrap is HTTP-only and fail closed', async 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 () => { 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/lib/early-birds/__tests__/auth-session-observation.test.ts b/src/lib/early-birds/__tests__/auth-session-observation.test.ts new file mode 100644 index 00000000..0ccf73aa --- /dev/null +++ b/src/lib/early-birds/__tests__/auth-session-observation.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ getSession: vi.fn() })); + +// Better Auth is stubbed at the module boundary currentEarlyBirdSession +// actually imports: the resolver under test crosses the real session-cookie +// inspector, observability recording and fail-closed path, with no database +// or network involved. +vi.mock('better-auth/minimal', () => ({ + betterAuth: () => ({ api: { getSession: mocks.getSession }, options: {} }), +})); +vi.mock('better-auth/adapters/prisma', () => ({ prismaAdapter: () => ({}) })); +vi.mock('better-auth/cookies', () => ({ + getCookies: () => ({ + sessionToken: { + name: '__Secure-hb_earlybird_session', + attributes: { path: '/', httpOnly: true, sameSite: 'lax', secure: true }, + }, + }), +})); +vi.mock('@/lib/db', () => ({ prisma: {} })); + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { listenerSessionCookieNames } from '@/lib/listener/session-cookie-bridge'; +import { snapshotListenerSessionCookieObservations } from '@/lib/listener/session-cookie-observability'; +import * as observability from '@/lib/listener/session-cookie-observability'; + +const NAMES = listenerSessionCookieNames('__Secure-hb_earlybird_session'); +const VALUE = 'm1V0k2NlR3JlVG9rZW4.x%2B9ab%2Fcd%3D'; + +describe('currentEarlyBirdSession session-cookie observation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSession.mockResolvedValue(null); + }); + + it('records exactly one observation per resolver invocation', async () => { + const before = snapshotListenerSessionCookieObservations(); + + // Forwarded state: Better Auth runs (stubbed) and the fail-closed + // null shape is unchanged. + await expect(currentEarlyBirdSession(new Headers({ + host: 'listen.harmonicbeacon.com', + cookie: `${NAMES.legacy}=${VALUE}`, + }))).resolves.toBeNull(); + expect(mocks.getSession).toHaveBeenCalledOnce(); + + // Rejected state: fails closed before Better Auth, still one observation. + await expect(currentEarlyBirdSession(new Headers({ + host: 'listen.harmonicbeacon.com', + cookie: `${NAMES.canonical}=${VALUE}`, + }))).resolves.toBeNull(); + expect(mocks.getSession).toHaveBeenCalledOnce(); + + const after = snapshotListenerSessionCookieObservations(); + expect(after.counts.legacy_only - before.counts.legacy_only).toBe(1); + expect(after.counts.canonical_only - before.counts.canonical_only).toBe(1); + expect(after.startedAtSeconds).toBe(before.startedAtSeconds); + }); + + it('preserves the resolver result byte-for-byte when Better Auth answers', async () => { + const expiresAt = new Date('2026-09-01T00:00:00.000Z'); + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Listener', email: 'listener@example.test', image: null }, + session: { id: 'session-1', expiresAt }, + }); + await expect(currentEarlyBirdSession(new Headers({ + host: 'listen.harmonicbeacon.com', + cookie: `${NAMES.canonical}=${VALUE}; ${NAMES.legacy}=${VALUE}`, + }))).resolves.toEqual({ + user: { id: 'user-1', name: 'Listener', email: 'listener@example.test', image: null }, + session: { id: 'session-1', expiresAt }, + }); + }); + + it('does not count staging resolver invocations', async () => { + const before = snapshotListenerSessionCookieObservations(); + await expect(currentEarlyBirdSession(new Headers({ + host: 'earlybirds-staging.harmonicbeacon.com', + cookie: `${NAMES.legacy}=${VALUE}`, + }))).resolves.toBeNull(); + expect(snapshotListenerSessionCookieObservations().counts).toEqual(before.counts); + }); + + it('keeps accepted and rejected resolver behavior unchanged if observation throws', async () => { + const expiresAt = new Date('2026-09-01T00:00:00.000Z'); + const session = { + user: { id: 'user-1', name: 'Listener', email: 'listener@example.test', image: null }, + session: { id: 'session-1', expiresAt }, + }; + mocks.getSession.mockResolvedValue(session); + const spy = vi + .spyOn(observability, 'recordListenerSessionCookieObservation') + .mockImplementation(() => { + throw new Error('observer down'); + }); + try { + await expect(currentEarlyBirdSession(new Headers({ + host: 'listen.harmonicbeacon.com', + cookie: `${NAMES.legacy}=${VALUE}`, + }))).resolves.toEqual(session); + expect(mocks.getSession).toHaveBeenCalledOnce(); + + await expect(currentEarlyBirdSession(new Headers({ + host: 'listen.harmonicbeacon.com', + cookie: `${NAMES.canonical}=${VALUE}`, + }))).resolves.toBeNull(); + expect(mocks.getSession).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalledTimes(2); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/lib/early-birds/auth.ts b/src/lib/early-birds/auth.ts index 937e4fab..bf5506ad 100644 --- a/src/lib/early-birds/auth.ts +++ b/src/lib/early-birds/auth.ts @@ -7,11 +7,13 @@ import { magicLink } from 'better-auth/plugins'; import { prisma } from '@/lib/db'; import { LISTENER_SESSION_COOKIE, + inspectListenerSessionCookie, listenerSessionAuthHandler, listenerSessionCookieNames, - resolveListenerSessionCookie, type ListenerSessionCookieNames, } from '@/lib/listener/session-cookie-bridge'; +import { recordListenerSessionCookieObservation } from '@/lib/listener/session-cookie-observability'; +import { isCanonicalListenerHost } from '@/lib/listener/public-discovery'; import { listenerRuntimeBundle, listenerRuntimeFlag, @@ -297,11 +299,20 @@ export async function currentEarlyBirdSession( const resolvedHeaders = suppliedHeaders ?? new Headers(await requestHeaders()); // The same strict inbound policy as the route handler: ambiguous // session-cookie states never reach Better Auth, they fail closed here. - const resolution = resolveListenerSessionCookie( + // Exactly one aggregate observation is recorded per resolver invocation; + // the metric counts invocations, not unique users, browsers or sessions, + // so multiple observations per navigation are expected. Recording is + // fail-soft and can never change the fail-closed outcome below. + const inspection = inspectListenerSessionCookie( resolvedHeaders.get('cookie'), earlyBirdSessionCookieNames(), ); - if (resolution.kind === 'reject') return null; + if (isCanonicalListenerHost(resolvedHeaders)) { + try { + recordListenerSessionCookieObservation(inspection.state); + } catch { /* Observation must never affect session resolution. */ } + } + if (inspection.resolution.kind === 'reject') return null; const result = await earlyBirdAuth().api.getSession({ headers: resolvedHeaders }); if (!result) return null; diff --git a/src/lib/listener/__tests__/session-cookie-bridge.integration.test.ts b/src/lib/listener/__tests__/session-cookie-bridge.integration.test.ts index 52dc2f5d..091f991a 100644 --- a/src/lib/listener/__tests__/session-cookie-bridge.integration.test.ts +++ b/src/lib/listener/__tests__/session-cookie-bridge.integration.test.ts @@ -9,6 +9,7 @@ import { listenerSessionCookieNames, type ListenerSessionCookieNames, } from '@/lib/listener/session-cookie-bridge'; +import { snapshotListenerSessionCookieObservations } from '@/lib/listener/session-cookie-observability'; type MemoryRow = Record; type BridgedHandler = (request: Request) => Promise; @@ -55,13 +56,19 @@ function bridge(updateAge = 60 * 60 * 24) { }; } -function signUp(handler: BridgedHandler, email: string, cookie?: string): Promise { +function signUp( + handler: BridgedHandler, + email: string, + cookie?: string, + host?: string, +): Promise { return handler(new Request(`${BASE_URL}/api/early-birds/auth/sign-up/email`, { method: 'POST', headers: { origin: BASE_URL, 'content-type': 'application/json', ...(cookie ? { cookie } : {}), + ...(host ? { host } : {}), }, body: JSON.stringify({ email, name: 'Listener', password: 'listener-password-1' }), })); @@ -91,9 +98,12 @@ function sessionCookieValue(response: Response, name: string): string { return entry.slice(name.length + 1, entry.indexOf(';')); } -async function getSession(handler: BridgedHandler, cookie: string | null) { +async function getSession(handler: BridgedHandler, cookie: string | null, host?: string) { const response = await handler(new Request(`${BASE_URL}/api/early-birds/auth/get-session`, { - headers: cookie ? { cookie } : {}, + headers: { + ...(cookie ? { cookie } : {}), + ...(host ? { host } : {}), + }, })); expect(response.status).toBe(200); return { response, body: await response.json() as { user?: { email?: string } } | null }; @@ -451,3 +461,46 @@ describe('Listener session-cookie bridge over a real Better Auth pipeline', () = expect(canonical).toBe(`${state.names.canonical}${legacy.slice(state.names.legacy.length)}`); }); }); + +describe('Listener session-cookie observability over the real pipeline', () => { + it('the auth handler records exactly one observation per invocation', async () => { + const state = bridge(); + const before = snapshotListenerSessionCookieObservations(); + + // Sign-up with no session cookie: one invocation, state `none`. + const response = await signUp( + state.handler, + 'observed@example.test', + undefined, + 'listen.harmonicbeacon.com', + ); + expect(response.status).toBe(200); + const legacy = sessionCookieValue(response, state.names.legacy); + + // One dual-pair get-session: one invocation, state `dual_identical`. + const dual = await getSession( + state.handler, + `${state.names.canonical}=${legacy}; ${state.names.legacy}=${legacy}`, + 'listen.harmonicbeacon.com', + ); + expect(dual.body?.user?.email).toBe('observed@example.test'); + + // One canonical-only rejection: one invocation, state `canonical_only`, + // with the dual-clear behavior unchanged. + const rejected = await state.handler( + new Request(`${BASE_URL}/api/early-birds/auth/get-session`, { + headers: { + host: 'listen.harmonicbeacon.com', + cookie: `${state.names.canonical}=${legacy}`, + }, + }), + ); + await expectBridgeRejection(rejected, 401, state.names); + + const after = snapshotListenerSessionCookieObservations(); + expect(after.counts.none - before.counts.none).toBe(1); + expect(after.counts.dual_identical - before.counts.dual_identical).toBe(1); + expect(after.counts.canonical_only - before.counts.canonical_only).toBe(1); + expect(after.startedAtSeconds).toBe(before.startedAtSeconds); + }); +}); diff --git a/src/lib/listener/__tests__/session-cookie-bridge.test.ts b/src/lib/listener/__tests__/session-cookie-bridge.test.ts index d72fd993..db2a74fb 100644 --- a/src/lib/listener/__tests__/session-cookie-bridge.test.ts +++ b/src/lib/listener/__tests__/session-cookie-bridge.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { LISTENER_SESSION_COOKIE, + inspectListenerSessionCookie, listenerSessionAuthHandler, listenerSessionClearCookies, listenerSessionCookieNames, @@ -9,6 +10,11 @@ import { mirrorListenerSessionResponse, resolveListenerSessionCookie, } from '@/lib/listener/session-cookie-bridge'; +import { + LISTENER_SESSION_COOKIE_STATES, + snapshotListenerSessionCookieObservations, +} from '@/lib/listener/session-cookie-observability'; +import * as observability from '@/lib/listener/session-cookie-observability'; const NAMES = listenerSessionCookieNames('hb_earlybird_session'); const SECURE_NAMES = listenerSessionCookieNames('__Secure-hb_earlybird_session'); @@ -333,3 +339,154 @@ describe('listenerSessionAuthHandler', () => { expect(seenCookies).toEqual([null, `${NAMES.legacy}=${VALUE}`, dual]); }); }); + +describe('inspectListenerSessionCookie', () => { + type Resolution = ReturnType; + type State = ReturnType['state']; + const oversizedValue = `${NAMES.legacy}=${'a'.repeat(513)}`; + const oversizedHeader = `${NAMES.legacy}=${VALUE}; filler=${'f'.repeat(9000)}`; + + // The full state matrix: every inspection state with the exact resolution + // the bridge enforces for it. + const matrix: [string, string | null, Resolution][] = [ + ['none', null, { kind: 'forward', header: null }], + ['none', 'cart=1; theme=dark', { kind: 'forward', header: 'cart=1; theme=dark' }], + ['legacy_only', `${NAMES.legacy}=${VALUE}`, { kind: 'forward', header: `${NAMES.legacy}=${VALUE}` }], + [ + 'dual_identical', + `${NAMES.canonical}=${VALUE}; ${NAMES.legacy}=${VALUE}`, + { kind: 'forward', header: `${NAMES.canonical}=${VALUE}; ${NAMES.legacy}=${VALUE}` }, + ], + ['canonical_only', `${NAMES.canonical}=${VALUE}`, { kind: 'reject', status: 401 }], + [ + 'conflicting_pair', + `${NAMES.canonical}=${OTHER_VALUE}; ${NAMES.legacy}=${VALUE}`, + { kind: 'reject', status: 400 }, + ], + ['duplicate_name', `${NAMES.legacy}=${VALUE}; ${NAMES.legacy}=${VALUE}`, { kind: 'reject', status: 400 }], + ['malformed_value', `${NAMES.legacy}=${VALUE.slice(0, 10)}%zz`, { kind: 'reject', status: 400 }], + ['oversized_value', oversizedValue, { kind: 'reject', status: 400 }], + ['oversized_header', oversizedHeader, { kind: 'reject', status: 400 }], + ]; + + it.each(matrix)('classifies %s with the exact bridge resolution', (state, header, resolution) => { + const inspection = inspectListenerSessionCookie(header, NAMES); + expect(inspection.state).toBe(state); + expect(inspection.resolution).toEqual(resolution); + // resolveListenerSessionCookie is a pure delegation to the inspector. + expect(resolveListenerSessionCookie(header, NAMES)).toEqual(resolution); + }); + + it('covers exactly the closed observability allowlist', () => { + const states = new Set(matrix.map(([state]) => state)); + expect([...states].sort()).toEqual([...LISTENER_SESSION_COOKIE_STATES].sort()); + }); + + it('applies the documented precedence when several states overlap', () => { + const cases: [string, string, State][] = [ + // An oversized header wins over duplicates and malformed values. + ['oversized_header', `${oversizedHeader}; ${NAMES.legacy}=${VALUE}`, 'oversized_header'], + // A duplicate wins over an oversized or malformed value. + ['duplicate_name', `${NAMES.legacy}=${'a'.repeat(513)}; ${NAMES.legacy}=${VALUE.slice(0, 10)}%zz`, 'duplicate_name'], + // An oversized value wins over bad percent encoding. + ['oversized_value', `${NAMES.legacy}=${'a'.repeat(510)}%zz`, 'oversized_value'], + // A malformed value wins over the canonical-only policy state. + ['malformed_value', `${NAMES.canonical}=${VALUE.slice(0, 10)}%2g`, 'malformed_value'], + // A well-formed pair conflict outranks nothing else: it needs both names. + ['conflicting_pair', `${NAMES.legacy}=${VALUE}; ${NAMES.canonical}=${OTHER_VALUE}`, 'conflicting_pair'], + ]; + for (const [expected, header] of cases) { + expect(inspectListenerSessionCookie(header, NAMES).state, header).toBe(expected); + } + }); +}); + +describe('listenerSessionAuthHandler observability', () => { + it('records exactly one observation per invocation with the inspected state', async () => { + const before = snapshotListenerSessionCookieObservations(); + const handler = listenerSessionAuthHandler(async () => new Response('ok'), NAMES); + + await handler(new Request('https://listen.harmonicbeacon.com/x', { + headers: { host: 'listen.harmonicbeacon.com' }, + })); + await handler(new Request('https://listen.harmonicbeacon.com/x', { + headers: { host: 'listen.harmonicbeacon.com', cookie: `${NAMES.legacy}=${VALUE}` }, + })); + await handler(new Request('https://listen.harmonicbeacon.com/x', { + headers: { host: 'listen.harmonicbeacon.com', cookie: `${NAMES.canonical}=${VALUE}` }, + })); + + const after = snapshotListenerSessionCookieObservations(); + expect(after.counts.none - before.counts.none).toBe(1); + expect(after.counts.legacy_only - before.counts.legacy_only).toBe(1); + expect(after.counts.canonical_only - before.counts.canonical_only).toBe(1); + expect(after.startedAtSeconds).toBe(before.startedAtSeconds); + }); + + it('does not mix staging or synthetic-host traffic into the support window', async () => { + const before = snapshotListenerSessionCookieObservations(); + const handler = listenerSessionAuthHandler(async () => new Response('ok'), NAMES); + await handler(new Request('https://earlybirds-staging.harmonicbeacon.com/x', { + headers: { + host: 'earlybirds-staging.harmonicbeacon.com', + cookie: `${NAMES.legacy}=${VALUE}`, + }, + })); + expect(snapshotListenerSessionCookieObservations().counts).toEqual(before.counts); + }); + + it('fails soft when the observer throws: rejections stay byte-identical', async () => { + const inner = vi.fn(async () => new Response('ok')); + const handler = listenerSessionAuthHandler(inner, NAMES); + const spy = vi + .spyOn(observability, 'recordListenerSessionCookieObservation') + .mockImplementation(() => { + throw new Error('observer down'); + }); + try { + const response = await handler(new Request('https://listen.example.test/x', { + headers: { + host: 'listen.harmonicbeacon.com', + cookie: `${NAMES.canonical}=${VALUE}`, + }, + })); + expect(spy).toHaveBeenCalledOnce(); + expect(response.status).toBe(401); + expect(setCookiesOf(response)).toEqual(listenerSessionClearCookies(NAMES)); + await expect(response.json()).resolves.toEqual({ error: 'invalid session credentials' }); + expect(inner).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + it('fails soft when the observer throws: accepted states still reach the handler', async () => { + const headers = new Headers(); + headers.append('set-cookie', `${NAMES.legacy}=${VALUE}; Path=/; HttpOnly`); + const inner = vi.fn(async () => new Response('ok', { headers })); + const handler = listenerSessionAuthHandler(inner, NAMES); + const spy = vi + .spyOn(observability, 'recordListenerSessionCookieObservation') + .mockImplementation(() => { + throw new Error('observer down'); + }); + try { + const response = await handler(new Request('https://listen.example.test/x', { + headers: { + host: 'listen.harmonicbeacon.com', + cookie: `${NAMES.legacy}=${VALUE}`, + }, + })); + expect(spy).toHaveBeenCalledOnce(); + expect(response.status).toBe(200); + expect(setCookiesOf(response)).toEqual([ + `${NAMES.legacy}=${VALUE}; Path=/; HttpOnly`, + `${NAMES.canonical}=${VALUE}; Path=/; HttpOnly`, + ]); + await expect(response.text()).resolves.toBe('ok'); + expect(inner).toHaveBeenCalledOnce(); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/lib/listener/__tests__/session-cookie-observability.test.ts b/src/lib/listener/__tests__/session-cookie-observability.test.ts new file mode 100644 index 00000000..19e7c845 --- /dev/null +++ b/src/lib/listener/__tests__/session-cookie-observability.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; + +import { + LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC, + LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC, + LISTENER_SESSION_COOKIE_STATES, + recordListenerSessionCookieObservation, + renderListenerSessionCookieObservations, + snapshotListenerSessionCookieObservations, +} from '@/lib/listener/session-cookie-observability'; + +const REGISTRY_DESCRIPTION = 'harmonic-beacon.listener.session-cookie-observations'; + +type Registry = { + startedAtSeconds: number; + counts: Record; +}; + +// Vitest evaluates the module in a separate realm whose `Symbol.for` registry +// is realm-local, so the test reaches the shared globalThis slot by symbol +// description instead of by `Symbol.for` identity. +function internalRegistry(): Registry { + const symbol = Object.getOwnPropertySymbols(globalThis) + .find((candidate) => candidate.description === REGISTRY_DESCRIPTION); + expect(symbol, 'registry symbol on globalThis').toBeDefined(); + return (globalThis as Record)[symbol as symbol]; +} + +describe('Listener session-cookie observability registry', () => { + it('exposes exactly the closed nine-state allowlist', () => { + expect(LISTENER_SESSION_COOKIE_STATES).toEqual([ + 'none', + 'legacy_only', + 'dual_identical', + 'canonical_only', + 'conflicting_pair', + 'duplicate_name', + 'malformed_value', + 'oversized_value', + 'oversized_header', + ]); + }); + + it('counts every allowlisted state and keeps a stable series order', () => { + const before = snapshotListenerSessionCookieObservations(); + for (const state of LISTENER_SESSION_COOKIE_STATES) { + recordListenerSessionCookieObservation(state); + } + recordListenerSessionCookieObservation('dual_identical'); + const after = snapshotListenerSessionCookieObservations(); + + expect(Object.keys(after.counts)).toEqual([...LISTENER_SESSION_COOKIE_STATES]); + for (const state of LISTENER_SESSION_COOKIE_STATES) { + expect(after.counts[state]).toBe(before.counts[state] + (state === 'dual_identical' ? 2 : 1)); + } + }); + + it('exposes all nine series including zero values in snapshots and render', () => { + const snapshot = snapshotListenerSessionCookieObservations(); + for (const state of LISTENER_SESSION_COOKIE_STATES) { + expect(snapshot.counts[state]).toBeGreaterThanOrEqual(0); + } + const render = renderListenerSessionCookieObservations(); + for (const state of LISTENER_SESSION_COOKIE_STATES) { + expect(render).toMatch( + new RegExp(`^${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC}\\{state="${state}"\\} \\d+$`, 'm'), + ); + } + }); + + it('discards unknown states without throwing', () => { + const before = snapshotListenerSessionCookieObservations(); + expect(() => { + recordListenerSessionCookieObservation('unknown_state'); + recordListenerSessionCookieObservation(''); + recordListenerSessionCookieObservation('state","x="1'); + recordListenerSessionCookieObservation(undefined as unknown as string); + }).not.toThrow(); + expect(snapshotListenerSessionCookieObservations().counts).toEqual(before.counts); + }); + + it('keeps snapshot and render nonthrowing if the process-local registry is corrupted', () => { + const holder = internalRegistry() as unknown as { counts: unknown }; + const saved = holder.counts; + try { + holder.counts = null; + expect(() => snapshotListenerSessionCookieObservations()).not.toThrow(); + expect(snapshotListenerSessionCookieObservations().counts) + .toEqual(Object.fromEntries(LISTENER_SESSION_COOKIE_STATES.map((state) => [state, 0]))); + expect(() => renderListenerSessionCookieObservations()).not.toThrow(); + expect(renderListenerSessionCookieObservations()) + .toContain(`${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC}{state="none"} 0`); + } finally { + holder.counts = saved; + } + }); + + it('is monotonic and saturates at Number.MAX_SAFE_INTEGER', () => { + const counts = internalRegistry().counts; + const saved = counts.legacy_only; + try { + recordListenerSessionCookieObservation('legacy_only'); + expect(counts.legacy_only).toBe(saved + 1); + + counts.legacy_only = Number.MAX_SAFE_INTEGER - 1; + recordListenerSessionCookieObservation('legacy_only'); + expect(counts.legacy_only).toBe(Number.MAX_SAFE_INTEGER); + recordListenerSessionCookieObservation('legacy_only'); + expect(counts.legacy_only).toBe(Number.MAX_SAFE_INTEGER); + } finally { + counts.legacy_only = saved + 1; + } + }); + + it('keeps one registry and a stable process start epoch across the process', () => { + const first = snapshotListenerSessionCookieObservations(); + const second = snapshotListenerSessionCookieObservations(); + expect(second.startedAtSeconds).toBe(first.startedAtSeconds); + expect(Number.isInteger(first.startedAtSeconds)).toBe(true); + expect(first.startedAtSeconds).toBeGreaterThan(0); + expect(internalRegistry().startedAtSeconds).toBe(first.startedAtSeconds); + }); + + it('renders valid text exposition with only the fixed state label and gauge', () => { + const render = renderListenerSessionCookieObservations(); + expect(render.endsWith('\n')).toBe(true); + const lines = render.trimEnd().split('\n'); + expect(lines[0]).toBe(`# HELP ${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC} Listener session-cookie compatibility states observed by session resolver invocations (aggregate per process; not unique users, browsers or sessions).`); + expect(lines[1]).toBe(`# TYPE ${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC} counter`); + for (const [index, state] of LISTENER_SESSION_COOKIE_STATES.entries()) { + expect(lines[2 + index]).toMatch( + new RegExp(`^${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC}\\{state="${state}"\\} \\d+$`), + ); + } + const tail = lines.slice(2 + LISTENER_SESSION_COOKIE_STATES.length); + expect(tail).toEqual([ + `# HELP ${LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC} Unix epoch seconds when this observer process created its session-cookie observation registry.`, + `# TYPE ${LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC} gauge`, + `${LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC} ${snapshotListenerSessionCookieObservations().startedAtSeconds}`, + ]); + }); + + it('renders no cookie, header, user, session, account, IP or UA material', () => { + const render = renderListenerSessionCookieObservations(); + // Every label set is exactly one fixed `state` label over the allowlist. + const labelSets = [...render.matchAll(/\{([^}]*)\}/g)].map((match) => match[1]); + expect(labelSets).toHaveLength(LISTENER_SESSION_COOKIE_STATES.length); + for (const labelSet of labelSets) { + expect(labelSet).toMatch(/^state="(none|legacy_only|dual_identical|canonical_only|conflicting_pair|duplicate_name|malformed_value|oversized_value|oversized_header)"$/); + } + // No request-shaped material can appear: values are only integers. + for (const line of render.trimEnd().split('\n')) { + expect(line).toMatch(/^(# (HELP|TYPE) [a-z_]+ .+|[a-z_]+(\{state="[a-z_]+"\})? \d+)$/); + } + }); +}); diff --git a/src/lib/listener/session-cookie-bridge.ts b/src/lib/listener/session-cookie-bridge.ts index e0471c81..8a0a92b1 100644 --- a/src/lib/listener/session-cookie-bridge.ts +++ b/src/lib/listener/session-cookie-bridge.ts @@ -43,6 +43,12 @@ * any other non-session cookies pass through untouched in both directions. */ +import { + recordListenerSessionCookieObservation, + type ListenerSessionCookieState, +} from '@/lib/listener/session-cookie-observability'; +import { isCanonicalListenerHost } from '@/lib/listener/public-discovery'; + export const LISTENER_SESSION_COOKIE = 'hb_listener_session'; const SECURE_COOKIE_PREFIX = '__Secure-'; @@ -161,44 +167,89 @@ function wellFormedSessionValue(value: string): boolean { !INVALID_PERCENT_ESCAPE.test(value); } +/** + * Pure inbound inspection: the aggregate compatibility `state` observed for + * observability plus the strict `resolution` the bridge enforces. The state + * classification is closed and ordered by precedence: + * + * 1. no relevant session cookie (or no header at all) -> `none`; + * 2. oversized whole Cookie header -> `oversized_header`; + * 3. a duplicate of either relevant name -> `duplicate_name`; + * 4. a relevant value over 512 characters -> `oversized_value`; + * 5. an empty, non-wire-charset or bad-percent relevant value -> `malformed_value`; + * 6. a well-formed canonical cookie without its legacy counterpart -> `canonical_only`; + * 7. a canonical/legacy pair whose values differ -> `conflicting_pair`; + * 8. exactly one legacy cookie -> `legacy_only`; + * 9. a byte-identical canonical/legacy pair -> `dual_identical`. + * + * `resolveListenerSessionCookie` is exactly this inspection's `resolution`. + */ +export type ListenerSessionCookieInspection = { + readonly state: ListenerSessionCookieState; + readonly resolution: ListenerSessionCookieResolution; +}; + /** * Resolves whether an inbound Cookie header may reach Better Auth under the - * bridge policy. Accepted states are forwarded byte-for-byte (Better Auth - * ignores the canonical name and reads the legacy one); no state is ever - * rewritten or stripped, because repairing an ambiguous header is exactly - * what better-call's first-wins parser would do silently. + * bridge policy, and classifies the aggregate compatibility state observed. + * Accepted states are forwarded byte-for-byte (Better Auth ignores the + * canonical name and reads the legacy one); no state is ever rewritten or + * stripped, because repairing an ambiguous header is exactly what + * better-call's first-wins parser would do silently. */ -export function resolveListenerSessionCookie( +export function inspectListenerSessionCookie( header: string | null, names: ListenerSessionCookieNames, -): ListenerSessionCookieResolution { +): ListenerSessionCookieInspection { const forward: ListenerSessionCookieResolution = { kind: 'forward', header }; - if (!header) return forward; + if (!header) return { state: 'none', resolution: forward }; const parts = parseCookieParts(header); const legacyParts = parts.filter((part) => part.name === names.legacy); const canonicalParts = parts.filter((part) => part.name === names.canonical); - if (legacyParts.length === 0 && canonicalParts.length === 0) return forward; + if (legacyParts.length === 0 && canonicalParts.length === 0) { + return { state: 'none', resolution: forward }; + } const reject = (status: 400 | 401): ListenerSessionCookieResolution => ({ kind: 'reject', status }); - if ( - header.length > MAX_COOKIE_HEADER_LENGTH || - legacyParts.length > 1 || - canonicalParts.length > 1 || - legacyParts.some((part) => !wellFormedSessionValue(part.value)) || - canonicalParts.some((part) => !wellFormedSessionValue(part.value)) - ) return reject(400); + if (header.length > MAX_COOKIE_HEADER_LENGTH) { + return { state: 'oversized_header', resolution: reject(400) }; + } + if (legacyParts.length > 1 || canonicalParts.length > 1) { + return { state: 'duplicate_name', resolution: reject(400) }; + } + const relevant = [...legacyParts, ...canonicalParts]; + if (relevant.some((part) => part.value.length > MAX_SESSION_COOKIE_VALUE_LENGTH)) { + return { state: 'oversized_value', resolution: reject(400) }; + } + if (relevant.some((part) => !wellFormedSessionValue(part.value))) { + return { state: 'malformed_value', resolution: reject(400) }; + } const legacy = legacyParts[0]; const canonical = canonicalParts[0]; // A well-formed canonical credential without its legacy counterpart is // not accepted during the rollback-compatible phase. - if (!legacy) return reject(401); + if (!legacy) return { state: 'canonical_only', resolution: reject(401) }; // Conflicting values are never arbitrated between. - if (canonical && canonical.value !== legacy.value) return reject(400); - return forward; + if (canonical && canonical.value !== legacy.value) { + return { state: 'conflicting_pair', resolution: reject(400) }; + } + return { state: canonical ? 'dual_identical' : 'legacy_only', resolution: forward }; +} + +/** + * Resolves whether an inbound Cookie header may reach Better Auth under the + * bridge policy. Pure delegation to `inspectListenerSessionCookie`; the + * resolution behavior is byte-identical to the pre-observability bridge. + */ +export function resolveListenerSessionCookie( + header: string | null, + names: ListenerSessionCookieNames, +): ListenerSessionCookieResolution { + return inspectListenerSessionCookie(header, names).resolution; } /** @@ -299,15 +350,24 @@ export function mirrorListenerSessionResponse( * ambiguous request can mint, rotate or clear a session; ambiguous outbound * output fails closed with a generic 500 carrying no Set-Cookie at all. The * wrapped handler stays the only code that touches sessions. + * + * Each invocation also records the inspected aggregate compatibility state + * for observability. Recording is fail-soft: an observer failure can never + * change the resolution, the handler invocation, or the response. */ export function listenerSessionAuthHandler( handler: (request: Request) => Promise, names: ListenerSessionCookieNames, ): (request: Request) => Promise { return async (request) => { - const resolution = resolveListenerSessionCookie(request.headers.get('cookie'), names); - if (resolution.kind === 'reject') { - return rejectionResponse(resolution.status, names); + const inspection = inspectListenerSessionCookie(request.headers.get('cookie'), names); + if (isCanonicalListenerHost(request.headers)) { + try { + recordListenerSessionCookieObservation(inspection.state); + } catch { /* Observation must never affect authentication. */ } + } + if (inspection.resolution.kind === 'reject') { + return rejectionResponse(inspection.resolution.status, names); } return mirrorListenerSessionResponse(await handler(request), names); }; diff --git a/src/lib/listener/session-cookie-observability.ts b/src/lib/listener/session-cookie-observability.ts new file mode 100644 index 00000000..6f3ebcf6 --- /dev/null +++ b/src/lib/listener/session-cookie-observability.ts @@ -0,0 +1,158 @@ +/** + * Listener session-cookie compatibility observability. + * + * A process-local, in-memory registry that counts how often the Listener + * session resolvers (the auth-handler bridge and `currentEarlyBirdSession`) + * observe each inbound session-cookie compatibility state. It exists to size + * the rollback-compatible dual-cookie support window; it is deliberately + * aggregate-only: + * + * - exactly one fixed label, `state`, over a closed allowlist of nine states; + * no external labels are ever accepted, and + * - no cookie, header, user, session, account, IP or user-agent value is ever + * stored or rendered. + * + * Counters measure resolver INVOCATIONS, not unique users, browsers or + * sessions: one navigation can invoke a resolver several times, and one + * session is observed on every request. The registry is per process/replica + * and resets on restart; the unlabeled process-start gauge separates epochs, + * and snapshots must be archived externally per epoch to establish any + * continuity (a current zero cannot prove seven quiet days). + * + * The registry lives on `globalThis` under a `Symbol.for` key so a module + * reload (development hot reload, test re-import) shares one registry per + * process instead of resetting it. Recording, snapshots and rendering are + * bounded, synchronous and nonthrowing: observation must never affect + * authentication behavior. + */ + +export const LISTENER_SESSION_COOKIE_STATES = [ + 'none', + 'legacy_only', + 'dual_identical', + 'canonical_only', + 'conflicting_pair', + 'duplicate_name', + 'malformed_value', + 'oversized_value', + 'oversized_header', +] as const; + +export type ListenerSessionCookieState = typeof LISTENER_SESSION_COOKIE_STATES[number]; + +export const LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC = + 'beacon_listener_session_cookie_observations_total'; +export const LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC = + 'beacon_listener_session_cookie_observer_process_start_time_seconds'; + +const REGISTRY_KEY = Symbol.for('harmonic-beacon.listener.session-cookie-observations'); +const PROCESS_START_SECONDS = Math.floor(Date.now() / 1000); + +type ListenerSessionCookieObservationRegistry = { + /** Unix epoch seconds when this process first created the registry. */ + readonly startedAtSeconds: number; + readonly counts: Record; +}; + +export type ListenerSessionCookieObservationSnapshot = { + readonly startedAtSeconds: number; + readonly counts: Record; +}; + +function emptyCounts(): Record { + return Object.fromEntries( + LISTENER_SESSION_COOKIE_STATES.map((state) => [state, 0]), + ) as Record; +} + +function registry(): ListenerSessionCookieObservationRegistry { + const scope = globalThis as Record; + let existing = scope[REGISTRY_KEY]; + if (!existing) { + existing = { + startedAtSeconds: PROCESS_START_SECONDS, + counts: emptyCounts(), + }; + scope[REGISTRY_KEY] = existing; + } + return existing; +} + +/** + * Records one resolver observation of an inbound session-cookie state. + * Unknown states are discarded, increments saturate at + * `Number.MAX_SAFE_INTEGER`, and any observer failure is swallowed: this call + * must never change an auth outcome. + */ +export function recordListenerSessionCookieObservation(state: string): void { + try { + const counts = registry().counts; + if (!Object.hasOwn(counts, state)) return; + const known = state as ListenerSessionCookieState; + counts[known] = Math.min(counts[known] + 1, Number.MAX_SAFE_INTEGER); + } catch { /* Observation is best-effort and must never throw. */ } +} + +/** + * A copy of the current registry: all nine series in stable allowlist order + * (including zero values) plus the stable process-start epoch. + */ +export function snapshotListenerSessionCookieObservations(): ListenerSessionCookieObservationSnapshot { + try { + const current = registry(); + const counts = emptyCounts(); + for (const state of LISTENER_SESSION_COOKIE_STATES) { + const value = current.counts[state]; + counts[state] = Number.isSafeInteger(value) && value >= 0 ? value : 0; + } + const startedAtSeconds = Number.isSafeInteger(current.startedAtSeconds) && + current.startedAtSeconds > 0 + ? current.startedAtSeconds + : PROCESS_START_SECONDS; + return { startedAtSeconds, counts }; + } catch { + return { startedAtSeconds: PROCESS_START_SECONDS, counts: emptyCounts() }; + } +} + +/** + * Prometheus text exposition (0.0.4) of the registry. Every line is fixed + * except the aggregate numbers; the only label is `state` over the closed + * allowlist, so no request material can ever reach the output. + */ +export function renderListenerSessionCookieObservations(): string { + try { + const snapshot = snapshotListenerSessionCookieObservations(); + const lines = [ + `# HELP ${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC} Listener session-cookie compatibility states observed by session resolver invocations (aggregate per process; not unique users, browsers or sessions).`, + `# TYPE ${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC} counter`, + ]; + for (const state of LISTENER_SESSION_COOKIE_STATES) { + lines.push(`${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC}{state="${state}"} ${snapshot.counts[state]}`); + } + lines.push( + `# HELP ${LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC} Unix epoch seconds when this observer process created its session-cookie observation registry.`, + `# TYPE ${LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC} gauge`, + `${LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC} ${snapshot.startedAtSeconds}`, + '', + ); + return lines.join('\n'); + } catch { + // Even a corrupted process-local registry must not affect auth. The + // fixed empty exposition remains privacy-safe and identifies this + // process epoch; operators must treat the missing prior counts as a + // continuity gap rather than evidence of zero legacy use. + const lines = LISTENER_SESSION_COOKIE_STATES.map( + (state) => `${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC}{state="${state}"} 0`, + ); + return [ + `# HELP ${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC} Listener session-cookie compatibility states observed by session resolver invocations (aggregate per process; not unique users, browsers or sessions).`, + `# TYPE ${LISTENER_SESSION_COOKIE_OBSERVATIONS_METRIC} counter`, + ...lines, + `# HELP ${LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC} Unix epoch seconds when this observer process created its session-cookie observation registry.`, + `# TYPE ${LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC} gauge`, + `${LISTENER_SESSION_COOKIE_OBSERVER_START_METRIC} ${PROCESS_START_SECONDS}`, + '', + ].join('\n'); + } +} From 00fddc5197cf6d50106a93cb61d843fcbb019832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 21:47:37 -0300 Subject: [PATCH 136/376] feat(listener): replace daily Free UI with weekly quota --- src/app/globals.css | 84 +++------ .../early-birds/AccessBoundarySync.tsx | 96 ---------- src/components/early-birds/EarlyBirdHome.tsx | 32 ++-- .../early-birds/EarlyBirdLanding.tsx | 28 ++- .../early-birds/FreeQuotaStatus.tsx | 163 +++++++++++++++++ .../early-birds/FreeWindowSetup.tsx | 168 ------------------ .../early-birds/WelcomeAccessAction.tsx | 51 ------ .../__tests__/AccessBoundarySync.test.tsx | 80 --------- .../__tests__/EarlyBirdLanding.test.tsx | 59 ++---- .../__tests__/FreeQuotaStatus.test.tsx | 125 +++++++++++++ .../__tests__/FreeWindowSetup.test.tsx | 115 ------------ .../listener-visual-isolation.test.tsx | 66 +++---- src/components/early-birds/free-quota.ts | 99 +++++++++++ src/lib/early-birds/copy.ts | 82 ++++----- 14 files changed, 516 insertions(+), 732 deletions(-) delete mode 100644 src/components/early-birds/AccessBoundarySync.tsx create mode 100644 src/components/early-birds/FreeQuotaStatus.tsx delete mode 100644 src/components/early-birds/FreeWindowSetup.tsx delete mode 100644 src/components/early-birds/WelcomeAccessAction.tsx delete mode 100644 src/components/early-birds/__tests__/AccessBoundarySync.test.tsx create mode 100644 src/components/early-birds/__tests__/FreeQuotaStatus.test.tsx delete mode 100644 src/components/early-birds/__tests__/FreeWindowSetup.test.tsx create mode 100644 src/components/early-birds/free-quota.ts diff --git a/src/app/globals.css b/src/app/globals.css index dce6beda..e26fbd31 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1686,28 +1686,43 @@ body { backdrop-filter: blur(18px); } -.listener-free-window h3, -.listener-welcome-access h3 { - font-family: var(--font-cormorant), Georgia, serif; - font-size: clamp(1.8rem, 5vw, 2.5rem); - font-weight: 500; - line-height: 1; +.listener-quota { + display: grid; + gap: 0.45rem; + padding: 1rem; + border: 1px solid rgba(255, 214, 112, 0.22); + border-radius: 0.9rem; + background: rgba(255, 214, 112, 0.06); } -.listener-welcome-access { - display: grid; - gap: 0.65rem; - margin-bottom: 1.5rem; - padding-bottom: 1.5rem; - border-bottom: 1px solid var(--border-subtle); +.listener-quota strong { + color: var(--paper); + font-size: 0.95rem; + font-weight: 500; } -.listener-welcome-access > p { - color: var(--text-muted); +.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-membership-status { display: grid; gap: 0.45rem; @@ -1751,47 +1766,6 @@ body { line-height: 1.5; } -.listener-free-window > p { - margin-top: 0.85rem; - color: var(--text-secondary); - font-size: 0.88rem; - line-height: 1.6; -} - -.listener-free-window__fact { - padding: 0.85rem 0; - border-bottom: 1px solid var(--border-subtle); -} - -.listener-free-window__fact span, -.listener-free-window__fact strong { - display: block; -} - -.listener-free-window__fact span, -.listener-free-window__chooser label span { - color: var(--text-muted); - font-family: var(--font-space-mono), monospace; - font-size: 0.66rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.listener-free-window__fact strong { margin-top: 0.3rem; color: var(--paper); font-weight: 500; } -.listener-free-window__actions, -.listener-free-window__chooser { display: grid; gap: 0.75rem; margin-top: 1.2rem; } -.listener-free-window__chooser label { display: grid; gap: 0.45rem; } -.listener-free-window__chooser input { - min-height: 3.25rem; - padding: 0.65rem 0.9rem; - border: 1px solid var(--border-subtle); - border-radius: 0.8rem; - color: var(--paper); - background: rgba(255, 255, 255, 0.055); - color-scheme: dark; -} -.listener-free-window__chooser p { color: var(--text-muted); font-size: 0.72rem; } - .listener-account-link { min-height: 2.75rem; width: 100%; diff --git a/src/components/early-birds/AccessBoundarySync.tsx b/src/components/early-birds/AccessBoundarySync.tsx deleted file mode 100644 index 4eb8dc8a..00000000 --- a/src/components/early-birds/AccessBoundarySync.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; - -import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; - -type AccessKind = 'membership' | 'free-window' | 'welcome' | 'denied'; - -/** - * Revalidates once at a server-computed authorization boundary. Stream grants - * remain authoritative; this only keeps an already-open UI in sync without a - * reload or a continuous polling loop. - */ -export default function AccessBoundarySync({ - expectedKind, - boundaryAt, - serverNow, - onAccessChanged, -}: { - expectedKind: AccessKind; - boundaryAt: string | null; - serverNow: string; - onAccessChanged?: () => void; -}) { - const router = useRouter(); - - useEffect(() => { - if (!boundaryAt) return; - let cancelled = false; - let inFlight = false; - let timer: number | null = null; - const mountedAt = Date.now(); - const boundaryDelay = new Date(boundaryAt).getTime() - new Date(serverNow).getTime(); - - const revalidate = async () => { - if (cancelled || inFlight) return; - inFlight = true; - if (timer !== null) window.clearTimeout(timer); - try { - const response = await fetch(LISTENER_NAMESPACE.canonical.api.accessState, { - cache: 'no-store', - headers: { Accept: 'application/json' }, - }); - if (cancelled) return; - if (!response.ok) throw new Error('access state unavailable'); - const payload = await response.json() as { - access?: { kind?: AccessKind; allowedUntil?: string | null }; - }; - if ( - payload.access?.kind !== expectedKind - || (expectedKind !== 'denied' && payload.access?.allowedUntil !== boundaryAt) - ) { - // Refresh the server component tree without replacing the - // browser document. The new tree authoritatively enters - // or leaves Listener while the browser session survives. - if (onAccessChanged) onAccessChanged(); - else router.refresh(); - return; - } - // A client clock may be ahead of the server by a few seconds. - timer = window.setTimeout(revalidate, 2_000); - } catch { - // Authorization and media leases still fail closed. Visibility - // or pageshow will provide another bounded opportunity. - timer = window.setTimeout(revalidate, 5_000); - } finally { - inFlight = false; - } - }; - - const arm = () => { - const elapsed = Date.now() - mountedAt; - const remaining = Math.max(0, boundaryDelay - elapsed + 750); - timer = window.setTimeout(revalidate, remaining); - }; - const revalidateAfterResume = () => { - const elapsed = Date.now() - mountedAt; - if (document.visibilityState !== 'hidden' && elapsed + 750 >= boundaryDelay) { - void revalidate(); - } - }; - - arm(); - window.addEventListener('pageshow', revalidateAfterResume); - document.addEventListener('visibilitychange', revalidateAfterResume); - return () => { - cancelled = true; - if (timer !== null) window.clearTimeout(timer); - window.removeEventListener('pageshow', revalidateAfterResume); - document.removeEventListener('visibilitychange', revalidateAfterResume); - }; - }, [boundaryAt, expectedKind, onAccessChanged, router, serverNow]); - - return null; -} diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index e7a7941b..4a3c9aaf 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -9,29 +9,32 @@ import type { ListenerMembershipPresentation } from '@/lib/early-birds/membershi import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; import ListenerPlayer from './ListenerPlayer'; -import AccessBoundarySync from './AccessBoundarySync'; import CosmicCampfire from './CosmicCampfire'; +import FreeQuotaStatus from './FreeQuotaStatus'; +import type { SerializedEarlyBirdQuotaSnapshot } from './free-quota'; export default function EarlyBirdHome({ displayName, membership, accessKind = 'membership', - accessUntil = null, serverNow = new Date(0).toISOString(), dropIns, publicAccess = false, campfirePrototype = false, campfireFixture = 'empty', + quota = null, }: { displayName: string; membership: ListenerMembershipPresentation; - accessKind?: 'membership' | 'free-window' | 'welcome'; + accessKind?: 'membership' | 'free-quota' | 'free-window' | 'welcome'; + /** Retained temporarily so this UI commit remains rebaseable over the legacy page. */ accessUntil?: string | null; serverNow?: string; dropIns: { es: string | null; en: string | null }; publicAccess?: boolean; campfirePrototype?: boolean; campfireFixture?: ListenerCampfireFixture; + quota?: SerializedEarlyBirdQuotaSnapshot | null; }) { const { locale } = useLocale(); const copy = earlyBirdHomeCopy[locale]; @@ -45,31 +48,30 @@ export default function EarlyBirdHome({ return (
{campfirePrototype && } - {accessKind !== 'membership' && ( - - )}
+ {publicAccess && ( + + )} {!publicAccess &&
{displayName.slice(0, 1).toUpperCase()}

{displayName}

- {accessKind === 'free-window' - ? copy.freeActive - : accessKind === 'welcome' - ? copy.welcomeActive - : membershipCopy?.title ?? copy.active} + {accessKind === 'free-quota' ? ( + + ) : ( + {membershipCopy?.title ?? copy.active} + )} {accessKind === 'membership' && membership.kind === 'founder' && membershipCopy?.detail && ( {membershipCopy.detail} )} + {accessKind === 'membership' && membership.kind === 'founder' && ( + + )}
} diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index fe3eebed..211cf76b 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -6,16 +6,13 @@ import BrandLockup from '@/components/brand/BrandLockup'; import { useLocale } from '@/context/LocaleContext'; import { earlyBirdAuthClient } from '@/lib/early-birds/auth-client'; import { earlyBirdCopy, listenerMembershipPresentationCopy } from '@/lib/early-birds/copy'; -import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; -import type { SerializedEarlyBirdWelcomeAccessState } from '@/lib/early-birds/welcome-access'; import type { ListenerMembershipPresentation } from '@/lib/early-birds/membership-presentation'; import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; -import AccessBoundarySync from './AccessBoundarySync'; import BeaconField from './BeaconField'; -import FreeWindowSetup from './FreeWindowSetup'; +import FreeQuotaStatus from './FreeQuotaStatus'; import SyntheticTeamEntryForm from './SyntheticTeamEntryForm'; -import WelcomeAccessAction from './WelcomeAccessAction'; +import type { SerializedEarlyBirdQuotaSnapshot } from './free-quota'; type Props = { signedIn: boolean; @@ -26,8 +23,10 @@ type Props = { providers: { google: boolean; apple: boolean }; emailMagicLinkAvailable: boolean; syntheticTeamEntryAvailable: boolean; - freeWindow: SerializedEarlyBirdFreeWindowState | null; - welcome: SerializedEarlyBirdWelcomeAccessState | null; + /** Legacy props are retained only until the quota backend lands. */ + freeWindow?: unknown; + welcome?: unknown; + quota?: SerializedEarlyBirdQuotaSnapshot | null; membership: ListenerMembershipPresentation; serverNow: string; }; @@ -92,13 +91,6 @@ export default function EarlyBirdLanding(props: Props) { return (
- {props.signedIn && props.freeWindow?.nextStart && ( - - )}
@@ -159,7 +151,7 @@ export default function EarlyBirdLanding(props: Props) { )}
- ) : props.signedIn && props.freeWindow && props.welcome ? ( + ) : props.signedIn ? (

{copy.signedIn}

{props.entitled ? ( @@ -178,8 +170,10 @@ export default function EarlyBirdLanding(props: Props) { {membership.detail &&

{membership.detail}

}
)} - {props.welcome.available && } - + )} - -
- )} - - {mayChoose && choosing && ( -
- -

{copy.freeTimeZone} · {timeZone}

- - -
- )} - - {error &&

{copy.freeScheduleError}

} -
- ); -} diff --git a/src/components/early-birds/WelcomeAccessAction.tsx b/src/components/early-birds/WelcomeAccessAction.tsx deleted file mode 100644 index ce4c7027..00000000 --- a/src/components/early-birds/WelcomeAccessAction.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { useRef, useState } from 'react'; - -import { useLocale } from '@/context/LocaleContext'; -import { earlyBirdCopy } from '@/lib/early-birds/copy'; -import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; - -export default function WelcomeAccessAction() { - const { locale } = useLocale(); - const copy = earlyBirdCopy[locale]; - const [busy, setBusy] = useState(false); - const [error, setError] = useState(false); - const activationRequestId = useRef(null); - - async function start() { - if (busy) return; - activationRequestId.current ??= crypto.randomUUID(); - setBusy(true); - setError(false); - try { - const response = await fetch(LISTENER_NAMESPACE.canonical.api.welcomeAccess, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ activationRequestId: activationRequestId.current }), - }); - if (response.ok) { - window.location.reload(); - return; - } - } catch {} - setBusy(false); - setError(true); - } - - return ( -
-

{copy.welcomeTitle}

-

{copy.welcomeDescription}

- - {error &&

{copy.welcomeError}

} -
- ); -} diff --git a/src/components/early-birds/__tests__/AccessBoundarySync.test.tsx b/src/components/early-birds/__tests__/AccessBoundarySync.test.tsx deleted file mode 100644 index 03210df1..00000000 --- a/src/components/early-birds/__tests__/AccessBoundarySync.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -// @vitest-environment jsdom -import { act, cleanup, render } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import AccessBoundarySync from '../AccessBoundarySync'; - -const navigation = vi.hoisted(() => ({ refresh: vi.fn() })); -vi.mock('next/navigation', () => ({ - useRouter: () => ({ refresh: navigation.refresh }), -})); - -describe('Listener access boundary synchronization', () => { - beforeEach(() => vi.useFakeTimers()); - afterEach(() => { - cleanup(); - navigation.refresh.mockReset(); - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - it('revalidates once at the server boundary and enters an active Free window', async () => { - const changed = vi.fn(); - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ - access: { kind: 'free-window', allowedUntil: '2026-08-07T17:00:00.000Z' }, - }), { status: 200 })); - render( - , - ); - - await act(async () => { await vi.advanceTimersByTimeAsync(60_750); }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith('/api/listener/access-state', { - cache: 'no-store', - headers: { Accept: 'application/json' }, - }); - expect(changed).toHaveBeenCalledTimes(1); - }); - - it('revalidates an active welcome at expiry and leaves the player', async () => { - const changed = vi.fn(); - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ - access: { kind: 'denied', allowedUntil: null }, - }), { status: 200 })); - render( - , - ); - - await act(async () => { await vi.advanceTimersByTimeAsync(1_750); }); - - expect(changed).toHaveBeenCalledTimes(1); - }); - - it('refreshes the server component tree without reloading the document', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ - access: { kind: 'free-window', allowedUntil: '2026-08-07T17:00:00.000Z' }, - }), { status: 200 })); - render( - , - ); - - await act(async () => { await vi.advanceTimersByTimeAsync(60_750); }); - - expect(navigation.refresh).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx index 78cd28e4..03373739 100644 --- a/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdLanding.test.tsx @@ -30,26 +30,6 @@ function renderLanding(overrides: Partial { renderLanding({ signedIn: kind === 'access', serviceUnavailable: kind, - freeWindow: null, - welcome: null, }); expect(screen.getByRole('alert')).toHaveTextContent(detail); expect(screen.getByRole('link', { name: 'Try again' })).toHaveAttribute('href', '/listener'); - expect(screen.queryByRole('heading', { name: 'Your first listen · 30 minutes' })).toBeNull(); - expect(screen.queryByRole('heading', { name: 'Your daily time · 2 hours' })).toBeNull(); + expect(screen.queryByText(/daily time|first listen/i)).toBeNull(); expect(screen.queryByRole('button', { name: 'Continue with Google' })).toBeNull(); expect(screen.queryByLabelText('Email address')).toBeNull(); }); @@ -178,29 +155,27 @@ describe('EarlyBird public landing', () => { expect(screen.getByRole('button', { name: 'Sign out' })).toBeEnabled(); }); - it('offers the one-time welcome listen without selecting a recurring schedule', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('{}', { status: 409 })); + it('shows the server-supplied weekly quota without a schedule or welcome action', () => { renderLanding({ signedIn: true, providers: { google: true, apple: false }, - welcome: { - available: true, - active: false, - used: false, - startedAt: null, - endsAt: null, + quota: { + policy: 'personal-7-day-v1', + status: 'available', + cycleStartedAt: '2026-08-07T15:00:00.000Z', + cycleEndsAt: '2026-08-14T15:00:00.000Z', + baseAllowanceMs: 10_800_000, + bonusAllowanceMs: 0, + consumedMs: 1_140_000, + remainingMs: 9_660_000, + activelyConsuming: false, + exhaustsAt: '2026-08-07T17:41:00.000Z', + nextCycleAt: '2026-08-14T15:00:00.000Z', }, }); - expect(screen.getByRole('heading', { name: 'Your first listen · 30 minutes' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Your daily time · 2 hours' })).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: 'Listen now' })); - - expect(fetchMock).toHaveBeenCalledWith('/api/listener/welcome-access', expect.objectContaining({ - method: 'POST', - })); - expect(fetchMock.mock.calls[0]?.[1]?.body).toContain('activationRequestId'); - fetchMock.mockRestore(); + expect(screen.getByText('You have 2h 41m left this week')).toBeInTheDocument(); + expect(screen.queryByText(/daily time|first listen/i)).toBeNull(); }); it('explains terminal Founder access and returns the account to truthful Free choices', () => { @@ -215,7 +190,7 @@ describe('EarlyBird public landing', () => { expect(screen.getByRole('status')).toHaveTextContent( 'You can continue with the Free listening available to your account.', ); - expect(screen.getByRole('heading', { name: 'Your daily time · 2 hours' })).toBeInTheDocument(); + expect(screen.queryByText(/daily time|first listen/i)).toBeNull(); expect(screen.queryByText('MERCADO_PAGO')).not.toBeInTheDocument(); }); diff --git a/src/components/early-birds/__tests__/FreeQuotaStatus.test.tsx b/src/components/early-birds/__tests__/FreeQuotaStatus.test.tsx new file mode 100644 index 00000000..93368f4d --- /dev/null +++ b/src/components/early-birds/__tests__/FreeQuotaStatus.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocaleProvider } from '@/context/LocaleContext'; + +const refresh = vi.hoisted(() => vi.fn()); +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) })); + +import FreeQuotaStatus from '../FreeQuotaStatus'; + +const snapshot = { + policy: 'personal-7-day-v1' as const, + status: 'listening' as const, + cycleStartedAt: '2026-08-07T15:00:00.000Z', + cycleEndsAt: '2026-08-14T15:00:00.000Z', + baseAllowanceMs: 10_800_000, + bonusAllowanceMs: 1_800_000, + consumedMs: 0, + remainingMs: 10_800_000, + activelyConsuming: true, + exhaustsAt: '2026-08-07T18:00:00.000Z', + nextCycleAt: '2026-08-14T15:00:00.000Z', +}; + +describe('Listener weekly quota presentation', () => { + beforeEach(() => { + cleanup(); + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ + serverNow: '2026-08-07T15:00:30.000Z', + access: { kind: 'free-quota', quota: { ...snapshot, remainingMs: 10_770_000 } }, + }), { status: 200 }))); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + refresh.mockReset(); + }); + + it('renders remaining weekly time, credits, and renewal from a canonical server snapshot', () => { + render( + + + , + ); + + expect(screen.getByText('You have 3h left this week')).toBeInTheDocument(); + expect(screen.getByText('Includes 30m of extra credit.')).toBeInTheDocument(); + expect(screen.getByText('Renews in 168h')).toBeInTheDocument(); + }); + + it('never fabricates an allowance from an incomplete snapshot', () => { + render( + + + , + ); + + expect(screen.queryByText(/left this week/i)).toBeNull(); + expect(screen.queryByText(/starts when you listen/i)).toBeNull(); + }); + + it('revalidates on explicit playback presence without treating it as local authority', async () => { + render( + + + , + ); + + window.dispatchEvent(new Event('listener:playback-presence')); + await act(async () => {}); + expect(fetch).toHaveBeenCalledWith('/api/listener/access-state', { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }); + }); + + it('refreshes the server component tree when the canonical quota state changes', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ + serverNow: '2026-08-14T15:00:01.000Z', + access: { + kind: 'free-quota', + quota: { + ...snapshot, + status: 'available', + activelyConsuming: false, + remainingMs: 10_800_000, + nextCycleAt: '2026-08-21T15:00:00.000Z', + }, + }, + }), { status: 200 }))); + render( + + + , + ); + + window.dispatchEvent(new Event('listener:playback-presence')); + await act(async () => {}); + expect(refresh).toHaveBeenCalledTimes(1); + expect(screen.getByText('You have 3h left this week')).toBeInTheDocument(); + }); + + it('uses a monotonic elapsed clock only while the server says consumption is active', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + render( + + + , + ); + + expect(screen.getByText('You have 2m left this week')).toBeInTheDocument(); + await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); + expect(screen.getByText('You have 1m left this week')).toBeInTheDocument(); + }); +}); diff --git a/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx b/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx deleted file mode 100644 index 9d58f654..00000000 --- a/src/components/early-birds/__tests__/FreeWindowSetup.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { LocaleProvider } from '@/context/LocaleContext'; -import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; - -import FreeWindowSetup from '../FreeWindowSetup'; - -const refresh = vi.hoisted(() => vi.fn()); -vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) })); - -const emptyState: SerializedEarlyBirdFreeWindowState = { - configured: false, - active: false, - timeZone: null, - localStartMinute: null, - selectedAt: null, - changeAllowedAt: null, - canChange: true, - activeStart: null, - activeEnd: null, - nextStart: null, - nextEnd: null, -}; - -function renderSetup(state = emptyState) { - return render( - - - , - ); -} - -describe('Free listening schedule UI', () => { - beforeEach(() => { - refresh.mockReset(); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 400 }))); - }); - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('offers immediate or chosen daily Free hours after registration', async () => { - renderSetup(); - expect(screen.getByRole('heading', { name: 'Your daily time · 2 hours' })).toBeInTheDocument(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Listen free now' })).toBeEnabled()); - expect(screen.getByRole('button', { name: 'Choose another time' })).toBeEnabled(); - }); - - it('submits a chosen wall-clock minute with the browser IANA zone', async () => { - renderSetup(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Choose another time' })).toBeEnabled()); - await userEvent.click(screen.getByRole('button', { name: 'Choose another time' })); - expect(screen.getByRole('button', { name: 'Back' })).toBeEnabled(); - await userEvent.clear(screen.getByLabelText('Start time')); - await userEvent.type(screen.getByLabelText('Start time'), '09:45'); - await userEvent.click(screen.getByRole('button', { name: 'Save my listening time' })); - - expect(fetch).toHaveBeenCalledOnce(); - expect(vi.mocked(fetch).mock.calls[0][0]).toBe('/api/listener/free-window'); - const init = vi.mocked(fetch).mock.calls[0][1] as RequestInit; - expect(JSON.parse(init.body as string)).toMatchObject({ - mode: 'custom', - localStartMinute: 9 * 60 + 45, - }); - expect(JSON.parse(init.body as string).timeZone).toBeTruthy(); - expect(JSON.parse(init.body as string).selectionRequestId).toMatch(/^[0-9a-f-]{36}$/i); - }); - - it('can leave the custom chooser without changing the saved schedule', async () => { - renderSetup(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Choose another time' })).toBeEnabled()); - await userEvent.click(screen.getByRole('button', { name: 'Choose another time' })); - await userEvent.click(screen.getByRole('button', { name: 'Back' })); - - expect(screen.getByRole('button', { name: 'Listen free now' })).toBeEnabled(); - expect(fetch).not.toHaveBeenCalled(); - }); - - it('refreshes authoritative state without leaving the chooser busy after save', async () => { - vi.mocked(fetch).mockResolvedValueOnce(new Response('{}', { status: 200 })); - renderSetup(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Choose another time' })).toBeEnabled()); - await userEvent.click(screen.getByRole('button', { name: 'Choose another time' })); - await userEvent.click(screen.getByRole('button', { name: 'Save my listening time' })); - - expect(refresh).toHaveBeenCalledOnce(); - expect(screen.getByRole('button', { name: 'Listen free now' })).toBeEnabled(); - expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); - }); - - it('shows the next window and cooldown without offering a forbidden change', () => { - renderSetup({ - ...emptyState, - configured: true, - timeZone: 'UTC', - localStartMinute: 600, - selectedAt: '2026-08-07T10:00:00.000Z', - changeAllowedAt: '2026-08-14T10:00:00.000Z', - canChange: false, - nextStart: '2026-08-08T10:00:00.000Z', - nextEnd: '2026-08-08T12:00:00.000Z', - }); - - expect(screen.getByText('Your next listening window begins')).toBeInTheDocument(); - expect(screen.getByText('Your daily time')).toBeInTheDocument(); - expect(screen.getByText(/10:00.*UTC/)).toBeInTheDocument(); - expect(screen.getByText('You can change this schedule')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Listen free now' })).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/early-birds/__tests__/listener-visual-isolation.test.tsx b/src/components/early-birds/__tests__/listener-visual-isolation.test.tsx index ed669901..235e994a 100644 --- a/src/components/early-birds/__tests__/listener-visual-isolation.test.tsx +++ b/src/components/early-birds/__tests__/listener-visual-isolation.test.tsx @@ -1,12 +1,11 @@ // @vitest-environment jsdom import { readFileSync } from 'node:fs'; -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LocaleProvider } from '@/context/LocaleContext'; -import type { SerializedEarlyBirdFreeWindowState } from '@/lib/early-birds/free-window'; const refresh = vi.hoisted(() => vi.fn()); const signInSocial = vi.hoisted(() => vi.fn()); @@ -23,26 +22,11 @@ vi.mock('@/components/brand/BrandLockup', () => ({ import EarlyBirdLanding from '../EarlyBirdLanding'; import EarlyBirdUnavailable from '../EarlyBirdUnavailable'; import FreeInvitationRedeemer from '../FreeInvitationRedeemer'; -import FreeWindowSetup from '../FreeWindowSetup'; +import FreeQuotaStatus from '../FreeQuotaStatus'; import SyntheticTeamEntryForm from '../SyntheticTeamEntryForm'; -import WelcomeAccessAction from '../WelcomeAccessAction'; const EVENT_VISUAL_CLASS = /event-(shell|button|alert|field|card)/; -const emptyFreeWindow: SerializedEarlyBirdFreeWindowState = { - configured: false, - active: false, - timeZone: null, - localStartMinute: null, - selectedAt: null, - changeAllowedAt: null, - canChange: true, - activeStart: null, - activeEnd: null, - nextStart: null, - nextEnd: null, -}; - function renderLanding(overrides: Partial> = {}) { return render( @@ -55,8 +39,6 @@ function renderLanding(overrides: Partial { + it('weekly quota uses only listener-scoped presentation classes', () => { render( - , + + + , ); - - const primary = await waitFor(() => screen.getByRole('button', { name: 'Listen free now' })); - expect(primary).toHaveClass('listener-button', 'listener-button--primary'); - expect(screen.getByRole('button', { name: 'Choose another time' })) - .toHaveClass('listener-button', 'listener-button--secondary'); - - await userEvent.click(primary); - const alert = await screen.findByRole('alert'); - // `event-alert--error` never had a rule; the listener-scoped variant does. - expect(alert).toHaveClass('listener-alert', 'listener-alert--error'); - }); - - it('welcome access errors surface through the styled listener error alert variant', async () => { - render(); - - const action = screen.getByRole('button', { name: 'Escuchar ahora' }); - expect(action).toHaveClass('listener-button', 'listener-button--primary'); - - await userEvent.click(action); - const alert = await screen.findByRole('alert'); - expect(alert).toHaveClass('listener-alert', 'listener-alert--error'); + expect(screen.getByText('You have 3h left this week').parentElement) + .toHaveClass('listener-quota'); }); it('the staging team entry form uses listener fields, alert and button classes only', async () => { diff --git a/src/components/early-birds/free-quota.ts b/src/components/early-birds/free-quota.ts new file mode 100644 index 00000000..49ad13c5 --- /dev/null +++ b/src/components/early-birds/free-quota.ts @@ -0,0 +1,99 @@ +/** + * Presentation-only adapter for the Listener weekly quota contract. + * + * The access endpoint remains the authority. Keeping its wire shape at this + * boundary makes the client deliberately conservative while the canonical + * backend is rolling out: unknown or incomplete fields never manufacture + * access, and only a newer server timestamp may replace a snapshot. + */ +export type ListenerQuotaStatus = 'not-started' | 'available' | 'listening' | 'exhausted'; + +export type SerializedEarlyBirdQuotaSnapshot = { + policy: 'personal-7-day-v1'; + status: ListenerQuotaStatus; + cycleStartedAt: string | null; + cycleEndsAt: string | null; + baseAllowanceMs: number; + bonusAllowanceMs: number; + consumedMs: number; + remainingMs: number; + activelyConsuming: boolean; + exhaustsAt: string | null; + nextCycleAt: string | null; +}; + +export type ListenerQuotaSnapshot = SerializedEarlyBirdQuotaSnapshot & { serverNow: string }; + +const WEEKLY_FREE_MS = 3 * 60 * 60 * 1000; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}T/; + +function finiteMilliseconds(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : null; +} + +function isoDate(value: unknown): string | null { + return typeof value === 'string' && ISO_DATE.test(value) && Number.isFinite(Date.parse(value)) + ? value + : null; +} + +export function listenerQuotaSnapshot( + value: SerializedEarlyBirdQuotaSnapshot | null | undefined, + fallbackServerNow: string, +): ListenerQuotaSnapshot | null { + if (!value || value.policy !== 'personal-7-day-v1' || !isoDate(fallbackServerNow)) return null; + const numbers = [ + value.baseAllowanceMs, + value.bonusAllowanceMs, + value.consumedMs, + value.remainingMs, + ].map(finiteMilliseconds); + if (numbers.some((number) => number === null)) return null; + if (value.status !== 'not-started' && value.status !== 'available' && value.status !== 'listening' && value.status !== 'exhausted') return null; + if ((value.cycleStartedAt !== null && !isoDate(value.cycleStartedAt)) + || (value.cycleEndsAt !== null && !isoDate(value.cycleEndsAt)) + || (value.exhaustsAt !== null && !isoDate(value.exhaustsAt)) + || (value.nextCycleAt !== null && !isoDate(value.nextCycleAt))) return null; + + return { + ...value, + baseAllowanceMs: numbers[0]!, + bonusAllowanceMs: numbers[1]!, + consumedMs: numbers[2]!, + remainingMs: numbers[3]!, + serverNow: fallbackServerNow, + }; +} + +export function quotaSnapshotFromAccessState(payload: unknown): ListenerQuotaSnapshot | null { + if (!payload || typeof payload !== 'object') return null; + const root = payload as Record; + const access = root.access && typeof root.access === 'object' + ? root.access as Record + : null; + if (!access) return null; + return listenerQuotaSnapshot( + access.quota as SerializedEarlyBirdQuotaSnapshot | null | undefined, + typeof root.serverNow === 'string' ? root.serverNow : '', + ); +} + +export function isNewerQuotaSnapshot( + next: ListenerQuotaSnapshot, + current: ListenerQuotaSnapshot, +) { + return Date.parse(next.serverNow) > Date.parse(current.serverNow); +} + +export function formatQuotaDuration(milliseconds: number, locale: 'es' | 'en') { + const minutes = Math.max(0, Math.ceil(milliseconds / 60_000)); + const hours = Math.floor(minutes / 60); + const remainder = minutes % 60; + if (hours === 0) return locale === 'es' ? `${remainder} min` : `${remainder}m`; + if (remainder === 0) return locale === 'es' ? `${hours} h` : `${hours}h`; + return locale === 'es' ? `${hours} h ${remainder} min` : `${hours}h ${remainder}m`; +} + +export const LISTENER_WEEKLY_FREE_MS = WEEKLY_FREE_MS; diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 629d02b4..2f8d0d35 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -6,9 +6,9 @@ export const earlyBirdCopy = { eyebrow: 'HARMONIC BEACON · LISTENER', title: 'Recuerda tu centro armónico.', intro: 'Un campo armónico continuo, compartido alrededor del mundo.', - live: 'Escucha durante el acceso disponible para tu cuenta', + live: 'Escucha dentro del tiempo disponible para tu cuenta', privateDropIns: 'Una introducción opcional antes de entrar al Beacon', - membership: 'Primera escucha y horario Free diario', + membership: 'Tres horas Free por semana', signInGoogle: 'Continuar con Google', signInApple: 'Continuar con Apple', signingIn: 'Abriendo acceso…', @@ -23,30 +23,23 @@ export const earlyBirdCopy = { signOut: 'Cerrar sesión', enter: 'Entrar al Beacon', redeem: 'Activar mi invitación', - accessNeeded: 'Tu cuenta todavía no tiene una membresía activa.', - freeTitle: 'Tu horario diario · 2 horas', - freeDescription: 'Elige una hora local. Será la misma todos los días y podrás cambiarla después de siete días.', - listenFreeNow: 'Escuchar gratis ahora', - chooseFreeTime: 'Elegir otro horario', - freeStartTime: 'Hora de inicio', - saveFreeTime: 'Guardar mi horario', - savingFreeTime: 'Guardando horario…', - startingFreeTime: 'Abriendo horario Free…', - cancelFreeTime: 'Volver', - freeTimeZone: 'Zona horaria', - savedFreeTime: 'Tu horario diario', - nextFreeWindow: 'Tu próxima escucha comienza', - freeScheduleLocked: 'Podrás cambiar este horario', - freeScheduleError: 'No pudimos guardar el horario. Inténtalo nuevamente.', - welcomeTitle: 'Tu primera escucha · 30 minutos', - welcomeListen: 'Escuchar ahora', - welcomeStarting: 'Abriendo el Beacon…', - welcomeDescription: 'Tu primera escucha no fija todavía el horario diario.', - welcomeError: 'No pudimos abrir esta primera escucha. Inténtalo nuevamente.', + accessNeeded: 'Tu cuenta todavía no tiene acceso activo.', + freeQuotaTitle: 'Tiempo Free semanal', + freeQuotaNotStarted: 'Tu tiempo empieza cuando escuchas.', + freeQuotaRemaining: 'Te quedan {time} esta semana', + freeQuotaAvailable: 'Disponible para escuchar.', + freeQuotaListening: 'El tiempo se actualiza mientras escuchas.', + freeQuotaExhausted: 'Usaste tu tiempo Free de esta semana.', + freeQuotaRenews: 'Volverá a estar disponible al renovarse tu ciclo.', + freeQuotaResetsIn: 'Se renueva en {time}', + freeQuotaExtra: 'Incluye {time} de crédito extra.', + freeQuotaFounder: 'Acceso Founder', + freeQuotaFreeForAll: 'Acceso libre', + freeQuotaUnlimited: 'Puedes escuchar sin límite de tiempo.', authError: 'No pudimos completar el acceso. Usa el mismo proveedor con el que creaste tu cuenta o contacta a soporte.', serviceUnavailableTitle: 'No podemos confirmar tu acceso ahora.', identityUnavailable: 'El servicio de identidad no está respondiendo. Tus datos y tu acceso no cambiaron.', - accessUnavailable: 'No pudimos consultar tu horario o membresía. No mostraremos un estado estimado.', + accessUnavailable: 'No pudimos consultar tu acceso o membresía. No mostraremos un estado estimado.', retryAccess: 'Intentar nuevamente', privacy: 'Tu cuenta y membresía administran el acceso. Durante la escucha sólo compartimos una presencia regional amplia y efímera; nunca tu ubicación exacta ni un historial personal de escucha.', membershipInvitation: 'Acceso por invitación', @@ -66,9 +59,9 @@ export const earlyBirdCopy = { eyebrow: 'HARMONIC BEACON · LISTENER', title: 'Remember your harmonic center.', intro: 'A continuous harmonic field, shared across the world.', - live: 'Listen during the access available to your account', + live: 'Listen within the time available to your account', privateDropIns: 'An optional introduction before entering the Beacon', - membership: 'A first listen and a daily Free schedule', + membership: 'Three Free hours each week', signInGoogle: 'Continue with Google', signInApple: 'Continue with Apple', signingIn: 'Opening access…', @@ -83,30 +76,23 @@ export const earlyBirdCopy = { signOut: 'Sign out', enter: 'Enter the Beacon', redeem: 'Activate my invitation', - accessNeeded: 'Your account does not have an active membership yet.', - freeTitle: 'Your daily time · 2 hours', - freeDescription: 'Choose a local start time. It repeats daily and can be changed after seven days.', - listenFreeNow: 'Listen free now', - chooseFreeTime: 'Choose another time', - freeStartTime: 'Start time', - saveFreeTime: 'Save my listening time', - savingFreeTime: 'Saving your time…', - startingFreeTime: 'Opening your Free time…', - cancelFreeTime: 'Back', - freeTimeZone: 'Time zone', - savedFreeTime: 'Your daily time', - nextFreeWindow: 'Your next listening window begins', - freeScheduleLocked: 'You can change this schedule', - freeScheduleError: 'We could not save the schedule. Please try again.', - welcomeTitle: 'Your first listen · 30 minutes', - welcomeListen: 'Listen now', - welcomeStarting: 'Opening the Beacon…', - welcomeDescription: 'Your first listen does not set your daily time yet.', - welcomeError: 'We could not open this first listen. Please try again.', + accessNeeded: 'Your account does not have active access yet.', + freeQuotaTitle: 'Weekly Free time', + freeQuotaNotStarted: 'Your time starts when you listen.', + freeQuotaRemaining: 'You have {time} left this week', + freeQuotaAvailable: 'Ready when you are.', + freeQuotaListening: 'Your time updates while you listen.', + freeQuotaExhausted: 'You have used your Free time for this week.', + freeQuotaRenews: 'It will be available again when your cycle renews.', + freeQuotaResetsIn: 'Renews in {time}', + freeQuotaExtra: 'Includes {time} of extra credit.', + freeQuotaFounder: 'Founder access', + freeQuotaFreeForAll: 'Open access', + freeQuotaUnlimited: 'You can listen without a time limit.', authError: 'We could not complete sign-in. Use the provider that created your account, or contact support.', serviceUnavailableTitle: 'We cannot confirm your access right now.', identityUnavailable: 'The identity service is not responding. Your data and access have not changed.', - accessUnavailable: 'We could not check your schedule or membership. We will not show an estimated state.', + accessUnavailable: 'We could not check your access or membership. We will not show an estimated state.', retryAccess: 'Try again', privacy: 'Your account and membership manage access. While you listen, we share only broad, ephemeral regional presence—never your exact location or a personal listening history.', membershipInvitation: 'Invitation access', @@ -179,8 +165,6 @@ export const earlyBirdHomeCopy = { master: 'Volumen', signOut: 'Cerrar sesión', active: 'Listener activo', - freeActive: 'Horario Free activo', - welcomeActive: 'Primera escucha activa', account: 'Cuenta', }, en: { @@ -214,8 +198,6 @@ export const earlyBirdHomeCopy = { master: 'Volume', signOut: 'Sign out', active: 'Listener active', - freeActive: 'Free window active', - welcomeActive: 'First listen active', account: 'Account', }, } satisfies Record>; From ecdf48d6e3c894b95121acfa460cd1786f7b802c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 21:44:29 -0300 Subject: [PATCH 137/376] docs: define weekly Listener Free quota --- docs/architecture/EARLY_BIRDS_LISTENER.md | 87 +++++++++---------- .../operations/EARLY_BIRDS_FREE_ACCEPTANCE.md | 22 ++++- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 18 +++- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 18 ++++ docs/plans/EARLY_BIRDS.md | 19 ++-- 5 files changed, 110 insertions(+), 54 deletions(-) diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 4a6f84f3..9fee912b 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -111,53 +111,52 @@ 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. -## Ordinary Free listening window - -Registration does not fabricate a commerce membership. A signed-in account -without current canonical membership and without a previously selected Free -schedule may explicitly start one 30-minute first listen. Registration, OAuth -callback, page view, Free for All and canonical membership never create or -consume it. Its durable one-row marker is account-bound and cannot be reset by -retry, refresh or a second device; leases and manifests are capped at the exact -server-side end. - -The same account may instead select one recurring local -wall-clock start and listen for two real hours each day. The first selection is -either **Listen free now**, derived from server time in the validated browser -IANA zone, or an explicit local time. The selection is account-bound and may be -changed again at or after `selected_at + 7 days`. - -`early_bird_free_schedules` is a separate access layer from -`early_bird_membership_projections`. It stores only account ID, canonical IANA -zone, local start minute, selection/cooldown instants, idempotency request ID and -revision. It never writes provider, offer, price, Purchase or membership state. +## 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. a time-valid canonical membership grants its canonical boundary or anytime - access; -2. otherwise the current recurring Free window grants access until its exact - end; -3. otherwise an already-started first listen grants access until its exact - 30-minute end; -4. otherwise access fails closed. - -Starting the first listen requires a same-origin authenticated idempotent POST. -It is available only before a recurring schedule exists. Selecting the schedule -first does not create or consume the first-listen row. The operational Free for -All override rejects first-listen activation, so public access never spends an -account's welcome session. - -The server resolves wall-clock dates with `Intl` timezone data. A fall-back -ambiguity uses the first occurrence; a spring-forward nonexistent minute moves -to the first real local minute after it. Window duration is always 120 real -minutes. Stream leases, signed manifests and segment signatures are capped at -the resulting boundary. Changing an unlocked schedule evicts existing leases -so every device must reauthorize. Browser time is presentation/input only and -never authorizes a request. - -The operator `EARLY_BIRDS_FREE_FOR_ALL=1` override remains route-level, -anonymous and independent. It creates neither a Free schedule nor membership. +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. + +Future discretionary Free credits are distinct append-only, idempotent grants. +Each grant has an opaque idempotency identity, a fixed amount and an optional +expiry; it is never a mutable replacement for the base cycle or membership. +The policy for applying an unexpired grant is server-side and must be explicit +when such grants are implemented. + +`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 diff --git a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md index 782d41d7..f0532cce 100644 --- a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md +++ b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md @@ -5,11 +5,31 @@ 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. Future discretionary credits, if added, are +append-only idempotent grants with 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. -## Fixed candidate +## Historical fixed candidate — previous daily/welcome policy - Listener application: `dad29d4dc5010603a5bbc7ed309c8f78e7c0f384` - Listener schema: `20260807100000_early_bird_welcome_access` diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 7a3a6dac..65098d1c 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,6 +1,22 @@ # EarlyBirds isolated staging runtime -## 2026-08-08 public Listener convergence +## Planned weekly-Free cutover — not yet deployed + +The staging evidence below, including release `20406da`, records the prior +daily-schedule and welcome-access policy. It does not prove or deploy the +planned weekly-Free cutover. + +The planned policy is three hours per registered account in a fixed seven-day +cycle, anchored at first real authorized playback, with no base rollover and +server-time metering. Concurrent devices consume their listening union once; +intro and Beacon count; Stop/disconnect are bounded by leases. Active canonical +membership/invitation and FFA are unlimited/non-metered. Any future discretionary +credit is append-only, idempotent and optionally expiring. Legacy schedule and +welcome tables remain retained but retired from authorization. After migration, +rollback is stop/kill-switch and a roll-forward repair, never activation of the +old authorization rules. + +## 2026-08-08 public Listener convergence (historical previous release) The isolated Listener runs application SHA `20406dae49e8cbabba38d0cb099d8f400276113e`, image diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index 364fc5e1..f979c147 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -12,6 +12,24 @@ 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. +## Status: previous release policy; weekly cutover not yet deployed + +Release `20406da` is retained as historical evidence for the prior daily Free +schedule and one-time welcome policy. It is not an authorization rollback +target for the planned weekly-Free cutover. + +The planned 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 future +credits are append-only idempotent grants with optional expiry. No weekly +cutover implementation, migration, validation or deployment is asserted here. + +After the additive weekly migration, rollback means stop/kill-switch and +roll-forward repair. It must never restore the `20406da` daily/welcome +authorization behavior. + ## Candidate identity | Artifact | Exact value | diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index a5c13000..3869d70b 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -21,9 +21,9 @@ 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 one immediate 30-minute welcome listen, select a recurring two-hour daily - Free window, redeem a controlled invitation, or activate a valid paid - Founding Listener membership; +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 @@ -55,7 +55,7 @@ change has passed its own audio and operational acceptance. | 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 | Desktop HLS stays about five segments behind the edge with a 60-second target buffer; Stop and a later Listen rejoin the current configured edge. | | 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 | Welcome access and recurring Free windows are server-authoritative access layers that never fabricate membership or Purchase; invitations and PayPal/MercadoPago converge on the revocable membership state machine. | +| 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 for life | Accepted | First canonical paid activation grants the opaque account a lifetime right to the USD 2/month founder offer; cancellation ends access but not that price eligibility. | | 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. | @@ -137,9 +137,8 @@ steps are not safe to execute literally. - 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. -- One explicit 30-minute welcome listen, a recurring two-hour daily Free window - locked for seven days, one-use signed invitations and canonical paid - membership entitlements. +- 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. @@ -675,7 +674,7 @@ event sound and reliability are at least as good as the current path. | 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, offers one explicit 30-minute first listen, then grants one recurring two-hour daily wall-clock window locked for rolling seven days; the server resolves IANA/DST boundaries and caps leases. Founder membership grants anytime access, while the operator Free for All override remains independent. | +| 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 @@ -710,6 +709,10 @@ its own explicit approval. 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. - 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. From 91026304dd7a6d408a8fdbc93292ef4e279b0449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 8 Aug 2026 22:03:23 -0300 Subject: [PATCH 138/376] fix(listener): surface weekly quota safely --- docs/architecture/EARLY_BIRDS_LISTENER.md | 10 +++++----- docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md | 4 ++-- docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md | 11 +++++++---- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 15 ++++++++++----- src/components/early-birds/EarlyBirdHome.tsx | 11 +++++------ src/components/early-birds/EarlyBirdLanding.tsx | 3 --- src/components/early-birds/FreeQuotaStatus.tsx | 14 +++++++++++--- .../early-birds/__tests__/EarlyBirdHome.test.tsx | 3 +++ .../__tests__/EarlyBirdLanding.test.tsx | 6 +++--- src/lib/__tests__/auth.test.ts | 4 ++-- src/lib/__tests__/principal.test.ts | 4 ++-- src/lib/early-birds/copy.ts | 4 ++-- 12 files changed, 52 insertions(+), 37 deletions(-) diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 9fee912b..55fd41f0 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -145,11 +145,11 @@ 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. -Future discretionary Free credits are distinct append-only, idempotent grants. -Each grant has an opaque idempotency identity, a fixed amount and an optional -expiry; it is never a mutable replacement for the base cycle or membership. -The policy for applying an unexpired grant is server-side and must be explicit -when such grants are implemented. +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 diff --git a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md index f0532cce..a4537d10 100644 --- a/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md +++ b/docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md @@ -14,8 +14,8 @@ 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. Future discretionary credits, if added, are -append-only idempotent grants with optional expiry. +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 diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 65098d1c..b29e40fd 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -10,8 +10,9 @@ The planned policy is three hours per registered account in a fixed seven-day cycle, anchored at first real authorized playback, with no base rollover and server-time metering. Concurrent devices consume their listening union once; intro and Beacon count; Stop/disconnect are bounded by leases. Active canonical -membership/invitation and FFA are unlimited/non-metered. Any future discretionary -credit is append-only, idempotent and optionally expiring. Legacy schedule and +membership/invitation and FFA are unlimited/non-metered. Discretionary credit +uses auditable, idempotent grants with immutable facts, monotonic consumed +totals and optional expiry. Legacy schedule and welcome tables remain retained but retired from authorization. After migration, rollback is stop/kill-switch and a roll-forward repair, never activation of the old authorization rules. @@ -51,10 +52,12 @@ Free, invitation or Founder authority. 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. -- Immediate rollback retains schema/media and selects image `b8a04fe`; image +- 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. Never roll back the additive schema. + 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) diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index f979c147..e964ebf5 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -22,8 +22,9 @@ The planned 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 future -credits are append-only idempotent grants with optional expiry. No weekly +membership/invitation and FFA remain unlimited/non-metered. Optional credits +use auditable idempotent grants with immutable facts, a monotonic consumed +total and optional expiry. No weekly cutover implementation, migration, validation or deployment is asserted here. After the additive weekly migration, rollback means stop/kill-switch and @@ -39,8 +40,8 @@ authorization behavior. | Listener database schema | `20260807200000_listener_regional_presence` | | Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | | Public mode | Free for All OFF during coordinated registered-Free acceptance | -| Immediate Listener rollback | `b8a04fe` | -| Additional Listener rollback | `2344b10` | +| Historical pre-weekly Listener rollback | `b8a04fe` | +| Historical additional pre-weekly rollback | `2344b10` | Health must attest the deployed application SHA, not the later documentation or test-only branch head. @@ -153,7 +154,11 @@ worksheet. 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. -## Rollback +## Historical rollback (valid only before the weekly migration) + +The following procedure is evidence for the pre-weekly release only. It becomes +invalid as soon as the weekly policy marker is applied; after that cutover use +stop/kill-switch and a forward repair. Run the fixed disable command from release `20406da` first. Restore the exact root-only environment backup `/etc/harmonic-beacon/earlybirds-preview.env.pre-20406da`, diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index 4a3c9aaf..4aec4055 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -26,9 +26,7 @@ export default function EarlyBirdHome({ }: { displayName: string; membership: ListenerMembershipPresentation; - accessKind?: 'membership' | 'free-quota' | 'free-window' | 'welcome'; - /** Retained temporarily so this UI commit remains rebaseable over the legacy page. */ - accessUntil?: string | null; + accessKind?: 'membership' | 'free-quota'; serverNow?: string; dropIns: { es: string | null; en: string | null }; publicAccess?: boolean; @@ -55,15 +53,16 @@ export default function EarlyBirdHome({ {publicAccess && ( )} + {!publicAccess && accessKind === 'free-quota' && ( + + )} {!publicAccess &&
{displayName.slice(0, 1).toUpperCase()}

{displayName}

- {accessKind === 'free-quota' ? ( - - ) : ( + {accessKind === 'membership' && ( {membershipCopy?.title ?? copy.active} )} {accessKind === 'membership' && membership.kind === 'founder' && membershipCopy?.detail && ( diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index 211cf76b..e1dca201 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -23,9 +23,6 @@ type Props = { providers: { google: boolean; apple: boolean }; emailMagicLinkAvailable: boolean; syntheticTeamEntryAvailable: boolean; - /** Legacy props are retained only until the quota backend lands. */ - freeWindow?: unknown; - welcome?: unknown; quota?: SerializedEarlyBirdQuotaSnapshot | null; membership: ListenerMembershipPresentation; serverNow: string; diff --git a/src/components/early-birds/FreeQuotaStatus.tsx b/src/components/early-birds/FreeQuotaStatus.tsx index bd165829..bbe81786 100644 --- a/src/components/early-birds/FreeQuotaStatus.tsx +++ b/src/components/early-birds/FreeQuotaStatus.tsx @@ -27,7 +27,7 @@ function monotonicNow() { } function serverElapsed(receivedAt: number, now: number) { - // Date.now is used only as an elapsed-time clock after a server snapshot; + // performance.now is used only as an elapsed-time clock after a server snapshot; // browser wall time never authorizes or chooses a quota boundary. return Math.max(0, now - receivedAt); } @@ -60,6 +60,7 @@ export default function FreeQuotaStatus({ snapshot, serverNow, compact = false, let cancelled = false; let inFlight = false; let retryTimer: number | null = null; + let presenceRetryTimer: number | null = null; const revalidate = async () => { if (cancelled || inFlight) return; @@ -84,7 +85,6 @@ export default function FreeQuotaStatus({ snapshot, serverNow, compact = false, } const nextKind = payload?.access?.kind; if (nextKind && nextKind !== 'free-quota' && nextKind !== 'membership') router.refresh(); - if (next?.status === 'exhausted') router.refresh(); } catch { // A failed client revalidation never invents entitlement. Media // authorization continues to fail closed at the server. @@ -96,7 +96,14 @@ export default function FreeQuotaStatus({ snapshot, serverNow, compact = false, const onResume = () => { if (document.visibilityState !== 'hidden') void revalidate(); }; - const onPlaybackPresence = () => void revalidate(); + const onPlaybackPresence = () => { + void revalidate(); + // sendBeacon/presence activation and this presentation event are + // intentionally decoupled. Recheck once after the server has had a + // bounded opportunity to commit the authoritative transition. + if (presenceRetryTimer !== null) window.clearTimeout(presenceRetryTimer); + presenceRetryTimer = window.setTimeout(() => void revalidate(), 750); + }; const interval = state?.activelyConsuming && !unlimited ? window.setInterval(() => void revalidate(), 30_000) : null; @@ -116,6 +123,7 @@ export default function FreeQuotaStatus({ snapshot, serverNow, compact = false, cancelled = true; if (interval !== null) window.clearInterval(interval); if (retryTimer !== null) window.clearTimeout(retryTimer); + if (presenceRetryTimer !== null) window.clearTimeout(presenceRetryTimer); window.removeEventListener('pageshow', onResume); window.removeEventListener('listener:playback-presence', onPlaybackPresence); document.removeEventListener('visibilitychange', onResume); diff --git a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx index 0bece594..11ad46df 100644 --- a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx @@ -7,6 +7,9 @@ import { LocaleProvider } from '@/context/LocaleContext'; vi.mock('../ListenerPlayer', () => ({ default: () =>
, })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); vi.mock('../CosmicCampfire', () => ({ default: ({ fixture }: { fixture: string }) => (
); diff --git a/src/components/early-birds/__tests__/FreeQuotaStatus.test.tsx b/src/components/early-birds/__tests__/FreeQuotaStatus.test.tsx index ce4ea7af..a75dd9b5 100644 --- a/src/components/early-birds/__tests__/FreeQuotaStatus.test.tsx +++ b/src/components/early-birds/__tests__/FreeQuotaStatus.test.tsx @@ -49,7 +49,18 @@ describe('Listener weekly quota presentation', () => { expect(screen.getByText('You have 3h left this week')).toBeInTheDocument(); expect(screen.getByText('Includes 30m of extra credit.')).toBeInTheDocument(); - expect(screen.getByText('Renews in 168h')).toBeInTheDocument(); + expect(screen.getByText('Renews in 7d')).toBeInTheDocument(); + }); + + it('expresses renewal time in days and hours instead of a large hour count', () => { + render( + + + , + ); + + expect(screen.getByText('Renews in 6d 23h')).toBeInTheDocument(); + expect(screen.queryByText(/167h/)).toBeNull(); }); it('never fabricates an allowance from an incomplete snapshot', () => { diff --git a/src/components/early-birds/free-quota.ts b/src/components/early-birds/free-quota.ts index 49ad13c5..4049d195 100644 --- a/src/components/early-birds/free-quota.ts +++ b/src/components/early-birds/free-quota.ts @@ -96,4 +96,16 @@ export function formatQuotaDuration(milliseconds: number, locale: 'es' | 'en') { return locale === 'es' ? `${hours} h ${remainder} min` : `${hours}h ${remainder}m`; } +export function formatQuotaRenewalDuration(milliseconds: number, locale: 'es' | 'en') { + const totalMinutes = Math.max(0, Math.ceil(milliseconds / 60_000)); + if (totalMinutes < 60) return locale === 'es' ? `${totalMinutes} min` : `${totalMinutes}m`; + + const totalHours = Math.ceil(totalMinutes / 60); + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + if (days === 0) return locale === 'es' ? `${hours} h` : `${hours}h`; + if (hours === 0) return locale === 'es' ? `${days} d` : `${days}d`; + return locale === 'es' ? `${days} d ${hours} h` : `${days}d ${hours}h`; +} + export const LISTENER_WEEKLY_FREE_MS = WEEKLY_FREE_MS; From e4db22efdf15b7c09902637dd8bdf2239a061675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 00:09:11 -0300 Subject: [PATCH 148/376] docs(listener): record renewal display release --- docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md | 8 +++++--- docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 4c939f33..1619eb92 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -3,9 +3,11 @@ ## 2026-08-08 weekly-Free Listener release The isolated public Listener runs application SHA -`808bf0e8fabfaec0b7ee6396fa078739f68f73d4`, image -`harmonic-beacon/earlybirds-preview-listener:808bf0e` and schema -`20260808160000_listener_weekly_quota`. Operational smoke head is `8444ed7`. +`7036eb34a2cbed681b7e5230591ea14efeff10b5`, image +`harmonic-beacon/earlybirds-preview-listener:7036eb3` and schema +`20260808160000_listener_weekly_quota`. Weekly policy smoke head is `8444ed7`; +the later runtime changes only the renewal presentation from a large hour count +to days and hours. Free for All is OFF. - Each registered Free account has three hours in a fixed seven-day cycle, diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index 239a8b67..33150f36 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -14,7 +14,7 @@ an event-stack deployment or an acoustic change. ## Status: weekly Free deployed for acceptance -Release `808bf0e8fabfaec0b7ee6396fa078739f68f73d4` is deployed on the isolated +Release `7036eb34a2cbed681b7e5230591ea14efeff10b5` 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 @@ -32,7 +32,7 @@ repair, never restoring daily/welcome authorization. | Artifact | Exact value | |---|---| -| Deployed Listener application | `808bf0e8fabfaec0b7ee6396fa078739f68f73d4` | +| Deployed Listener application | `7036eb34a2cbed681b7e5230591ea14efeff10b5` | | Operational smoke/documentation head | `8444ed7d06b2764c519f65ce4d32932346a94fdd` | | Listener database schema | `20260808160000_listener_weekly_quota` | | Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | @@ -81,6 +81,7 @@ test-only branch head. - `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. Historical pre-weekly experiments: @@ -112,7 +113,7 @@ deployed image; later documentation-only commits do not require rebuilding it. - 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 `808bf0e`. Earlier policy images remain only as historical +- Current image is `7036eb3`. Earlier policy images remain only as historical artifacts and are not valid rollback targets. - The fixed public-disable command was exercised after deployment. Its first health probe observed the normal Next.js startup connection reset, retried, From 6a5d4b6d3f228a2d88232415585c624fc81e78fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 00:31:13 -0300 Subject: [PATCH 149/376] fix(listener): default volume to seventy percent --- src/components/early-birds/ListenerPlayer.tsx | 3 ++- .../early-birds/__tests__/ListenerTransport.test.tsx | 4 ++-- src/lib/listener/__tests__/media-boundary.test.ts | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 95a672e7..31a0ea50 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -46,6 +46,7 @@ const RECOVERY_DELAYS_MS = [0, 1_000, 3_000] as const; const STALL_RECOVERY_DELAY_MS = 1_000; const LIVE_FADE_IN_MS = 3_000; const TRANSPORT_FADE_OUT_MS = 650; +const DEFAULT_LISTENER_VOLUME = 0.7; export const LISTENER_PLAYBACK_PRESENCE_EVENT = 'listener:playback-presence'; @@ -238,7 +239,7 @@ export default function ListenerPlayer({ const pendingLiveFade = useRef(false); const activeDrop = useRef(null); const dropGeneration = useRef(0); - const volumeRef = useRef(1); + const volumeRef = useRef(DEFAULT_LISTENER_VOLUME); const livePreparedRef = useRef(false); const livePreparation = useRef | null>(null); const manifestUrl = useRef(null); diff --git a/src/components/early-birds/__tests__/ListenerTransport.test.tsx b/src/components/early-birds/__tests__/ListenerTransport.test.tsx index a24421d9..b6021ceb 100644 --- a/src/components/early-birds/__tests__/ListenerTransport.test.tsx +++ b/src/components/early-birds/__tests__/ListenerTransport.test.tsx @@ -242,7 +242,7 @@ describe('Listener one-action playlist transport', () => { expect(await screen.findByRole('slider', { name: 'Seek' })) .toBeInTheDocument(); expect(screen.queryByText('Amara Sol · English')).not.toBeInTheDocument(); - expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'Volume' })).toHaveValue('0.7'); }); it('updates media-element volume directly without changing the live transport state', async () => { @@ -304,7 +304,7 @@ describe('Listener one-action playlist transport', () => { fireEvent.ended(intro); expect(play.mock.instances).not.toContain(live); frames.shift()?.(3_000); - expect(live.volume).toBeCloseTo(1); + expect(live.volume).toBeCloseTo(0.7); expect(live.muted).toBe(false); expectPhase('beacon'); }); diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts index fe6d93cd..2dc5efda 100644 --- a/src/lib/listener/__tests__/media-boundary.test.ts +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -6,9 +6,10 @@ import { describe, expect, it } from 'vitest'; const MEDIA_FILE_SHA256 = { // Authorization handlers and non-acoustic lease signaling were reviewed - // and re-pinned for personal quota enforcement. Media sources and signal - // processing remain unchanged and independently pinned. - 'src/components/early-birds/ListenerPlayer.tsx': '50287416aeaa5337fa7786012678679f48f3d672c67816c084bb0baf8557f628', + // and re-pinned for personal quota enforcement. The Listener's initial + // media-element volume was explicitly approved at 70%; sources and all + // other signal processing remain independently pinned. + 'src/components/early-birds/ListenerPlayer.tsx': '1a9400bbed3013095b207812bd70386930fa3f481d1122eb0229d1ccc3d4d77f', 'src/lib/early-birds/stream.ts': '96a2d9fe798591833327631b59a73a5b2fc5ca06be7081945a0b07450970da84', 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', 'src/app/api/early-birds/stream/lease/route.ts': 'ec0e8780387bc1f493eb33d13a2d90e01cfdb6d899fc6232e04f51aaf2dfc508', From 4c41343b755945b7f2d77003e115d348488d03c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 00:36:39 -0300 Subject: [PATCH 150/376] docs(listener): record approved volume release --- docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md | 8 ++++---- docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 1619eb92..ed7abb1b 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -3,11 +3,11 @@ ## 2026-08-08 weekly-Free Listener release The isolated public Listener runs application SHA -`7036eb34a2cbed681b7e5230591ea14efeff10b5`, image -`harmonic-beacon/earlybirds-preview-listener:7036eb3` and schema +`6a5d4b6d3f228a2d88232415585c624fc81e78fd`, image +`harmonic-beacon/earlybirds-preview-listener:6a5d4b6` and schema `20260808160000_listener_weekly_quota`. Weekly policy smoke head is `8444ed7`; -the later runtime changes only the renewal presentation from a large hour count -to days and hours. +the later runtime changes renewal presentation to days/hours and, with Nico's +explicit acoustic approval, sets new Listener sessions to 70% initial volume. Free for All is OFF. - Each registered Free account has three hours in a fixed seven-day cycle, diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index 33150f36..28fdeec8 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -14,7 +14,7 @@ an event-stack deployment or an acoustic change. ## Status: weekly Free deployed for acceptance -Release `7036eb34a2cbed681b7e5230591ea14efeff10b5` is deployed on the isolated +Release `6a5d4b6d3f228a2d88232415585c624fc81e78fd` 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 @@ -32,7 +32,7 @@ repair, never restoring daily/welcome authorization. | Artifact | Exact value | |---|---| -| Deployed Listener application | `7036eb34a2cbed681b7e5230591ea14efeff10b5` | +| Deployed Listener application | `6a5d4b6d3f228a2d88232415585c624fc81e78fd` | | Operational smoke/documentation head | `8444ed7d06b2764c519f65ce4d32932346a94fdd` | | Listener database schema | `20260808160000_listener_weekly_quota` | | Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | @@ -82,6 +82,7 @@ test-only branch head. - `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. Historical pre-weekly experiments: @@ -113,7 +114,7 @@ deployed image; later documentation-only commits do not require rebuilding it. - 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 `7036eb3`. Earlier policy images remain only as historical +- Current image is `6a5d4b6`. Earlier policy images remain only as historical artifacts and are not valid rollback targets. - The fixed public-disable command was exercised after deployment. Its first health probe observed the normal Next.js startup connection reset, retried, From 68b930ca86d8b13df2dd288199f04b47af1053fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 01:07:42 -0300 Subject: [PATCH 151/376] refactor(listener): simplify introduction choice --- src/app/globals.css | 75 ++++++++----- src/components/early-birds/ListenerPlayer.tsx | 105 ++---------------- .../__tests__/ListenerTransport.test.tsx | 95 ++++++---------- src/lib/early-birds/copy.ts | 12 +- .../listener/__tests__/media-boundary.test.ts | 2 +- 5 files changed, 101 insertions(+), 188 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index e26fbd31..7c2ae2f9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1426,36 +1426,44 @@ body { box-shadow: 0 0 0.9rem var(--listener-phase); } -.listener-mode { - width: min(100%, 32rem); - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.5rem; +.listener-intro-option { + width: fit-content; + max-width: 100%; + display: flex; + align-items: center; + gap: 0.7rem; margin: 0.6rem auto 0; - padding: 0.35rem; + padding: 0.7rem 0.9rem; border: 1px solid var(--border-subtle); - border-radius: 1rem; + border-radius: 999px; background: rgba(255, 255, 255, 0.035); + color: var(--paper); + font-family: var(--font-space-mono), monospace; + font-size: 0.72rem; + letter-spacing: 0.06em; + cursor: pointer; + transition: border-color 180ms ease, background-color 180ms ease; } -.listener-mode button { - min-height: 3.35rem; - padding: 0.55rem 0.85rem; - border: 1px solid transparent; - border-radius: 0.75rem; - color: var(--text-muted); - cursor: pointer; - transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease; +.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-mode button span { display: block; } -.listener-mode button span { font-size: 0.82rem; } -.listener-mode button[aria-checked='true'] { - border-color: color-mix(in srgb, var(--listener-phase) 45%, transparent); - color: var(--paper); - background: color-mix(in srgb, var(--listener-phase) 11%, transparent); +.listener-intro-option:focus-within { + outline: 2px solid var(--cyan); + outline-offset: 3px; +} + +.listener-intro-option input { + width: 1.15rem; + height: 1.15rem; + margin: 0; + flex: 0 0 auto; + accent-color: var(--gold); } -.listener-mode button:disabled { cursor: default; opacity: 0.72; } + +.listener-intro-option:has(input:disabled) { cursor: wait; opacity: 0.62; } .listener-transport { width: min(100%, 28rem); @@ -1534,10 +1542,26 @@ body { .listener-details__selection select { width: 100%; margin-top: 0.55rem; - border: 0; + min-height: 2.75rem; + padding: 0.6rem 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 0.7rem; color: var(--paper); - background: transparent; + background-color: #0d1917; + color-scheme: dark; font-size: 0.78rem; + cursor: pointer; +} + +.listener-details__selection select:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 2px; + border-color: transparent; +} + +.listener-details__selection select option { + color: #fff9e9; + background: #0d1917; } .listener-details input[type='range'] { @@ -2016,8 +2040,7 @@ body { .listener-experience { margin-top: 0.55rem; } .listener-field { width: min(100%, 28rem); aspect-ratio: 1.28; } .listener-experience .listener-field { width: min(100%, 25rem); aspect-ratio: 1.55; } - .listener-mode { margin-top: 0.5rem; } - .listener-mode button { min-height: 3.25rem; padding-inline: 0.55rem; } + .listener-intro-option { margin-top: 0.5rem; } .listener-transport { position: sticky; z-index: 10; diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 31a0ea50..0b91152c 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -137,89 +137,6 @@ function preferredDropLanguage( return englishDropIn ? 'en' : 'es'; } -function ListenerPlaybackModeSelector({ - value, - introLabel, - beaconLabel, - groupLabel, - introDisabled, - beaconDisabled, - onChange, -}: { - value: PlaybackMode; - introLabel: string; - beaconLabel: string; - groupLabel: string; - introDisabled: boolean; - beaconDisabled: boolean; - onChange: (mode: PlaybackMode) => void; -}) { - const introButton = useRef(null); - const beaconButton = useRef(null); - const modes: PlaybackMode[] = ['intro', 'beacon']; - const disabled = { intro: introDisabled, beacon: beaconDisabled }; - const buttons = { intro: introButton, beacon: beaconButton }; - const enabledModes = modes.filter((mode) => !disabled[mode]); - const tabStop = enabledModes.includes(value) ? value : enabledModes[0]; - - function selectAndFocus(mode: PlaybackMode) { - if (disabled[mode]) return; - onChange(mode); - buttons[mode].current?.focus(); - } - - function handleKeyDown(event: React.KeyboardEvent, current: PlaybackMode) { - if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return; - event.preventDefault(); - if (enabledModes.length === 0) return; - - if (event.key === 'Home') { - selectAndFocus(enabledModes[0]); - return; - } - if (event.key === 'End') { - selectAndFocus(enabledModes[enabledModes.length - 1]); - return; - } - - const currentIndex = enabledModes.indexOf(current); - const direction = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1; - const nextIndex = currentIndex < 0 - ? 0 - : (currentIndex + direction + enabledModes.length) % enabledModes.length; - selectAndFocus(enabledModes[nextIndex]); - } - - return ( -
- - -
- ); -} - export default function ListenerPlayer({ dropIns, }: { @@ -1387,18 +1304,20 @@ export default function ListenerPlayer({ {phaseLabel}

} - + {availableDropCount > 0 && !transportActive && ( + + )}
- {availableDropCount > 1 && !transportActive && ( + {availableDropCount > 1 && playbackMode === 'intro' && !transportActive && ( - )} - -
- {availableDropCount > 1 && playbackMode === 'intro' && !transportActive && ( - - )} - - {volumeSupported && ( -
}
- + {!publicAccess && accessKind === 'free-quota' && (
diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 22c47ef1..9c71e1c6 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -1,10 +1,22 @@ 'use client'; import type Hls from 'hls.js'; -import { useCallback, useEffect, useRef, useState } from 'react'; - +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; + +import { + DEFAULT_REACTIVE_CAMPFIRE_SETTINGS, + ReactiveCampfireCanvas, + ReactiveCampfireTuningPanel, + resolveReactiveRenderPolicy, + type ReactiveCampfireSettings, +} from '@/components/listener/reactive'; import { useLocale } from '@/context/LocaleContext'; import { earlyBirdHomeCopy } from '@/lib/early-birds/copy'; +import { + WebAudioHarmonicAnalysisProvider, + type HarmonicAnalysisFrame, + type HarmonicAnalysisProvider, +} from '@/lib/listener/analysis'; import { deriveListenerPresentationPhase } from './listener-presentation'; import { ListenerTabIdentityCoordinator } from './listener-tab-identity'; @@ -50,6 +62,16 @@ const DEFAULT_LISTENER_VOLUME = 0.7; export const LISTENER_PLAYBACK_PRESENCE_EVENT = 'listener:playback-presence'; +export function resolveListenerAnalysisFramesPerSecond({ + reducedMotion, + saveData, +}: { + reducedMotion: boolean; + saveData: boolean; +}): number { + return resolveReactiveRenderPolicy({ reducedMotion, saveData }).conservative ? 2 : 30; +} + type LeaseCursor = { leaseId: string; leaseGeneration: number; @@ -121,6 +143,15 @@ export function prefersNativeHls( && Boolean(audio.canPlayType('application/vnd.apple.mpegurl')); } +export function supportsReactiveListenerVisualization( + browser: Pick, +): boolean { + // WebKit native HLS does not yet offer a sufficiently reliable analysed + // MediaElement source/fade path. Keep the established direct player there + // until the real-device acoustic acceptance gate is completed. + return browser.vendor !== 'Apple Computer, Inc.'; +} + function formatTime(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; const rounded = Math.floor(seconds); @@ -137,18 +168,45 @@ function preferredDropLanguage( return englishDropIn ? 'en' : 'es'; } -export default function ListenerPlayer({ - dropIns, -}: { +type ListenerPlayerProps = { dropIns: { es: string | null; en: string | null }; -}) { + reactiveVisualizationAvailable?: boolean; +}; + +type ListenerPlayerControllerProps = ListenerPlayerProps & { + reactiveVisualizationEnabled: boolean; + reactiveSettings: ReactiveCampfireSettings; + onReactiveSettingsChange: (settings: ReactiveCampfireSettings) => void; + onReactiveVisualizationChange: (enabled: boolean) => void; + onReactiveVisualizationFailure: () => void; + reactiveFallbackNotice: boolean; +}; + +const subscribeRuntimeVisualizationCapability = () => () => undefined; + +function ListenerPlayerController({ + dropIns, + reactiveVisualizationAvailable = false, + reactiveVisualizationEnabled, + reactiveSettings, + onReactiveSettingsChange, + onReactiveVisualizationChange, + onReactiveVisualizationFailure, + reactiveFallbackNotice, +}: ListenerPlayerControllerProps) { const { locale } = useLocale(); const copy = earlyBirdHomeCopy[locale]; const liveAudio = useRef(null); - const dropAudio = { - es: useRef(null), - en: useRef(null), - }; + const spanishDropAudio = useRef(null); + const englishDropAudio = useRef(null); + const dropAudio = useMemo(() => ({ + es: spanishDropAudio, + en: englishDropAudio, + }), []); + const analysisProvider = useRef(null); + const analysisFrameListeners = useRef(new Set<(frame: HarmonicAnalysisFrame | null) => void>()); + const analysisFrameUnsubscribe = useRef<(() => void) | null>(null); + const analysisStatusUnsubscribe = useRef<(() => void) | null>(null); const hls = useRef(null); const liveSuppressedForDrop = useRef(false); const liveFadeFrame = useRef(null); @@ -195,12 +253,104 @@ export default function ListenerPlayer({ const [livePreparing, setLivePreparing] = useState(true); const [devicePreparedByGesture, setDevicePreparedByGesture] = useState(false); const [prepareFailure, setPrepareFailure] = useState<'capacity' | 'unavailable' | null>(null); + const [reactiveRendererAvailable, setReactiveRendererAvailable] = useState(true); const updateLiveState = useCallback((state: LiveState) => { liveStateRef.current = state; setLiveState(state); }, []); + const subscribeReactiveFrames = useCallback(( + listener: (frame: HarmonicAnalysisFrame | null) => void, + ) => { + analysisFrameListeners.current.add(listener); + return () => { analysisFrameListeners.current.delete(listener); }; + }, []); + + const startReactiveAnalysis = useCallback((sourceId: string) => { + if (!reactiveVisualizationEnabled || !reactiveRendererAvailable) return; + let provider = analysisProvider.current; + if (!provider) { + const live = liveAudio.current; + if (!live) return; + const sources = [ + { id: 'beacon', kind: 'beacon' as const, element: live }, + ...(['es', 'en'] as const).flatMap((language) => { + const element = dropAudio[language].current; + return element && dropIns[language] + ? [{ id: `intro-${language}`, kind: 'intro' as const, element }] + : []; + }), + ]; + try { + const reducedMotion = typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const saveData = Boolean((navigator as Navigator & { + connection?: { saveData?: boolean }; + }).connection?.saveData); + provider = new WebAudioHarmonicAnalysisProvider({ + sources, + activeSourceId: sourceId, + fftSize: reactiveSettings.fftSize, + baselineSeconds: reactiveSettings.baselineDurationSeconds, + framesPerSecond: resolveListenerAnalysisFramesPerSecond({ + reducedMotion, + saveData, + }), + }); + } catch { + onReactiveVisualizationFailure(); + return; + } + analysisProvider.current = provider; + analysisFrameUnsubscribe.current = provider.subscribe((frame) => { + for (const listener of analysisFrameListeners.current) { + try { listener(frame); } catch { /* renderer isolation */ } + } + }); + analysisStatusUnsubscribe.current = provider.subscribeStatus((status) => { + if (status.error?.code === 'ANALYSIS_FAILED') { + provider?.pauseAnalysis(); + setReactiveRendererAvailable(false); + } + }); + } + + const selected = provider.setActiveSource(sourceId); + if (!selected.ok) { + onReactiveVisualizationFailure(); + return; + } + // start() creates/resumes the complete graph synchronously until its + // first await. Do not await here: Safari must still receive play() for + // the intro and prepared Beacon inside this same trusted gesture. + void provider.start().then((result) => { + if (!result.ok) { + onReactiveVisualizationFailure(); + return; + } + if (!wantsLivePlayback.current) provider?.pauseAnalysis(); + }).catch(() => onReactiveVisualizationFailure()); + }, [ + dropAudio, + dropIns, + onReactiveVisualizationFailure, + reactiveRendererAvailable, + reactiveSettings.baselineDurationSeconds, + reactiveSettings.fftSize, + reactiveVisualizationEnabled, + ]); + + useEffect(() => () => { + analysisFrameUnsubscribe.current?.(); + analysisStatusUnsubscribe.current?.(); + analysisFrameUnsubscribe.current = null; + analysisStatusUnsubscribe.current = null; + analysisProvider.current?.stop(); + analysisProvider.current = null; + analysisFrameListeners.current.clear(); + }, []); + const cancelRecovery = useCallback((resetAttempts = false) => { if (recoveryTimer.current !== null) { window.clearTimeout(recoveryTimer.current); @@ -903,6 +1053,7 @@ export default function ListenerPlayer({ function playBeaconOnly() { dropGeneration.current += 1; if (!livePreparedRef.current) return; + startReactiveAnalysis('beacon'); setHasStarted(true); setTransportStopped(false); setTransportPaused(false); @@ -920,6 +1071,7 @@ export default function ListenerPlayer({ const intro = dropAudio[language].current; if (!intro) return; try { + startReactiveAnalysis(`intro-${language}`); await intro.play(); setTransportPaused(false); reportPresence('listening'); @@ -932,6 +1084,7 @@ export default function ListenerPlayer({ cancelDropFade(); const intro = dropAudio[language].current; intro?.pause(); + analysisProvider.current?.pauseAnalysis(); storeProgress(language); setTransportPaused(true); reportPresence('idle'); @@ -943,6 +1096,7 @@ export default function ListenerPlayer({ setTransportPaused(false); wantsLivePlayback.current = false; reportPresence('idle'); + analysisProvider.current?.pauseAnalysis(); cancelRecovery(true); pendingLiveFade.current = false; liveSuppressedForDrop.current = false; @@ -1046,6 +1200,24 @@ export default function ListenerPlayer({ }; }, [beginLiveFade, deferLiveFade, revalidateIdlePreparedSource, scheduleAutomaticRecovery]); + useEffect(() => { + if (!reactiveVisualizationEnabled) return; + const resumeAnalysisAfterForeground = () => { + const provider = analysisProvider.current; + if (document.visibilityState !== 'visible' || !provider || !wantsLivePlayback.current) return; + if (provider.getStatus().phase !== 'suspended') return; + void provider.start().then((result) => { + if (!result.ok) onReactiveVisualizationFailure(); + }).catch(() => onReactiveVisualizationFailure()); + }; + document.addEventListener('visibilitychange', resumeAnalysisAfterForeground); + window.addEventListener('pageshow', resumeAnalysisAfterForeground); + return () => { + document.removeEventListener('visibilitychange', resumeAnalysisAfterForeground); + window.removeEventListener('pageshow', resumeAnalysisAfterForeground); + }; + }, [onReactiveVisualizationFailure, reactiveVisualizationEnabled]); + useEffect(() => { const interval = window.setInterval(async () => { // The native element is prepared before the first gesture for iOS, @@ -1135,6 +1307,7 @@ export default function ListenerPlayer({ async function playWithIntro(language: DropLanguage) { const selected = dropAudio[language].current; if (!selected || !dropIns[language]) return; + startReactiveAnalysis(`intro-${language}`); // The intro already contains the Beacon, so the shared stream must stay // inaudible underneath it. Starting both prepared elements inside this // gesture preserves iOS authorization for the later automatic handoff. @@ -1231,6 +1404,7 @@ export default function ListenerPlayer({ [language]: { current: 0, duration: current[language].duration }, })); audio!.currentTime = 0; + startReactiveAnalysis('beacon'); if (liveAudio.current && !liveAudio.current.paused) { cancelRecovery(true); setTransportPaused(false); @@ -1293,13 +1467,34 @@ export default function ListenerPlayer({ function skipToBeacon() { dropGeneration.current += 1; setTransportPaused(false); + startReactiveAnalysis('beacon'); void playLive(false, dropGeneration.current); } return (
+ {reactiveVisualizationEnabled && reactiveRendererAvailable && ( +
+ { + const provider = analysisProvider.current; + provider?.pauseAnalysis(); + setReactiveRendererAvailable(false); + // Before playback the visual graph is disposable, + // so a fresh direct remount is safe. Once the graph + // is audible, never interrupt media for a Canvas + // failure; only stop analysis/rendering. + if (!provider) onReactiveVisualizationFailure(); + }} + /> +
+ )}
); } + +export default function ListenerPlayer({ + dropIns, + reactiveVisualizationAvailable = false, +}: ListenerPlayerProps) { + const [reactiveVisualizationEnabled, setReactiveVisualizationEnabled] = useState(false); + const [reactiveFallbackNotice, setReactiveFallbackNotice] = useState(false); + const [reactiveSettings, setReactiveSettings] = useState({ + ...DEFAULT_REACTIVE_CAMPFIRE_SETTINGS, + }); + const runtimeVisualizationAvailable = useSyncExternalStore( + subscribeRuntimeVisualizationCapability, + () => ( + reactiveVisualizationAvailable + && supportsReactiveListenerVisualization(navigator) + ), + () => false, + ); + const analysisConfigurationKey = [ + reactiveVisualizationEnabled ? 'visual' : 'direct', + reactiveSettings.fftSize, + reactiveSettings.baselineDurationSeconds, + ].join(':'); + + return ( + { + setReactiveFallbackNotice(false); + setReactiveVisualizationEnabled(enabled); + }} + onReactiveVisualizationFailure={() => { + setReactiveFallbackNotice(true); + setReactiveVisualizationEnabled(false); + }} + /> + ); +} diff --git a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx index 3420c2a3..4d82851b 100644 --- a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx @@ -5,16 +5,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { LocaleProvider } from '@/context/LocaleContext'; vi.mock('../ListenerPlayer', () => ({ - default: () =>
, + default: ({ reactiveVisualizationAvailable }: { reactiveVisualizationAvailable?: boolean }) => ( +
+ ), })); vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }), })); -vi.mock('../CosmicCampfire', () => ({ - default: ({ fixture }: { fixture: string }) => ( -
@@ -1723,15 +1739,8 @@ export default function ListenerPlayer({ ), () => false, ); - const analysisConfigurationKey = [ - reactiveVisualizationEnabled ? 'visual' : 'direct', - reactiveSettings.fftSize, - reactiveSettings.baselineDurationSeconds, - ].join(':'); - return ( ({ startResult: { ok: true } as { ok: boolean; error?: { code: string; message: string } }, instances: [] as Array<{ options: { + endpoint: string; framesPerSecond?: number; - sources: Array<{ id: string; kind: string; element: HTMLMediaElement }>; + sources: Array<{ id: string; kind: string }>; + getPlaybackProgramTimeMs: () => number | null; + getLeaseCursor: () => { leaseId: string; leaseGeneration: number } | null; }; start: ReturnType; setActiveSource: ReturnType; @@ -50,8 +53,11 @@ vi.mock('@/lib/listener/analysis', async (importOriginal) => { getStatus = vi.fn().mockReturnValue({ phase: 'running', error: null }); constructor(readonly options: { + endpoint: string; framesPerSecond?: number; - sources: Array<{ id: string; kind: string; element: HTMLMediaElement }>; + sources: Array<{ id: string; kind: string }>; + getPlaybackProgramTimeMs: () => number | null; + getLeaseCursor: () => { leaseId: string; leaseGeneration: number } | null; }) { analysisHarness.instances.push(this); } @@ -65,7 +71,7 @@ vi.mock('@/lib/listener/analysis', async (importOriginal) => { }); } } - return { ...actual, WebAudioHarmonicAnalysisProvider: TestAnalysisProvider }; + return { ...actual, RemoteHarmonicAnalysisProvider: TestAnalysisProvider }; }); vi.mock('hls.js', () => { class TestHls { @@ -102,6 +108,7 @@ import ListenerPlayer, { getOrCreateEarlyBirdDeviceId, LISTENER_HLS_BUFFER_CONFIG, LISTENER_PLAYBACK_PRESENCE_EVENT, + nativeHlsProgramTimeMs, nextPresenceSequence, prefersNativeHls, seekNativeAudioToLiveEdge, @@ -125,7 +132,7 @@ afterEach(() => { }); describe('EarlyBird Listener player', () => { - it('keeps direct playback as the default and attaches the visual graph only after staging opt-in', async () => { + it('keeps native playback intact and starts remote visual frames only after staging opt-in', async () => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext') .mockReturnValue({} as CanvasRenderingContext2D); vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1); @@ -160,12 +167,14 @@ describe('EarlyBird Listener player', () => { expect(toggle).not.toBeChecked(); expect(container.querySelector('audio[crossorigin]')).toBeNull(); expect(analysisHarness.instances).toHaveLength(0); + const originalAudio = container.querySelector('audio'); await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); fireEvent.click(toggle); await waitFor(() => expect(screen.getByTestId('listener-reactive-field')).toBeInTheDocument()); - expect(container.querySelectorAll('audio[crossorigin="anonymous"]')).toHaveLength(3); + expect(container.querySelector('audio')).toBe(originalAudio); + expect(container.querySelector('audio[crossorigin]')).toBeNull(); expect(screen.getByTestId('reactive-campfire-tuning-panel')).toBeInTheDocument(); await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); @@ -173,9 +182,11 @@ describe('EarlyBird Listener player', () => { await waitFor(() => expect(analysisHarness.instances).toHaveLength(1)); const analysis = analysisHarness.instances[0]; expect(analysis.options.sources.map(({ id }) => id)).toEqual(['beacon', 'intro-es', 'intro-en']); + expect(analysis.options.sources.every((source) => !('element' in source))).toBe(true); + expect(analysis.options.endpoint).toBe('/api/listener/analysis/frame'); expect(analysis.setActiveSource).toHaveBeenCalledWith('intro-en'); expect(analysis.start).toHaveBeenCalledOnce(); - expect(analysis.options.framesPerSecond).toBe(30); + expect(analysis.options.framesPerSecond).toBe(4); expect(screen.queryByRole('checkbox', { name: 'Reactive field · experimental' })).toBeNull(); const englishIntro = screen.getByLabelText('Warm-up · English'); @@ -184,7 +195,7 @@ describe('EarlyBird Listener player', () => { expect(analysis.setActiveSource).toHaveBeenLastCalledWith('beacon'); }); - it('isolates analysis from rendering and can reduce the running workload to a minimal pulse', async () => { + it('offers the minimal server-frame renderer without an analysis-only audio mode', async () => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext') .mockReturnValue({} as CanvasRenderingContext2D); vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1); @@ -216,23 +227,17 @@ describe('EarlyBird Listener player', () => { fireEvent.click(await screen.findByRole('checkbox', { name: 'Reactive field · experimental' })); fireEvent.change(screen.getByLabelText('Visualization'), { - target: { value: 'analysis-only' }, + target: { value: 'minimal-pulse' }, }); - expect(screen.queryByTestId('listener-reactive-field')).toBeNull(); - expect(screen.getByText(/Full audio analysis stays active/)).toBeInTheDocument(); + expect(screen.getByTestId('listener-reactive-field')).toBeInTheDocument(); + expect(screen.getByText(/One measured level halo at 2 fps/)).toBeInTheDocument(); + expect(screen.queryByText(/Analysis only/)).toBeNull(); await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: 'Listen' })); await waitFor(() => expect(analysisHarness.instances).toHaveLength(1)); const provider = analysisHarness.instances[0]; - expect(provider.options.framesPerSecond).toBe(30); - - fireEvent.change(screen.getByLabelText('Visualization'), { - target: { value: 'minimal-pulse' }, - }); - await waitFor(() => expect(screen.getByTestId('listener-reactive-field')).toBeInTheDocument()); - expect(screen.getByText(/One measured level halo at 2 fps/)).toBeInTheDocument(); - expect(provider.setFramesPerSecond).toHaveBeenLastCalledWith(2); + expect(provider.options.framesPerSecond).toBe(2); }); it('remounts the untouched direct player when Canvas 2D is unavailable', async () => { @@ -450,7 +455,7 @@ describe('EarlyBird Listener player', () => { expect(screen.queryByTestId('listener-reactive-field')).toBeNull(); }); - it('keeps the reactive experiment unavailable on Apple native-HLS clients', async () => { + it('offers remote visualization on Apple without attaching native HLS to Web Audio', async () => { vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.'); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('synthetic offline'))); render( @@ -463,12 +468,20 @@ describe('EarlyBird Listener player', () => { ); await waitFor(() => expect( - screen.queryByRole('checkbox', { name: 'Reactive field · experimental' }), - ).toBeNull()); - expect(supportsReactiveListenerVisualization({ vendor: 'Apple Computer, Inc.' })).toBe(false); + screen.getByRole('checkbox', { name: 'Reactive field · experimental' }), + ).toBeInTheDocument()); + expect(supportsReactiveListenerVisualization({ vendor: 'Apple Computer, Inc.' })).toBe(true); expect(supportsReactiveListenerVisualization({ vendor: 'Google Inc.' })).toBe(true); }); + it('uses native HLS program date without inferring wall time from the live edge', () => { + expect(nativeHlsProgramTimeMs({ + currentTime: 12.25, + getStartDate: () => new Date('2026-08-09T10:00:00.000Z'), + })).toBe(Date.parse('2026-08-09T10:00:12.250Z')); + expect(nativeHlsProgramTimeMs({ currentTime: 12.25 })).toBeNull(); + }); + it('keeps the connection identifier stable per tab without sharing it across tabs', () => { const first = getOrCreateEarlyBirdDeviceId(window.sessionStorage); const second = getOrCreateEarlyBirdDeviceId(window.sessionStorage); diff --git a/src/components/listener/reactive/ReactiveCampfireTuningPanel.tsx b/src/components/listener/reactive/ReactiveCampfireTuningPanel.tsx index 2fb6ef21..cfb7b29d 100644 --- a/src/components/listener/reactive/ReactiveCampfireTuningPanel.tsx +++ b/src/components/listener/reactive/ReactiveCampfireTuningPanel.tsx @@ -16,6 +16,7 @@ export type ReactiveCampfireTuningPanelProps = { settings: ReactiveCampfireSettings; onChange: (settings: ReactiveCampfireSettings) => void; analysisControlsLocked?: boolean; + analysisSource?: 'client' | 'server'; }; type NumberField = Exclude< @@ -47,7 +48,6 @@ const NUMBER_FIELDS: Array<{ ]; const VISUALIZATION_LABELS: Record = { - 'analysis-only': 'Analysis only · no Canvas', 'minimal-pulse': 'Minimal pulse · 2 fps', 'harmonic-radial-series': 'Harmonic radial series', 'radial-ribbons': 'Radial ribbons', @@ -68,6 +68,7 @@ export function ReactiveCampfireTuningPanel({ settings, onChange, analysisControlsLocked = false, + analysisSource = 'client', }: ReactiveCampfireTuningPanelProps) { const [status, setStatus] = useState(''); if (!enabled) return null; @@ -92,8 +93,8 @@ export function ReactiveCampfireTuningPanel({ Reactive field lab

- Test: field off = direct baseline; Analysis only = analysis without Canvas; - Minimal pulse = lowest visual workload; then compare a full field. + Audio stays native. Visual frames come from the server; Minimal pulse is the + lowest rendering workload before comparing a full field.

{NUMBER_FIELDS.map((field) => ( diff --git a/src/components/listener/reactive/__tests__/components.test.tsx b/src/components/listener/reactive/__tests__/components.test.tsx index 6ba6d961..6720f2b7 100644 --- a/src/components/listener/reactive/__tests__/components.test.tsx +++ b/src/components/listener/reactive/__tests__/components.test.tsx @@ -139,7 +139,7 @@ describe('reactive campfire components', () => { expect(screen.getByLabelText('Visualization')).toHaveTextContent('Harmonic radial series'); expect(screen.getByLabelText('Visualization')).toHaveTextContent('Radial ribbons'); expect(screen.getByLabelText('Visualization')).toHaveTextContent('Horizon flow'); - expect(screen.getByLabelText('Visualization')).toHaveTextContent('Analysis only · no Canvas'); + expect(screen.getByLabelText('Visualization')).not.toHaveTextContent('Analysis only'); expect(screen.getByLabelText('Visualization')).toHaveTextContent('Minimal pulse · 2 fps'); fireEvent.change(screen.getByLabelText('Visualization'), { target: { value: 'horizon-flow' }, @@ -151,4 +151,19 @@ describe('reactive campfire components', () => { expect(screen.getByLabelText(/Zoom/i)).toHaveValue('100'); expect(screen.getByLabelText(/Activation TTL/i)).toHaveValue('8'); }); + + it('makes server-owned analysis parameters explicit and read-only', () => { + render( + , + ); + + expect(screen.getByLabelText('FFT size')).toBeDisabled(); + expect(screen.getByLabelText(/Slow baseline/i)).toBeDisabled(); + expect(screen.getByText('Server analysis · fixed at 16384')).toBeInTheDocument(); + }); }); diff --git a/src/components/listener/reactive/settings.ts b/src/components/listener/reactive/settings.ts index 6c65e13f..fea391b1 100644 --- a/src/components/listener/reactive/settings.ts +++ b/src/components/listener/reactive/settings.ts @@ -1,6 +1,5 @@ export const REACTIVE_PALETTES = ['ember', 'aurora', 'moon'] as const; export const REACTIVE_VISUALIZATION_MODES = [ - 'analysis-only', 'minimal-pulse', 'harmonic-radial-series', 'radial-ribbons', diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts index 9712f7b8..bafb39f7 100644 --- a/src/lib/listener/__tests__/media-boundary.test.ts +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -5,13 +5,11 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const MEDIA_FILE_SHA256 = { - // Reviewed and re-pinned for the staging-only, opt-in passive WebAudio - // analysis graph and its diagnostic workload selector. Analysis-only - // suppresses Canvas; minimal-pulse changes analysis cadence without - // reconnecting media. Direct playback remains the default; HLS parameters, - // source URLs, media assets, element gain/fades and event audio are - // unchanged. Apple/native-HLS remains on the direct path. - 'src/components/early-birds/ListenerPlayer.tsx': '8e7b0d7fe30b8d3ed67a080b9bdacf829facb70782177ab05f36c9c74301cde6', + // Reviewed and re-pinned for staging-only remote harmonic frames. The + // browser no longer creates Web Audio nodes, adds crossOrigin or remounts + // media when the field is toggled. HLS parameters, source URLs, media + // assets, element gain/fades and event audio remain unchanged. + 'src/components/early-birds/ListenerPlayer.tsx': '18d8274f3078a85a743485168c7309278701467f1967dc1890e3232e4e919042', 'src/lib/early-birds/stream.ts': '96a2d9fe798591833327631b59a73a5b2fc5ca06be7081945a0b07450970da84', 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', 'src/app/api/early-birds/stream/lease/route.ts': 'ec0e8780387bc1f493eb33d13a2d90e01cfdb6d899fc6232e04f51aaf2dfc508', diff --git a/src/lib/listener/analysis/__tests__/remote-provider.test.ts b/src/lib/listener/analysis/__tests__/remote-provider.test.ts new file mode 100644 index 00000000..b6280b38 --- /dev/null +++ b/src/lib/listener/analysis/__tests__/remote-provider.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + parseRemoteHarmonicFrame, + RemoteHarmonicAnalysisProvider, +} from '../remote-provider'; + +function wireFrame() { + return { + schemaVersion: 1, + capturedAtMs: 1_786_233_600_000, + sourceTimeSeconds: 12.5, + overallDb: -18, + harmonicAbsoluteDb: [-8, -20, -30], + harmonicDeltaDb: [1, -1, 0], + spectralEnvelopeDb: [-10, -20], + stereoBalance: 0.1, + stereoWidth: 0.2, + confidence: 1, + sourceKind: 'beacon', + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('RemoteHarmonicAnalysisProvider', () => { + it('accepts only the bounded renderer-neutral wire contract', () => { + const parsed = parseRemoteHarmonicFrame(wireFrame()); + expect(parsed?.harmonicAbsoluteDb).toBeInstanceOf(Float32Array); + expect(parsed?.sourceKind).toBe('beacon'); + expect(parseRemoteHarmonicFrame({ ...wireFrame(), sourceKind: 'intro' })).toBeNull(); + expect(parseRemoteHarmonicFrame({ + ...wireFrame(), + harmonicAbsoluteDb: new Array(513).fill(-20), + harmonicDeltaDb: new Array(513).fill(0), + })).toBeNull(); + }); + + it('requests the server frame aligned to audible HLS program time', async () => { + vi.useFakeTimers(); + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(wireFrame()))); + const listener = vi.fn(); + const provider = new RemoteHarmonicAnalysisProvider({ + endpoint: '/api/listener/analysis/frame', + sources: [{ id: 'beacon', kind: 'beacon' }], + getPlaybackProgramTimeMs: () => 1_786_233_600_125, + getLeaseCursor: () => ({ + leaseId: '00000000-0000-4000-8000-000000000003', + leaseGeneration: 7, + }), + fetcher, + }); + provider.subscribe(listener); + + await provider.start(); + await vi.advanceTimersByTimeAsync(1); + + expect(fetcher).toHaveBeenCalledWith( + '/api/listener/analysis/frame?at=1786233600125' + + '&leaseId=00000000-0000-4000-8000-000000000003&leaseGeneration=7', + expect.objectContaining({ cache: 'no-store', credentials: 'same-origin' }), + ); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ overallDb: -18 })); + provider.stop(); + }); + + it('never fetches while the audible source is an introduction', async () => { + vi.useFakeTimers(); + const fetcher = vi.fn(); + const provider = new RemoteHarmonicAnalysisProvider({ + endpoint: '/api/listener/analysis/frame', + sources: [ + { id: 'beacon', kind: 'beacon' }, + { id: 'intro-en', kind: 'intro' }, + ], + activeSourceId: 'intro-en', + getPlaybackProgramTimeMs: () => 1_786_233_600_125, + getLeaseCursor: () => ({ + leaseId: '00000000-0000-4000-8000-000000000003', + leaseGeneration: 7, + }), + fetcher, + }); + + await provider.start(); + await vi.advanceTimersByTimeAsync(1_000); + expect(fetcher).not.toHaveBeenCalled(); + provider.stop(); + }); + + it('fails the visual provider closed after bounded server failures', async () => { + vi.useFakeTimers(); + const fetcher = vi.fn().mockRejectedValue(new Error('offline')); + const provider = new RemoteHarmonicAnalysisProvider({ + endpoint: '/api/listener/analysis/frame', + sources: [{ id: 'beacon', kind: 'beacon' }], + getPlaybackProgramTimeMs: () => 1_786_233_600_125, + getLeaseCursor: () => ({ + leaseId: '00000000-0000-4000-8000-000000000003', + leaseGeneration: 7, + }), + fetcher, + framesPerSecond: 4, + }); + + await provider.start(); + await vi.advanceTimersByTimeAsync(1_000); + expect(fetcher).toHaveBeenCalledTimes(4); + expect(provider.getStatus()).toMatchObject({ + phase: 'error', + error: { code: 'ANALYSIS_FAILED' }, + }); + }); + + it('never publishes a resolved Beacon frame after switching to an intro', async () => { + vi.useFakeTimers(); + let resolveFetch!: (response: Response) => void; + const fetcher = vi.fn(() => new Promise((resolve) => { resolveFetch = resolve; })); + const listener = vi.fn(); + const provider = new RemoteHarmonicAnalysisProvider({ + endpoint: '/api/listener/analysis/frame', + sources: [ + { id: 'beacon', kind: 'beacon' }, + { id: 'intro-en', kind: 'intro' }, + ], + getPlaybackProgramTimeMs: () => 1_786_233_600_125, + getLeaseCursor: () => ({ + leaseId: '00000000-0000-4000-8000-000000000003', + leaseGeneration: 7, + }), + fetcher, + }); + provider.subscribe(listener); + await provider.start(); + await vi.advanceTimersByTimeAsync(1); + expect(fetcher).toHaveBeenCalledOnce(); + + provider.setActiveSource('intro-en'); + resolveFetch(new Response(JSON.stringify(wireFrame()))); + await vi.advanceTimersByTimeAsync(1); + + expect(listener).not.toHaveBeenCalled(); + provider.stop(); + }); + + it('invalidates an in-flight frame when render cadence changes', async () => { + vi.useFakeTimers(); + const resolvers: Array<(response: Response) => void> = []; + const fetcher = vi.fn(() => new Promise((resolve) => { resolvers.push(resolve); })); + const listener = vi.fn(); + const provider = new RemoteHarmonicAnalysisProvider({ + endpoint: '/api/listener/analysis/frame', + sources: [{ id: 'beacon', kind: 'beacon' }], + getPlaybackProgramTimeMs: () => 1_786_233_600_125, + getLeaseCursor: () => ({ + leaseId: '00000000-0000-4000-8000-000000000003', + leaseGeneration: 7, + }), + fetcher, + }); + provider.subscribe(listener); + await provider.start(); + await vi.advanceTimersByTimeAsync(1); + + provider.setFramesPerSecond(2); + await vi.advanceTimersByTimeAsync(1); + expect(fetcher).toHaveBeenCalledTimes(2); + resolvers[1](new Response(JSON.stringify(wireFrame()))); + await vi.advanceTimersByTimeAsync(1); + resolvers[0](new Response(JSON.stringify({ ...wireFrame(), capturedAtMs: 1 }))); + await vi.advanceTimersByTimeAsync(1); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ + capturedAtMs: 1_786_233_600_000, + })); + provider.stop(); + }); +}); diff --git a/src/lib/listener/analysis/__tests__/server-harmonic-analyzer.test.ts b/src/lib/listener/analysis/__tests__/server-harmonic-analyzer.test.ts new file mode 100644 index 00000000..6d10c54a --- /dev/null +++ b/src/lib/listener/analysis/__tests__/server-harmonic-analyzer.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { + analyzePcmSegment, + normalizeAnalysisArtifactMetadata, + resolveArtifactPosition, +} from '../server-harmonic-analyzer'; + +describe('server harmonic analyzer', () => { + it('maps wall-clock program time into the looping HLS artifact', () => { + const metadata = normalizeAnalysisArtifactMetadata({ + schemaVersion: 2, + artifactId: 'beacon-test', + timing: { + epochUtc: '2026-08-09T00:00:00.000Z', + segmentCount: 3, + loopDurationSeconds: 30, + }, + initialization: { file: 'init.mp4' }, + segments: [ + { file: '00000.m4s', durationSeconds: 10 }, + { file: '00001.m4s', durationSeconds: 10 }, + { file: '00002.m4s', durationSeconds: 10 }, + ], + }); + const epoch = Date.parse('2026-08-09T00:00:00.000Z'); + + expect(resolveArtifactPosition(metadata, epoch + 12_250)).toEqual({ + segmentIndex: 1, + segmentOffsetSeconds: 2.25, + segmentProgramStartMs: epoch + 10_000, + }); + expect(resolveArtifactPosition(metadata, epoch + 42_250)).toEqual({ + segmentIndex: 1, + segmentOffsetSeconds: 2.25, + segmentProgramStartMs: epoch + 40_000, + }); + }); + + it('extracts the real 40.4 Hz fundamental from decoded stereo PCM', () => { + const seconds = 1; + const sampleRate = 48_000; + const pcm = new Float32Array(sampleRate * seconds * 2); + for (let sample = 0; sample < sampleRate * seconds; sample += 1) { + const value = 0.5 * Math.sin(2 * Math.PI * 40.4 * sample / sampleRate); + pcm[sample * 2] = value; + pcm[sample * 2 + 1] = value; + } + + const frames = analyzePcmSegment(pcm, seconds); + expect(frames).toHaveLength(4); + expect(frames[1].harmonicAbsoluteDb[0]).toBeGreaterThan(-15); + expect(frames[1].harmonicAbsoluteDb[0] - frames[1].harmonicAbsoluteDb[9]).toBeGreaterThan(25); + expect(frames[1].spectralEnvelopeDb).toHaveLength(32); + expect(frames[1].stereoWidth).toBeLessThan(0.01); + }); + + it('rejects malformed or path-like artifact metadata', () => { + expect(() => normalizeAnalysisArtifactMetadata({ + schemaVersion: 2, + artifactId: 'beacon-test', + timing: { epochUtc: '2026-08-09T00:00:00Z', segmentCount: 1 }, + segments: [{ file: '../secret.m4s', durationSeconds: 10 }], + })).toThrow('Invalid Listener analysis segment metadata'); + }); +}); diff --git a/src/lib/listener/analysis/__tests__/web-audio-provider.test.ts b/src/lib/listener/analysis/__tests__/web-audio-provider.test.ts deleted file mode 100644 index 38e4db16..00000000 --- a/src/lib/listener/analysis/__tests__/web-audio-provider.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { - WebAudioHarmonicAnalysisProvider, - type HarmonicAnalysisScheduler, -} from '../web-audio-provider'; - -class FakeNode { - readonly connections: Array<{ target: FakeNode; output?: number }> = []; - disconnectCalls = 0; - - connect(target: FakeNode, output?: number): FakeNode { - this.connections.push({ target, output }); - return target; - } - - disconnect(): void { - this.disconnectCalls += 1; - this.connections.length = 0; - } -} - -class FakeAnalyser extends FakeNode { - fftSize = 2048; - smoothingTimeConstant = 0; - minDecibels = -100; - maxDecibels = -30; - - get frequencyBinCount(): number { - return this.fftSize / 2; - } - - getFloatFrequencyData(target: Float32Array): void { - target.fill(-110); - const fundamentalBin = Math.round(40.4 / (48_000 / this.fftSize)); - target[fundamentalBin] = -10; - } - - getFloatTimeDomainData(target: Float32Array): void { - target.fill(0.1); - } -} - -class FakeAudioContext { - readonly destination = new FakeNode(); - readonly sourceNodes: FakeNode[] = []; - readonly splitterNodes: FakeNode[] = []; - readonly analyserNodes: FakeAnalyser[] = []; - readonly sampleRate = 48_000; - state: AudioContextState = 'suspended'; - close = vi.fn(async () => undefined); - resumeCalls = 0; - private resumeResolver: (() => void) | null = null; - private readonly listeners = new Set(); - - constructor(private readonly rejectResume = false) {} - - createMediaElementSource(): MediaElementAudioSourceNode { - const node = new FakeNode(); - this.sourceNodes.push(node); - return node as unknown as MediaElementAudioSourceNode; - } - - createChannelSplitter(): ChannelSplitterNode { - const node = new FakeNode(); - this.splitterNodes.push(node); - return node as unknown as ChannelSplitterNode; - } - - createAnalyser(): AnalyserNode { - const node = new FakeAnalyser(); - this.analyserNodes.push(node); - return node as unknown as AnalyserNode; - } - - resume(): Promise { - this.resumeCalls += 1; - if (this.rejectResume) return Promise.reject(new Error('gesture rejected')); - return new Promise((resolve) => { - this.resumeResolver = () => { - this.state = 'running'; - resolve(); - }; - }); - } - - resolveResume(): void { - this.resumeResolver?.(); - } - - addEventListener(_type: string, listener: EventListenerOrEventListenerObject): void { - this.listeners.add(listener); - } - - removeEventListener(_type: string, listener: EventListenerOrEventListenerObject): void { - this.listeners.delete(listener); - } -} - -function media(currentTime: number): HTMLMediaElement { - return { currentTime } as HTMLMediaElement; -} - -function scheduler(): HarmonicAnalysisScheduler & { - callbacks: FrameRequestCallback[]; - delays: number[]; -} { - const callbacks: FrameRequestCallback[] = []; - const delays: number[] = []; - return { - callbacks, - delays, - request: (callback, delayMs) => { - callbacks.push(callback); - delays.push(delayMs); - return callbacks.length; - }, - cancel: vi.fn(), - now: () => 1_000, - }; -} - -describe('WebAudioHarmonicAnalysisProvider', () => { - it('attaches all sources synchronously before resume and creates one direct audible branch each', async () => { - const context = new FakeAudioContext(); - const frameScheduler = scheduler(); - const provider = new WebAudioHarmonicAnalysisProvider({ - audioContext: context as unknown as AudioContext, - scheduler: frameScheduler, - fftSize: 8192, - sources: [ - { id: 'intro-en', kind: 'intro', element: media(2) }, - { id: 'beacon', kind: 'beacon', element: media(90) }, - ], - }); - - const started = provider.start(); - const concurrentStart = provider.start(); - expect(concurrentStart).toBe(started); - expect(context.sourceNodes).toHaveLength(2); - expect(context.splitterNodes).toHaveLength(2); - expect(context.analyserNodes).toHaveLength(4); - expect(context.analyserNodes.every(({ fftSize }) => fftSize === 8192)).toBe(true); - expect(context.analyserNodes.every(({ minDecibels }) => minDecibels === -120)).toBe(true); - expect(context.analyserNodes.every(({ maxDecibels }) => maxDecibels === 0)).toBe(true); - expect(context.resumeCalls).toBe(1); - for (let index = 0; index < context.sourceNodes.length; index += 1) { - const source = context.sourceNodes[index]; - expect(source?.connections).toEqual([ - { target: context.destination, output: undefined }, - { target: context.splitterNodes[index], output: undefined }, - ]); - expect(context.splitterNodes[index]?.connections).toEqual([ - { target: context.analyserNodes[index * 2], output: 0 }, - { target: context.analyserNodes[index * 2 + 1], output: 1 }, - ]); - } - expect(context.analyserNodes.every(({ connections }) => connections.length === 0)).toBe(true); - - context.resolveResume(); - await expect(started).resolves.toEqual({ ok: true }); - await expect(concurrentStart).resolves.toEqual({ ok: true }); - expect(provider.getStatus().phase).toBe('running'); - expect(frameScheduler.callbacks).toHaveLength(1); - expect(context.sourceNodes.every(({ disconnectCalls }) => disconnectCalls === 0)).toBe(true); - expect(context.close).not.toHaveBeenCalled(); - }); - - it('switches observed source without reconnecting paths and emits the selected kind', async () => { - const context = new FakeAudioContext(); - context.state = 'running'; - const frameScheduler = scheduler(); - const provider = new WebAudioHarmonicAnalysisProvider({ - audioContext: context as unknown as AudioContext, - scheduler: frameScheduler, - fftSize: 8192, - sources: [ - { id: 'intro-es', kind: 'intro', element: media(12) }, - { id: 'beacon', kind: 'beacon', element: media(240) }, - ], - }); - const frames = vi.fn(); - provider.subscribe(frames); - await expect(provider.start()).resolves.toEqual({ ok: true }); - const connectionCounts = context.sourceNodes.map(({ connections }) => connections.length); - - expect(provider.setActiveSource('beacon')).toEqual({ ok: true }); - expect(context.sourceNodes.map(({ connections }) => connections.length)).toEqual(connectionCounts); - frameScheduler.callbacks.shift()?.(1_000); - - expect(frames).toHaveBeenCalledOnce(); - expect(frames.mock.calls[0]?.[0]).toMatchObject({ - sourceKind: 'beacon', - sourceTimeSeconds: 240, - }); - expect(frames.mock.calls[0]?.[0].harmonicAbsoluteDb[0]).toBeGreaterThan(-30); - expect(frames.mock.calls[0]?.[0].harmonicAbsoluteDb[10]).toBeLessThan(-100); - }); - - it('can lower live analysis cadence to 2 fps without running a 60 fps polling loop', async () => { - const context = new FakeAudioContext(); - context.state = 'running'; - const frameScheduler = scheduler(); - const provider = new WebAudioHarmonicAnalysisProvider({ - audioContext: context as unknown as AudioContext, - scheduler: frameScheduler, - sources: [{ id: 'beacon', kind: 'beacon', element: media(0) }], - }); - await provider.start(); - expect(frameScheduler.delays[0]).toBe(0); - frameScheduler.callbacks.shift()?.(1_000); - - expect(provider.setFramesPerSecond(2)).toEqual({ ok: true }); - expect(frameScheduler.delays.at(-1)).toBe(500); - expect(provider.setFramesPerSecond(0)).toMatchObject({ - ok: false, - error: { code: 'INVALID_CONFIGURATION' }, - }); - }); - - it('pauses frame capture without disconnecting the irreversible audible graph', async () => { - const context = new FakeAudioContext(); - context.state = 'running'; - const frameScheduler = scheduler(); - const provider = new WebAudioHarmonicAnalysisProvider({ - audioContext: context as unknown as AudioContext, - scheduler: frameScheduler, - sources: [{ id: 'beacon', kind: 'beacon', element: media(0) }], - }); - await provider.start(); - const audibleConnections = context.sourceNodes[0]?.connections.slice(); - - provider.pauseAnalysis(); - expect(provider.getStatus().phase).toBe('paused'); - expect(context.sourceNodes[0]?.connections).toEqual(audibleConnections); - expect(frameScheduler.cancel).toHaveBeenCalledOnce(); - - expect(provider.resumeAnalysis()).toEqual({ ok: true }); - expect(provider.getStatus().phase).toBe('running'); - expect(context.sourceNodes[0]?.connections).toEqual(audibleConnections); - }); - - it('retains the direct branch when context resume fails so integration can remount', async () => { - const context = new FakeAudioContext(true); - const provider = new WebAudioHarmonicAnalysisProvider({ - audioContextFactory: () => context as unknown as AudioContext, - scheduler: scheduler(), - sources: [{ id: 'beacon', kind: 'beacon', element: media(0) }], - }); - - await expect(provider.start()).resolves.toMatchObject({ - ok: false, - error: { code: 'AUDIO_CONTEXT_SUSPENDED', recoverable: true }, - }); - expect(provider.getStatus().phase).toBe('suspended'); - expect(context.sourceNodes[0]?.connections[0]?.target).toBe(context.destination); - expect(context.sourceNodes[0]?.disconnectCalls).toBe(0); - expect(context.close).not.toHaveBeenCalled(); - }); - - it('never attaches the same element twice and does not recreate sources on repeated start', async () => { - const sharedElement = media(0); - expect(() => new WebAudioHarmonicAnalysisProvider({ - sources: [ - { id: 'one', kind: 'intro', element: sharedElement }, - { id: 'two', kind: 'beacon', element: sharedElement }, - ], - })).toThrow('Each media element may be attached only once'); - - const context = new FakeAudioContext(); - context.state = 'running'; - const provider = new WebAudioHarmonicAnalysisProvider({ - audioContext: context as unknown as AudioContext, - scheduler: scheduler(), - sources: [{ id: 'beacon', kind: 'beacon', element: media(0) }], - }); - await provider.start(); - await provider.start(); - expect(context.sourceNodes).toHaveLength(1); - }); - - it('returns explicit errors and teardown remains non-throwing', async () => { - const context = new FakeAudioContext(); - context.state = 'running'; - const provider = new WebAudioHarmonicAnalysisProvider({ - audioContext: context as unknown as AudioContext, - scheduler: scheduler(), - sources: [{ id: 'beacon', kind: 'beacon', element: media(0) }], - }); - const statuses = vi.fn(); - provider.subscribeStatus(statuses); - await provider.start(); - - expect(provider.setActiveSource('missing')).toMatchObject({ - ok: false, - error: { code: 'NO_ACTIVE_SOURCE', recoverable: true }, - }); - expect(() => provider.stop()).not.toThrow(); - expect(() => provider.stop()).not.toThrow(); - expect(provider.getStatus()).toEqual({ - phase: 'stopped', - activeSourceId: null, - activeSourceKind: null, - error: null, - }); - expect(context.close).not.toHaveBeenCalled(); - expect(statuses).toHaveBeenCalled(); - }); -}); diff --git a/src/lib/listener/analysis/index.ts b/src/lib/listener/analysis/index.ts index 35b26870..2f73980c 100644 --- a/src/lib/listener/analysis/index.ts +++ b/src/lib/listener/analysis/index.ts @@ -1,3 +1,3 @@ export * from './harmonic-mapper'; +export * from './remote-provider'; export * from './types'; -export * from './web-audio-provider'; diff --git a/src/lib/listener/analysis/remote-provider.ts b/src/lib/listener/analysis/remote-provider.ts new file mode 100644 index 00000000..e21ee742 --- /dev/null +++ b/src/lib/listener/analysis/remote-provider.ts @@ -0,0 +1,310 @@ +import { + HARMONIC_ANALYSIS_SCHEMA_VERSION, + type HarmonicAnalysisError, + type HarmonicAnalysisFrame, + type HarmonicAnalysisFrameListener, + type HarmonicAnalysisProvider, + type HarmonicAnalysisProviderStatus, + type HarmonicAnalysisSourceKind, + type HarmonicAnalysisStartResult, + type HarmonicAnalysisStatusListener, +} from './types'; + +type RemoteAnalysisSource = { id: string; kind: HarmonicAnalysisSourceKind }; + +export type RemoteHarmonicAnalysisOptions = { + endpoint: string; + sources: readonly RemoteAnalysisSource[]; + activeSourceId?: string; + framesPerSecond?: number; + getPlaybackProgramTimeMs: () => number | null; + getLeaseCursor: () => { leaseId: string; leaseGeneration: number } | null; + fetcher?: typeof fetch; + setTimer?: typeof window.setTimeout; + clearTimer?: typeof window.clearTimeout; +}; + +function publicError(code: HarmonicAnalysisError['code'], message: string): HarmonicAnalysisError { + return { code, message, recoverable: true }; +} + +function finiteArray(value: unknown, maximumLength: number): Float32Array | null { + if (!Array.isArray(value) || value.length > maximumLength) return null; + const output = new Float32Array(value.length); + for (let index = 0; index < value.length; index += 1) { + if (typeof value[index] !== 'number' || !Number.isFinite(value[index])) return null; + output[index] = value[index]; + } + return output; +} + +export function parseRemoteHarmonicFrame(value: unknown): HarmonicAnalysisFrame | null { + if (!value || typeof value !== 'object') return null; + const input = value as Record; + const absolute = finiteArray(input.harmonicAbsoluteDb, 512); + const delta = finiteArray(input.harmonicDeltaDb, 512); + const envelope = finiteArray(input.spectralEnvelopeDb, 64); + if (input.schemaVersion !== HARMONIC_ANALYSIS_SCHEMA_VERSION + || typeof input.capturedAtMs !== 'number' || !Number.isFinite(input.capturedAtMs) + || (input.sourceTimeSeconds !== null + && (typeof input.sourceTimeSeconds !== 'number' || !Number.isFinite(input.sourceTimeSeconds))) + || typeof input.overallDb !== 'number' || !Number.isFinite(input.overallDb) + || !absolute || !delta || absolute.length !== delta.length || absolute.length < 1 + || !envelope + || typeof input.stereoBalance !== 'number' || !Number.isFinite(input.stereoBalance) + || typeof input.stereoWidth !== 'number' || !Number.isFinite(input.stereoWidth) + || typeof input.confidence !== 'number' || !Number.isFinite(input.confidence) + || input.sourceKind !== 'beacon') return null; + return { + schemaVersion: HARMONIC_ANALYSIS_SCHEMA_VERSION, + capturedAtMs: input.capturedAtMs, + sourceTimeSeconds: input.sourceTimeSeconds, + overallDb: input.overallDb, + harmonicAbsoluteDb: absolute, + harmonicDeltaDb: delta, + spectralEnvelopeDb: envelope, + stereoBalance: input.stereoBalance, + stereoWidth: input.stereoWidth, + confidence: input.confidence, + sourceKind: 'beacon', + }; +} + +export class RemoteHarmonicAnalysisProvider implements HarmonicAnalysisProvider { + private readonly sources: readonly RemoteAnalysisSource[]; + private readonly endpoint: string; + private readonly getPlaybackProgramTimeMs: () => number | null; + private readonly getLeaseCursor: () => { leaseId: string; leaseGeneration: number } | null; + private readonly fetcher: typeof fetch; + private readonly setTimer: typeof window.setTimeout; + private readonly clearTimer: typeof window.clearTimeout; + private readonly frameListeners = new Set(); + private readonly statusListeners = new Set(); + private activeSourceId: string; + private framesPerSecond: number; + private timer: number | null = null; + private request: AbortController | null = null; + private failures = 0; + private requestGeneration = 0; + private baselineDb: Float32Array | null = null; + private previousCapturedAtMs: number | null = null; + private status: HarmonicAnalysisProviderStatus; + + constructor(options: RemoteHarmonicAnalysisOptions) { + if (!options.endpoint.startsWith('/')) throw new Error('Remote analysis endpoint must be same-origin'); + if (options.sources.length === 0) throw new Error('At least one remote analysis source is required'); + const ids = new Set(options.sources.map(({ id }) => id)); + const activeSourceId = options.activeSourceId ?? options.sources[0].id; + if (!ids.has(activeSourceId)) throw new Error('Invalid active remote analysis source'); + this.sources = options.sources; + this.endpoint = options.endpoint; + this.activeSourceId = activeSourceId; + this.framesPerSecond = this.validatedFramesPerSecond(options.framesPerSecond ?? 4); + this.getPlaybackProgramTimeMs = options.getPlaybackProgramTimeMs; + this.getLeaseCursor = options.getLeaseCursor; + this.fetcher = options.fetcher ?? fetch; + this.setTimer = options.setTimer ?? window.setTimeout.bind(window); + this.clearTimer = options.clearTimer ?? window.clearTimeout.bind(window); + const active = this.sources.find(({ id }) => id === activeSourceId)!; + this.status = { + phase: 'idle', + activeSourceId, + activeSourceKind: active.kind, + error: null, + }; + } + + private validatedFramesPerSecond(value: number): number { + if (!Number.isFinite(value) || value < 1 || value > 10) { + throw new Error('Remote analysis cadence must be between 1 and 10 fps'); + } + return value; + } + + getStatus(): HarmonicAnalysisProviderStatus { return { ...this.status }; } + + start(): Promise { + if (this.status.phase === 'stopped') { + return Promise.resolve({ + ok: false, + error: publicError('PROVIDER_STOPPED', 'Remote analysis was stopped'), + }); + } + this.setStatus({ ...this.status, phase: 'running', error: null }); + this.schedule(0); + return Promise.resolve({ ok: true }); + } + + setActiveSource(sourceId: string): HarmonicAnalysisStartResult { + const source = this.sources.find(({ id }) => id === sourceId); + if (!source) { + return { + ok: false, + error: publicError('NO_ACTIVE_SOURCE', 'Unknown remote analysis source'), + }; + } + const wasRunning = this.status.phase === 'running'; + this.cancelPending(); + this.baselineDb = null; + this.previousCapturedAtMs = null; + this.activeSourceId = sourceId; + this.setStatus({ + ...this.status, + activeSourceId: sourceId, + activeSourceKind: source.kind, + error: null, + }); + if (wasRunning) this.schedule(0); + return { ok: true }; + } + + setFramesPerSecond(framesPerSecond: number): HarmonicAnalysisStartResult { + try { + this.framesPerSecond = this.validatedFramesPerSecond(framesPerSecond); + if (this.status.phase === 'running') { + this.cancelPending(); + this.schedule(0); + } + return { ok: true }; + } catch { + return { + ok: false, + error: publicError('INVALID_CONFIGURATION', 'Invalid remote analysis cadence'), + }; + } + } + + pauseAnalysis(): void { + if (this.status.phase !== 'running') return; + this.cancelPending(); + this.setStatus({ ...this.status, phase: 'paused', error: null }); + } + + resumeAnalysis(): HarmonicAnalysisStartResult { + if (this.status.phase === 'stopped') { + return { + ok: false, + error: publicError('PROVIDER_STOPPED', 'Remote analysis was stopped'), + }; + } + this.setStatus({ ...this.status, phase: 'running', error: null }); + this.schedule(0); + return { ok: true }; + } + + subscribe(listener: HarmonicAnalysisFrameListener): () => void { + this.frameListeners.add(listener); + return () => { this.frameListeners.delete(listener); }; + } + + subscribeStatus(listener: HarmonicAnalysisStatusListener): () => void { + this.statusListeners.add(listener); + listener(this.getStatus()); + return () => { this.statusListeners.delete(listener); }; + } + + stop(): void { + if (this.status.phase === 'stopped') return; + this.cancelPending(); + this.frameListeners.clear(); + this.setStatus({ phase: 'stopped', activeSourceId: null, activeSourceKind: null, error: null }); + } + + private cancelPending(): void { + this.requestGeneration += 1; + if (this.timer !== null) this.clearTimer(this.timer); + this.timer = null; + this.request?.abort(); + this.request = null; + } + + private schedule(delayMs: number): void { + if (this.status.phase !== 'running') return; + if (this.timer !== null) this.clearTimer(this.timer); + this.timer = this.setTimer(() => { + this.timer = null; + void this.capture(); + }, delayMs); + } + + private async capture(): Promise { + if (this.status.phase !== 'running') return; + const source = this.sources.find(({ id }) => id === this.activeSourceId); + const programTimeMs = source?.kind === 'beacon' ? this.getPlaybackProgramTimeMs() : null; + const lease = source?.kind === 'beacon' ? this.getLeaseCursor() : null; + if (programTimeMs === null || !Number.isFinite(programTimeMs) || !lease) { + this.schedule(250); + return; + } + const generation = this.requestGeneration; + const sourceId = this.activeSourceId; + const controller = new AbortController(); + this.request = controller; + try { + const response = await this.fetcher( + `${this.endpoint}?at=${encodeURIComponent(Math.round(programTimeMs))}` + + `&leaseId=${encodeURIComponent(lease.leaseId)}` + + `&leaseGeneration=${encodeURIComponent(lease.leaseGeneration)}`, + { cache: 'no-store', credentials: 'same-origin', signal: controller.signal }, + ); + if (!response.ok) throw new Error('Remote analysis unavailable'); + const parsed = parseRemoteHarmonicFrame(await response.json()); + if (controller.signal.aborted + || generation !== this.requestGeneration + || sourceId !== this.activeSourceId + || this.status.phase !== 'running') return; + const frame = parsed ? this.applySlowBaseline(parsed) : null; + if (!frame) throw new Error('Invalid remote analysis frame'); + this.failures = 0; + for (const listener of this.frameListeners) { + try { listener(frame); } catch { /* renderer isolation */ } + } + } catch { + if (controller.signal.aborted || this.status.phase !== 'running') return; + this.failures += 1; + if (this.failures >= 4) { + this.setStatus({ + ...this.status, + phase: 'error', + error: publicError('ANALYSIS_FAILED', 'Server analysis is temporarily unavailable'), + }); + return; + } + } finally { + if (this.request === controller) this.request = null; + } + this.schedule(1_000 / this.framesPerSecond); + } + + private applySlowBaseline(frame: HarmonicAnalysisFrame): HarmonicAnalysisFrame { + const baselineDb = this.baselineDb; + const previousCapturedAtMs = this.previousCapturedAtMs; + if (!baselineDb + || baselineDb.length !== frame.harmonicAbsoluteDb.length + || previousCapturedAtMs === null + || frame.capturedAtMs < previousCapturedAtMs + || frame.capturedAtMs - previousCapturedAtMs > 10_000) { + this.baselineDb = new Float32Array(frame.harmonicAbsoluteDb); + this.previousCapturedAtMs = frame.capturedAtMs; + return { ...frame, harmonicDeltaDb: new Float32Array(frame.harmonicAbsoluteDb.length) }; + } + const elapsedSeconds = Math.max(0, frame.capturedAtMs - previousCapturedAtMs) / 1_000; + const alpha = 1 - Math.exp(-elapsedSeconds / 24); + const delta = new Float32Array(frame.harmonicAbsoluteDb.length); + for (let index = 0; index < delta.length; index += 1) { + const absolute = frame.harmonicAbsoluteDb[index]; + const baseline = baselineDb[index]; + delta[index] = Math.min(24, Math.max(-24, absolute - baseline)); + baselineDb[index] = baseline + alpha * (absolute - baseline); + } + this.previousCapturedAtMs = frame.capturedAtMs; + return { ...frame, harmonicDeltaDb: delta }; + } + + private setStatus(status: HarmonicAnalysisProviderStatus): void { + this.status = status; + for (const listener of this.statusListeners) { + try { listener(this.getStatus()); } catch { /* observer isolation */ } + } + } +} diff --git a/src/lib/listener/analysis/server-harmonic-analyzer.ts b/src/lib/listener/analysis/server-harmonic-analyzer.ts new file mode 100644 index 00000000..662db77c --- /dev/null +++ b/src/lib/listener/analysis/server-harmonic-analyzer.ts @@ -0,0 +1,398 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { + BEACON_FUNDAMENTAL_HZ, + HARMONIC_ANALYSIS_MAX_HZ, + HARMONIC_ANALYSIS_SCHEMA_VERSION, + type HarmonicAnalysisFrame, +} from './types'; + +const SAMPLE_RATE = 48_000; +const FFT_SIZE = 16_384; +const FRAMES_PER_SECOND = 4; +const MAX_CACHE_SEGMENTS = 36; +const MAX_CONCURRENT_DECODES = 2; +const MAX_PCM_BYTES = 4 * 1024 * 1024; +const ARTIFACT_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/; +const MEDIA_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export type ArtifactSegment = { file: string; durationSeconds: number }; +export type ArtifactMetadata = { + schemaVersion: 1 | 2; + artifactId: string; + timing: { + epochUtc: string; + segmentDurationSeconds?: number; + loopDurationSeconds?: number; + segmentCount: number; + }; + initialization?: { file: string }; + segments: ArtifactSegment[]; +}; + +export type ArtifactPosition = { + segmentIndex: number; + segmentOffsetSeconds: number; + segmentProgramStartMs: number; +}; + +type RelativeFrame = Omit & { + offsetSeconds: number; +}; + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(maximum, Math.max(minimum, value)); +} + +function decibels(amplitude: number): number { + return clamp(20 * Math.log10(Math.max(amplitude, 1e-6)), -120, 0); +} + +export function normalizeAnalysisArtifactMetadata(raw: ArtifactMetadata): ArtifactMetadata & { + epochMs: number; + loopDurationSeconds: number; + segmentStartsSeconds: number[]; +} { + if (!raw || ![1, 2].includes(raw.schemaVersion) + || !ARTIFACT_ID.test(raw.artifactId) + || !Number.isFinite(Date.parse(raw.timing?.epochUtc)) + || !Number.isSafeInteger(raw.timing?.segmentCount) + || raw.timing.segmentCount < 1 + || !Array.isArray(raw.segments) + || raw.segments.length !== raw.timing.segmentCount) { + throw new Error('Invalid Listener analysis artifact metadata'); + } + const durationFallback = raw.timing.segmentDurationSeconds; + const segments = raw.segments.map((segment) => { + const durationSeconds = segment.durationSeconds ?? durationFallback; + if (!MEDIA_FILE.test(segment.file) || !Number.isFinite(durationSeconds) || durationSeconds <= 0) { + throw new Error('Invalid Listener analysis segment metadata'); + } + return { file: segment.file, durationSeconds }; + }); + if (raw.initialization && !MEDIA_FILE.test(raw.initialization.file)) { + throw new Error('Invalid Listener analysis initialization metadata'); + } + const segmentStartsSeconds: number[] = []; + let measuredDuration = 0; + for (const segment of segments) { + segmentStartsSeconds.push(measuredDuration); + measuredDuration += segment.durationSeconds; + } + const declaredLoop = raw.timing.loopDurationSeconds ?? measuredDuration; + if (!Number.isFinite(declaredLoop) || Math.abs(declaredLoop - measuredDuration) > 0.01) { + throw new Error('Listener analysis loop duration mismatch'); + } + return { + ...raw, + segments, + epochMs: Date.parse(raw.timing.epochUtc), + loopDurationSeconds: measuredDuration, + segmentStartsSeconds, + }; +} + +export function resolveArtifactPosition( + metadata: ReturnType, + programTimeMs: number, +): ArtifactPosition { + if (!Number.isFinite(programTimeMs)) throw new Error('Invalid program timestamp'); + const elapsedSeconds = Math.max(0, (programTimeMs - metadata.epochMs) / 1_000); + const loopPosition = elapsedSeconds % metadata.loopDurationSeconds; + let segmentIndex = metadata.segments.length - 1; + for (let index = 0; index < metadata.segments.length; index += 1) { + if (loopPosition < metadata.segmentStartsSeconds[index] + + metadata.segments[index].durationSeconds) { + segmentIndex = index; + break; + } + } + const segmentOffsetSeconds = loopPosition - metadata.segmentStartsSeconds[segmentIndex]; + return { + segmentIndex, + segmentOffsetSeconds, + segmentProgramStartMs: programTimeMs - segmentOffsetSeconds * 1_000, + }; +} + +const hannWindow = Float64Array.from( + { length: FFT_SIZE }, + (_, index) => 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (FFT_SIZE - 1)), +); +const hannSum = hannWindow.reduce((sum, value) => sum + value, 0); + +function fft(real: Float64Array, imaginary: Float64Array): void { + const size = real.length; + for (let index = 1, reversed = 0; index < size; index += 1) { + let bit = size >> 1; + while (reversed & bit) { + reversed ^= bit; + bit >>= 1; + } + reversed ^= bit; + if (index < reversed) { + [real[index], real[reversed]] = [real[reversed], real[index]]; + [imaginary[index], imaginary[reversed]] = [imaginary[reversed], imaginary[index]]; + } + } + for (let length = 2; length <= size; length <<= 1) { + const angle = -2 * Math.PI / length; + const baseReal = Math.cos(angle); + const baseImaginary = Math.sin(angle); + for (let start = 0; start < size; start += length) { + let twiddleReal = 1; + let twiddleImaginary = 0; + for (let offset = 0; offset < length / 2; offset += 1) { + const even = start + offset; + const odd = even + length / 2; + const oddReal = real[odd] * twiddleReal - imaginary[odd] * twiddleImaginary; + const oddImaginary = real[odd] * twiddleImaginary + imaginary[odd] * twiddleReal; + real[odd] = real[even] - oddReal; + imaginary[odd] = imaginary[even] - oddImaginary; + real[even] += oddReal; + imaginary[even] += oddImaginary; + const nextReal = twiddleReal * baseReal - twiddleImaginary * baseImaginary; + twiddleImaginary = twiddleReal * baseImaginary + twiddleImaginary * baseReal; + twiddleReal = nextReal; + } + } + } +} + +export function analyzePcmSegment( + pcm: Float32Array, + durationSeconds: number, +): RelativeFrame[] { + const sampleFrames = Math.floor(pcm.length / 2); + if (sampleFrames < FFT_SIZE || durationSeconds <= 0) return []; + const frameCount = Math.max(1, Math.floor(durationSeconds * FRAMES_PER_SECOND)); + const harmonicCount = Math.floor( + Math.min(HARMONIC_ANALYSIS_MAX_HZ, SAMPLE_RATE / 2) / BEACON_FUNDAMENTAL_HZ, + ); + const frames: RelativeFrame[] = []; + + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + const offsetSeconds = Math.min( + durationSeconds, + (frameIndex + 0.5) / FRAMES_PER_SECOND, + ); + const centerSample = Math.round(offsetSeconds * SAMPLE_RATE); + const firstSample = clamp( + centerSample - FFT_SIZE / 2, + 0, + Math.max(0, sampleFrames - FFT_SIZE), + ); + const real = new Float64Array(FFT_SIZE); + const imaginary = new Float64Array(FFT_SIZE); + let leftPower = 0; + let rightPower = 0; + let sidePower = 0; + for (let index = 0; index < FFT_SIZE; index += 1) { + const left = pcm[(firstSample + index) * 2] ?? 0; + const right = pcm[(firstSample + index) * 2 + 1] ?? left; + const mono = (left + right) * 0.5; + real[index] = mono * hannWindow[index]; + leftPower += left * left; + rightPower += right * right; + const side = left - right; + sidePower += side * side; + } + fft(real, imaginary); + const spectralEnvelopeDb = new Float32Array(32); + const minimumHz = BEACON_FUNDAMENTAL_HZ / 2; + const maximumHz = Math.min(HARMONIC_ANALYSIS_MAX_HZ, SAMPLE_RATE / 2); + const frequencyRatio = maximumHz / minimumHz; + for (let band = 0; band < spectralEnvelopeDb.length; band += 1) { + const firstHz = minimumHz * frequencyRatio ** (band / spectralEnvelopeDb.length); + const lastHz = minimumHz * frequencyRatio ** ((band + 1) / spectralEnvelopeDb.length); + const firstBin = clamp(Math.floor(firstHz * FFT_SIZE / SAMPLE_RATE), 1, FFT_SIZE / 2 - 1); + const lastBin = clamp(Math.ceil(lastHz * FFT_SIZE / SAMPLE_RATE), firstBin, FFT_SIZE / 2 - 1); + let power = 0; + let count = 0; + for (let bin = firstBin; bin <= lastBin; bin += 1) { + const amplitude = 2 * Math.hypot(real[bin], imaginary[bin]) / hannSum; + power += amplitude * amplitude; + count += 1; + } + spectralEnvelopeDb[band] = decibels(Math.sqrt(power / Math.max(1, count))); + } + const harmonicAbsoluteDb = new Float32Array(harmonicCount); + for (let harmonicIndex = 0; harmonicIndex < harmonicCount; harmonicIndex += 1) { + const frequency = (harmonicIndex + 1) * BEACON_FUNDAMENTAL_HZ; + const centerBin = frequency * FFT_SIZE / SAMPLE_RATE; + const minimumBin = Math.max(1, Math.floor(centerBin) - 2); + const maximumBin = Math.min(FFT_SIZE / 2 - 1, Math.ceil(centerBin) + 2); + let power = 0; + for (let bin = minimumBin; bin <= maximumBin; bin += 1) { + const amplitude = 2 * Math.hypot(real[bin], imaginary[bin]) / hannSum; + power += amplitude * amplitude; + } + harmonicAbsoluteDb[harmonicIndex] = decibels(Math.sqrt(power)); + } + const leftRms = Math.sqrt(leftPower / FFT_SIZE); + const rightRms = Math.sqrt(rightPower / FFT_SIZE); + const totalRms = Math.sqrt((leftPower + rightPower) / (FFT_SIZE * 2)); + const denominator = leftRms + rightRms; + frames.push({ + schemaVersion: HARMONIC_ANALYSIS_SCHEMA_VERSION, + offsetSeconds, + sourceTimeSeconds: offsetSeconds, + overallDb: decibels(totalRms), + harmonicAbsoluteDb, + // The slow baseline is client-instance state over ordered remote + // frames. Keeping it out of segment cache avoids false resets at + // every HLS fragment boundary or cross-client state contamination. + harmonicDeltaDb: new Float32Array(harmonicCount), + spectralEnvelopeDb, + stereoBalance: denominator > 0 ? (rightRms - leftRms) / denominator : 0, + stereoWidth: clamp(Math.sqrt(sidePower / FFT_SIZE) / Math.max(totalRms * 2, 1e-6), 0, 1), + confidence: totalRms > 1e-6 ? 1 : 0, + sourceKind: 'beacon', + }); + } + return frames; +} + +async function decodeSegment( + artifactRoot: string, + metadata: ReturnType, + segmentIndex: number, +): Promise { + const segment = metadata.segments[segmentIndex]; + const segmentsRoot = path.resolve(artifactRoot, 'segments'); + const segmentPath = path.resolve(segmentsRoot, segment.file); + if (!segmentPath.startsWith(`${segmentsRoot}${path.sep}`)) throw new Error('Unsafe segment path'); + const inputs = metadata.initialization + ? [path.resolve(segmentsRoot, metadata.initialization.file), segmentPath] + : [segmentPath]; + const input = `concat:${inputs.join('|')}`; + const chunks: Buffer[] = []; + let bytes = 0; + await new Promise((resolve, reject) => { + const process = spawn('ffmpeg', [ + '-hide_banner', '-loglevel', 'error', '-i', input, + '-vn', '-ac', '2', '-ar', String(SAMPLE_RATE), '-f', 'f32le', 'pipe:1', + ], { stdio: ['ignore', 'pipe', 'ignore'] }); + const timeout = setTimeout(() => { + process.kill('SIGKILL'); + reject(new Error('Listener analysis decode timed out')); + }, 10_000); + process.stdout.on('data', (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > MAX_PCM_BYTES) { + process.kill('SIGKILL'); + reject(new Error('Listener analysis PCM exceeded bound')); + return; + } + chunks.push(chunk); + }); + process.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + process.once('close', (code) => { + clearTimeout(timeout); + if (code === 0) resolve(); + else reject(new Error('Listener analysis decode failed')); + }); + }); + const pcm = Buffer.concat(chunks); + return new Float32Array( + pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength), + ); +} + +export class ServerHarmonicAnalyzer { + private metadataPromise: Promise> | null = null; + private readonly cache = new Map>(); + private activeDecodes = 0; + + constructor( + private readonly mediaRoot: string, + private readonly artifactId: string, + ) { + if (!ARTIFACT_ID.test(artifactId)) throw new Error('Invalid Listener analysis artifact ID'); + } + + private async metadata() { + this.metadataPromise ??= readFile( + path.resolve(this.mediaRoot, this.artifactId, 'artifact.json'), + 'utf8', + ).then((value) => normalizeAnalysisArtifactMetadata(JSON.parse(value) as ArtifactMetadata)); + return this.metadataPromise; + } + + private async frames( + metadata: ReturnType, + segmentIndex: number, + ): Promise { + let pending = this.cache.get(segmentIndex); + if (!pending) { + if (this.activeDecodes >= MAX_CONCURRENT_DECODES) { + throw new Error('Listener analysis decoder is busy'); + } + const artifactRoot = path.resolve(this.mediaRoot, this.artifactId); + this.activeDecodes += 1; + pending = decodeSegment(artifactRoot, metadata, segmentIndex) + .then((pcm) => analyzePcmSegment(pcm, metadata.segments[segmentIndex].durationSeconds)) + .catch((error) => { + this.cache.delete(segmentIndex); + throw error; + }) + .finally(() => { + this.activeDecodes -= 1; + }); + this.cache.set(segmentIndex, pending); + while (this.cache.size > MAX_CACHE_SEGMENTS) { + const oldest = this.cache.keys().next().value as number | undefined; + if (oldest === undefined) break; + this.cache.delete(oldest); + } + } + return pending; + } + + async frameAt(programTimeMs: number): Promise { + const metadata = await this.metadata(); + const position = resolveArtifactPosition(metadata, programTimeMs); + const frames = await this.frames(metadata, position.segmentIndex); + if (frames.length === 0) throw new Error('Listener analysis segment produced no frames'); + const selected = frames.reduce((nearest, candidate) => ( + Math.abs(candidate.offsetSeconds - position.segmentOffsetSeconds) + < Math.abs(nearest.offsetSeconds - position.segmentOffsetSeconds) + ? candidate + : nearest + )); + const { offsetSeconds, ...frame } = selected; + return { + ...frame, + capturedAtMs: position.segmentProgramStartMs + offsetSeconds * 1_000, + sourceTimeSeconds: ( + metadata.segmentStartsSeconds[position.segmentIndex] + offsetSeconds + ) % metadata.loopDurationSeconds, + }; + } +} + +const globalAnalyzer = Symbol.for('harmonic-beacon.listener.server-harmonic-analyzer.v1'); +type AnalyzerGlobal = typeof globalThis & { [globalAnalyzer]?: ServerHarmonicAnalyzer }; + +export function listenerServerHarmonicAnalyzer(environment = process.env): ServerHarmonicAnalyzer { + const artifactId = environment.EARLY_BIRDS_STREAM_ARTIFACT_ID?.trim(); + const mediaRoot = environment.EARLY_BIRDS_STREAM_MEDIA_ROOT?.trim() || '/media/artifacts'; + if (!artifactId) throw new Error('Listener server analysis is not configured'); + const root = globalThis as AnalyzerGlobal; + root[globalAnalyzer] ??= new ServerHarmonicAnalyzer(mediaRoot, artifactId); + return root[globalAnalyzer]; +} + +export function serializeServerHarmonicFrame(frame: HarmonicAnalysisFrame) { + return { + ...frame, + harmonicAbsoluteDb: Array.from(frame.harmonicAbsoluteDb), + harmonicDeltaDb: Array.from(frame.harmonicDeltaDb), + spectralEnvelopeDb: Array.from(frame.spectralEnvelopeDb), + }; +} diff --git a/src/lib/listener/analysis/types.ts b/src/lib/listener/analysis/types.ts index 04b4e7cd..c83aa752 100644 --- a/src/lib/listener/analysis/types.ts +++ b/src/lib/listener/analysis/types.ts @@ -26,9 +26,6 @@ export type HarmonicAnalysisFrame = { }; export type HarmonicAnalysisErrorCode = - | 'AUDIO_CONTEXT_UNAVAILABLE' - | 'AUDIO_CONTEXT_SUSPENDED' - | 'GRAPH_ATTACH_FAILED' | 'INVALID_CONFIGURATION' | 'NO_ACTIVE_SOURCE' | 'PROVIDER_STOPPED' diff --git a/src/lib/listener/analysis/web-audio-provider.ts b/src/lib/listener/analysis/web-audio-provider.ts deleted file mode 100644 index ddc178d3..00000000 --- a/src/lib/listener/analysis/web-audio-provider.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { - HarmonicFrameMapper, - validateHarmonicAnalysisConfig, - type ValidatedHarmonicAnalysisConfig, -} from './harmonic-mapper'; -import { - type HarmonicAnalysisConfig, - type HarmonicAnalysisError, - type HarmonicAnalysisFrameListener, - type HarmonicAnalysisProvider, - type HarmonicAnalysisProviderStatus, - type HarmonicAnalysisSourceKind, - type HarmonicAnalysisStartResult, - type HarmonicAnalysisStatusListener, -} from './types'; - -export type WebAudioAnalysisSource = { - id: string; - kind: HarmonicAnalysisSourceKind; - element: HTMLMediaElement; -}; - -export type HarmonicAnalysisScheduler = { - request(callback: FrameRequestCallback, delayMs: number): number; - cancel(handle: number): void; - now(): number; -}; - -export type WebAudioHarmonicAnalysisOptions = HarmonicAnalysisConfig & { - sources: readonly WebAudioAnalysisSource[]; - activeSourceId?: string; - audioContext?: AudioContext; - audioContextFactory?: () => AudioContext; - scheduler?: HarmonicAnalysisScheduler; -}; - -type AttachedSource = WebAudioAnalysisSource & { - sourceNode: MediaElementAudioSourceNode; - splitter: ChannelSplitterNode; - leftAnalyser: AnalyserNode; - rightAnalyser: AnalyserNode; - leftSpectrumDb: Float32Array; - rightSpectrumDb: Float32Array; - leftWaveform: Float32Array; - rightWaveform: Float32Array; - mapper: HarmonicFrameMapper; -}; - -type WindowWithWebkitAudioContext = Window & typeof globalThis & { - webkitAudioContext?: typeof AudioContext; -}; - -// A media element can be used to create only one MediaElementSourceNode for -// its lifetime. Keeping the claim weak avoids retaining discarded elements. -const claimedMediaElements = new WeakSet(); - -function defaultAudioContextFactory(): AudioContext { - if (typeof window === 'undefined') throw new Error('Web Audio is unavailable outside a browser'); - const browserWindow = window as WindowWithWebkitAudioContext; - const Constructor = browserWindow.AudioContext ?? browserWindow.webkitAudioContext; - if (!Constructor) throw new Error('This browser does not provide AudioContext'); - return new Constructor(); -} - -function defaultScheduler(): HarmonicAnalysisScheduler { - return { - request: (callback, delayMs) => window.setTimeout( - () => callback(performance.now()), - delayMs, - ), - cancel: (handle) => window.clearTimeout(handle), - now: () => performance.now(), - }; -} - -function publicError( - code: HarmonicAnalysisError['code'], - message: string, - recoverable: boolean, -): HarmonicAnalysisError { - return { code, message, recoverable }; -} - -function stoppedStatus(): HarmonicAnalysisProviderStatus { - return { phase: 'stopped', activeSourceId: null, activeSourceKind: null, error: null }; -} - -/** - * A renderer-independent analysis session for every media element involved in - * one Listener playback. All sources are attached synchronously during start(), - * before the first await, so a single trusted Listen gesture can create and - * resume the complete graph. Switching the observed source never reconnects an - * audible path. - */ -export class WebAudioHarmonicAnalysisProvider implements HarmonicAnalysisProvider { - private readonly sources: readonly WebAudioAnalysisSource[]; - private readonly contextFactory: () => AudioContext; - private readonly injectedContext: AudioContext | undefined; - private readonly scheduler: HarmonicAnalysisScheduler; - private readonly config: ValidatedHarmonicAnalysisConfig; - private readonly frameListeners = new Set(); - private readonly statusListeners = new Set(); - private readonly attached = new Map(); - private readonly terminalNodes = new Set(); - private context: AudioContext | null = null; - private activeSourceId: string; - private animationHandle: number | null = null; - private lastFrameAtMs = Number.NEGATIVE_INFINITY; - private status: HarmonicAnalysisProviderStatus; - private ownsContext = false; - private startPromise: Promise | null = null; - - constructor(options: WebAudioHarmonicAnalysisOptions) { - if (options.sources.length === 0) throw new Error('At least one analysis source is required'); - const ids = new Set(); - const elements = new Set(); - for (const source of options.sources) { - if (!source.id.trim()) throw new Error('Analysis source ids cannot be empty'); - if (ids.has(source.id)) throw new Error(`Duplicate analysis source id: ${source.id}`); - if (elements.has(source.element)) throw new Error('Each media element may be attached only once'); - ids.add(source.id); - elements.add(source.element); - } - const activeSourceId = options.activeSourceId ?? options.sources[0]?.id; - if (!activeSourceId || !ids.has(activeSourceId)) { - throw new Error('activeSourceId must identify a configured source'); - } - if (options.audioContext && options.audioContextFactory) { - throw new Error('Provide audioContext or audioContextFactory, not both'); - } - - this.sources = options.sources; - this.activeSourceId = activeSourceId; - this.injectedContext = options.audioContext; - this.contextFactory = options.audioContextFactory ?? defaultAudioContextFactory; - this.scheduler = options.scheduler ?? defaultScheduler(); - this.config = validateHarmonicAnalysisConfig({ - fftSize: options.fftSize, - baselineSeconds: options.baselineSeconds, - framesPerSecond: options.framesPerSecond, - spectralEnvelopeBands: options.spectralEnvelopeBands, - }); - const active = this.sources.find(({ id }) => id === activeSourceId) ?? null; - this.status = { - phase: 'idle', - activeSourceId, - activeSourceKind: active?.kind ?? null, - error: null, - }; - } - - getStatus(): HarmonicAnalysisProviderStatus { - return { ...this.status }; - } - - /** Graph attachment and context.resume() invocation happen before the first await. */ - start(): Promise { - if (this.status.phase === 'stopped') { - return Promise.resolve(this.fail(publicError( - 'PROVIDER_STOPPED', - 'A stopped analysis provider cannot be restarted', - false, - ))); - } - if (this.startPromise) return this.startPromise; - if (this.status.phase === 'running') return Promise.resolve({ ok: true }); - if (this.status.phase === 'paused') return Promise.resolve(this.resumeAnalysis()); - if (this.status.phase === 'suspended' && this.context) { - return this.trackStart(this.resumeAttachedContext()); - } - if (this.status.phase === 'error' && this.terminalNodes.size > 0) { - return Promise.resolve({ - ok: false, - error: this.status.error ?? publicError( - 'GRAPH_ATTACH_FAILED', - 'The existing media graph cannot be attached again', - true, - ), - }); - } - if (this.status.phase === 'starting') { - // Defensive fallback: normal starts always publish startPromise - // before another browser event can run. - return Promise.resolve({ - ok: false, - error: publicError( - 'ANALYSIS_FAILED', - 'Analysis startup state is inconsistent', - true, - ), - }); - } - - this.setStatus({ ...this.status, phase: 'starting', error: null }); - try { - this.context = this.injectedContext ?? this.contextFactory(); - this.ownsContext = !this.injectedContext; - } catch { - return Promise.resolve(this.fail(publicError( - 'AUDIO_CONTEXT_UNAVAILABLE', - 'The browser could not create an audio analysis context', - true, - ))); - } - - try { - this.attachAllSources(this.context); - this.context.addEventListener('statechange', this.handleContextStateChange); - } catch { - // createMediaElementSource is irreversible for the lifetime of its - // media element. Preserve every direct branch already attached; - // integration can keep it audible or remount fresh media elements. - return Promise.resolve(this.fail(publicError( - 'GRAPH_ATTACH_FAILED', - 'The media elements could not be attached for analysis', - true, - ))); - } - - // Calling resume in this synchronous portion of start preserves the - // trusted user activation. Only its completion is awaited. - const resumePromise = this.context.state === 'running' - ? Promise.resolve() - : this.context.resume(); - return this.trackStart(this.completeStart(this.context, resumePromise)); - } - - private async completeStart( - context: AudioContext, - resumePromise: Promise, - ): Promise { - try { - await resumePromise; - } catch { - if (this.status.phase === 'stopped') { - return { - ok: false, - error: publicError('PROVIDER_STOPPED', 'Analysis was stopped during startup', false), - }; - } - return this.fail(publicError( - 'AUDIO_CONTEXT_SUSPENDED', - 'The audio analysis context could not be resumed', - true, - ), 'suspended'); - } - - if (this.status.phase === 'stopped' || this.context !== context) { - return { - ok: false, - error: publicError('PROVIDER_STOPPED', 'Analysis was stopped during startup', false), - }; - } - if (context.state !== 'running') { - return this.fail(publicError( - 'AUDIO_CONTEXT_SUSPENDED', - 'The audio analysis context remains suspended', - true, - ), 'suspended'); - } - this.setStatus({ ...this.status, phase: 'running', error: null }); - this.scheduleNextFrame(); - return { ok: true }; - } - - private trackStart( - promise: Promise, - ): Promise { - this.startPromise = promise; - void promise.then( - () => { - if (this.startPromise === promise) this.startPromise = null; - }, - () => { - if (this.startPromise === promise) this.startPromise = null; - }, - ); - return promise; - } - - setActiveSource(sourceId: string): HarmonicAnalysisStartResult { - if (this.status.phase === 'stopped') { - return this.fail(publicError( - 'PROVIDER_STOPPED', - 'A stopped analysis provider cannot change source', - false, - )); - } - const source = this.sources.find(({ id }) => id === sourceId); - if (!source) { - const error = publicError( - 'NO_ACTIVE_SOURCE', - 'The requested analysis source is not attached', - true, - ); - this.setStatus({ ...this.status, error }); - return { ok: false, error }; - } - this.activeSourceId = sourceId; - this.setStatus({ - ...this.status, - activeSourceId: sourceId, - activeSourceKind: source.kind, - error: null, - }); - return { ok: true }; - } - - setFramesPerSecond(framesPerSecond: number): HarmonicAnalysisStartResult { - let validated: ValidatedHarmonicAnalysisConfig; - try { - validated = validateHarmonicAnalysisConfig({ - ...this.config, - framesPerSecond, - }); - } catch { - const error = publicError( - 'INVALID_CONFIGURATION', - 'Analysis cadence must be between 1 and 60 frames per second', - true, - ); - this.setStatus({ ...this.status, error }); - return { ok: false, error }; - } - this.config.framesPerSecond = validated.framesPerSecond; - if (this.status.phase === 'running' && this.animationHandle !== null) { - try { this.scheduler.cancel(this.animationHandle); } catch { /* non-throwing reschedule */ } - this.animationHandle = null; - this.scheduleNextFrame(); - } - return { ok: true }; - } - - pauseAnalysis(): void { - if (this.status.phase !== 'running') return; - if (this.animationHandle !== null) { - try { this.scheduler.cancel(this.animationHandle); } catch { /* non-throwing pause */ } - this.animationHandle = null; - } - this.setStatus({ ...this.status, phase: 'paused', error: null }); - } - - resumeAnalysis(): HarmonicAnalysisStartResult { - if (this.status.phase === 'stopped') { - return this.fail(publicError( - 'PROVIDER_STOPPED', - 'A stopped analysis provider cannot be resumed', - false, - )); - } - if (!this.context || this.context.state !== 'running') { - return this.fail(publicError( - 'AUDIO_CONTEXT_SUSPENDED', - 'The audio analysis context is not running', - true, - ), 'suspended'); - } - this.setStatus({ ...this.status, phase: 'running', error: null }); - this.scheduleNextFrame(); - return { ok: true }; - } - - subscribe(listener: HarmonicAnalysisFrameListener): () => void { - this.frameListeners.add(listener); - return () => { this.frameListeners.delete(listener); }; - } - - subscribeStatus(listener: HarmonicAnalysisStatusListener): () => void { - this.statusListeners.add(listener); - listener(this.getStatus()); - return () => { this.statusListeners.delete(listener); }; - } - - /** - * Terminal teardown. A MediaElementSource attachment is irreversible, so - * callers may stop only when every attached media element will be discarded. - * Use pauseAnalysis() to hide or suspend rendering while audio keeps playing. - */ - stop(): void { - if (this.status.phase === 'stopped') return; - if (this.animationHandle !== null) { - try { this.scheduler.cancel(this.animationHandle); } catch { /* non-throwing teardown */ } - this.animationHandle = null; - } - if (this.context) { - try { this.context.removeEventListener('statechange', this.handleContextStateChange); } catch { /* noop */ } - } - this.disconnectGraph(); - if (this.context && this.ownsContext) { - try { void this.context.close().catch(() => undefined); } catch { /* noop */ } - } - this.context = null; - this.setStatus(stoppedStatus()); - } - - private attachAllSources(context: AudioContext): void { - if (this.attached.size > 0) return; - for (const source of this.sources) { - if (claimedMediaElements.has(source.element)) { - throw new Error('Media element was already attached to a Web Audio source'); - } - const sourceNode = context.createMediaElementSource(source.element); - claimedMediaElements.add(source.element); - this.terminalNodes.add(sourceNode); - // Establish the sole audible branch immediately. If any later - // analysis setup fails, the remapped element remains audible. - sourceNode.connect(context.destination); - const splitter = context.createChannelSplitter(2); - this.terminalNodes.add(splitter); - const leftAnalyser = context.createAnalyser(); - this.terminalNodes.add(leftAnalyser); - const rightAnalyser = context.createAnalyser(); - this.terminalNodes.add(rightAnalyser); - const mapper = new HarmonicFrameMapper(this.config); - const fftSize = this.config.fftSize; - leftAnalyser.fftSize = fftSize; - rightAnalyser.fftSize = fftSize; - leftAnalyser.minDecibels = -120; - rightAnalyser.minDecibels = -120; - leftAnalyser.maxDecibels = 0; - rightAnalyser.maxDecibels = 0; - leftAnalyser.smoothingTimeConstant = 0; - rightAnalyser.smoothingTimeConstant = 0; - - // The sole audible branch is direct and unprocessed. Neither - // analyser is connected to destination. - sourceNode.connect(splitter); - splitter.connect(leftAnalyser, 0); - splitter.connect(rightAnalyser, 1); - - this.attached.set(source.id, { - ...source, - sourceNode, - splitter, - leftAnalyser, - rightAnalyser, - leftSpectrumDb: new Float32Array(leftAnalyser.frequencyBinCount), - rightSpectrumDb: new Float32Array(rightAnalyser.frequencyBinCount), - leftWaveform: new Float32Array(fftSize), - rightWaveform: new Float32Array(fftSize), - mapper, - }); - } - } - - private readonly handleContextStateChange = (): void => { - if (!this.context || this.status.phase === 'stopped') return; - if (this.context.state === 'running') { - if (this.status.phase === 'paused') return; - this.setStatus({ ...this.status, phase: 'running', error: null }); - this.scheduleNextFrame(); - return; - } - this.setStatus({ - ...this.status, - phase: 'suspended', - error: publicError( - 'AUDIO_CONTEXT_SUSPENDED', - 'Audio analysis was interrupted or suspended', - true, - ), - }); - }; - - private scheduleNextFrame(): void { - if (this.animationHandle !== null || this.status.phase !== 'running') return; - const intervalMs = 1000 / this.config.framesPerSecond; - const elapsedMs = this.scheduler.now() - this.lastFrameAtMs; - const delayMs = Number.isFinite(elapsedMs) - ? Math.max(0, intervalMs - elapsedMs) - : 0; - this.animationHandle = this.scheduler.request(this.captureFrame, delayMs); - } - - private readonly captureFrame: FrameRequestCallback = (timestamp): void => { - this.animationHandle = null; - if (this.status.phase !== 'running' || !this.context) return; - const framesPerSecond = this.config.framesPerSecond; - const capturedAtMs = Number.isFinite(timestamp) ? timestamp : this.scheduler.now(); - if (capturedAtMs - this.lastFrameAtMs < 1000 / framesPerSecond) { - this.scheduleNextFrame(); - return; - } - this.lastFrameAtMs = capturedAtMs; - const source = this.attached.get(this.activeSourceId); - if (!source) { - this.fail(publicError( - 'NO_ACTIVE_SOURCE', - 'No attached source is available for analysis', - true, - )); - return; - } - try { - source.leftAnalyser.getFloatFrequencyData(source.leftSpectrumDb); - source.rightAnalyser.getFloatFrequencyData(source.rightSpectrumDb); - source.leftAnalyser.getFloatTimeDomainData(source.leftWaveform); - source.rightAnalyser.getFloatTimeDomainData(source.rightWaveform); - const sourceTimeSeconds = Number.isFinite(source.element.currentTime) - ? source.element.currentTime - : null; - const frame = source.mapper.map({ - capturedAtMs, - sourceTimeSeconds, - sourceKind: source.kind, - sampleRate: this.context.sampleRate, - fftSize: this.config.fftSize, - leftSpectrumDb: source.leftSpectrumDb, - rightSpectrumDb: source.rightSpectrumDb, - leftWaveform: source.leftWaveform, - rightWaveform: source.rightWaveform, - }); - for (const listener of this.frameListeners) { - try { listener(frame); } catch { /* renderer failures never affect audio or analysis */ } - } - } catch { - this.fail(publicError( - 'ANALYSIS_FAILED', - 'The current audio frame could not be analysed', - true, - )); - return; - } - this.scheduleNextFrame(); - }; - - private disconnectGraph(): void { - for (const node of this.terminalNodes) { - try { node.disconnect(); } catch { /* non-throwing teardown */ } - } - this.terminalNodes.clear(); - this.attached.clear(); - } - - private async resumeAttachedContext(): Promise { - if (!this.context) { - return this.fail(publicError( - 'AUDIO_CONTEXT_UNAVAILABLE', - 'The existing audio analysis context is unavailable', - true, - )); - } - this.setStatus({ ...this.status, phase: 'starting', error: null }); - // Invocation occurs synchronously when start() is called from a new - // trusted gesture; no media source is created or reconnected. - const resumePromise = this.context.resume(); - try { - await resumePromise; - } catch { - return this.fail(publicError( - 'AUDIO_CONTEXT_SUSPENDED', - 'The audio analysis context could not be resumed', - true, - ), 'suspended'); - } - if (this.context.state !== 'running') { - return this.fail(publicError( - 'AUDIO_CONTEXT_SUSPENDED', - 'The audio analysis context remains suspended', - true, - ), 'suspended'); - } - this.setStatus({ ...this.status, phase: 'running', error: null }); - this.scheduleNextFrame(); - return { ok: true }; - } - - private fail( - error: HarmonicAnalysisError, - phase: HarmonicAnalysisProviderStatus['phase'] = 'error', - ): HarmonicAnalysisStartResult { - this.setStatus({ ...this.status, phase, error }); - return { ok: false, error }; - } - - private setStatus(status: HarmonicAnalysisProviderStatus): void { - this.status = status; - for (const listener of this.statusListeners) { - try { listener(this.getStatus()); } catch { /* subscriber isolation */ } - } - } -} From e51614433f6ec90f217234d15c914fb064d764b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 08:23:37 -0300 Subject: [PATCH 178/376] fix(listener): keep reactive field visible through transient frames --- src/components/early-birds/EarlyBirdHome.tsx | 1 + src/components/early-birds/ListenerPlayer.tsx | 8 ++++-- .../__tests__/EarlyBirdHome.test.tsx | 11 +++++++- .../__tests__/ListenerPlayer.test.tsx | 26 ++++++++++++++++++- .../listener/__tests__/media-boundary.test.ts | 7 ++--- .../__tests__/remote-provider.test.ts | 21 ++++++++++++--- src/lib/listener/analysis/remote-provider.ts | 11 +++++--- 7 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index 43fa151b..916cae24 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -71,6 +71,7 @@ export default function EarlyBirdHome({ {!publicAccess && accessKind === 'free-quota' && (
diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index b4c03416..0a1ad3c8 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -186,6 +186,7 @@ function preferredDropLanguage( type ListenerPlayerProps = { dropIns: { es: string | null; en: string | null }; reactiveVisualizationAvailable?: boolean; + reactiveVisualizationInitiallyEnabled?: boolean; }; type ListenerPlayerControllerProps = ListenerPlayerProps & { @@ -334,7 +335,7 @@ function ListenerPlayerController({ } }); analysisStatusUnsubscribe.current = provider.subscribeStatus((status) => { - if (status.error?.code === 'ANALYSIS_FAILED') { + if (status.phase === 'error' && status.error?.code === 'ANALYSIS_FAILED') { provider?.pauseAnalysis(); setReactiveRendererAvailable(false); } @@ -1725,8 +1726,11 @@ function ListenerPlayerController({ export default function ListenerPlayer({ dropIns, reactiveVisualizationAvailable = false, + reactiveVisualizationInitiallyEnabled = false, }: ListenerPlayerProps) { - const [reactiveVisualizationEnabled, setReactiveVisualizationEnabled] = useState(false); + const [reactiveVisualizationEnabled, setReactiveVisualizationEnabled] = useState( + reactiveVisualizationAvailable && reactiveVisualizationInitiallyEnabled, + ); const [reactiveFallbackNotice, setReactiveFallbackNotice] = useState(false); const [reactiveSettings, setReactiveSettings] = useState({ ...DEFAULT_REACTIVE_CAMPFIRE_SETTINGS, diff --git a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx index 4d82851b..a923b38b 100644 --- a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx @@ -5,10 +5,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { LocaleProvider } from '@/context/LocaleContext'; vi.mock('../ListenerPlayer', () => ({ - default: ({ reactiveVisualizationAvailable }: { reactiveVisualizationAvailable?: boolean }) => ( + default: ({ + reactiveVisualizationAvailable, + reactiveVisualizationInitiallyEnabled, + }: { + reactiveVisualizationAvailable?: boolean; + reactiveVisualizationInitiallyEnabled?: boolean; + }) => (
), })); @@ -67,6 +74,7 @@ describe('EarlyBird Listener home access chrome', () => { ); expect(screen.getByLabelText('listener-player')).toHaveAttribute('data-reactive-available', 'false'); + expect(screen.getByLabelText('listener-player')).toHaveAttribute('data-reactive-initially-enabled', 'false'); view.rerender( { ); expect(screen.getByLabelText('listener-player')).toHaveAttribute('data-reactive-available', 'true'); + expect(screen.getByLabelText('listener-player')).toHaveAttribute('data-reactive-initially-enabled', 'true'); }); it('presents a normalized Founder status and provider without raw membership source', () => { diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index c62fe2c1..e79954d1 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -64,7 +64,7 @@ vi.mock('@/lib/listener/analysis', async (importOriginal) => { emitAnalysisFailure() { this.statusListener?.({ - phase: 'failed', + phase: 'error', activeSourceId: 'beacon', activeSourceKind: 'beacon', error: { code: 'ANALYSIS_FAILED', message: 'synthetic' }, @@ -195,6 +195,30 @@ describe('EarlyBird Listener player', () => { expect(analysis.setActiveSource).toHaveBeenLastCalledWith('beacon'); }); + it('starts with the reactive field visible when the staging surface requests the lab default', async () => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue({} as CanvasRenderingContext2D); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('synthetic offline'))); + + render( + + + , + ); + + expect(await screen.findByRole('checkbox', { + name: 'Reactive field · experimental', + })).toBeChecked(); + expect(screen.getByTestId('listener-reactive-field')).toBeInTheDocument(); + expect(screen.getByTestId('reactive-campfire-tuning-panel')).toBeInTheDocument(); + }); + it('offers the minimal server-frame renderer without an analysis-only audio mode', async () => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext') .mockReturnValue({} as CanvasRenderingContext2D); diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts index bafb39f7..0b4bbe88 100644 --- a/src/lib/listener/__tests__/media-boundary.test.ts +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -7,9 +7,10 @@ import { describe, expect, it } from 'vitest'; const MEDIA_FILE_SHA256 = { // Reviewed and re-pinned for staging-only remote harmonic frames. The // browser no longer creates Web Audio nodes, adds crossOrigin or remounts - // media when the field is toggled. HLS parameters, source URLs, media - // assets, element gain/fades and event audio remain unchanged. - 'src/components/early-birds/ListenerPlayer.tsx': '18d8274f3078a85a743485168c7309278701467f1967dc1890e3232e4e919042', + // media when the field is toggled. The staging-only lab now starts visible; + // HLS parameters, source URLs, media assets, element gain/fades and event + // audio remain unchanged. + 'src/components/early-birds/ListenerPlayer.tsx': '95fb0c835091c4e0d214c7c763ad2a0f50f4a85ccdf3e08f8ae258dfc32c3249', 'src/lib/early-birds/stream.ts': '96a2d9fe798591833327631b59a73a5b2fc5ca06be7081945a0b07450970da84', 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', 'src/app/api/early-birds/stream/lease/route.ts': 'ec0e8780387bc1f493eb33d13a2d90e01cfdb6d899fc6232e04f51aaf2dfc508', diff --git a/src/lib/listener/analysis/__tests__/remote-provider.test.ts b/src/lib/listener/analysis/__tests__/remote-provider.test.ts index b6280b38..e2349d8e 100644 --- a/src/lib/listener/analysis/__tests__/remote-provider.test.ts +++ b/src/lib/listener/analysis/__tests__/remote-provider.test.ts @@ -91,9 +91,15 @@ describe('RemoteHarmonicAnalysisProvider', () => { provider.stop(); }); - it('fails the visual provider closed after bounded server failures', async () => { + it('backs off through transient server failures and recovers without removing the field', async () => { vi.useFakeTimers(); - const fetcher = vi.fn().mockRejectedValue(new Error('offline')); + const fetcher = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue(new Response(JSON.stringify(wireFrame()))); + const listener = vi.fn(); const provider = new RemoteHarmonicAnalysisProvider({ endpoint: '/api/listener/analysis/frame', sources: [{ id: 'beacon', kind: 'beacon' }], @@ -105,14 +111,21 @@ describe('RemoteHarmonicAnalysisProvider', () => { fetcher, framesPerSecond: 4, }); + provider.subscribe(listener); await provider.start(); - await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(2_000); expect(fetcher).toHaveBeenCalledTimes(4); expect(provider.getStatus()).toMatchObject({ - phase: 'error', + phase: 'running', error: { code: 'ANALYSIS_FAILED' }, }); + + await vi.advanceTimersByTimeAsync(1_751); + expect(fetcher).toHaveBeenCalledTimes(5); + expect(listener).toHaveBeenCalledOnce(); + expect(provider.getStatus()).toMatchObject({ phase: 'running', error: null }); + provider.stop(); }); it('never publishes a resolved Beacon frame after switching to an intro', async () => { diff --git a/src/lib/listener/analysis/remote-provider.ts b/src/lib/listener/analysis/remote-provider.ts index e21ee742..e2f337cd 100644 --- a/src/lib/listener/analysis/remote-provider.ts +++ b/src/lib/listener/analysis/remote-provider.ts @@ -240,6 +240,7 @@ export class RemoteHarmonicAnalysisProvider implements HarmonicAnalysisProvider const sourceId = this.activeSourceId; const controller = new AbortController(); this.request = controller; + let nextDelayMs = 1_000 / this.framesPerSecond; try { const response = await this.fetcher( `${this.endpoint}?at=${encodeURIComponent(Math.round(programTimeMs))}` @@ -255,25 +256,29 @@ export class RemoteHarmonicAnalysisProvider implements HarmonicAnalysisProvider || this.status.phase !== 'running') return; const frame = parsed ? this.applySlowBaseline(parsed) : null; if (!frame) throw new Error('Invalid remote analysis frame'); + const wasRecovering = this.failures > 0 || this.status.error !== null; this.failures = 0; + if (wasRecovering) { + this.setStatus({ ...this.status, phase: 'running', error: null }); + } for (const listener of this.frameListeners) { try { listener(frame); } catch { /* renderer isolation */ } } } catch { if (controller.signal.aborted || this.status.phase !== 'running') return; this.failures += 1; + nextDelayMs = Math.min(5_000, 250 * (2 ** Math.min(this.failures - 1, 4))); if (this.failures >= 4) { this.setStatus({ ...this.status, - phase: 'error', + phase: 'running', error: publicError('ANALYSIS_FAILED', 'Server analysis is temporarily unavailable'), }); - return; } } finally { if (this.request === controller) this.request = null; } - this.schedule(1_000 / this.framesPerSecond); + this.schedule(nextDelayMs); } private applySlowBaseline(frame: HarmonicAnalysisFrame): HarmonicAnalysisFrame { From bbfcc8ce649516ae78e65a013ba70b2ecb7184c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 08:45:03 -0300 Subject: [PATCH 179/376] fix(listener): derive reactive time from audible HLS fragment --- src/components/early-birds/ListenerPlayer.tsx | 37 ++++++++++++++++++- .../__tests__/ListenerPlayer.test.tsx | 33 ++++++++++++++++- .../listener/__tests__/media-boundary.test.ts | 2 +- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 0a1ad3c8..6d41d3db 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -167,6 +167,17 @@ export function nativeHlsProgramTimeMs( } } +export function hlsFragmentProgramTimeMs( + mediaTimeSeconds: number, + anchor: { mediaStartSeconds: number; programStartMs: number } | null, +): number | null { + if (!anchor + || !Number.isFinite(mediaTimeSeconds) + || !Number.isFinite(anchor.mediaStartSeconds) + || !Number.isFinite(anchor.programStartMs)) return null; + return anchor.programStartMs + (mediaTimeSeconds - anchor.mediaStartSeconds) * 1_000; +} + function formatTime(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; const rounded = Math.floor(seconds); @@ -224,6 +235,10 @@ function ListenerPlayerController({ const analysisFrameUnsubscribe = useRef<(() => void) | null>(null); const analysisStatusUnsubscribe = useRef<(() => void) | null>(null); const hls = useRef(null); + const hlsProgramAnchor = useRef<{ + mediaStartSeconds: number; + programStartMs: number; + } | null>(null); const liveSuppressedForDrop = useRef(false); const liveFadeFrame = useRef(null); const dropFadeFrame = useRef(null); @@ -285,9 +300,17 @@ function ListenerPlayerController({ const currentBeaconProgramTimeMs = useCallback((): number | null => { const playingDate = hls.current?.playingDate; - if (playingDate && Number.isFinite(playingDate.getTime())) return playingDate.getTime(); + if (playingDate && Number.isFinite(playingDate.getTime())) { + return playingDate.getTime(); + } const audio = liveAudio.current; - return audio ? nativeHlsProgramTimeMs(audio) : null; + if (!audio) return null; + const fragmentTime = hlsFragmentProgramTimeMs(audio.currentTime, hlsProgramAnchor.current); + if (fragmentTime !== null) { + return fragmentTime; + } + const nativeTime = nativeHlsProgramTimeMs(audio); + return nativeTime; }, []); const startReactiveAnalysis = useCallback((sourceId: string) => { @@ -405,6 +428,7 @@ function ListenerPlayerController({ const stopHls = useCallback(() => { hls.current?.destroy(); hls.current = null; + hlsProgramAnchor.current = null; }, []); const attachManifest = useCallback(async (url: string) => { @@ -440,6 +464,15 @@ function ListenerPlayerController({ liveAudio.current?.pause(); automaticRecovery.current(0); }); + instance.on(HlsConstructor.Events.FRAG_CHANGED, (_event, data) => { + const programStartMs = data.frag.programDateTime; + const mediaStartSeconds = data.frag.start; + hlsProgramAnchor.current = typeof programStartMs === 'number' + && Number.isFinite(programStartMs) + && Number.isFinite(mediaStartSeconds) + ? { programStartMs, mediaStartSeconds } + : null; + }); instance.loadSource(url); instance.attachMedia(audio); hls.current = instance; diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index e79954d1..174ce036 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -8,6 +8,7 @@ type HlsTestInstance = { destroy: ReturnType; loadedSources: string[]; emitFatal(): void; + emitFragChanged(programDateTime: number, start: number): void; }; const hlsHarness = vi.hoisted(() => ({ instances: [] as HlsTestInstance[] })); @@ -75,12 +76,15 @@ vi.mock('@/lib/listener/analysis', async (importOriginal) => { }); vi.mock('hls.js', () => { class TestHls { - static Events = { ERROR: 'error' }; + static Events = { ERROR: 'error', FRAG_CHANGED: 'fragChanged' }; static isSupported = () => true; liveSyncPosition: number | null = null; destroy = vi.fn(); loadedSources: string[] = []; private errorHandler: ((_event: string, data: { fatal: boolean }) => void) | null = null; + private fragChangedHandler: ((_event: string, data: { + frag: { programDateTime: number; start: number }; + }) => void) | null = null; constructor() { hlsHarness.instances.push(this); @@ -88,6 +92,9 @@ vi.mock('hls.js', () => { on(event: string, handler: (_event: string, data: { fatal: boolean }) => void) { if (event === TestHls.Events.ERROR) this.errorHandler = handler; + if (event === TestHls.Events.FRAG_CHANGED) { + this.fragChangedHandler = handler as unknown as typeof this.fragChangedHandler; + } } loadSource(url: string) { @@ -99,6 +106,12 @@ vi.mock('hls.js', () => { emitFatal() { this.errorHandler?.('error', { fatal: true }); } + + emitFragChanged(programDateTime: number, start: number) { + this.fragChangedHandler?.('fragChanged', { + frag: { programDateTime, start }, + }); + } } return { default: TestHls }; }); @@ -106,6 +119,7 @@ import ListenerPlayer, { acceptsLeaseCursor, earlyBirdLeaseRecoveryDisposition, getOrCreateEarlyBirdDeviceId, + hlsFragmentProgramTimeMs, LISTENER_HLS_BUFFER_CONFIG, LISTENER_PLAYBACK_PRESENCE_EVENT, nativeHlsProgramTimeMs, @@ -189,6 +203,15 @@ describe('EarlyBird Listener player', () => { expect(analysis.options.framesPerSecond).toBe(4); expect(screen.queryByRole('checkbox', { name: 'Reactive field · experimental' })).toBeNull(); + const beaconAudio = screen.getByLabelText('Beacon'); + Object.defineProperty(beaconAudio, 'currentTime', { value: 191.75, configurable: true }); + hlsHarness.instances[0].emitFragChanged( + Date.parse('2026-08-09T11:35:17.282Z'), + 186, + ); + expect(analysis.options.getPlaybackProgramTimeMs()) + .toBe(Date.parse('2026-08-09T11:35:23.032Z')); + const englishIntro = screen.getByLabelText('Warm-up · English'); Object.defineProperty(englishIntro, 'ended', { value: true, configurable: true }); fireEvent.ended(englishIntro); @@ -506,6 +529,14 @@ describe('EarlyBird Listener player', () => { expect(nativeHlsProgramTimeMs({ currentTime: 12.25 })).toBeNull(); }); + it('maps HLS media time from the exact audible fragment program date', () => { + expect(hlsFragmentProgramTimeMs(191.75, { + mediaStartSeconds: 186, + programStartMs: Date.parse('2026-08-09T11:35:17.282Z'), + })).toBe(Date.parse('2026-08-09T11:35:23.032Z')); + expect(hlsFragmentProgramTimeMs(191.75, null)).toBeNull(); + }); + it('keeps the connection identifier stable per tab without sharing it across tabs', () => { const first = getOrCreateEarlyBirdDeviceId(window.sessionStorage); const second = getOrCreateEarlyBirdDeviceId(window.sessionStorage); diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts index 0b4bbe88..257ae610 100644 --- a/src/lib/listener/__tests__/media-boundary.test.ts +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -10,7 +10,7 @@ const MEDIA_FILE_SHA256 = { // media when the field is toggled. The staging-only lab now starts visible; // HLS parameters, source URLs, media assets, element gain/fades and event // audio remain unchanged. - 'src/components/early-birds/ListenerPlayer.tsx': '95fb0c835091c4e0d214c7c763ad2a0f50f4a85ccdf3e08f8ae258dfc32c3249', + 'src/components/early-birds/ListenerPlayer.tsx': 'af5c3fcf3d860d015bfff5e7b523215d1751d61e71330ccf1ab908b53178858c', 'src/lib/early-birds/stream.ts': '96a2d9fe798591833327631b59a73a5b2fc5ca06be7081945a0b07450970da84', 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', 'src/app/api/early-birds/stream/lease/route.ts': 'ec0e8780387bc1f493eb33d13a2d90e01cfdb6d899fc6232e04f51aaf2dfc508', From c153f36a0c15462f21303f00cec79bc0dbaa0820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 08:56:29 -0300 Subject: [PATCH 180/376] fix(listener): bind remote analysis fetch --- .../__tests__/remote-provider.test.ts | 28 +++++++++++++++++++ src/lib/listener/analysis/remote-provider.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/lib/listener/analysis/__tests__/remote-provider.test.ts b/src/lib/listener/analysis/__tests__/remote-provider.test.ts index e2349d8e..bcb9d4af 100644 --- a/src/lib/listener/analysis/__tests__/remote-provider.test.ts +++ b/src/lib/listener/analysis/__tests__/remote-provider.test.ts @@ -24,6 +24,7 @@ function wireFrame() { afterEach(() => { vi.useRealTimers(); + vi.unstubAllGlobals(); }); describe('RemoteHarmonicAnalysisProvider', () => { @@ -67,6 +68,33 @@ describe('RemoteHarmonicAnalysisProvider', () => { provider.stop(); }); + it('binds the browser native fetch receiver before requesting a frame', async () => { + vi.useFakeTimers(); + const browserFetch = vi.fn(function browserFetch(this: unknown) { + if (this !== window) throw new TypeError('Illegal invocation'); + return Promise.resolve(new Response(JSON.stringify(wireFrame()))); + }); + vi.stubGlobal('fetch', browserFetch); + const listener = vi.fn(); + const provider = new RemoteHarmonicAnalysisProvider({ + endpoint: '/api/listener/analysis/frame', + sources: [{ id: 'beacon', kind: 'beacon' }], + getPlaybackProgramTimeMs: () => 1_786_233_600_125, + getLeaseCursor: () => ({ + leaseId: '00000000-0000-4000-8000-000000000003', + leaseGeneration: 7, + }), + }); + provider.subscribe(listener); + + await provider.start(); + await vi.advanceTimersByTimeAsync(1); + + expect(browserFetch).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledOnce(); + provider.stop(); + }); + it('never fetches while the audible source is an introduction', async () => { vi.useFakeTimers(); const fetcher = vi.fn(); diff --git a/src/lib/listener/analysis/remote-provider.ts b/src/lib/listener/analysis/remote-provider.ts index e2f337cd..85da0b16 100644 --- a/src/lib/listener/analysis/remote-provider.ts +++ b/src/lib/listener/analysis/remote-provider.ts @@ -102,7 +102,7 @@ export class RemoteHarmonicAnalysisProvider implements HarmonicAnalysisProvider this.framesPerSecond = this.validatedFramesPerSecond(options.framesPerSecond ?? 4); this.getPlaybackProgramTimeMs = options.getPlaybackProgramTimeMs; this.getLeaseCursor = options.getLeaseCursor; - this.fetcher = options.fetcher ?? fetch; + this.fetcher = options.fetcher ?? window.fetch.bind(window); this.setTimer = options.setTimer ?? window.setTimeout.bind(window); this.clearTimer = options.clearTimer ?? window.clearTimeout.bind(window); const active = this.sources.find(({ id }) => id === activeSourceId)!; From 1f8368d2fda19b30b74c95af884d862838f73305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 09:16:23 -0300 Subject: [PATCH 181/376] feat(listener): publish accepted reactive field --- docs/design/LISTENER_REACTIVE_CAMPFIRE.md | 43 ++++++++++--------- ops/early-birds-preview/compose.yml | 1 + .../listen.harmonicbeacon.com.conf.template | 23 ++++++++++ .../preview.env.synthetic.example | 1 + .../test/preview-contract.test.mjs | 8 ++-- scripts/early-birds-preview/lib.sh | 2 + scripts/early-birds-preview/validate.mjs | 2 + .../analysis/frame/__tests__/route.test.ts | 5 ++- src/app/api/listener/analysis/frame/route.ts | 7 ++- src/app/early-birds/__tests__/page.test.tsx | 25 ++++++++++- src/app/early-birds/page.tsx | 8 +++- src/components/early-birds/EarlyBirdHome.tsx | 3 ++ src/components/early-birds/ListenerPlayer.tsx | 34 +++++++++------ .../__tests__/EarlyBirdHome.test.tsx | 5 +++ .../__tests__/ListenerPlayer.test.tsx | 9 ++++ .../reactive/__tests__/components.test.tsx | 6 +-- .../listener/reactive/__tests__/scene.test.ts | 19 ++++---- .../reactive/__tests__/settings.test.ts | 24 +++++++++++ src/components/listener/reactive/settings.ts | 16 +++---- .../listener/__tests__/media-boundary.test.ts | 7 +-- 20 files changed, 182 insertions(+), 66 deletions(-) diff --git a/docs/design/LISTENER_REACTIVE_CAMPFIRE.md b/docs/design/LISTENER_REACTIVE_CAMPFIRE.md index 8ed67d54..68029aee 100644 --- a/docs/design/LISTENER_REACTIVE_CAMPFIRE.md +++ b/docs/design/LISTENER_REACTIVE_CAMPFIRE.md @@ -1,8 +1,9 @@ # Listener reactive field -The disposable Listener staging workbench can opt into a visualization of the -source that is actually audible. Playback stays on the browser's native HLS and -HTML media path. The experiment is not exposed by `listen.harmonicbeacon.com`. +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 @@ -33,11 +34,11 @@ 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 host, requires the same -active lease authority as the HLS manifest, accepts only the bounded audible -latency window, is edge-rate-limited, returns `no-store`, and is absent from the -public Listener nginx surface. Decode concurrency is globally bounded and the -cache retains the complete accepted timestamp window. Failure is visual-only: +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. @@ -77,20 +78,22 @@ parameters in this build and remain visibly read-only in the laboratory. ## Staging and acceptance -The exact staging host exposes the opt-in and parameter panel. Presets export as -versioned JSON. The current default is the human-selected Radial ribbons Ember -preset: sensitivity 3, -120 dB floor, 24 s baseline, 20 ms attack, 140 ms release, -no movement trails, density 1, upper-detail bias 1, center field 4%, outer-spacing -growth 65%, zoom 100%, activation TTL 8 seconds, ribbon width 3 and FFT 16384. +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 Radial ribbons Ember +default is: sensitivity 3, -70 dB floor, 24 s baseline, 20 ms attack, 220 ms +release, 4 s whole-ribbon trail, density 1, upper-detail bias 0.7, center field +7%, outer-spacing growth 65%, zoom 165%, activation TTL 30 seconds, ribbon width +2.45 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. -Before public exposure, compare direct and visual modes on Chrome, Firefox, -Android and iPhone, including ES/EN introduction handoff, Beacon-only, Stop, -reconnect, headphones, Bluetooth and a 60-minute listen. Confirm that toggling -and renderer failure preserve the same audio element and audible stream. Record -server decode latency/cache behavior, client network cadence, CPU, memory and -frame pacing. `listen.harmonicbeacon.com` remains unchanged until Nico accepts -both the visual result and absence of acoustic degradation. +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/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index d9a8ed5e..0e8b2e92 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -91,6 +91,7 @@ services: 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} BEACON_LISTENER_GEOIP_DB_PATH: /data/geoip/dbip-country-lite.mmdb TRUSTED_PROXY_HOPS: "1" ports: diff --git a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template index da88e048..cdca46b5 100644 --- a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -3,6 +3,7 @@ # 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; server { listen 80; @@ -255,6 +256,28 @@ server { 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; diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example index d5938201..9bf9d803 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -37,6 +37,7 @@ EARLY_BIRDS_TEST_LOGIN_SECRET=synthetic-preview-login-secret-at-least-32-charact # 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 EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com # Local country-only GeoIP data. The reviewed July 2026 DB-IP Lite artifact is diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 4e4d2993..4b083180 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -112,6 +112,7 @@ test('compose gates the loopback Listener on a forward-only isolated database mi 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, /NODE_ENV: production/); assert.match(source, /preview_db:[\s\S]*internal: true/); assert.match(source, /listener_egress:/); @@ -181,8 +182,8 @@ test('nginx templates isolate staging, stream and the constrained public Listene ); assert.equal( (listener.match(/X-Harmonic-Beacon-Environment "listener-public-free"/g) ?? []).length, - 6, - 'server plus five sensitive HTTPS locations retain the environment attestation when add_header inheritance stops', + 7, + 'server plus six 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); @@ -190,7 +191,8 @@ test('nginx templates isolate staging, stream and the constrained public Listene 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, /limit_req_zone \$binary_remote_addr zone=listener_visual_analysis:1m rate=20r\/s;/); assert.match(app, /location = \/api\/listener\/analysis\/frame \{[^}]*limit_req zone=listener_visual_analysis burst=40 nodelay;/s); - assert.doesNotMatch(listener, /location = \/api\/listener\/analysis\/frame/); + 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;/); diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index 8d178306..43687dd6 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -99,6 +99,8 @@ require_synthetic_env() { 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 require_exact_preview_value EARLY_BIRDS_TEST_ACCESS_ENABLED 1 "$env_file" authority_network=$(preview_env_value EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK "$env_file") diff --git a/scripts/early-birds-preview/validate.mjs b/scripts/early-birds-preview/validate.mjs index d5c582bc..e8dc2360 100644 --- a/scripts/early-birds-preview/validate.mjs +++ b/scripts/early-birds-preview/validate.mjs @@ -32,6 +32,7 @@ const syntheticEnv = [ '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', 'EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com', 'BEACON_LISTENER_GEOIP_HOST_PATH=.', 'EARLY_BIRDS_AUTHORITY_BASE_URL=https://authority.example.invalid', @@ -102,6 +103,7 @@ try { 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.EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS, 'earlybirds-staging.harmonicbeacon.com', diff --git a/src/app/api/listener/analysis/frame/__tests__/route.test.ts b/src/app/api/listener/analysis/frame/__tests__/route.test.ts index 9da6bb08..d11086bc 100644 --- a/src/app/api/listener/analysis/frame/__tests__/route.test.ts +++ b/src/app/api/listener/analysis/frame/__tests__/route.test.ts @@ -58,7 +58,7 @@ describe('GET /api/listener/analysis/frame', () => { }); }); - it('serves no-store frames only on the exact staging host', async () => { + 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'); @@ -70,8 +70,9 @@ describe('GET /api/listener/analysis/frame', () => { ); expect(analyzer.frameAt).toHaveBeenCalledOnce(); + expect((await GET(request('listen.harmonicbeacon.com'))).status).toBe(200); + for (const host of [ - 'listen.harmonicbeacon.com', 'live.harmonicbeacon.com', 'earlybirds-staging.harmonicbeacon.com.evil.test', ]) { diff --git a/src/app/api/listener/analysis/frame/route.ts b/src/app/api/listener/analysis/frame/route.ts index 8dd4667a..5927d262 100644 --- a/src/app/api/listener/analysis/frame/route.ts +++ b/src/app/api/listener/analysis/frame/route.ts @@ -1,4 +1,7 @@ -import { isListenerStagingHost } from '@/lib/listener/public-discovery'; +import { + isCanonicalListenerHost, + isListenerStagingHost, +} from '@/lib/listener/public-discovery'; import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; import { earlyBirdsEnabled, earlyBirdsFreeForAll } from '@/lib/early-birds/enabled'; import { @@ -22,7 +25,7 @@ const MAX_AUDIBLE_LATENCY_MS = 90_000; const MAX_FUTURE_SKEW_MS = 5_000; export async function GET(request: Request) { - if (!isListenerStagingHost(request.headers)) { + if (!isCanonicalListenerHost(request.headers) && !isListenerStagingHost(request.headers)) { return new Response('not found\n', { status: 404, headers: NO_STORE_HEADERS }); } if (!earlyBirdsEnabled()) { diff --git a/src/app/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx index eb146757..e5913dbf 100644 --- a/src/app/early-birds/__tests__/page.test.tsx +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -69,7 +69,7 @@ describe('EarlyBird Listener page', () => { expect(mocks.getEarlyBirdListeningAccess).not.toHaveBeenCalled(); }); - it('exposes the reactive experiment only on the exact staging hostname', async () => { + it('exposes the field on exact Listener hosts while keeping the lab default-off', async () => { vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); mocks.headers.mockResolvedValue(new Headers({ @@ -81,6 +81,29 @@ describe('EarlyBird Listener page', () => { expect(result.type).toBe(EarlyBirdHome); expect(result.props).toMatchObject({ reactiveVisualizationAvailable: true, + reactiveFieldLabAvailable: false, + }); + + mocks.headers.mockResolvedValue(new Headers({ host: 'listen.harmonicbeacon.com' })); + const publicResult = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + expect(publicResult.props).toMatchObject({ + reactiveVisualizationAvailable: true, + 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, }); }); diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index a431e85f..de1012eb 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -43,13 +43,18 @@ export default async function EarlyBirdsPage({ }) { if (!earlyBirdsEnabled()) return ; const incomingHeaders = new Headers(await requestHeaders()); - const reactiveVisualizationAvailable = isListenerStagingHost(incomingHeaders); + const listenerStagingHost = isListenerStagingHost(incomingHeaders); + const reactiveVisualizationAvailable = listenerStagingHost + || isCanonicalListenerHost(incomingHeaders); + const reactiveFieldLabAvailable = listenerStagingHost + && process.env.BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED === '1'; if (earlyBirdsFreeForAll()) { return ( {!publicAccess && accessKind === 'free-quota' && (
diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 6d41d3db..961c7165 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -198,6 +198,7 @@ type ListenerPlayerProps = { dropIns: { es: string | null; en: string | null }; reactiveVisualizationAvailable?: boolean; reactiveVisualizationInitiallyEnabled?: boolean; + reactiveFieldLabAvailable?: boolean; }; type ListenerPlayerControllerProps = ListenerPlayerProps & { @@ -214,6 +215,7 @@ const subscribeRuntimeVisualizationCapability = () => () => undefined; function ListenerPlayerController({ dropIns, reactiveVisualizationAvailable = false, + reactiveFieldLabAvailable = false, reactiveVisualizationEnabled, reactiveSettings, onReactiveSettingsChange, @@ -1586,14 +1588,14 @@ function ListenerPlayerController({

}
- {reactiveFallbackNotice && ( + {reactiveFieldLabAvailable && reactiveFallbackNotice && (

{locale === 'es' ? 'El campo reactivo no pudo iniciarse. El reproductor directo está listo.' : 'The reactive field could not start. Direct playback is ready.'}

)} - {reactiveVisualizationAvailable && !transportActive && ( + {reactiveFieldLabAvailable && reactiveVisualizationAvailable && !transportActive && (
- onReactiveSettingsChange({ - ...next, - fftSize: reactiveSettings.fftSize, - baselineDurationSeconds: reactiveSettings.baselineDurationSeconds, - })} - /> + {reactiveFieldLabAvailable && ( + onReactiveSettingsChange({ + ...next, + fftSize: reactiveSettings.fftSize, + baselineDurationSeconds: reactiveSettings.baselineDurationSeconds, + })} + /> + )}
@@ -1760,6 +1764,7 @@ export default function ListenerPlayer({ dropIns, reactiveVisualizationAvailable = false, reactiveVisualizationInitiallyEnabled = false, + reactiveFieldLabAvailable = false, }: ListenerPlayerProps) { const [reactiveVisualizationEnabled, setReactiveVisualizationEnabled] = useState( reactiveVisualizationAvailable && reactiveVisualizationInitiallyEnabled, @@ -1780,6 +1785,7 @@ export default function ListenerPlayer({ ({ default: ({ reactiveVisualizationAvailable, reactiveVisualizationInitiallyEnabled, + reactiveFieldLabAvailable, }: { reactiveVisualizationAvailable?: boolean; reactiveVisualizationInitiallyEnabled?: boolean; + reactiveFieldLabAvailable?: boolean; }) => (
), })); @@ -79,6 +82,7 @@ describe('EarlyBird Listener home access chrome', () => { { expect(screen.getByLabelText('listener-player')).toHaveAttribute('data-reactive-available', 'true'); expect(screen.getByLabelText('listener-player')).toHaveAttribute('data-reactive-initially-enabled', 'true'); + expect(screen.getByLabelText('listener-player')).toHaveAttribute('data-reactive-lab', 'true'); }); it('presents a normalized Founder status and provider without raw membership source', () => { diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index 174ce036..1b01886e 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -172,6 +172,7 @@ describe('EarlyBird Listener player', () => { , @@ -229,6 +230,7 @@ describe('EarlyBird Listener player', () => { @@ -267,6 +269,7 @@ describe('EarlyBird Listener player', () => { , @@ -294,6 +297,7 @@ describe('EarlyBird Listener player', () => { , @@ -334,6 +338,7 @@ describe('EarlyBird Listener player', () => { , @@ -375,6 +380,7 @@ describe('EarlyBird Listener player', () => { , @@ -415,6 +421,7 @@ describe('EarlyBird Listener player', () => { , @@ -468,6 +475,7 @@ describe('EarlyBird Listener player', () => { , @@ -509,6 +517,7 @@ describe('EarlyBird Listener player', () => { , diff --git a/src/components/listener/reactive/__tests__/components.test.tsx b/src/components/listener/reactive/__tests__/components.test.tsx index 6720f2b7..86055cd0 100644 --- a/src/components/listener/reactive/__tests__/components.test.tsx +++ b/src/components/listener/reactive/__tests__/components.test.tsx @@ -147,9 +147,9 @@ describe('reactive campfire components', () => { expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ visualizationMode: 'horizon-flow', })); - expect(screen.getByText(/4% center · 96% outer/)).toBeInTheDocument(); - expect(screen.getByLabelText(/Zoom/i)).toHaveValue('100'); - expect(screen.getByLabelText(/Activation TTL/i)).toHaveValue('8'); + expect(screen.getByText(/7% center · 93% outer/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Zoom/i)).toHaveValue('165'); + expect(screen.getByLabelText(/Activation TTL/i)).toHaveValue('30'); }); it('makes server-owned analysis parameters explicit and read-only', () => { diff --git a/src/components/listener/reactive/__tests__/scene.test.ts b/src/components/listener/reactive/__tests__/scene.test.ts index 1fda9f8e..9420f4f1 100644 --- a/src/components/listener/reactive/__tests__/scene.test.ts +++ b/src/components/listener/reactive/__tests__/scene.test.ts @@ -57,6 +57,7 @@ describe('reactive campfire scene', () => { density: 1, highDetail: 1, sensitivity: 3, + absoluteFloorDb: -120, centerCutPercent: 16, }); const strongLow = scene.rings.find((ring) => ring.harmonicIndex === 2); @@ -66,7 +67,7 @@ describe('reactive campfire scene', () => { expect(quietHigh).toBeDefined(); expect(quietHigh!.opacity).toBeLessThan(strongLow!.opacity); expect(quietHigh!.weight).toBeLessThan(strongLow!.weight); - expect(quietHigh!.emphasis).toBeLessThan(absoluteEnergy(absolute[50])); + expect(quietHigh!.emphasis).toBeLessThan(absoluteEnergy(absolute[50], -120)); }); it('settles to a truthful rest state for silence or a fully decayed stop', () => { @@ -156,18 +157,18 @@ describe('reactive campfire scene', () => { it('retains stable defaults in the deterministic fixture', () => { expect(DEFAULT_REACTIVE_CAMPFIRE_SETTINGS).toMatchObject({ sensitivity: 3, - absoluteFloorDb: -120, + absoluteFloorDb: -70, baselineDurationSeconds: 24, attackMs: 20, - releaseMs: 140, - trailSeconds: 0, + releaseMs: 220, + trailSeconds: 4, density: 1, - highDetail: 1, - centerCutPercent: 4, + highDetail: 0.7, + centerCutPercent: 7, radialSpacingGrowthPercent: 65, - zoomPercent: 100, - activationTtlSeconds: 8, - ribbonWidth: 3, + zoomPercent: 165, + activationTtlSeconds: 30, + ribbonWidth: 2.45, palette: 'ember', visualizationMode: 'radial-ribbons', fftSize: 16_384, diff --git a/src/components/listener/reactive/__tests__/settings.test.ts b/src/components/listener/reactive/__tests__/settings.test.ts index 865a56bc..ba25755d 100644 --- a/src/components/listener/reactive/__tests__/settings.test.ts +++ b/src/components/listener/reactive/__tests__/settings.test.ts @@ -7,6 +7,30 @@ import { } from '../settings'; describe('reactive campfire settings', () => { + it('uses the accepted public radial-ribbons preset exactly', () => { + expect(JSON.parse(serializeReactiveCampfirePreset( + DEFAULT_REACTIVE_CAMPFIRE_SETTINGS, + ))).toEqual({ + schemaVersion: 1, + sensitivity: 3, + absoluteFloorDb: -70, + baselineDurationSeconds: 24, + attackMs: 20, + releaseMs: 220, + trailSeconds: 4, + density: 1, + highDetail: 0.7, + centerCutPercent: 7, + radialSpacingGrowthPercent: 65, + zoomPercent: 165, + activationTtlSeconds: 30, + ribbonWidth: 2.45, + palette: 'ember', + visualizationMode: 'radial-ribbons', + fftSize: 16_384, + }); + }); + it('clamps unsafe numeric input and rejects unknown enumerations', () => { const settings = validateReactiveCampfireSettings({ sensitivity: Number.NaN, diff --git a/src/components/listener/reactive/settings.ts b/src/components/listener/reactive/settings.ts index fea391b1..08dd9fd0 100644 --- a/src/components/listener/reactive/settings.ts +++ b/src/components/listener/reactive/settings.ts @@ -30,18 +30,18 @@ export type ReactiveCampfireSettings = { export const DEFAULT_REACTIVE_CAMPFIRE_SETTINGS: Readonly = Object.freeze({ sensitivity: 3, - absoluteFloorDb: -120, + absoluteFloorDb: -70, baselineDurationSeconds: 24, attackMs: 20, - releaseMs: 140, - trailSeconds: 0, + releaseMs: 220, + trailSeconds: 4, density: 1, - highDetail: 1, - centerCutPercent: 4, + highDetail: 0.7, + centerCutPercent: 7, radialSpacingGrowthPercent: 65, - zoomPercent: 100, - activationTtlSeconds: 8, - ribbonWidth: 3, + zoomPercent: 165, + activationTtlSeconds: 30, + ribbonWidth: 2.45, palette: 'ember', visualizationMode: 'radial-ribbons', fftSize: 16_384, diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts index 257ae610..40bc3ac6 100644 --- a/src/lib/listener/__tests__/media-boundary.test.ts +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -5,12 +5,13 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const MEDIA_FILE_SHA256 = { - // Reviewed and re-pinned for staging-only remote harmonic frames. The + // Reviewed and re-pinned for the public server-side harmonic field. The // browser no longer creates Web Audio nodes, adds crossOrigin or remounts - // media when the field is toggled. The staging-only lab now starts visible; + // media for analysis. This change only hides the tuning lab behind a + // server-side staging flag while leaving the accepted field enabled; // HLS parameters, source URLs, media assets, element gain/fades and event // audio remain unchanged. - 'src/components/early-birds/ListenerPlayer.tsx': 'af5c3fcf3d860d015bfff5e7b523215d1751d61e71330ccf1ab908b53178858c', + 'src/components/early-birds/ListenerPlayer.tsx': 'fd80bb558317c78c983e1ae623b7697bb778b44def0bafe0d0b4ca4e421cd358', 'src/lib/early-birds/stream.ts': '96a2d9fe798591833327631b59a73a5b2fc5ca06be7081945a0b07450970da84', 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', 'src/app/api/early-birds/stream/lease/route.ts': 'ec0e8780387bc1f493eb33d13a2d90e01cfdb6d899fc6232e04f51aaf2dfc508', From 66fef242013b6db5f4b5064b376e56251054bd9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 09:29:07 -0300 Subject: [PATCH 182/376] docs(listener): record public reactive release --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 13 ++++++++++++ .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 20 +++++++++++-------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 5355f5c5..6037d437 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,5 +1,18 @@ # EarlyBirds isolated staging runtime +## 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 diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index ac5a53ca..ebf87284 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -14,7 +14,7 @@ an event-stack deployment or an acoustic change. ## Status: weekly Free deployed for acceptance -Release `ae1d0baa4527a74c2908f84692e677f5b96f615a` is deployed on the isolated +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 @@ -32,7 +32,7 @@ repair, never restoring daily/welcome authorization. | Artifact | Exact value | |---|---| -| Deployed Listener application | `ae1d0baa4527a74c2908f84692e677f5b96f615a` | +| Deployed Listener application | `1f8368d2fda19b30b74c95af884d862838f73305` | | Operational smoke/documentation head | `8444ed7d06b2764c519f65ce4d32932346a94fdd` | | Listener database schema | `20260808160000_listener_weekly_quota` | | Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | @@ -67,12 +67,13 @@ test-only branch head. | 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; this registration slice changed no asset, codec, rate, channel, gain, fade, buffer, routing or event audio. | +| 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,401 tests with 28 standard skips, ESLint, TypeScript, build, Prisma, real PostgreSQL, preview, origin, nginx and observability checks are green. | +| 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 @@ -88,6 +89,8 @@ test-only branch head. 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: @@ -119,8 +122,9 @@ deployed image; later documentation-only commits do not require rebuilding it. - 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 `ae1d0ba`. Earlier policy images remain only as historical - artifacts and are not valid rollback targets. +- Current image is `1f8368d`. Image `ae1d0ba` remains the same-schema recovery + target for this visual-only release; earlier policy images are historical and + are not valid rollback targets. - 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. @@ -146,8 +150,8 @@ deployed image; later documentation-only commits do not require rebuilding it. - #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 remains feature-flagged pending visual acceptance and - does not block the minimal public Listener. +- #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. ## Remaining human sequence From 16bea0e9cb283dd3834af222ef46a4a3c1adb03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 09:57:02 -0300 Subject: [PATCH 183/376] feat(listener): stage sandbox membership checkout --- contracts/early-bird-checkout/v2/SHA256SUMS | 2 +- .../v2/checkout-create.schema.json | 7 +- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 10 +- docs/plans/EARLY_BIRDS.md | 9 +- ops/early-birds-preview/compose.yml | 2 + ...s-staging.harmonicbeacon.com.conf.template | 66 ++++++ .../preview.env.synthetic.example | 2 + .../test/preview-contract.test.mjs | 33 ++- scripts/early-birds-preview/lib.sh | 4 + .../listener/checkout/__tests__/route.test.ts | 126 +++++++++++ src/app/api/listener/checkout/route.ts | 90 ++++++++ src/app/early-birds/page.tsx | 6 + src/app/globals.css | 30 +++ src/components/early-birds/EarlyBirdHome.tsx | 11 +- .../early-birds/EarlyBirdLanding.tsx | 10 + .../early-birds/FoundingListenerCheckout.tsx | 77 +++++++ .../FoundingListenerCheckout.test.tsx | 55 +++++ .../__tests__/checkout-contract.test.ts | 8 +- .../early-birds/__tests__/checkout.test.ts | 122 +++++++++++ src/lib/early-birds/checkout.ts | 203 ++++++++++++++++++ src/lib/early-birds/copy.ts | 12 ++ 21 files changed, 869 insertions(+), 16 deletions(-) create mode 100644 src/app/api/listener/checkout/__tests__/route.test.ts create mode 100644 src/app/api/listener/checkout/route.ts create mode 100644 src/components/early-birds/FoundingListenerCheckout.tsx create mode 100644 src/components/early-birds/__tests__/FoundingListenerCheckout.test.tsx create mode 100644 src/lib/early-birds/__tests__/checkout.test.ts create mode 100644 src/lib/early-birds/checkout.ts diff --git a/contracts/early-bird-checkout/v2/SHA256SUMS b/contracts/early-bird-checkout/v2/SHA256SUMS index 3362fb01..7da2d4c8 100644 --- a/contracts/early-bird-checkout/v2/SHA256SUMS +++ b/contracts/early-bird-checkout/v2/SHA256SUMS @@ -1,3 +1,3 @@ a0008e822c60c4d8b7804da90c411c7f07a1b0457b74192260a5d8a14617a191 README.md 128a8b6e1e91604db276ba4b9e4bc8f592ddacadffec19b2d2f037f1dc8d9c87 checkout-create.fixture.json -1e3cbcc19b723ba5fb52ee9b4134533c8ae6f4626d9d884787f5d6585aa5156a checkout-create.schema.json +c3e274f4cdc94ffece082382fbe2d063bb6f55e21aeba7df94a396a52871c67a checkout-create.schema.json diff --git a/contracts/early-bird-checkout/v2/checkout-create.schema.json b/contracts/early-bird-checkout/v2/checkout-create.schema.json index e72289b7..b121ae38 100644 --- a/contracts/early-bird-checkout/v2/checkout-create.schema.json +++ b/contracts/early-bird-checkout/v2/checkout-create.schema.json @@ -7,7 +7,12 @@ "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", "pattern": "^acct_[A-Za-z0-9_-]{16,128}$"}, + "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}, diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index ebf87284..bcbe4caa 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -137,8 +137,8 @@ deployed image; later documentation-only commits do not require rebuilding it. - #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 remains open: separately approved paid-provider rollout; canonical - membership consumption itself is complete. +- #197 is closed: provider-neutral membership and Founder eligibility are + integrated. Provider acceptance remains separately tracked by #199/#200/#261. - #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 @@ -152,6 +152,12 @@ deployed image; later documentation-only commits do not require rebuilding it. - #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 remain open for real sandbox lifecycle evidence. Both adapters are + merged backend-side and default-off. Backend draft PR #57 and the paired + Beacon slice version isolated egress, exact staging webhook ingress and a + session-derived checkout that is visible only on staging when its provider + flag is explicitly enabled. No provider credential, checkout flag or sale is + active in the deployed Listener. ## Remaining human sequence diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 3869d70b..ae170eb8 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -467,10 +467,11 @@ The webapp vendors byte-exact copies of the canonical backend contracts under the canonical founder membership reads. - `contracts/early-bird-checkout/v2`: the Mercado Pago checkout command for `POST /api/internal/v2/early-bird-checkouts`, vendored byte-exact from - backend commit `f4b19a5dee8b2054a59284c719450f72d99af8aa`, merged to backend - main as `1d555aab9ca48d7374a577b40243fb9d43ccc067` - (SairaAsua/proyecciones-mito#54). The provider remains TEST-only and disabled - by default; no public checkout UI or sales activation is part of this slice. + reviewed backend draft `4929b2463c5943d6705409f1e9391331be280f7b` + (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. Mandatory external gate before any paid activation: real Mercado Pago TEST proof that `/preapproval/search?q=hb_` returns the exact created preapproval. diff --git a/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index 0e8b2e92..d9ac75d8 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -92,6 +92,8 @@ services: 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} + 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} BEACON_LISTENER_GEOIP_DB_PATH: /data/geoip/dbip-country-lite.mmdb TRUSTED_PROXY_HOPS: "1" ports: 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 index 474b800c..944cb659 100644 --- a/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -1,6 +1,9 @@ # 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; +log_format listener_payment_webhook '$remote_addr - $request_method $uri $status $body_bytes_sent'; server { listen 80; @@ -102,6 +105,45 @@ server { 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 is the mutable UI workbench. It targets the # disposable Next dev runtime on 13001; the accepted Listener release stays # on 13000 behind listen.harmonicbeacon.com. @@ -246,6 +288,30 @@ server { proxy_read_timeout 30s; } + # Authenticated browser checkout exists only in the sandbox workbench and + # is independently fail-closed inside the application. The public Listener + # vhost has no matching location until a separately approved sales cutover. + 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; + } + # Renderer-neutral frames for the disposable visual workbench. The public # Listener edge intentionally has no matching location. location = /api/listener/analysis/frame { diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example index 9bf9d803..77c0a6f7 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -38,6 +38,8 @@ EARLY_BIRDS_TEST_LOGIN_SECRET=synthetic-preview-login-secret-at-least-32-charact # window, then enable it together with EARLY_BIRDS_ENABLED. EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0 BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED=0 +BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED=0 +BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED=0 EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com # Local country-only GeoIP data. The reviewed July 2026 DB-IP Lite artifact is diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 4b083180..527b7393 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -31,6 +31,8 @@ test('synthetic guard accepts the example and rejects unsafe effective values', ['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 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/], ['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/], @@ -113,11 +115,13 @@ test('compose gates the loopback Listener on a forward-only isolated database mi 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, /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, /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|mercadopago|checkout/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'); @@ -168,7 +172,7 @@ test('nginx templates isolate staging, stream and the constrained public Listene ]); 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)$/.test(target))); + 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/); @@ -177,8 +181,8 @@ test('nginx templates isolate staging, stream and the constrained public Listene assert.match(app, /location \^~ \/api\/early-birds\//); assert.equal( (app.match(/X-Harmonic-Beacon-Environment "early-birds-staging"/g) ?? []).length, - 7, - 'server plus six sensitive HTTPS staging locations retain the environment attestation when add_header inheritance stops', + 8, + 'server plus seven 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, @@ -189,7 +193,28 @@ test('nginx templates isolate staging, stream and the constrained public Listene 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); 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.doesNotMatch(listener, /location = \/api\/listener\/checkout/); 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); + assert.doesNotMatch(listener, /127\.0\.0\.1:18876|\/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); diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index 43687dd6..b816ce92 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -101,6 +101,10 @@ require_synthetic_env() { 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 + 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 EARLY_BIRDS_TEST_ACCESS_ENABLED 1 "$env_file" authority_network=$(preview_env_value EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK "$env_file") 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..de310acb --- /dev/null +++ b/src/app/api/listener/checkout/__tests__/route.test.ts @@ -0,0 +1,126 @@ +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'); + 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, email and callbacks from the session and exact staging origin', 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', + email: 'listener@example.com', + provider: 'paypal', + attemptId: ATTEMPT, + returnUrl: `https://${HOST}/?checkout=returned`, + cancelUrl: `https://${HOST}/?checkout=cancelled`, + }); + }); + + it.each([ + ['canonical public host', 'https://listen.harmonicbeacon.com', 'listen.harmonicbeacon.com'], + ['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, 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], + ])('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/route.ts b/src/app/api/listener/checkout/route.ts new file mode 100644 index 00000000..97770a4c --- /dev/null +++ b/src/app/api/listener/checkout/route.ts @@ -0,0 +1,90 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; +import { + HttpListenerCheckoutGateway, + listenerCheckoutAvailability, + ListenerCheckoutUnavailableError, + type ListenerCheckoutProvider, +} from '@/lib/early-birds/checkout'; +import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; +import { isListenerStagingHost } from '@/lib/listener/public-discovery'; + +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 requestOrigin(request: NextRequest): string | null { + const host = request.headers.get('host')?.trim().toLowerCase(); + const protocol = request.headers.get('x-forwarded-proto')?.trim().toLowerCase(); + if (!host || protocol !== 'https' || !isListenerStagingHost(request.headers)) return null; + const expected = `https://${host}`; + return request.headers.get('origin') === expected ? expected : 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 origin = requestOrigin(request); + if (!origin) 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') !== ['attemptId', 'provider'].join('\0')) { + return json({ error: 'Invalid request.' }, 400); + } + const body = input as Record; + const provider = providerFrom(body.provider); + const attemptId = typeof body.attemptId === 'string' ? body.attemptId : ''; + 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); + } + + const available = listenerCheckoutAvailability(); + 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, + email: session.user.email, + provider, + attemptId, + returnUrl: `${origin}/?checkout=returned`, + cancelUrl: `${origin}/?checkout=cancelled`, + }); + 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/early-birds/page.tsx b/src/app/early-birds/page.tsx index de1012eb..5440a743 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -16,6 +16,7 @@ import { syntheticTeamEntryAllowed } from '@/lib/early-birds/synthetic-team-entr import { configuredEarlyBirdDropIn } from '@/lib/early-birds/drop-ins'; import { earlyBirdMagicLinkAvailable } from '@/lib/early-birds/magic-link'; import { serializeEarlyBirdQuotaSnapshot } from '@/lib/early-birds/quota'; +import { listenerCheckoutAvailability } from '@/lib/early-birds/checkout'; import { listenerMembershipPresentation } from '@/lib/early-birds/membership-presentation'; import { isCanonicalListenerHost, @@ -48,6 +49,9 @@ export default async function EarlyBirdsPage({ || isCanonicalListenerHost(incomingHeaders); const reactiveFieldLabAvailable = listenerStagingHost && process.env.BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED === '1'; + const checkoutAvailability = listenerStagingHost + ? listenerCheckoutAvailability() + : { paypal: false, mercadoPago: false }; if (earlyBirdsFreeForAll()) { return ( @@ -89,6 +93,7 @@ export default async function EarlyBirdsPage({ membership={listenerMembershipPresentation(access.membership.projection)} accessKind={access.kind === 'free-quota' ? 'free-quota' : 'membership'} quota={access.quota ? serializeEarlyBirdQuotaSnapshot(access.quota) : null} + checkoutAvailability={checkoutAvailability} serverNow={access.serverNow.toISOString()} dropIns={{ es: configuredEarlyBirdDropIn('es'), @@ -120,6 +125,7 @@ export default async function EarlyBirdsPage({ emailMagicLinkAvailable={emailMagicLinkAvailable} syntheticTeamEntryAvailable={syntheticTeamEntryAvailable} quota={access?.quota ? serializeEarlyBirdQuotaSnapshot(access.quota) : null} + checkoutAvailability={checkoutAvailability} membership={listenerMembershipPresentation(access?.membership.projection ?? null)} serverNow={access?.serverNow.toISOString() ?? new Date().toISOString()} /> diff --git a/src/app/globals.css b/src/app/globals.css index f184f213..29adaffa 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1829,6 +1829,36 @@ body { font-size: 0.7rem; } +.listener-checkout { + margin-top: 0.65rem; + color: var(--text-secondary); + font-size: 0.75rem; + text-align: center; +} + +.listener-checkout summary { + width: fit-content; + margin-inline: auto; + color: var(--gold); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 0.24em; +} + +.listener-checkout__options { + display: grid; + gap: 0.7rem; + margin-top: 0.8rem; + padding: 0.9rem; + border: 1px solid var(--border-subtle); + border-radius: 0.9rem; + background: rgba(13, 33, 26, 0.88); +} + +.listener-checkout__options strong { color: var(--paper); } +.listener-checkout__options p { line-height: 1.45; } +.listener-checkout__options [role="alert"] { color: #fecaca; } + .listener-membership-status { display: grid; gap: 0.45rem; diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index f8abfc79..723d4ee9 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -9,6 +9,7 @@ import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; import ListenerPlayer from './ListenerPlayer'; import FreeQuotaStatus from './FreeQuotaStatus'; +import FoundingListenerCheckout from './FoundingListenerCheckout'; import type { SerializedEarlyBirdQuotaSnapshot } from './free-quota'; export default function EarlyBirdHome({ @@ -21,6 +22,7 @@ export default function EarlyBirdHome({ reactiveVisualizationAvailable = false, reactiveFieldLabAvailable = false, quota = null, + checkoutAvailability = { paypal: false, mercadoPago: false }, }: { displayName: string; membership: ListenerMembershipPresentation; @@ -31,6 +33,7 @@ export default function EarlyBirdHome({ reactiveVisualizationAvailable?: boolean; reactiveFieldLabAvailable?: boolean; quota?: SerializedEarlyBirdQuotaSnapshot | null; + checkoutAvailability?: { paypal: boolean; mercadoPago: boolean }; }) { const { locale } = useLocale(); const copy = earlyBirdHomeCopy[locale]; @@ -78,7 +81,13 @@ export default function EarlyBirdHome({ /> {!publicAccess && accessKind === 'free-quota' && (
- + +
)}
diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index e1dca201..10af51c3 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -11,6 +11,7 @@ import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; import BeaconField from './BeaconField'; import FreeQuotaStatus from './FreeQuotaStatus'; +import FoundingListenerCheckout from './FoundingListenerCheckout'; import SyntheticTeamEntryForm from './SyntheticTeamEntryForm'; import type { SerializedEarlyBirdQuotaSnapshot } from './free-quota'; @@ -26,6 +27,7 @@ type Props = { quota?: SerializedEarlyBirdQuotaSnapshot | null; membership: ListenerMembershipPresentation; serverNow: string; + checkoutAvailability?: { paypal: boolean; mercadoPago: boolean }; }; export default function EarlyBirdLanding(props: Props) { @@ -170,7 +172,15 @@ export default function EarlyBirdLanding(props: Props) { + )} + )} + {available.mercadoPago && ( + + )} + {failed &&

{copy.checkoutUnavailable}

} +
+ + ); +} diff --git a/src/components/early-birds/__tests__/FoundingListenerCheckout.test.tsx b/src/components/early-birds/__tests__/FoundingListenerCheckout.test.tsx new file mode 100644 index 00000000..7ef47674 --- /dev/null +++ b/src/components/early-birds/__tests__/FoundingListenerCheckout.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocaleProvider } from '@/context/LocaleContext'; + +import FoundingListenerCheckout from '../FoundingListenerCheckout'; + +const attemptId = '123e4567-e89b-42d3-a456-426614174000'; + +beforeEach(() => { + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue(attemptId); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('Founding Listener sandbox checkout', () => { + it('renders nothing while every provider is disabled', () => { + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('sends only provider and a stable retry id without identity or price fields', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response( + JSON.stringify({ error: 'Checkout unavailable.' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }, + )); + vi.stubGlobal('fetch', fetchMock); + render( + + + , + ); + + fireEvent.click(screen.getByText('Become a member for full access')); + fireEvent.click(screen.getByRole('button', { name: 'Continue with PayPal' })); + await screen.findByRole('alert'); + fireEvent.click(screen.getByRole('button', { name: 'Continue with PayPal' })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + const first = JSON.parse(fetchMock.mock.calls[0][1].body) as Record; + const second = JSON.parse(fetchMock.mock.calls[1][1].body) as Record; + expect(first).toEqual({ provider: 'paypal', attemptId }); + expect(second).toEqual(first); + expect(JSON.stringify(first)).not.toMatch(/email|account|price|currency/i); + }); +}); diff --git a/src/lib/early-birds/__tests__/checkout-contract.test.ts b/src/lib/early-birds/__tests__/checkout-contract.test.ts index 9d03ec92..b5273c70 100644 --- a/src/lib/early-birds/__tests__/checkout-contract.test.ts +++ b/src/lib/early-birds/__tests__/checkout-contract.test.ts @@ -8,6 +8,8 @@ import { describe, expect, it } from 'vitest'; const REPO_ROOT = fileURLToPath(new URL('../../../../', import.meta.url)); const CONTRACT_DIRS = [ 'contracts/early-bird-authority/v1', + 'contracts/early-bird-authority/v2', + 'contracts/early-bird-checkout/v2', 'contracts/early-bird-membership/v1', ] as const; const AUTHORITY_DIR = `${REPO_ROOT}${CONTRACT_DIRS[0]}`; @@ -103,7 +105,7 @@ describe('early-bird-authority v1 checkout approval_url hardening', () => { }); }); -describe('EarlyBird v1 contract manifests', () => { +describe('Listener authority contract manifests', () => { it.each(CONTRACT_DIRS)('%s SHA256SUMS matches the shipped bytes', (dir) => { const manifest = readFileSync(`${REPO_ROOT}${dir}/SHA256SUMS`, 'utf8'); for (const line of manifest.split('\n').filter(Boolean)) { @@ -117,7 +119,7 @@ describe('EarlyBird v1 contract manifests', () => { }); const BACKEND_REPO = process.env.EARLY_BIRDS_BACKEND_REPO ?? '/home/nicolas/Projects/proyecciones-mito'; -const BACKEND_COMMIT = 'f90ffb92bca34952f3bb41c835014783b6187162'; +const BACKEND_COMMIT = '4929b2463c5943d6705409f1e9391331be280f7b'; const backendAvailable = existsSync(`${BACKEND_REPO}/.git`); // Git hooks (e.g. the pre-commit suite run) export GIT_DIR and friends, which @@ -131,7 +133,7 @@ function canonicalGitEnv(): NodeJS.ProcessEnv { return env; } -describe.skipIf(!backendAvailable)('byte-equivalence with canonical backend f90ffb9', () => { +describe.skipIf(!backendAvailable)(`byte-equivalence with canonical backend ${BACKEND_COMMIT.slice(0, 7)}`, () => { for (const dir of CONTRACT_DIRS) { const files = readdirSync(`${REPO_ROOT}${dir}`).sort(); it.each(files)(`${dir}/%s is byte-identical`, (filename) => { diff --git a/src/lib/early-birds/__tests__/checkout.test.ts b/src/lib/early-birds/__tests__/checkout.test.ts new file mode 100644 index 00000000..06dd368f --- /dev/null +++ b/src/lib/early-birds/__tests__/checkout.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + HttpListenerCheckoutGateway, + listenerCheckoutAvailability, + ListenerCheckoutUnavailableError, +} from '../checkout'; + +const config = { + baseUrl: 'http://pmp-myth-api:8765', + keyId: 'beacon-listener-v1', + token: 't'.repeat(43), +}; +const base = { + accountId: 'betterAuthOpaqueId_123', + email: 'Listener@Example.COM ', + attemptId: '123e4567-e89b-42d3-a456-426614174000', + returnUrl: 'https://earlybirds-staging.harmonicbeacon.com/?checkout=returned', + cancelUrl: 'https://earlybirds-staging.harmonicbeacon.com/?checkout=cancelled', +}; + +function result(provider: 'paypal' | 'mercado_pago', overrides: Record = {}) { + return { + schema_version: 'early-bird-authority.checkout.v1', + account_id: base.accountId, + provider, + external_subscription_id: 'sandbox-subscription-1', + approval_url: provider === 'paypal' + ? 'https://www.sandbox.paypal.com/checkoutnow?token=test' + : 'https://www.mercadopago.com.ar/subscriptions/checkout?preapproval_id=test', + currency: provider === 'paypal' ? 'USD' : 'ARS', + amount_minor: provider === 'paypal' ? 200 : 250000, + sandbox: true, + ...overrides, + }; +} + +describe('Listener sandbox checkout gateway', () => { + it('keeps both providers disabled unless each explicit flag is exactly 1', () => { + expect(listenerCheckoutAvailability({} as NodeJS.ProcessEnv)).toEqual({ + paypal: false, + mercadoPago: false, + }); + expect(listenerCheckoutAvailability({ + NODE_ENV: 'test', + BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED: '1', + BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED: 'true', + } as NodeJS.ProcessEnv)).toEqual({ paypal: true, mercadoPago: false }); + }); + + it('creates PayPal through v1 without sending email and keeps retries idempotent', async () => { + const requests: Request[] = []; + const request = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + requests.push(new Request(input, init)); + return Response.json(result('paypal')); + }); + const gateway = new HttpListenerCheckoutGateway(config, request as typeof fetch); + + await gateway.create({ ...base, provider: 'paypal' }); + await gateway.create({ ...base, provider: 'paypal' }); + + expect(requests).toHaveLength(2); + expect(requests[0].url).toBe('http://pmp-myth-api:8765/api/internal/v1/early-bird-checkouts'); + const body = await requests[0].json(); + expect(body).toEqual({ + schema_version: 'early-bird-authority.checkout-create.v1', + account_id: base.accountId, + provider: 'paypal', + return_url: base.returnUrl, + cancel_url: base.cancelUrl, + }); + expect(JSON.stringify(body)).not.toContain('Listener@'); + expect(requests[0].headers.get('idempotency-key')) + .toBe(requests[1].headers.get('idempotency-key')); + }); + + it('creates Mercado Pago through v2 with only the normalized session email', async () => { + let captured: Request | null = null; + const request = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + captured = new Request(input, init); + return Response.json(result('mercado_pago')); + }); + const gateway = new HttpListenerCheckoutGateway(config, request as typeof fetch); + const created = await gateway.create({ ...base, provider: 'mercado_pago' }); + + expect(created).toEqual({ + provider: 'mercado_pago', + approvalUrl: 'https://www.mercadopago.com.ar/subscriptions/checkout?preapproval_id=test', + }); + expect(captured!.url).toBe('http://pmp-myth-api:8765/api/internal/v2/early-bird-checkouts'); + expect(await captured!.json()).toEqual(expect.objectContaining({ + schema_version: 'early-bird-checkout.checkout-create.v2', + account_id: base.accountId, + provider: 'mercado_pago', + payer_email: 'listener@example.com', + })); + }); + + it.each([ + ['non-sandbox', { sandbox: false }], + ['wrong account', { account_id: 'another-account' }], + ['wrong provider', { provider: 'mercado_pago' }], + ['wrong currency', { currency: 'ARS' }], + ['credential URL', { approval_url: 'https://user@www.sandbox.paypal.com/checkout' }], + ['unofficial URL', { approval_url: 'https://sandbox-paypal.example/checkout' }], + ['unknown property', { buyer_email: 'pii@example.invalid' }], + ])('rejects %s authority responses', async (_label, overrides) => { + const request = vi.fn(async () => Response.json(result('paypal', overrides))); + const gateway = new HttpListenerCheckoutGateway(config, request as typeof fetch); + await expect(gateway.create({ ...base, provider: 'paypal' })) + .rejects.toBeInstanceOf(ListenerCheckoutUnavailableError); + }); + + it('rejects oversized responses before parsing or redirecting', async () => { + const request = vi.fn(async () => new Response('{}', { + headers: { 'Content-Type': 'application/json', 'Content-Length': '70000' }, + })); + const gateway = new HttpListenerCheckoutGateway(config, request as typeof fetch); + await expect(gateway.create({ ...base, provider: 'paypal' })) + .rejects.toBeInstanceOf(ListenerCheckoutUnavailableError); + }); +}); diff --git a/src/lib/early-birds/checkout.ts b/src/lib/early-birds/checkout.ts new file mode 100644 index 00000000..0d267590 --- /dev/null +++ b/src/lib/early-birds/checkout.ts @@ -0,0 +1,203 @@ +import { createHash } from 'node:crypto'; + +import { isEarlyBirdAccountId } from './account-id'; + +export type ListenerCheckoutProvider = 'paypal' | 'mercado_pago'; + +const REQUEST_TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 64 * 1024; + +export class ListenerCheckoutUnavailableError extends Error { + constructor() { + super('Listener sandbox checkout is unavailable'); + this.name = 'ListenerCheckoutUnavailableError'; + } +} + +export type ListenerCheckoutResult = { + provider: ListenerCheckoutProvider; + approvalUrl: string; +}; + +export function listenerCheckoutAvailability(environment: NodeJS.ProcessEnv = process.env) { + return { + paypal: environment.BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED === '1', + mercadoPago: environment.BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED === '1', + } as const; +} + +function normalizedEmail(value: string): string { + const normalized = value.trim().toLowerCase(); + if (normalized.length < 3 || normalized.length > 320 || + normalized.split('@').length !== 2 || /[\s\x00-\x20\x7f]/.test(normalized)) { + throw new ListenerCheckoutUnavailableError(); + } + return normalized; +} + +function authorityConfig(environment: NodeJS.ProcessEnv = process.env) { + const rawBaseUrl = environment.EARLY_BIRDS_AUTHORITY_BASE_URL?.trim(); + const keyId = environment.EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID?.trim(); + const token = environment.EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN?.trim(); + if (!rawBaseUrl || !keyId || !token || token.length < 43) { + throw new ListenerCheckoutUnavailableError(); + } + let baseUrl: URL; + try { + baseUrl = new URL(rawBaseUrl); + } catch { + throw new ListenerCheckoutUnavailableError(); + } + if (!['http:', 'https:'].includes(baseUrl.protocol) || baseUrl.username || baseUrl.password) { + throw new ListenerCheckoutUnavailableError(); + } + return { baseUrl: baseUrl.toString().replace(/\/$/, ''), keyId, token }; +} + +async function boundedJson(response: Response): Promise { + 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 ListenerCheckoutUnavailableError(); + } + const reader = response.body?.getReader(); + if (!reader) throw new ListenerCheckoutUnavailableError(); + const chunks: Uint8Array[] = []; + 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 ListenerCheckoutUnavailableError(); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)) as unknown; + } catch { + throw new ListenerCheckoutUnavailableError(); + } +} + +function checkoutResult( + raw: unknown, + accountId: string, + provider: ListenerCheckoutProvider, +): ListenerCheckoutResult { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new ListenerCheckoutUnavailableError(); + } + const input = raw as Record; + const expectedKeys = [ + 'account_id', 'amount_minor', 'approval_url', 'currency', + 'external_subscription_id', 'provider', 'sandbox', 'schema_version', + ]; + if (Object.keys(input).sort().join('\0') !== expectedKeys.sort().join('\0') || + input.schema_version !== 'early-bird-authority.checkout.v1' || + input.account_id !== accountId || input.provider !== provider || input.sandbox !== true || + !Number.isSafeInteger(input.amount_minor) || Number(input.amount_minor) <= 0 || + typeof input.external_subscription_id !== 'string' || !input.external_subscription_id || + (provider === 'paypal' ? input.currency !== 'USD' : input.currency !== 'ARS') || + typeof input.approval_url !== 'string') { + throw new ListenerCheckoutUnavailableError(); + } + let approval: URL; + try { + approval = new URL(input.approval_url); + } catch { + throw new ListenerCheckoutUnavailableError(); + } + const allowedHost = provider === 'paypal' + ? approval.hostname === 'sandbox.paypal.com' || approval.hostname.endsWith('.sandbox.paypal.com') + : approval.hostname === 'www.mercadopago.com.ar'; + if (approval.protocol !== 'https:' || approval.username || approval.password || !allowedHost || + input.approval_url.length > 2048) { + throw new ListenerCheckoutUnavailableError(); + } + return { provider, approvalUrl: approval.toString() }; +} + +function idempotencyKey(accountId: string, provider: ListenerCheckoutProvider, attemptId: string): string { + const digest = createHash('sha256') + .update(`listener-checkout-v1\n${accountId}\n${provider}\n${attemptId}`) + .digest('hex'); + return `listener-checkout:${digest}`; +} + +export class HttpListenerCheckoutGateway { + constructor( + private readonly config = authorityConfig(), + private readonly request: typeof fetch = fetch, + ) {} + + async create(input: { + accountId: string; + email: string; + provider: ListenerCheckoutProvider; + attemptId: string; + returnUrl: string; + cancelUrl: string; + }): Promise { + if (!isEarlyBirdAccountId(input.accountId) || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.attemptId)) { + throw new ListenerCheckoutUnavailableError(); + } + const endpoint = input.provider === 'paypal' + ? '/api/internal/v1/early-bird-checkouts' + : '/api/internal/v2/early-bird-checkouts'; + const payload = input.provider === 'paypal' ? { + schema_version: 'early-bird-authority.checkout-create.v1', + account_id: input.accountId, + provider: input.provider, + return_url: input.returnUrl, + cancel_url: input.cancelUrl, + } : { + schema_version: 'early-bird-checkout.checkout-create.v2', + account_id: input.accountId, + provider: input.provider, + payer_email: normalizedEmail(input.email), + return_url: input.returnUrl, + cancel_url: input.cancelUrl, + }; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await this.request(`${this.config.baseUrl}${endpoint}`, { + method: 'POST', + redirect: 'error', + cache: 'no-store', + signal: controller.signal, + headers: { + accept: 'application/json', + authorization: `Bearer ${this.config.token}`, + 'content-type': 'application/json', + 'idempotency-key': idempotencyKey(input.accountId, input.provider, input.attemptId), + 'x-hb-service-key-id': this.config.keyId, + }, + body: JSON.stringify(payload), + }); + if (!response.ok || response.headers.get('content-type')?.split(';', 1)[0] !== 'application/json') { + await response.body?.cancel().catch(() => undefined); + throw new ListenerCheckoutUnavailableError(); + } + return checkoutResult(await boundedJson(response), input.accountId, input.provider); + } catch (error) { + if (error instanceof ListenerCheckoutUnavailableError) throw error; + throw new ListenerCheckoutUnavailableError(); + } finally { + clearTimeout(timeout); + } + } +} diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 5c37b990..6f6d9760 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -34,6 +34,12 @@ export const earlyBirdCopy = { freeQuotaResetsIn: 'Se renueva en {time}', freeQuotaExtra: 'Incluye {time} de crédito extra.', freeQuotaMembershipCta: 'Hazte miembro para tener acceso completo', + checkoutSandboxTitle: 'Probar membresía Founder', + checkoutSandboxDetail: 'Checkout de prueba. No usa dinero real.', + checkoutPayPal: 'Continuar con PayPal', + checkoutMercadoPago: 'Continuar con Mercado Pago', + checkoutOpening: 'Abriendo checkout…', + checkoutUnavailable: 'El checkout de prueba no está disponible ahora.', freeQuotaFounder: 'Acceso Founder', freeQuotaFreeForAll: 'Acceso libre', freeQuotaUnlimited: 'Puedes escuchar sin límite de tiempo.', @@ -88,6 +94,12 @@ export const earlyBirdCopy = { freeQuotaResetsIn: 'Renews in {time}', freeQuotaExtra: 'Includes {time} of extra credit.', freeQuotaMembershipCta: 'Become a member for full access', + checkoutSandboxTitle: 'Try Founder membership', + checkoutSandboxDetail: 'Test checkout. No real money is used.', + checkoutPayPal: 'Continue with PayPal', + checkoutMercadoPago: 'Continue with Mercado Pago', + checkoutOpening: 'Opening checkout…', + checkoutUnavailable: 'Test checkout is unavailable right now.', freeQuotaFounder: 'Founder access', freeQuotaFreeForAll: 'Open access', freeQuotaUnlimited: 'You can listen without a time limit.', From 99dbaab91891d73e8091b82e74aef38cd33acf9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 15:03:35 -0300 Subject: [PATCH 184/376] docs(listener): repin reviewed payment authority --- docs/plans/EARLY_BIRDS.md | 2 +- src/lib/early-birds/__tests__/checkout-contract.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index ae170eb8..3faad8e9 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -467,7 +467,7 @@ The webapp vendors byte-exact copies of the canonical backend contracts under the canonical founder membership reads. - `contracts/early-bird-checkout/v2`: the Mercado Pago checkout command for `POST /api/internal/v2/early-bird-checkouts`, vendored byte-exact from - reviewed backend draft `4929b2463c5943d6705409f1e9391331be280f7b` + reviewed backend draft `e5e638a78d5e835bfb3cfa7be69740f0003ffb01` (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 diff --git a/src/lib/early-birds/__tests__/checkout-contract.test.ts b/src/lib/early-birds/__tests__/checkout-contract.test.ts index b5273c70..fd7513c7 100644 --- a/src/lib/early-birds/__tests__/checkout-contract.test.ts +++ b/src/lib/early-birds/__tests__/checkout-contract.test.ts @@ -119,7 +119,7 @@ describe('Listener authority contract manifests', () => { }); const BACKEND_REPO = process.env.EARLY_BIRDS_BACKEND_REPO ?? '/home/nicolas/Projects/proyecciones-mito'; -const BACKEND_COMMIT = '4929b2463c5943d6705409f1e9391331be280f7b'; +const BACKEND_COMMIT = 'e5e638a78d5e835bfb3cfa7be69740f0003ffb01'; const backendAvailable = existsSync(`${BACKEND_REPO}/.git`); // Git hooks (e.g. the pre-commit suite run) export GIT_DIR and friends, which From d3706f95c8a4e5ced0bff714e00d2acc5bb5e344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 15:05:34 -0300 Subject: [PATCH 185/376] docs(listener): record payment topology merge --- docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 2 +- docs/plans/EARLY_BIRDS.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index bcbe4caa..907f5e89 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -153,7 +153,7 @@ deployed image; later documentation-only commits do not require rebuilding it. - #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 remain open for real sandbox lifecycle evidence. Both adapters are - merged backend-side and default-off. Backend draft PR #57 and the paired + merged backend-side and default-off. Backend PR #57 and the paired Beacon slice version isolated egress, exact staging webhook ingress and a session-derived checkout that is visible only on staging when its provider flag is explicitly enabled. No provider credential, checkout flag or sale is diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 3faad8e9..f68eabf9 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -467,7 +467,8 @@ The webapp vendors byte-exact copies of the canonical backend contracts under the canonical founder membership reads. - `contracts/early-bird-checkout/v2`: the Mercado Pago checkout command for `POST /api/internal/v2/early-bird-checkouts`, vendored byte-exact from - reviewed backend draft `e5e638a78d5e835bfb3cfa7be69740f0003ffb01` + 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 From 83020019f91af37b22f40e99630e16a555e6980d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 16:16:16 -0300 Subject: [PATCH 186/376] feat(listener): set Founder offer to USD 5 --- contracts/early-bird-authority/v1/SHA256SUMS | 2 +- .../early-bird-authority/v1/checkout.fixture.json | 2 +- contracts/early-bird-authority/v2/README.md | 2 +- contracts/early-bird-authority/v2/SHA256SUMS | 6 +++--- .../v2/membership.fixture.json | 4 ++-- .../v2/membership.schema.json | 2 +- contracts/early-bird-membership/v1/SHA256SUMS | 2 +- .../early-bird-membership/v1/command.fixture.json | 2 +- .../0004-provider-neutral-earlybird-membership.md | 4 ++-- docs/plans/EARLY_BIRDS.md | 15 +++++++++++---- .../migration.sql | 13 +++++++++++++ .../[accountId]/reconcile/__tests__/route.test.ts | 4 ++-- .../[accountId]/__tests__/route.test.ts | 2 +- .../__tests__/checkout-contract.test.ts | 2 +- src/lib/early-birds/__tests__/checkout.test.ts | 2 +- .../founder-eligibility-contract.test.ts | 2 +- .../founder-eligibility.postgres.test.ts | 2 +- .../__tests__/founder-eligibility.test.ts | 4 ++-- src/lib/early-birds/founder-eligibility.ts | 2 +- src/lib/early-birds/membership-contract.ts | 6 +++--- 20 files changed, 50 insertions(+), 30 deletions(-) create mode 100644 prisma/migrations/20260809170000_listener_founder_price_usd5/migration.sql diff --git a/contracts/early-bird-authority/v1/SHA256SUMS b/contracts/early-bird-authority/v1/SHA256SUMS index f2c91baf..4c73aa89 100644 --- a/contracts/early-bird-authority/v1/SHA256SUMS +++ b/contracts/early-bird-authority/v1/SHA256SUMS @@ -1,7 +1,7 @@ b68d7933d72985709a94b2710d7cb57d8aec0b3581f7bc6aed9f940785fbc54e README.md 46ebfa406c3e17e7913122f5cb6fe16084fccef2baada373017bf411fe1908bf checkout-create.fixture.json 2d464d210c61e98489059a5828c83bbaf31370eb0ebd980edf1271e785d1d97a checkout-create.schema.json -6e73b4b0099afdaf4ce6d5894d845220b0828b9125ea9b574d8e8c7535120e4d checkout.fixture.json +c231e44fe3c141d9f5322a35267b38630529ef323f7236c3c2337acee447839e checkout.fixture.json d7865fe63b59ddb82285d4df30145a7c3cb59cf8a0923f93eabac8370dba3c9f checkout.schema.json 47c937f3f93ed94b9eff6750bd430131af0c18ace5c2a6684ba36b5f5b3d41f2 invitation-redeem.fixture.json 085d6b6bbf0e88e974ed63e0585d2e52601e859cd283d13dbaf4191a3ae30fd2 invitation-redeem.schema.json diff --git a/contracts/early-bird-authority/v1/checkout.fixture.json b/contracts/early-bird-authority/v1/checkout.fixture.json index 01ce5aab..91cab642 100644 --- a/contracts/early-bird-authority/v1/checkout.fixture.json +++ b/contracts/early-bird-authority/v1/checkout.fixture.json @@ -5,6 +5,6 @@ "external_subscription_id": "sandbox_paypal_0123456789abcdef01234567", "approval_url": "https://sandbox.invalid/paypal/sandbox_paypal_0123456789abcdef01234567", "currency": "USD", - "amount_minor": 200, + "amount_minor": 500, "sandbox": true } diff --git a/contracts/early-bird-authority/v2/README.md b/contracts/early-bird-authority/v2/README.md index 91096df7..560a18c2 100644 --- a/contracts/early-bird-authority/v2/README.md +++ b/contracts/early-bird-authority/v2/README.md @@ -12,7 +12,7 @@ Authentication, disabled-service and path-validation failures retain FastAPI's e 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 2/month offer earned by a confirmed paid +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. diff --git a/contracts/early-bird-authority/v2/SHA256SUMS b/contracts/early-bird-authority/v2/SHA256SUMS index 41b2e7e0..e4946113 100644 --- a/contracts/early-bird-authority/v2/SHA256SUMS +++ b/contracts/early-bird-authority/v2/SHA256SUMS @@ -1,3 +1,3 @@ -42a2dd7f1e74e295a073efd785e9eae05a27d74bf14cfcf44294f8eb83716d68 README.md -d7e19379aaa071ee34260e2e26cdab2fa0927e27765d7b52e22a9b403c864d4c membership.fixture.json -52bb0b8bb7a1545e6ae9c806d94b4e19b3e74c2bec4d58546987e74aa67afa82 membership.schema.json +6db01b88df755b52f5e70ed07e847ad8888c2cd475824eeaf56ca10e4a3e6e62 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 index 4be149d3..ad77fb0a 100644 --- a/contracts/early-bird-authority/v2/membership.fixture.json +++ b/contracts/early-bird-authority/v2/membership.fixture.json @@ -15,7 +15,7 @@ "provider": "paypal", "current_price": { "currency": "USD", - "amount_minor": 200 + "amount_minor": 500 }, "free_entitlement_consumed": true, "reason_code": "SUBSCRIPTION_CANCELLED", @@ -26,7 +26,7 @@ }, "canonical_price": { "currency": "USD", - "amount_minor": 200 + "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 index 84c6f697..349bcf59 100644 --- a/contracts/early-bird-authority/v2/membership.schema.json +++ b/contracts/early-bird-authority/v2/membership.schema.json @@ -100,7 +100,7 @@ "required": ["currency", "amount_minor"], "properties": { "currency": {"const": "USD"}, - "amount_minor": {"const": 200} + "amount_minor": {"const": 500} } }, "billing_period": {"const": "MONTHLY"}, diff --git a/contracts/early-bird-membership/v1/SHA256SUMS b/contracts/early-bird-membership/v1/SHA256SUMS index 4faa9bc9..a5be16f3 100644 --- a/contracts/early-bird-membership/v1/SHA256SUMS +++ b/contracts/early-bird-membership/v1/SHA256SUMS @@ -1,5 +1,5 @@ d3cd62086dc661acb4532fd725e592e70ddac565a490a4732d7d32321edc8d17 README.md -025d36d5aa4c3b455dd197acc4af23669f9cd02cc240e995bfc3cc0e693fd3cb command.fixture.json +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 index 462250ee..ca767528 100644 --- a/contracts/early-bird-membership/v1/command.fixture.json +++ b/contracts/early-bird-membership/v1/command.fixture.json @@ -14,7 +14,7 @@ "provider": "paypal", "current_price": { "currency": "USD", - "amount_minor": 200 + "amount_minor": 500 }, "reason_code": "PAYMENT_SUCCEEDED" } diff --git a/docs/decisions/0004-provider-neutral-earlybird-membership.md b/docs/decisions/0004-provider-neutral-earlybird-membership.md index 990d9db4..fe28afa1 100644 --- a/docs/decisions/0004-provider-neutral-earlybird-membership.md +++ b/docs/decisions/0004-provider-neutral-earlybird-membership.md @@ -9,7 +9,7 @@ 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 2/month offer revision. The right to that +The founder offer is an immutable USD 5/month offer revision. The right to that price is granted only after the first canonical paid activation and remains attached for life to the opaque Listener account, independently of email or identity provider. Voluntary cancellation ends active access after paid-through @@ -27,7 +27,7 @@ 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 2 and the ARS equivalent from BCRA A3500, locks the +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. diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index f68eabf9..46e219b0 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -56,7 +56,7 @@ change has passed its own audio and operational acceptance. | Favor continuity over low latency in the Listener | Accepted | Desktop HLS stays about five segments behind the edge with a 60-second target buffer; Stop and a later Listen rejoin the current configured edge. | | 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 for life | Accepted | First canonical paid activation grants the opaque account a lifetime right to the USD 2/month founder offer; cancellation ends access but not that price eligibility. | +| Preserve the Founder price for life | Accepted | First canonical paid activation grants the opaque account a lifetime right to the USD 5/month founder offer; cancellation ends access but not that price eligibility. | | 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. | @@ -428,7 +428,7 @@ 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" is not a boolean. It is a versioned USD 2/month +"Founder price locked for life" is not a boolean. It is a versioned USD 5/month offer grant recording amount/currency, first canonical paid activation and the opaque account that owns the durable eligibility. Voluntary cancellation preserves access through paid-through time and then ends active access, but the @@ -439,7 +439,7 @@ invent or erase 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 2 and the locked ARS +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. @@ -473,6 +473,8 @@ The webapp vendors byte-exact copies of the canonical backend contracts under 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 advance to USD 5 + in backend PR #59 / `cad4eded2e08ee46da49e54ee94e1ab8601d9495`. Mandatory external gate before any paid activation: real Mercado Pago TEST proof that `/preapproval/search?q=hb_` returns the exact created preapproval. @@ -661,10 +663,15 @@ 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 2/month founder offer; first canonical paid activation grants lifetime account-bound price eligibility; voluntary cancellation ends access after paid-through but a later reactivation retains that price; 14-day involuntary grace; refund/dispute/admin revoke access immediately. | +| D2 | USD 5/month founder offer; first canonical paid activation grants lifetime account-bound price eligibility; voluntary cancellation ends access after paid-through but a later reactivation retains that price; 14-day involuntary grace; refund/dispute/admin revoke access immediately. | | 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. | 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/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts b/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts index bc2c45a4..bf8fb35a 100644 --- a/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts +++ b/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts @@ -31,7 +31,7 @@ import { POST } from '../route'; const ACCOUNT = 'listener-1'; const eligibility = { offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, - canonical_price: { currency: 'USD', amount_minor: 200 }, + canonical_price: { currency: 'USD', amount_minor: 500 }, billing_period: 'MONTHLY', granted_at: '2026-08-06T12:00:00Z', }; @@ -47,7 +47,7 @@ const canonical = { grace_until: null, offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, provider: 'paypal', - current_price: { currency: 'USD', amount_minor: 200 }, + current_price: { currency: 'USD', amount_minor: 500 }, free_entitlement_consumed: true, reason_code: 'SUBSCRIPTION_CANCELLED', founder_price_eligibility: eligibility, diff --git a/src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts b/src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts index d8d5d236..4b01a092 100644 --- a/src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts +++ b/src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts @@ -32,7 +32,7 @@ const command = { paid_through: '2026-09-06T12:00:00Z', grace_until: null, provider: 'paypal', - current_price: { currency: 'USD', amount_minor: 200 }, + current_price: { currency: 'USD', amount_minor: 500 }, reason_code: 'PAYMENT_SUCCEEDED', }; const projection = { diff --git a/src/lib/early-birds/__tests__/checkout-contract.test.ts b/src/lib/early-birds/__tests__/checkout-contract.test.ts index fd7513c7..1c003f84 100644 --- a/src/lib/early-birds/__tests__/checkout-contract.test.ts +++ b/src/lib/early-birds/__tests__/checkout-contract.test.ts @@ -119,7 +119,7 @@ describe('Listener authority contract manifests', () => { }); const BACKEND_REPO = process.env.EARLY_BIRDS_BACKEND_REPO ?? '/home/nicolas/Projects/proyecciones-mito'; -const BACKEND_COMMIT = 'e5e638a78d5e835bfb3cfa7be69740f0003ffb01'; +const BACKEND_COMMIT = 'cad4eded2e08ee46da49e54ee94e1ab8601d9495'; const backendAvailable = existsSync(`${BACKEND_REPO}/.git`); // Git hooks (e.g. the pre-commit suite run) export GIT_DIR and friends, which diff --git a/src/lib/early-birds/__tests__/checkout.test.ts b/src/lib/early-birds/__tests__/checkout.test.ts index 06dd368f..a89f5a21 100644 --- a/src/lib/early-birds/__tests__/checkout.test.ts +++ b/src/lib/early-birds/__tests__/checkout.test.ts @@ -29,7 +29,7 @@ function result(provider: 'paypal' | 'mercado_pago', overrides: Record { ...active, founder_price_eligibility: { ...(active.founder_price_eligibility as object), - canonical_price: { currency: 'ARS', amount_minor: 200 }, + canonical_price: { currency: 'ARS', amount_minor: 500 }, }, }], ['wrong amount', { diff --git a/src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts b/src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts index bdd26474..9428a672 100644 --- a/src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts +++ b/src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts @@ -12,7 +12,7 @@ const postgres = process.env.LISTENER_TEST_DATABASE_URL ? describe : describe.sk const accountIds = ['listener-founder-pg-identical', 'listener-founder-pg-conflict']; const eligibility: FounderPriceEligibility = { offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, - canonical_price: { currency: 'USD', amount_minor: 200 }, + canonical_price: { currency: 'USD', amount_minor: 500 }, billing_period: 'MONTHLY', granted_at: '2026-08-06T12:00:00Z', }; diff --git a/src/lib/early-birds/__tests__/founder-eligibility.test.ts b/src/lib/early-birds/__tests__/founder-eligibility.test.ts index b05ea5d3..f1b5a03e 100644 --- a/src/lib/early-birds/__tests__/founder-eligibility.test.ts +++ b/src/lib/early-birds/__tests__/founder-eligibility.test.ts @@ -24,7 +24,7 @@ import type { FounderPriceEligibility } from '../membership-contract'; const NOW = new Date('2026-08-08T12:00:00Z'); const eligibility: FounderPriceEligibility = { offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, - canonical_price: { currency: 'USD', amount_minor: 200 }, + canonical_price: { currency: 'USD', amount_minor: 500 }, billing_period: 'MONTHLY', granted_at: '2026-08-06T12:00:00Z', }; @@ -67,7 +67,7 @@ describe('positive-only Founder eligibility projection', () => { data: expect.objectContaining({ accountId: 'listener-1', currency: 'USD', - amountMinor: 200, + amountMinor: 500, billingPeriod: 'MONTHLY', eligibilityHash: founderEligibilityHash(eligibility), }), diff --git a/src/lib/early-birds/founder-eligibility.ts b/src/lib/early-birds/founder-eligibility.ts index 954a452c..7e00540b 100644 --- a/src/lib/early-birds/founder-eligibility.ts +++ b/src/lib/early-birds/founder-eligibility.ts @@ -32,7 +32,7 @@ function normalizedEligibility(value: FounderPriceEligibility): FounderPriceElig || !Number.isSafeInteger(value.offer.revision) || value.offer.revision < 1 || value.canonical_price.currency !== 'USD' - || value.canonical_price.amount_minor !== 200 + || value.canonical_price.amount_minor !== 500 || value.billing_period !== 'MONTHLY' ) { throw new FounderEligibilityConflictError(); diff --git a/src/lib/early-birds/membership-contract.ts b/src/lib/early-birds/membership-contract.ts index 2b7ff02d..61746495 100644 --- a/src/lib/early-birds/membership-contract.ts +++ b/src/lib/early-birds/membership-contract.ts @@ -32,7 +32,7 @@ export type CanonicalAuthorityMembership = Omit Date: Sun, 9 Aug 2026 16:17:13 -0300 Subject: [PATCH 187/376] fix(listener): link brand to public website --- src/components/early-birds/EarlyBirdHome.tsx | 2 +- src/components/early-birds/EarlyBirdLanding.tsx | 2 +- src/components/early-birds/EarlyBirdUnavailable.tsx | 2 +- src/components/early-birds/FreeInvitationRedeemer.tsx | 2 +- .../early-birds/__tests__/FreeInvitationRedeemer.test.tsx | 2 +- src/lib/listener/namespace.ts | 1 + 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index 723d4ee9..d3be0a71 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -48,7 +48,7 @@ export default function EarlyBirdHome({
- +
{publicAccess && ( diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index 10af51c3..e8e4a547 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -92,7 +92,7 @@ export default function EarlyBirdLanding(props: Props) {
- +
diff --git a/src/components/early-birds/EarlyBirdUnavailable.tsx b/src/components/early-birds/EarlyBirdUnavailable.tsx index a00e766a..93edb6e7 100644 --- a/src/components/early-birds/EarlyBirdUnavailable.tsx +++ b/src/components/early-birds/EarlyBirdUnavailable.tsx @@ -25,7 +25,7 @@ export default function EarlyBirdUnavailable() {
- +
diff --git a/src/components/early-birds/FreeInvitationRedeemer.tsx b/src/components/early-birds/FreeInvitationRedeemer.tsx index 80abd8e7..7a5bc3ab 100644 --- a/src/components/early-birds/FreeInvitationRedeemer.tsx +++ b/src/components/early-birds/FreeInvitationRedeemer.tsx @@ -47,7 +47,7 @@ export default function FreeInvitationRedeemer() {
- +

{copy.eyebrow}

diff --git a/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx b/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx index 314249bc..01226525 100644 --- a/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx +++ b/src/components/early-birds/__tests__/FreeInvitationRedeemer.test.tsx @@ -41,7 +41,7 @@ describe('EarlyBird free invitation redeemer', () => { expect(await screen.findByRole('alert')).toHaveTextContent('This invitation is unavailable.'); expect(screen.getByRole('button', { name: 'Activate invitation' })).toBeEnabled(); expect(request).toHaveBeenCalledWith('/api/listener/free/redeem', { method: 'POST' }); - expect(screen.getByRole('link', { name: 'Harmonic Beacon' })).toHaveAttribute('href', '/listener'); + expect(screen.getByRole('link', { name: 'Harmonic Beacon' })).toHaveAttribute('href', 'https://harmonicbeacon.com/'); expect(JSON.stringify(request.mock.calls)).not.toContain('invitation-token'); }); }); diff --git a/src/lib/listener/namespace.ts b/src/lib/listener/namespace.ts index af8bde96..85e2c186 100644 --- a/src/lib/listener/namespace.ts +++ b/src/lib/listener/namespace.ts @@ -6,6 +6,7 @@ * migration step. */ export const LISTENER_NAMESPACE = { + publicWebsite: 'https://harmonicbeacon.com/', canonical: { home: '/listener', redeem: '/listener/redeem', From 3623c92d3f7ab0383d8a0b486811d9b1efc3f55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 16:46:59 -0300 Subject: [PATCH 188/376] ops(listener): add isolated PayPal checkout workbench mode --- docs/operations/LISTENER_UI_FAST_LOOP.md | 13 +++++++++++++ scripts/listener-ui-preview.sh | 15 ++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/operations/LISTENER_UI_FAST_LOOP.md b/docs/operations/LISTENER_UI_FAST_LOOP.md index 07d0dd98..5e8b4d44 100644 --- a/docs/operations/LISTENER_UI_FAST_LOOP.md +++ b/docs/operations/LISTENER_UI_FAST_LOOP.md @@ -16,6 +16,19 @@ Start or replace the workbench from the `early-birds` worktree: 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. +Neither switch changes the persistent Listener release or event services. + Keep local edits synchronized while iterating: ```bash diff --git a/scripts/listener-ui-preview.sh b/scripts/listener-ui-preview.sh index 3846dd43..bf3cd37f 100755 --- a/scripts/listener-ui-preview.sh +++ b/scripts/listener-ui-preview.sh @@ -12,6 +12,14 @@ 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_PAYPAL_CHECKOUT="${LISTENER_UI_PREVIEW_PAYPAL_SANDBOX_CHECKOUT_ENABLED:-0}" + +case "$PREVIEW_FREE_FOR_ALL:$PREVIEW_PAYPAL_CHECKOUT" in + 0:0|0:1|1:0) ;; + 1:1) echo "PayPal checkout requires Free For All to be disabled." >&2; exit 2 ;; + *) echo "Preview switches must be 0 or 1." >&2; exit 2 ;; +esac usage() { echo "Usage: $0 {start|sync|watch|status|stop|logs}" >&2 @@ -29,7 +37,7 @@ sync_source() { } start_remote() { - ssh "$PREVIEW_HOST" "REMOTE_SOURCE='$REMOTE_SOURCE' REMOTE_NEXT='$REMOTE_NEXT' DEV_CONTAINER='$DEV_CONTAINER' RELEASE_CONTAINER='$RELEASE_CONTAINER' bash -s" <<'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_PAYPAL_CHECKOUT='$PREVIEW_PAYPAL_CHECKOUT' bash -s" <<'REMOTE' set -euo pipefail image="$(docker inspect "$RELEASE_CONTAINER" --format '{{.Config.Image}}')" @@ -58,8 +66,9 @@ docker run -d \ -e BEACON_GIT_SHA=ui-dev \ -e EARLY_BIRDS_ENABLED=1 \ -e BEACON_LISTENER_ENABLED=1 \ - -e EARLY_BIRDS_FREE_FOR_ALL=1 \ - -e BEACON_LISTENER_FREE_FOR_ALL=1 \ + -e EARLY_BIRDS_FREE_FOR_ALL="$PREVIEW_FREE_FOR_ALL" \ + -e BEACON_LISTENER_FREE_FOR_ALL="$PREVIEW_FREE_FOR_ALL" \ + -e BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED="$PREVIEW_PAYPAL_CHECKOUT" \ --network earlybirds_preview_db_internal \ -p 127.0.0.1:13001:3000 \ -v "$REMOTE_SOURCE/src:/app/src:ro" \ From e0cfc900487421dba45852aad82c6c6a500d089e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 17:01:40 -0300 Subject: [PATCH 189/376] fix(listener): run payment workbench in production mode --- docs/operations/LISTENER_UI_FAST_LOOP.md | 6 ++++- scripts/listener-ui-preview.sh | 34 +++++++++++++++++------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/operations/LISTENER_UI_FAST_LOOP.md b/docs/operations/LISTENER_UI_FAST_LOOP.md index 5e8b4d44..e28049c6 100644 --- a/docs/operations/LISTENER_UI_FAST_LOOP.md +++ b/docs/operations/LISTENER_UI_FAST_LOOP.md @@ -27,7 +27,11 @@ scripts/listener-ui-preview.sh start ``` The script refuses the ambiguous combination of checkout plus Free For All. -Neither switch changes the persistent Listener release or event services. +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. Keep local edits synchronized while iterating: diff --git a/scripts/listener-ui-preview.sh b/scripts/listener-ui-preview.sh index bf3cd37f..7467f300 100755 --- a/scripts/listener-ui-preview.sh +++ b/scripts/listener-ui-preview.sh @@ -55,14 +55,34 @@ if docker container inspect "$DEV_CONTAINER" >/dev/null 2>&1; then docker rm -f "$DEV_CONTAINER" >/dev/null fi +runtime_args=() +command_args=() +if [ "$PREVIEW_PAYPAL_CHECKOUT" = 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 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" + ) + command_args=(npm run dev -- --hostname 0.0.0.0 --port 3000) +fi + docker run -d \ --name "$DEV_CONTAINER" \ --restart unless-stopped \ --init \ --env-file "$env_file" \ - -e NODE_ENV=development \ -e NEXT_TELEMETRY_DISABLED=1 \ - -e WATCHPACK_POLLING=true \ -e BEACON_GIT_SHA=ui-dev \ -e EARLY_BIRDS_ENABLED=1 \ -e BEACON_LISTENER_ENABLED=1 \ @@ -71,16 +91,10 @@ docker run -d \ -e BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED="$PREVIEW_PAYPAL_CHECKOUT" \ --network earlybirds_preview_db_internal \ -p 127.0.0.1:13001:3000 \ - -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" \ --volumes-from "$RELEASE_CONTAINER:ro" \ + "${runtime_args[@]}" \ "$image" \ - npm run dev -- --hostname 0.0.0.0 --port 3000 >/dev/null + "${command_args[@]}" >/dev/null docker network connect earlybirds_preview_listener_egress "$DEV_CONTAINER" docker network connect earlybirds_authority_private "$DEV_CONTAINER" From c55fd555e5586056e6fc53d06a0b14f734e5e626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sun, 9 Aug 2026 18:13:47 -0300 Subject: [PATCH 190/376] ops(listener): add Mercado Pago test workbench mode --- docs/operations/LISTENER_UI_FAST_LOOP.md | 12 ++++++++++++ scripts/listener-ui-preview.sh | 13 ++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/operations/LISTENER_UI_FAST_LOOP.md b/docs/operations/LISTENER_UI_FAST_LOOP.md index e28049c6..6c6d2d8c 100644 --- a/docs/operations/LISTENER_UI_FAST_LOOP.md +++ b/docs/operations/LISTENER_UI_FAST_LOOP.md @@ -33,6 +33,18 @@ 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. +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 diff --git a/scripts/listener-ui-preview.sh b/scripts/listener-ui-preview.sh index 7467f300..321a60a1 100755 --- a/scripts/listener-ui-preview.sh +++ b/scripts/listener-ui-preview.sh @@ -14,10 +14,12 @@ DEV_CONTAINER="listener-ui-dev" RELEASE_CONTAINER="earlybirds-preview-listener-1" PREVIEW_FREE_FOR_ALL="${LISTENER_UI_PREVIEW_FREE_FOR_ALL:-1}" PREVIEW_PAYPAL_CHECKOUT="${LISTENER_UI_PREVIEW_PAYPAL_SANDBOX_CHECKOUT_ENABLED:-0}" +PREVIEW_MERCADO_PAGO_CHECKOUT="${LISTENER_UI_PREVIEW_MERCADO_PAGO_TEST_CHECKOUT_ENABLED:-0}" -case "$PREVIEW_FREE_FOR_ALL:$PREVIEW_PAYPAL_CHECKOUT" in - 0:0|0:1|1:0) ;; - 1:1) echo "PayPal checkout requires Free For All to be disabled." >&2; exit 2 ;; +case "$PREVIEW_FREE_FOR_ALL:$PREVIEW_PAYPAL_CHECKOUT:$PREVIEW_MERCADO_PAGO_CHECKOUT" in + 0:0:0|0:1:0|0:0:1|1:0:0) ;; + 1:1:0|1:0:1) echo "Payment checkout requires Free For All to be disabled." >&2; exit 2 ;; + 0:1:1|1:1:1) echo "Select exactly one payment provider in the workbench." >&2; exit 2 ;; *) echo "Preview switches must be 0 or 1." >&2; exit 2 ;; esac @@ -37,7 +39,7 @@ sync_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_PAYPAL_CHECKOUT='$PREVIEW_PAYPAL_CHECKOUT' bash -s" <<'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_PAYPAL_CHECKOUT='$PREVIEW_PAYPAL_CHECKOUT' PREVIEW_MERCADO_PAGO_CHECKOUT='$PREVIEW_MERCADO_PAGO_CHECKOUT' bash -s" <<'REMOTE' set -euo pipefail image="$(docker inspect "$RELEASE_CONTAINER" --format '{{.Config.Image}}')" @@ -57,7 +59,7 @@ fi runtime_args=() command_args=() -if [ "$PREVIEW_PAYPAL_CHECKOUT" = 1 ]; then +if [ "$PREVIEW_PAYPAL_CHECKOUT" = 1 ] || [ "$PREVIEW_MERCADO_PAGO_CHECKOUT" = 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) @@ -89,6 +91,7 @@ docker run -d \ -e EARLY_BIRDS_FREE_FOR_ALL="$PREVIEW_FREE_FOR_ALL" \ -e BEACON_LISTENER_FREE_FOR_ALL="$PREVIEW_FREE_FOR_ALL" \ -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" \ From 2a4e66b999fa5fa4e2d842bb96ed470079450c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Mon, 10 Aug 2026 21:08:08 -0300 Subject: [PATCH 191/376] docs(listener): define Founder price continuity --- .../LISTENER_FOUNDER_ELIGIBILITY.md | 15 +++++++-- ...4-provider-neutral-earlybird-membership.md | 31 ++++++++++--------- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 6 ++-- docs/plans/EARLY_BIRDS.md | 29 +++++++++-------- 4 files changed, 49 insertions(+), 32 deletions(-) diff --git a/docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md b/docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md index b510b4a4..5a0c9974 100644 --- a/docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md +++ b/docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md @@ -1,12 +1,21 @@ -# Listener Founder price eligibility projection +# Listener Founder price continuity projection + +> **Product amendment — 2026-08-10:** the positive-only lifetime eligibility +> implementation described below is superseded and must not be enabled for +> production commerce. "Lifetime" now means only the uninterrupted lifetime of +> an active Founder subscription. Once paid-through/grace continuity ends, +> Founder status and USD 5 eligibility end; later signup uses the current public +> offer. This document retains the deployed design history so migration and +> rollback can be reviewed, but the authority and Listener projections require +> a coordinated forward-only correction before provider activation. ## Boundary -PMP Myth Bot is the only authority that can grant the lifetime Founding Listener price. Its private +PMP Myth Bot is the only authority that can project Founding Listener price continuity. Its private membership read v2 is versioned in `contracts/early-bird-authority/v2` and was introduced by backend merge `3febc1d525adf150bfdd75fd2b98b04771cb79b7`. -Listener stores a positive-only projection of that evidence in +The historical implementation stores a positive-only projection of that evidence in `early_bird_founder_eligibility_projections`. This row is deliberately separate from the v1 membership projection: diff --git a/docs/decisions/0004-provider-neutral-earlybird-membership.md b/docs/decisions/0004-provider-neutral-earlybird-membership.md index fe28afa1..d3aa0198 100644 --- a/docs/decisions/0004-provider-neutral-earlybird-membership.md +++ b/docs/decisions/0004-provider-neutral-earlybird-membership.md @@ -1,6 +1,6 @@ # Provider-neutral EarlyBird membership -*Accepted 2026-08-06 for the EarlyBirds milestone.* +*Accepted 2026-08-06 and amended 2026-08-10 for the EarlyBirds milestone.* ## Decision @@ -9,19 +9,22 @@ 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. The right to that -price is granted only after the first canonical paid activation and remains -attached for life to the opaque Listener account, independently of email or -identity provider. Voluntary cancellation ends active access after paid-through -time but does not remove the account's founder-price eligibility; a later -reactivation uses the founder offer again. Involuntary payment failure receives -14 days of grace. Refund, dispute and administrative revoke end access -immediately without using a browser redirect as commercial truth. - -Founder-price eligibility, active membership, current listening authorization -and payment/reconciliation history are separate durable concepts. A checkout -start, success redirect, failed attempt or unconfirmed provider event grants -none of them. +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 diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index 907f5e89..cdfd20dd 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -137,8 +137,10 @@ deployed image; later documentation-only commits do not require rebuilding it. - #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 is closed: provider-neutral membership and Founder eligibility are - integrated. Provider acceptance remains separately tracked by #199/#200/#261. +- #197 was reopened after the 2026-08-10 product amendment: the former + positive-only lifetime eligibility must become continuity-bound before any + production provider is enabled. Provider acceptance remains separately + tracked by #199/#200/#261. - #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 diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 46e219b0..5e604fb0 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -56,7 +56,7 @@ change has passed its own audio and operational acceptance. | Favor continuity over low latency in the Listener | Accepted | Desktop HLS stays about five segments behind the edge with a 60-second target buffer; Stop and a later Listen rejoin the current configured edge. | | 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 for life | Accepted | First canonical paid activation grants the opaque account a lifetime right to the USD 5/month founder offer; cancellation ends access but not that price eligibility. | +| 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. | @@ -115,9 +115,10 @@ steps are not safe to execute literally. 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 lifetime-price contract. Founder-price - eligibility requires a versioned offer, first canonical paid activation and - durable account-bound commercial evidence separate from active membership. +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. @@ -428,14 +429,16 @@ 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" is not a boolean. It is a versioned USD 5/month -offer grant recording amount/currency, first canonical paid activation and the -opaque account that owns the durable eligibility. Voluntary cancellation -preserves access through paid-through time and then ends active access, but the -same account retains the founder price for a later reactivation. Involuntary -payment failure receives 14 days of grace. Refund, dispute and administrative -revocation remove access immediately; they do not authorize the browser to -invent or erase commercial evidence. +"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 @@ -671,7 +674,7 @@ 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; first canonical paid activation grants lifetime account-bound price eligibility; voluntary cancellation ends access after paid-through but a later reactivation retains that price; 14-day involuntary grace; refund/dispute/admin revoke access immediately. | +| 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. | From e9390d7d017dd3f820e066a36c35c9e2a1eef2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Mon, 10 Aug 2026 22:43:57 -0300 Subject: [PATCH 192/376] feat(listener): enforce Founder service continuity --- contracts/early-bird-authority/v3/README.md | 12 + contracts/early-bird-authority/v3/SHA256SUMS | 3 + .../v3/membership.fixture.json | 28 +++ .../v3/membership.schema.json | 49 ++++ contracts/early-bird-membership/v2/README.md | 10 + contracts/early-bird-membership/v2/SHA256SUMS | 5 + .../v2/command.fixture.json | 26 ++ .../v2/command.schema.json | 41 +++ .../v2/result.fixture.json | 10 + .../v2/result.schema.json | 18 ++ docs/architecture/EARLY_BIRDS_LISTENER.md | 20 +- .../LISTENER_FOUNDER_CONTINUITY.md | 65 +++++ .../LISTENER_FOUNDER_ELIGIBILITY.md | 70 ------ .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 9 +- docs/plans/EARLY_BIRDS.md | 9 +- .../migration.sql | 93 +++++++ prisma/schema.prisma | 80 +++--- scripts/verify-early-bird-contracts.py | 2 + .../reconcile/__tests__/route.test.ts | 145 ----------- .../[accountId]/reconcile/route.ts | 83 ------ .../[accountId]/__tests__/route.test.ts | 47 +++- .../[accountId]/route.ts | 2 + src/app/early-birds/__tests__/page.test.tsx | 39 +++ src/app/early-birds/page.tsx | 16 +- .../__tests__/EarlyBirdHome.test.tsx | 17 +- src/lib/early-birds/__tests__/access.test.ts | 18 +- .../founder-continuity-contract.test.ts | 178 +++++++++++++ .../founder-continuity.postgres.test.ts | 204 +++++++++++++++ .../founder-eligibility-contract.test.ts | 238 ------------------ .../founder-eligibility.postgres.test.ts | 65 ----- .../__tests__/founder-eligibility.test.ts | 113 --------- .../__tests__/membership-gateway.test.ts | 5 +- .../__tests__/membership-presentation.test.ts | 66 +++-- .../early-birds/__tests__/membership.test.ts | 128 +++++++++- src/lib/early-birds/copy.ts | 12 +- src/lib/early-birds/founder-eligibility.ts | 104 -------- src/lib/early-birds/membership-contract.ts | 178 ++++++++----- src/lib/early-birds/membership-gateway.ts | 13 +- .../early-birds/membership-presentation.ts | 23 +- src/lib/early-birds/membership.ts | 174 ++++++++++++- 40 files changed, 1418 insertions(+), 1000 deletions(-) create mode 100644 contracts/early-bird-authority/v3/README.md create mode 100644 contracts/early-bird-authority/v3/SHA256SUMS create mode 100644 contracts/early-bird-authority/v3/membership.fixture.json create mode 100644 contracts/early-bird-authority/v3/membership.schema.json create mode 100644 contracts/early-bird-membership/v2/README.md create mode 100644 contracts/early-bird-membership/v2/SHA256SUMS create mode 100644 contracts/early-bird-membership/v2/command.fixture.json create mode 100644 contracts/early-bird-membership/v2/command.schema.json create mode 100644 contracts/early-bird-membership/v2/result.fixture.json create mode 100644 contracts/early-bird-membership/v2/result.schema.json create mode 100644 docs/architecture/LISTENER_FOUNDER_CONTINUITY.md delete mode 100644 docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md create mode 100644 prisma/migrations/20260810223000_listener_founder_continuity/migration.sql delete mode 100644 src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts delete mode 100644 src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/route.ts rename src/app/api/internal/{v1 => v2}/early-bird-memberships/[accountId]/__tests__/route.test.ts (68%) rename src/app/api/internal/{v1 => v2}/early-bird-memberships/[accountId]/route.ts (96%) create mode 100644 src/lib/early-birds/__tests__/founder-continuity-contract.test.ts create mode 100644 src/lib/early-birds/__tests__/founder-continuity.postgres.test.ts delete mode 100644 src/lib/early-birds/__tests__/founder-eligibility-contract.test.ts delete mode 100644 src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts delete mode 100644 src/lib/early-birds/__tests__/founder-eligibility.test.ts delete mode 100644 src/lib/early-birds/founder-eligibility.ts diff --git a/contracts/early-bird-authority/v3/README.md b/contracts/early-bird-authority/v3/README.md new file mode 100644 index 00000000..91b5aaa2 --- /dev/null +++ b/contracts/early-bird-authority/v3/README.md @@ -0,0 +1,12 @@ +# 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. diff --git a/contracts/early-bird-authority/v3/SHA256SUMS b/contracts/early-bird-authority/v3/SHA256SUMS new file mode 100644 index 00000000..807a5546 --- /dev/null +++ b/contracts/early-bird-authority/v3/SHA256SUMS @@ -0,0 +1,3 @@ +e92e9b616d4b0ebb7699c25628772fdf2c8b33baafaf2cdb737ba0dea1534741 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-membership/v2/README.md b/contracts/early-bird-membership/v2/README.md new file mode 100644 index 00000000..70b9cfc5 --- /dev/null +++ b/contracts/early-bird-membership/v2/README.md @@ -0,0 +1,10 @@ +# 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. diff --git a/contracts/early-bird-membership/v2/SHA256SUMS b/contracts/early-bird-membership/v2/SHA256SUMS new file mode 100644 index 00000000..1d044c59 --- /dev/null +++ b/contracts/early-bird-membership/v2/SHA256SUMS @@ -0,0 +1,5 @@ +199256c185cb977f109802984a7674cac887f5edb664ebfa637c06fbdf39bd45 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/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 55fd41f0..94d9b96c 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -63,20 +63,24 @@ auth plugin stay hidden and fail closed. ## Canonical membership boundary -Byte-exact copies live in `contracts/early-bird-authority/v1` and -`contracts/early-bird-membership/v1`. Verify them with `npm run contract:early-birds:verify`. +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 revisions to - `PUT /api/internal/v1/early-bird-memberships/{account_id}`. Beacon requires rotating Bearer/key-id +- 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 for exactly the twelve required - fields. Higher revisions are `APPLIED`, byte-semantic repeats are `REPLAYED`, lower revisions are +- 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. -- `ACTIVE`, time-valid `GRACE`, and time-valid `CANCELLED_PENDING_END` allow access. Every missing, - expired, revoked, refunded or unavailable state fails closed. +- 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 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_FOUNDER_ELIGIBILITY.md b/docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md deleted file mode 100644 index 5a0c9974..00000000 --- a/docs/architecture/LISTENER_FOUNDER_ELIGIBILITY.md +++ /dev/null @@ -1,70 +0,0 @@ -# Listener Founder price continuity projection - -> **Product amendment — 2026-08-10:** the positive-only lifetime eligibility -> implementation described below is superseded and must not be enabled for -> production commerce. "Lifetime" now means only the uninterrupted lifetime of -> an active Founder subscription. Once paid-through/grace continuity ends, -> Founder status and USD 5 eligibility end; later signup uses the current public -> offer. This document retains the deployed design history so migration and -> rollback can be reviewed, but the authority and Listener projections require -> a coordinated forward-only correction before provider activation. - -## Boundary - -PMP Myth Bot is the only authority that can project Founding Listener price continuity. Its private -membership read v2 is versioned in `contracts/early-bird-authority/v2` and was introduced by backend -merge `3febc1d525adf150bfdd75fd2b98b04771cb79b7`. - -The historical implementation stores a positive-only projection of that evidence in -`early_bird_founder_eligibility_projections`. This row is deliberately separate from the v1 -membership projection: - -- it has no membership revision and never participates in stream authorization; -- its hash covers only the canonical eligibility object; -- RFC 3339 timestamps are normalized to UTC millisecond precision before hashing and storage; -- the first positive value is immutable; -- `null` never deletes or downgrades an existing positive value; -- Free, welcome access, invitations and Free For All never create the row. - -The existing membership push route, public page, access resolver, leases and presentation remain -unchanged. Founder eligibility alone cannot grant listening access or produce a Purchase event. - -## Reconciliation - -An authenticated server-side caller may request: - -```text -POST /api/internal/v1/early-bird-founder-eligibilities/{account_id}/reconcile -Authorization: Bearer -X-HB-Service-Key-Id: -``` - -The route authenticates and verifies the local opaque account before contacting the authority. It -then performs a bounded, no-store GET of the private v2 membership document and applies only -`founder_price_eligibility` transactionally. - -Successful outcomes are: - -- `ABSENT`: the authority returned `null` and no local positive evidence exists; -- `APPLIED`: the first canonical positive evidence was stored; -- `REPLAYED`: the exact evidence already exists. - -Conflicting positive evidence, positive-to-null transitions and an authority 404 for an existing -local account return 409 and preserve local state. Authority timeout, authentication failure, 5xx, -oversized or malformed bodies and account mismatches return a generic 503 without mutation. -Unknown local accounts return 404 before outbound I/O. Unexpected database failures return a -generic 500 and roll back the transaction. - -The endpoint is not invoked by page rendering, OAuth, stream lease creation or the v1 push route. -Automation can be added later as an operations worker without changing the evidence semantics. -`observed_at` records the first successful local observation and is intentionally unchanged on replay. - -## Migration and rollback - -The migration is forward-only and additive. Deploying code before the migration is prohibited; -deploy applies the migration before selecting an image. Rolling the application image back is safe -because older code ignores the new table. The table is retained during rollback so positive -eligibility evidence is never destroyed. - -This subsystem belongs only to the isolated Listener product. It does not modify weekend events, -LiveKit, event tickets, tapestry, event audio or Proyección del Mito. diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index cdfd20dd..68b3db5e 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -137,10 +137,11 @@ deployed image; later documentation-only commits do not require rebuilding it. - #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 was reopened after the 2026-08-10 product amendment: the former - positive-only lifetime eligibility must become continuity-bound before any - production provider is enabled. Provider acceptance remains separately - tracked by #199/#200/#261. +- #197 was reopened after the 2026-08-10 product amendment. The continuity-bound + authority/Listener correction is implemented in coordinated review branches; + production providers remain disabled until both sides merge and staging proves + activation, paid-through/grace and terminal badge removal. Provider acceptance + remains separately tracked by #199/#200/#261. - #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 diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 5e604fb0..ef3f5393 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -464,10 +464,11 @@ age-specific profiles. 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/v1`: the checkout, invitation-redeem and - membership authority family. -- `contracts/early-bird-authority/v2` and `contracts/early-bird-membership/v1`: - the canonical founder membership reads. +- `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 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/schema.prisma b/prisma/schema.prisma index f2b2c22f..60b9ed67 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -99,6 +99,13 @@ enum EarlyBirdMembershipSource { 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 { @@ -445,7 +452,6 @@ model EarlyBirdUser { identities EarlyBirdIdentity[] authSessions EarlyBirdAuthSession[] membership EarlyBirdMembershipProjection? - founderEligibility EarlyBirdFounderEligibilityProjection? freeSchedule EarlyBirdFreeSchedule? welcomeAccess EarlyBirdWelcomeAccess? streamLeases EarlyBirdStreamLease[] @@ -523,51 +529,43 @@ model EarlyBirdMagicLinkThrottle { // 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) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + 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") } -// Positive-only projection of the canonical, lifetime Founder price evidence. -// It is deliberately separate from membership/access revisions and authorizes -// nothing by itself. -model EarlyBirdFounderEligibilityProjection { - accountId String @id @map("account_id") - account EarlyBirdUser @relation(fields: [accountId], references: [id], onDelete: Cascade) - offerCode String @map("offer_code") @db.VarChar(128) - offerRevision Int @map("offer_revision") - currency String @db.Char(3) - amountMinor Int @map("amount_minor") - billingPeriod String @map("billing_period") @db.VarChar(32) - grantedAt DateTime @map("granted_at") - eligibilityHash String @map("eligibility_hash") @db.Char(64) - observedAt DateTime @map("observed_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - @@index([grantedAt]) - @@map("early_bird_founder_eligibility_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. diff --git a/scripts/verify-early-bird-contracts.py b/scripts/verify-early-bird-contracts.py index 42cca7b1..6797339c 100644 --- a/scripts/verify-early-bird-contracts.py +++ b/scripts/verify-early-bird-contracts.py @@ -9,8 +9,10 @@ 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", ) diff --git a/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts b/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts deleted file mode 100644 index bf8fb35a..00000000 --- a/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/__tests__/route.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { NextRequest } from 'next/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - authorize: vi.fn(), - findAccount: vi.fn(), - readMembership: vi.fn(), - apply: vi.fn(), -})); - -vi.mock('@/lib/early-birds/service-auth', () => ({ - authorizeEarlyBirdMembershipService: mocks.authorize, -})); -vi.mock('@/lib/db', () => ({ - prisma: { earlyBirdUser: { findUnique: mocks.findAccount } }, -})); -vi.mock('@/lib/early-birds/membership-gateway', async (importOriginal) => ({ - ...await importOriginal(), - earlyBirdMembershipReader: () => ({ readMembership: mocks.readMembership }), -})); -vi.mock('@/lib/early-birds/founder-eligibility', async (importOriginal) => ({ - ...await importOriginal(), - applyFounderEligibilityProjection: mocks.apply, -})); - -import { FounderEligibilityConflictError } from '@/lib/early-birds/founder-eligibility'; -import { EarlyBirdMembershipGatewayUnavailableError } from '@/lib/early-birds/membership-gateway'; - -import { POST } from '../route'; - -const ACCOUNT = 'listener-1'; -const 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', -}; -const canonical = { - schema_version: 'early-bird-authority.membership.v2', - account_id: ACCOUNT, - 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: eligibility, -}; - -function request(headers: Record = {}) { - return new NextRequest( - `http://beacon-app:3000/api/internal/v1/early-bird-founder-eligibilities/${ACCOUNT}/reconcile`, - { - method: 'POST', - headers: { - authorization: 'Bearer secret-not-logged', - 'x-hb-service-key-id': 'current', - ...headers, - }, - }, - ); -} - -const params = { params: Promise.resolve({ accountId: ACCOUNT }) }; - -describe('private Founder eligibility reconciliation route', () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.authorize.mockReturnValue(true); - mocks.findAccount.mockResolvedValue({ id: ACCOUNT }); - mocks.readMembership.mockResolvedValue({ ok: true, membership: canonical }); - mocks.apply.mockResolvedValue('APPLIED'); - vi.spyOn(console, 'error').mockImplementation(() => undefined); - }); - - it('authenticates before local lookup or outbound authority access', async () => { - mocks.authorize.mockReturnValue(false); - const response = await POST(request(), params); - expect(response.status).toBe(401); - expect(response.headers.get('cache-control')).toBe('private, no-store'); - expect(mocks.findAccount).not.toHaveBeenCalled(); - expect(mocks.readMembership).not.toHaveBeenCalled(); - expect(mocks.apply).not.toHaveBeenCalled(); - }); - - it('rejects an unknown local account without contacting the authority', async () => { - mocks.findAccount.mockResolvedValue(null); - const response = await POST(request(), params); - expect(response.status).toBe(404); - expect(mocks.readMembership).not.toHaveBeenCalled(); - }); - - it.each(['ABSENT', 'APPLIED', 'REPLAYED'] as const)('returns only the sanitized %s outcome', async (outcome) => { - mocks.apply.mockResolvedValue(outcome); - if (outcome === 'ABSENT') { - mocks.readMembership.mockResolvedValue({ - ok: true, - membership: { ...canonical, founder_price_eligibility: null }, - }); - } - const response = await POST(request(), params); - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toBe('private, no-store'); - expect(await response.json()).toEqual({ - schema_version: 'listener-founder-eligibility-reconciliation.result.v1', - outcome, - founder_price_eligible: outcome !== 'ABSENT', - }); - expect(mocks.apply).toHaveBeenCalledWith( - ACCOUNT, - outcome === 'ABSENT' ? null : eligibility, - ); - }); - - it('maps authority absence and durable evidence conflict to generic 409', async () => { - mocks.readMembership.mockResolvedValue({ ok: false, reason: 'not-found' }); - const absent = await POST(request(), params); - expect(absent.status).toBe(409); - - mocks.readMembership.mockResolvedValue({ ok: true, membership: canonical }); - mocks.apply.mockRejectedValue(new FounderEligibilityConflictError()); - const conflict = await POST(request(), params); - expect(conflict.status).toBe(409); - expect(await conflict.json()).toEqual({ error: 'Founder eligibility reconciliation conflict.' }); - }); - - it('maps authority and database failures without leaking their material', async () => { - mocks.readMembership.mockRejectedValue(new EarlyBirdMembershipGatewayUnavailableError()); - const authority = await POST(request(), params); - expect(authority.status).toBe(503); - expect(await authority.json()).toEqual({ error: 'Founder eligibility authority unavailable.' }); - - mocks.readMembership.mockResolvedValue({ ok: true, membership: canonical }); - mocks.apply.mockRejectedValue(new Error('subscription secret provider body')); - const database = await POST(request(), params); - expect(database.status).toBe(500); - expect(JSON.stringify(await database.json())).not.toContain('subscription secret provider body'); - }); -}); diff --git a/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/route.ts b/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/route.ts deleted file mode 100644 index a2cc607e..00000000 --- a/src/app/api/internal/v1/early-bird-founder-eligibilities/[accountId]/reconcile/route.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -import { prisma } from '@/lib/db'; -import { - applyFounderEligibilityProjection, - FounderEligibilityAccountNotFoundError, - FounderEligibilityConflictError, -} from '@/lib/early-birds/founder-eligibility'; -import { - earlyBirdMembershipReader, - EarlyBirdMembershipGatewayUnavailableError, -} from '@/lib/early-birds/membership-gateway'; -import { isEarlyBirdAccountId } from '@/lib/early-birds/account-id'; -import { authorizeEarlyBirdMembershipService } from '@/lib/early-birds/service-auth'; - -export const dynamic = 'force-dynamic'; - -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'), - ); -} - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ accountId: string }> }, -): Promise { - if (!authorized(request)) return response({ error: 'Service authentication failed.' }, 401); - const { accountId } = await params; - if (!isEarlyBirdAccountId(accountId)) return response({ error: 'Resource not found.' }, 404); - - try { - const account = await prisma.earlyBirdUser.findUnique({ - where: { id: accountId }, - select: { id: true }, - }); - if (!account) return response({ error: 'Resource not found.' }, 404); - } catch { - console.error('[founder-eligibility] local account lookup failed'); - return response({ error: 'Founder eligibility reconciliation unavailable.' }, 500); - } - - let canonical; - try { - canonical = await earlyBirdMembershipReader().readMembership(accountId); - } catch (error) { - if (!(error instanceof EarlyBirdMembershipGatewayUnavailableError)) { - console.error('[founder-eligibility] unexpected authority read failure'); - } - return response({ error: 'Founder eligibility authority unavailable.' }, 503); - } - if (!canonical.ok) { - return response({ error: 'Founder eligibility reconciliation conflict.' }, 409); - } - - try { - const outcome = await applyFounderEligibilityProjection( - accountId, - canonical.membership.founder_price_eligibility, - ); - return response({ - schema_version: 'listener-founder-eligibility-reconciliation.result.v1', - outcome, - founder_price_eligible: canonical.membership.founder_price_eligibility !== null, - }); - } catch (error) { - if (error instanceof FounderEligibilityAccountNotFoundError) { - return response({ error: 'Resource not found.' }, 404); - } - if (error instanceof FounderEligibilityConflictError) { - return response({ error: 'Founder eligibility reconciliation conflict.' }, 409); - } - console.error('[founder-eligibility] projection failed without authority material'); - return response({ error: 'Founder eligibility reconciliation unavailable.' }, 500); - } -} diff --git a/src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts b/src/app/api/internal/v2/early-bird-memberships/[accountId]/__tests__/route.test.ts similarity index 68% rename from src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts rename to src/app/api/internal/v2/early-bird-memberships/[accountId]/__tests__/route.test.ts index 4b01a092..5f88e0de 100644 --- a/src/app/api/internal/v1/early-bird-memberships/[accountId]/__tests__/route.test.ts +++ b/src/app/api/internal/v2/early-bird-memberships/[accountId]/__tests__/route.test.ts @@ -21,8 +21,20 @@ vi.mock('@/lib/db', () => ({ import { GET, PUT } from '../route'; 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.v1', + schema_version: 'early-bird-membership.command.v2', account_id: ACCOUNT, membership_revision: 3, state: 'ACTIVE', @@ -34,6 +46,7 @@ const command = { provider: 'paypal', current_price: { currency: 'USD', amount_minor: 500 }, reason_code: 'PAYMENT_SUCCEEDED', + founder_continuity: continuity, }; const projection = { id: 'eb100000-0000-4000-8000-000000000001', @@ -48,16 +61,28 @@ const projection = { paidThrough: new Date('2026-09-06T12:00:00Z'), graceUntil: null, provider: 'paypal', - amountMinor: 200, + 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/v1/early-bird-memberships/${ACCOUNT}`, { + return new NextRequest(`http://beacon-app:3000/api/internal/v2/early-bird-memberships/${ACCOUNT}`, { method: 'PUT', headers: { authorization: 'Bearer secret-not-logged', @@ -72,7 +97,7 @@ function put(body: unknown = command, headers: Record = {}) { const params = { params: Promise.resolve({ accountId: ACCOUNT }) }; -describe('private EarlyBird membership projection route', () => { +describe('private EarlyBird membership projection v2 route', () => { beforeEach(() => { vi.clearAllMocks(); mocks.authorize.mockReturnValue(true); @@ -97,22 +122,22 @@ describe('private EarlyBird membership projection route', () => { }); }); - it('authenticates before parsing and enforces exact fields and idempotency', async () => { + 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); - const unknown = await PUT(put({ ...command, unexpected: true }), params); - expect(unknown.status).toBe(422); - const idempotency = await PUT(put(command, { 'idempotency-key': 'wrong' }), params); - expect(idempotency.status).toBe(422); + 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 for reconciliation', async () => { + it('returns the non-secret current projection acknowledgement', async () => { const request = new NextRequest( - `http://beacon-app:3000/api/internal/v1/early-bird-memberships/${ACCOUNT}`, + `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); diff --git a/src/app/api/internal/v1/early-bird-memberships/[accountId]/route.ts b/src/app/api/internal/v2/early-bird-memberships/[accountId]/route.ts similarity index 96% rename from src/app/api/internal/v1/early-bird-memberships/[accountId]/route.ts rename to src/app/api/internal/v2/early-bird-memberships/[accountId]/route.ts index a692c262..b005d518 100644 --- a/src/app/api/internal/v1/early-bird-memberships/[accountId]/route.ts +++ b/src/app/api/internal/v2/early-bird-memberships/[accountId]/route.ts @@ -29,6 +29,8 @@ function authorized(request: NextRequest): boolean { ); } +// 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, diff --git a/src/app/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx index e5913dbf..dab36488 100644 --- a/src/app/early-birds/__tests__/page.test.tsx +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -6,11 +6,15 @@ const mocks = vi.hoisted(() => ({ 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, @@ -51,6 +55,28 @@ const availableQuota = { }; describe('EarlyBird Listener page', () => { + 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'); @@ -147,7 +173,20 @@ describe('EarlyBird Listener page', () => { 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', }, diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index 5440a743..eb9a801d 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -1,4 +1,5 @@ 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'; @@ -52,6 +53,20 @@ export default async function EarlyBirdsPage({ const checkoutAvailability = listenerStagingHost ? listenerCheckoutAvailability() : { paypal: false, mercadoPago: false }; + const params = await searchParams; + const paypalReturn = params.paypal; + const checkoutReturn = params.checkout; + if (listenerStagingHost && ( + 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 ( @@ -69,7 +84,6 @@ export default async function EarlyBirdsPage({ ); } - const params = await searchParams; const sessionResolution = await currentEarlyBirdSession() .then((session) => ({ session, unavailable: false as const })) .catch(() => ({ session: null, unavailable: true as const })); diff --git a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx index 42bdefa7..ae1abcb7 100644 --- a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx @@ -30,6 +30,21 @@ import EarlyBirdHome from '../EarlyBirdHome'; afterEach(cleanup); describe('EarlyBird Listener home access chrome', () => { + it('labels a canonically active paid membership as Founding Listener', () => { + render( + + + , + ); + + expect(screen.getByText('Founding Listener · USD 5/month')).toBeInTheDocument(); + expect(screen.queryByText('Preview access')).not.toBeInTheDocument(); + }); + it('keeps account controls for a membership-backed Listener', () => { render( @@ -106,7 +121,7 @@ describe('EarlyBird Listener home access chrome', () => { , ); - expect(screen.getByText('Founder · active until the end of the period')).toBeInTheDocument(); + expect(screen.getByText('Founding Listener · USD 5/month · active through period end')).toBeInTheDocument(); expect(screen.getByText('Mercado Pago')).toBeInTheDocument(); expect(screen.queryByText('MERCADO_PAGO')).not.toBeInTheDocument(); }); diff --git a/src/lib/early-birds/__tests__/access.test.ts b/src/lib/early-birds/__tests__/access.test.ts index aeac6bc0..8abcbf09 100644 --- a/src/lib/early-birds/__tests__/access.test.ts +++ b/src/lib/early-birds/__tests__/access.test.ts @@ -20,13 +20,25 @@ function membership(overrides: Partial = {}): Ear offerCode: 'EARLY_BIRDS_FOUNDERS_V1', offerRevision: 1, effectiveAt: NOW, - paidThrough: null, + paidThrough: new Date('2026-09-08T12:00:00.000Z'), graceUntil: null, provider: 'paypal', - amountMinor: 200, + amountMinor: 500, currency: 'USD', reasonCode: 'PAYMENT_CONFIRMED', synthetic: false, + founderContinuityEpisodeId: '00000000-0000-4000-8000-000000000101', + founderContinuityRevision: 1, + founderContinuityState: 'ACTIVE', + founderContinuityOfferCode: 'EARLY_BIRDS_FOUNDERS_V1', + founderContinuityOfferRevision: 1, + founderContinuityCurrency: 'USD', + founderContinuityAmountMinor: 500, + founderContinuityBillingPeriod: 'MONTHLY', + founderContinuityActivatedAt: NOW, + founderContinuityServiceThrough: new Date('2026-09-08T12:00:00.000Z'), + founderContinuityEndedAt: null, + founderContinuityTerminalReason: null, createdAt: NOW, updatedAt: NOW, ...overrides, @@ -55,7 +67,7 @@ describe('Listener access contract', () => { expect(listeningAccessDecision(membership(), quota(), NOW)).toMatchObject({ allowed: true, kind: 'membership', - allowedUntil: null, + allowedUntil: new Date('2026-09-08T12:00:00.000Z'), quota: null, serverNow: NOW, }); diff --git a/src/lib/early-birds/__tests__/founder-continuity-contract.test.ts b/src/lib/early-birds/__tests__/founder-continuity-contract.test.ts new file mode 100644 index 00000000..8277b006 --- /dev/null +++ b/src/lib/early-birds/__tests__/founder-continuity-contract.test.ts @@ -0,0 +1,178 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { HttpEarlyBirdMembershipGateway } from '../membership-gateway'; +import { + EarlyBirdMembershipContractError, + authorityMembershipCommand, + parseCanonicalAuthorityMembershipV3, + parseMembershipProjectionCommand, +} from '../membership-contract'; + +const continuity = { + episode_id: '00000000-0000-4000-8000-000000000101', + revision: 3, + 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: '2027-08-08T12:00:00Z', + ended_at: null, + terminal_reason: null, +}; +const vendoredFixture = JSON.parse(readFileSync(resolve( + process.cwd(), + 'contracts/early-bird-authority/v3/membership.fixture.json', +), 'utf8')) as Record; + +const active: Record = { + schema_version: 'early-bird-authority.membership.v3', + account_id: 'listener-1', + membership_revision: 8, + state: 'ACTIVE', + source: 'PAYPAL', + access_allowed: true, + effective_at: '2026-08-08T12:00:00Z', + paid_through: '2027-08-08T12: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: 'PAYMENT_SUCCEEDED', + founder_continuity: continuity, +}; + +function cancellableResponse(status: number, headers: Record = {}) { + let cancelled = false; + const response = new Response(new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array([0x7b])); + }, + cancel() { + cancelled = true; + }, + }, { highWaterMark: 0 }), { status, headers }); + return { response, wasCancelled: () => cancelled }; +} + +describe('canonical Founder continuity contracts', () => { + it('parses current, ended tombstone and non-Founder snapshots', () => { + expect(parseCanonicalAuthorityMembershipV3(vendoredFixture).founder_continuity) + .toMatchObject({ state: 'ENDED', terminal_reason: 'PERIOD_ENDED' }); + expect(parseCanonicalAuthorityMembershipV3(active).founder_continuity) + .toMatchObject({ state: 'ACTIVE', activated_at: '2026-08-06T12:00:00.000Z' }); + + const ended = parseCanonicalAuthorityMembershipV3({ + ...active, + state: 'EXPIRED', + access_allowed: false, + paid_through: '2026-08-09T12:00:00Z', + reason_code: 'SERVICE_ENDED', + founder_continuity: { + ...continuity, + revision: 4, + state: 'ENDED', + service_through: '2026-08-09T12:00:00Z', + ended_at: '2026-08-09T12:00:00Z', + terminal_reason: 'SERVICE_ENDED', + }, + }); + expect(ended.founder_continuity?.state).toBe('ENDED'); + + expect(parseCanonicalAuthorityMembershipV3({ + ...active, + state: 'ACTIVE', + source: 'FREE', + provider: null, + paid_through: null, + current_price: null, + founder_continuity: null, + }).founder_continuity).toBeNull(); + }); + + it.each([ + ['extra top-level field', { ...active, email: 'private@example.invalid' }], + ['missing continuity', Object.fromEntries(Object.entries(active).filter(([key]) => key !== 'founder_continuity'))], + ['wrong amount', { ...active, founder_continuity: { ...continuity, canonical_price: { currency: 'USD', amount_minor: 700 } } }], + ['invalid episode', { ...active, founder_continuity: { ...continuity, episode_id: 'provider-subscription-id' } }], + ['ended without tombstone', { ...active, state: 'EXPIRED', access_allowed: false, founder_continuity: { ...continuity, state: 'ENDED' } }], + ['current with terminal evidence', { ...active, founder_continuity: { ...continuity, ended_at: '2026-08-09T12:00:00Z', terminal_reason: 'ENDED' } }], + ['paid access without continuity', { ...active, founder_continuity: null }], + ['continuity on Free', { ...active, source: 'FREE', provider: null }], + ['state mismatch', { ...active, state: 'GRACE', grace_until: '2027-08-08T12:00:00Z' }], + ['boundary mismatch', { ...active, paid_through: '2027-09-08T12:00:00Z' }], + ])('rejects %s', (_label, payload) => { + expect(() => parseCanonicalAuthorityMembershipV3(payload)) + .toThrow(EarlyBirdMembershipContractError); + }); + + it('produces only command v2 and rejects command v1', () => { + const membership = parseCanonicalAuthorityMembershipV3(active); + const command = authorityMembershipCommand(membership); + expect(command).toMatchObject({ + schema_version: 'early-bird-membership.command.v2', + founder_continuity: { episode_id: continuity.episode_id, state: 'ACTIVE' }, + }); + expect(parseMembershipProjectionCommand(command)).toEqual(command); + expect(() => parseMembershipProjectionCommand({ + ...command, + schema_version: 'early-bird-membership.command.v1', + })).toThrow(EarlyBirdMembershipContractError); + }); + + it('uses the exact private v3 GET and rejects mismatches or oversized bodies', async () => { + const request = vi.fn().mockResolvedValue(new Response(JSON.stringify(active), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + const gateway = new HttpEarlyBirdMembershipGateway({ + baseUrl: 'http://pmp-myth-api:8765', + keyId: '2026-08-current', + token: 's'.repeat(43), + }, request); + + await expect(gateway.readMembership('listener-1')).resolves.toMatchObject({ + ok: true, + membership: { account_id: 'listener-1' }, + }); + expect(request).toHaveBeenCalledWith( + 'http://pmp-myth-api:8765/api/internal/v3/early-bird-memberships/listener-1', + expect.objectContaining({ method: 'GET', redirect: 'error', cache: 'no-store' }), + ); + + request.mockResolvedValueOnce(new Response(JSON.stringify({ ...active, account_id: 'other' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); + + const oversized = cancellableResponse(200, { + 'content-type': 'application/json', + 'content-length': String(65 * 1024), + }); + request.mockResolvedValueOnce(oversized.response); + await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); + expect(oversized.wasCancelled()).toBe(true); + }); + + it('distinguishes canonical not-found and fails closed on other statuses', async () => { + const notFound = cancellableResponse(404); + const request = vi.fn().mockResolvedValue(notFound.response); + const gateway = new HttpEarlyBirdMembershipGateway({ + baseUrl: 'https://authority.example.test', + keyId: 'current', + token: 's'.repeat(43), + }, request); + await expect(gateway.readMembership('listener-1')).resolves.toEqual({ ok: false, reason: 'not-found' }); + expect(notFound.wasCancelled()).toBe(true); + + const unauthorized = cancellableResponse(401); + request.mockResolvedValueOnce(unauthorized.response); + await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); + expect(unauthorized.wasCancelled()).toBe(true); + }); +}); diff --git a/src/lib/early-birds/__tests__/founder-continuity.postgres.test.ts b/src/lib/early-birds/__tests__/founder-continuity.postgres.test.ts new file mode 100644 index 00000000..f9be417d --- /dev/null +++ b/src/lib/early-birds/__tests__/founder-continuity.postgres.test.ts @@ -0,0 +1,204 @@ +import { randomUUID } from 'node:crypto'; + +import type { PrismaClient } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + applyMembershipProjection, + EarlyBirdProjectionConflictError, + membershipAccessDecision, + type EarlyBirdMembershipProjectionCommand, +} from '../membership'; + +const listenerDatabaseUrl = process.env.LISTENER_TEST_DATABASE_URL; +if (listenerDatabaseUrl) process.env.DATABASE_URL = listenerDatabaseUrl; +const postgres = listenerDatabaseUrl ? describe : describe.skip; +let prisma: PrismaClient; +const suffix = randomUUID().slice(0, 8); +const accounts = [ + `listener-continuity-pg-replay-${suffix}`, + `listener-continuity-pg-conflict-${suffix}`, + `listener-continuity-pg-cutover-${suffix}`, +]; +const ACTIVATED = '2026-08-10T12:00:00.000Z'; +const THROUGH = '2026-09-10T12:00:00.000Z'; + +function command( + accountId: string, + overrides: Partial = {}, +): EarlyBirdMembershipProjectionCommand { + return { + schema_version: 'early-bird-membership.command.v2', + account_id: accountId, + membership_revision: 1, + state: 'ACTIVE', + source: 'PAYPAL', + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, + effective_at: ACTIVATED, + paid_through: THROUGH, + grace_until: null, + provider: 'paypal', + current_price: { currency: 'USD', amount_minor: 500 }, + reason_code: 'PAYMENT_SUCCEEDED', + founder_continuity: { + episode_id: randomUUID(), + revision: 1, + state: 'ACTIVE', + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, + canonical_price: { currency: 'USD', amount_minor: 500 }, + billing_period: 'MONTHLY', + activated_at: ACTIVATED, + service_through: THROUGH, + ended_at: null, + terminal_reason: null, + }, + ...overrides, + }; +} + +postgres('Listener Founder continuity PostgreSQL projection', () => { + beforeAll(async () => { + ({ prisma } = await import('@/lib/db')); + await prisma.earlyBirdUser.createMany({ + data: accounts.map((id, index) => ({ + id, + name: `Continuity Test ${index}`, + email: `continuity-${suffix}-${index}@example.invalid`, + emailVerified: true, + })), + }); + }); + + afterAll(async () => { + for (const accountId of accounts) { + await prisma.$executeRaw` + DELETE FROM "early_bird_retired_membership_projection_audit" + WHERE "account_id" = ${accountId} + `; + } + await prisma.earlyBirdUser.deleteMany({ where: { id: { in: accounts } } }); + await prisma.$disconnect(); + }); + + it('atomically converges concurrent exact retries to one continuity snapshot', async () => { + const exact = command(accounts[0]); + const results = await Promise.all([ + applyMembershipProjection(exact), + applyMembershipProjection(exact), + ]); + expect(results.map((result) => result.outcome).sort()).toEqual(['APPLIED', 'REPLAYED']); + const projection = await prisma.earlyBirdMembershipProjection.findUniqueOrThrow({ + where: { accountId: accounts[0] }, + }); + expect(projection).toMatchObject({ + revision: 1, + founderContinuityEpisodeId: exact.founder_continuity?.episode_id, + founderContinuityState: 'ACTIVE', + founderContinuityAmountMinor: 500, + }); + expect(membershipAccessDecision(projection, new Date('2026-08-11T12:00:00.000Z')).allowed).toBe(true); + }); + + it('rejects a conflicting concurrent revision and persists an ENDED tombstone', async () => { + const first = command(accounts[1]); + const conflicting = command(accounts[1], { + founder_continuity: { ...first.founder_continuity!, episode_id: randomUUID() }, + }); + const results = await Promise.allSettled([ + applyMembershipProjection(first), + applyMembershipProjection(conflicting), + ]); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + const rejected = results.find((result) => result.status === 'rejected'); + expect(rejected).toMatchObject({ reason: expect.any(EarlyBirdProjectionConflictError) }); + + const persisted = await prisma.earlyBirdMembershipProjection.findUniqueOrThrow({ + where: { accountId: accounts[1] }, + }); + + const endedAt = '2026-09-10T12:00:00.000Z'; + await applyMembershipProjection(command(accounts[1], { + membership_revision: 2, + state: 'EXPIRED', + paid_through: endedAt, + reason_code: 'SERVICE_ENDED', + founder_continuity: { + ...first.founder_continuity!, + episode_id: persisted.founderContinuityEpisodeId!, + revision: 2, + state: 'ENDED', + service_through: endedAt, + ended_at: endedAt, + terminal_reason: 'SERVICE_ENDED', + }, + })); + const ended = await prisma.earlyBirdMembershipProjection.findUniqueOrThrow({ + where: { accountId: accounts[1] }, + }); + expect(ended).toMatchObject({ + founderContinuityState: 'ENDED', + founderContinuityEndedAt: new Date(endedAt), + founderContinuityTerminalReason: 'SERVICE_ENDED', + }); + expect(membershipAccessDecision(ended, new Date('2026-09-11T12:00:00.000Z')).allowed).toBe(false); + }); + + it('keeps the retired positive-only rows audit-only and outside Prisma runtime', async () => { + const tables = await prisma.$queryRaw>` + SELECT + to_regclass('early_bird_retired_founder_eligibility_audit')::text AS "retiredEligibility", + to_regclass('early_bird_retired_membership_projection_audit')::text AS "retiredMembership", + to_regclass('early_bird_founder_eligibility_projections')::text AS "activeEligibility" + `; + expect(tables).toEqual([{ + retiredEligibility: 'early_bird_retired_founder_eligibility_audit', + retiredMembership: 'early_bird_retired_membership_projection_audit', + activeEligibility: null, + }]); + expect('earlyBirdFounderEligibilityProjection' in prisma).toBe(false); + }); + + it('retires a v1 hash so command.v2 can converge at the same membership revision', async () => { + const accountId = accounts[2]; + await prisma.earlyBirdMembershipProjection.create({ + data: { + accountId, + revision: 7, + commandHash: '1'.repeat(64), + state: 'ACTIVE', + source: 'PAYPAL', + offerCode: 'EARLY_BIRDS_FOUNDERS_V1', + offerRevision: 1, + effectiveAt: new Date(ACTIVATED), + paidThrough: new Date(THROUGH), + provider: 'paypal', + amountMinor: 500, + currency: 'USD', + reasonCode: 'LEGACY_V1_SYNTHETIC', + }, + }); + await prisma.$executeRaw` + INSERT INTO "early_bird_retired_membership_projection_audit" + SELECT * FROM "early_bird_membership_projections" + WHERE "account_id" = ${accountId} + `; + await prisma.earlyBirdMembershipProjection.delete({ where: { accountId } }); + + const applied = await applyMembershipProjection(command(accountId, { membership_revision: 7 })); + expect(applied.outcome).toBe('APPLIED'); + expect(applied.projection).toMatchObject({ + revision: 7, + founderContinuityState: 'ACTIVE', + }); + const retired = await prisma.$queryRaw>` + SELECT "command_hash" AS "commandHash" + FROM "early_bird_retired_membership_projection_audit" + WHERE "account_id" = ${accountId} + `; + expect(retired).toEqual([{ commandHash: '1'.repeat(64) }]); + }); +}); diff --git a/src/lib/early-birds/__tests__/founder-eligibility-contract.test.ts b/src/lib/early-birds/__tests__/founder-eligibility-contract.test.ts deleted file mode 100644 index 83bc92de..00000000 --- a/src/lib/early-birds/__tests__/founder-eligibility-contract.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { describe, expect, it, vi } from 'vitest'; - -import { - HttpEarlyBirdMembershipGateway, -} from '../membership-gateway'; -import { - EarlyBirdMembershipContractError, - parseCanonicalAuthorityMembershipV2, -} from '../membership-contract'; - -const CONTRACT = resolve(process.cwd(), 'contracts/early-bird-authority/v2'); -const fixture = JSON.parse(readFileSync(`${CONTRACT}/membership.fixture.json`, 'utf8')) as Record; -const active: Record = { - ...fixture, - account_id: 'listener-1', - state: 'ACTIVE', - access_allowed: true, - effective_at: '2026-08-08T12:00:00Z', - paid_through: '2027-08-08T12:00:00Z', - reason_code: 'PAYMENT_SUCCEEDED', -}; - -function cancellableResponse(status: number, headers: Record = {}) { - let cancelled = false; - const response = new Response(new ReadableStream({ - pull(controller) { - controller.enqueue(new Uint8Array([0x7b])); - }, - cancel() { - cancelled = true; - }, - }, { highWaterMark: 0 }), { status, headers }); - return { response, wasCancelled: () => cancelled }; -} - -describe('canonical Founder membership read v2', () => { - it('parses the byte-vendored positive fixture and a Free null eligibility', () => { - expect(parseCanonicalAuthorityMembershipV2(fixture).founder_price_eligibility) - .toEqual({ - ...(fixture.founder_price_eligibility as object), - granted_at: '2026-08-06T12:00:00.000Z', - }); - expect(parseCanonicalAuthorityMembershipV2({ - ...active, - source: 'FREE', - provider: null, - current_price: null, - founder_price_eligibility: null, - }).founder_price_eligibility).toBeNull(); - }); - - it.each([ - ['extra top-level field', { ...active, email: 'private@example.invalid' }], - ['missing eligibility', Object.fromEntries(Object.entries(active).filter(([key]) => key !== 'founder_price_eligibility'))], - ['wrong currency', { - ...active, - founder_price_eligibility: { - ...(active.founder_price_eligibility as object), - canonical_price: { currency: 'ARS', amount_minor: 500 }, - }, - }], - ['wrong amount', { - ...active, - founder_price_eligibility: { - ...(active.founder_price_eligibility as object), - canonical_price: { currency: 'USD', amount_minor: 201 }, - }, - }], - ['wrong period', { - ...active, - founder_price_eligibility: { - ...(active.founder_price_eligibility as object), - billing_period: 'YEARLY', - }, - }], - ['impossible calendar date', { - ...active, - founder_price_eligibility: { - ...(active.founder_price_eligibility as object), - granted_at: '2026-02-30T12:00:00Z', - }, - }], - ['invalid hour', { - ...active, - founder_price_eligibility: { - ...(active.founder_price_eligibility as object), - granted_at: '2026-08-06T24:00:00Z', - }, - }], - ['unsafe revision', { - ...active, - founder_price_eligibility: { - ...(active.founder_price_eligibility as object), - offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: Number.MAX_SAFE_INTEGER + 1 }, - }, - }], - ['paid access without eligibility', { ...active, founder_price_eligibility: null }], - ])('rejects %s', (_label, payload) => { - expect(() => parseCanonicalAuthorityMembershipV2(payload)) - .toThrow(EarlyBirdMembershipContractError); - }); - - it('accepts RFC 3339 offsets and canonicalizes equivalent instants to UTC milliseconds', () => { - const withOffset = parseCanonicalAuthorityMembershipV2({ - ...active, - effective_at: '2026-08-08T09:00:00-03:00', - paid_through: '2027-08-08T09:00:00.000000000-03:00', - founder_price_eligibility: { - ...(active.founder_price_eligibility as object), - granted_at: '2026-08-06T09:00:00-03:00', - }, - }); - const withFraction = parseCanonicalAuthorityMembershipV2({ - ...active, - founder_price_eligibility: { - ...(active.founder_price_eligibility as object), - granted_at: '2026-08-06T12:00:00.000000000Z', - }, - }); - expect(withOffset.founder_price_eligibility?.granted_at) - .toBe('2026-08-06T12:00:00.000Z'); - expect(withOffset.effective_at).toBe('2026-08-08T12:00:00.000Z'); - expect(withOffset.paid_through).toBe('2027-08-08T12:00:00.000Z'); - expect(withFraction.founder_price_eligibility?.granted_at) - .toBe('2026-08-06T12:00:00.000Z'); - }); - - it.each([ - ['effective_at', { effective_at: '2026-02-30T12:00:00Z' }], - ['paid_through', { paid_through: '2027-02-30T12:00:00Z' }], - ['grace_until', { grace_until: '2026-08-08T12:00:00+24:00' }], - ])('rejects invalid v2 %s while leaving the legacy v1 parser unchanged', (_label, override) => { - expect(() => parseCanonicalAuthorityMembershipV2({ ...active, ...override })) - .toThrow(EarlyBirdMembershipContractError); - }); - - it('uses the exact private GET and rejects mismatches or oversized bodies', async () => { - const request = vi.fn().mockResolvedValue(new Response(JSON.stringify(active), { - status: 200, - headers: { 'content-type': 'application/json' }, - })); - const gateway = new HttpEarlyBirdMembershipGateway({ - baseUrl: 'http://pmp-myth-api:8765', - keyId: '2026-08-current', - token: 's'.repeat(43), - }, request); - - await expect(gateway.readMembership('listener-1')).resolves.toMatchObject({ - ok: true, - membership: { account_id: 'listener-1' }, - }); - expect(request).toHaveBeenCalledWith( - 'http://pmp-myth-api:8765/api/internal/v2/early-bird-memberships/listener-1', - expect.objectContaining({ - method: 'GET', - redirect: 'error', - cache: 'no-store', - headers: expect.objectContaining({ - authorization: `Bearer ${'s'.repeat(43)}`, - 'x-hb-service-key-id': '2026-08-current', - }), - }), - ); - - request.mockResolvedValueOnce(new Response(JSON.stringify({ ...active, account_id: 'other' }), { - status: 200, - headers: { 'content-type': 'application/json' }, - })); - await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); - - const oversized = cancellableResponse(200, { - 'content-type': 'application/json', - 'content-length': String(65 * 1024), - }); - request.mockResolvedValueOnce(oversized.response); - await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); - expect(oversized.wasCancelled()).toBe(true); - }); - - it('distinguishes canonical not-found and fails closed on other statuses', async () => { - const notFound = cancellableResponse(404); - const request = vi.fn().mockResolvedValue(notFound.response); - const gateway = new HttpEarlyBirdMembershipGateway({ - baseUrl: 'https://authority.example.test', - keyId: 'current', - token: 's'.repeat(43), - }, request); - await expect(gateway.readMembership('listener-1')).resolves.toEqual({ - ok: false, - reason: 'not-found', - }); - expect(notFound.wasCancelled()).toBe(true); - - const unauthorized = cancellableResponse(401); - request.mockResolvedValueOnce(unauthorized.response); - await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); - expect(unauthorized.wasCancelled()).toBe(true); - - const wrongContentType = cancellableResponse(200, { 'content-type': 'text/html' }); - request.mockResolvedValueOnce(wrongContentType.response); - await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); - expect(wrongContentType.wasCancelled()).toBe(true); - }); - - it('cancels an undeclared chunked body as soon as it exceeds 64 KiB', async () => { - let pulls = 0; - let cancelled = false; - const body = new ReadableStream({ - pull(controller) { - pulls += 1; - if (pulls <= 4) { - controller.enqueue(new Uint8Array(24 * 1024).fill(0x20)); - } else { - controller.close(); - } - }, - cancel() { - cancelled = true; - }, - }, { highWaterMark: 0 }); - const request = vi.fn().mockResolvedValue(new Response(body, { - status: 200, - headers: { 'content-type': 'application/json' }, - })); - const gateway = new HttpEarlyBirdMembershipGateway({ - baseUrl: 'https://authority.example.test', - keyId: 'current', - token: 's'.repeat(43), - }, request); - - await expect(gateway.readMembership('listener-1')).rejects.toThrow('unavailable'); - expect(cancelled).toBe(true); - expect(pulls).toBe(3); - }); -}); diff --git a/src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts b/src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts deleted file mode 100644 index 9428a672..00000000 --- a/src/lib/early-birds/__tests__/founder-eligibility.postgres.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -import { prisma } from '@/lib/db'; - -import { - applyFounderEligibilityProjection, - FounderEligibilityConflictError, -} from '../founder-eligibility'; -import type { FounderPriceEligibility } from '../membership-contract'; - -const postgres = process.env.LISTENER_TEST_DATABASE_URL ? describe : describe.skip; -const accountIds = ['listener-founder-pg-identical', 'listener-founder-pg-conflict']; -const eligibility: FounderPriceEligibility = { - 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', -}; - -postgres('Founder eligibility PostgreSQL convergence', () => { - beforeAll(async () => { - await prisma.earlyBirdUser.createMany({ - data: accountIds.map((id, index) => ({ - id, - name: `Synthetic Listener ${index}`, - email: `founder-pg-${index}@example.invalid`, - emailVerified: true, - })), - skipDuplicates: true, - }); - }); - - afterAll(async () => { - await prisma.earlyBirdUser.deleteMany({ where: { id: { in: accountIds } } }); - await prisma.$disconnect(); - }); - - it('converges concurrent identical evidence to one positive row', async () => { - const outcomes = await Promise.all([ - applyFounderEligibilityProjection(accountIds[0], eligibility), - applyFounderEligibilityProjection(accountIds[0], eligibility), - ]); - expect(outcomes.sort()).toEqual(['APPLIED', 'REPLAYED']); - await expect(prisma.earlyBirdFounderEligibilityProjection.count({ - where: { accountId: accountIds[0] }, - })).resolves.toBe(1); - }); - - it('preserves one winner when concurrent positive evidence conflicts', async () => { - const conflicting: FounderPriceEligibility = { - ...eligibility, - offer: { ...eligibility.offer, revision: 2 }, - }; - const results = await Promise.allSettled([ - applyFounderEligibilityProjection(accountIds[1], eligibility), - applyFounderEligibilityProjection(accountIds[1], conflicting), - ]); - expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); - const rejected = results.find((result) => result.status === 'rejected'); - expect(rejected).toMatchObject({ reason: expect.any(FounderEligibilityConflictError) }); - await expect(prisma.earlyBirdFounderEligibilityProjection.count({ - where: { accountId: accountIds[1] }, - })).resolves.toBe(1); - }); -}); diff --git a/src/lib/early-birds/__tests__/founder-eligibility.test.ts b/src/lib/early-birds/__tests__/founder-eligibility.test.ts deleted file mode 100644 index f1b5a03e..00000000 --- a/src/lib/early-birds/__tests__/founder-eligibility.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const tx = vi.hoisted(() => ({ - $queryRaw: vi.fn(), - earlyBirdFounderEligibilityProjection: { - findUnique: vi.fn(), - create: vi.fn(), - }, -})); -const prisma = vi.hoisted(() => ({ - $transaction: vi.fn((callback: (client: typeof tx) => unknown) => callback(tx)), -})); - -vi.mock('@/lib/db', () => ({ prisma })); - -import { - applyFounderEligibilityProjection, - FounderEligibilityAccountNotFoundError, - FounderEligibilityConflictError, - founderEligibilityHash, -} from '../founder-eligibility'; -import type { FounderPriceEligibility } from '../membership-contract'; - -const NOW = new Date('2026-08-08T12:00:00Z'); -const eligibility: FounderPriceEligibility = { - 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', -}; - -function row(overrides: Record = {}) { - return { - accountId: 'listener-1', - offerCode: eligibility.offer.code, - offerRevision: eligibility.offer.revision, - currency: eligibility.canonical_price.currency, - amountMinor: eligibility.canonical_price.amount_minor, - billingPeriod: eligibility.billing_period, - grantedAt: new Date(eligibility.granted_at), - eligibilityHash: founderEligibilityHash(eligibility), - observedAt: NOW, - createdAt: NOW, - updatedAt: NOW, - ...overrides, - } as never; -} - -describe('positive-only Founder eligibility projection', () => { - beforeEach(() => { - vi.clearAllMocks(); - tx.$queryRaw.mockResolvedValue([{ id: 'listener-1' }]); - tx.earlyBirdFounderEligibilityProjection.findUnique.mockResolvedValue(null); - tx.earlyBirdFounderEligibilityProjection.create.mockResolvedValue(row()); - }); - - it('returns ABSENT without writing when canonical evidence is null', async () => { - await expect(applyFounderEligibilityProjection('listener-1', null, NOW)) - .resolves.toBe('ABSENT'); - expect(tx.earlyBirdFounderEligibilityProjection.create).not.toHaveBeenCalled(); - }); - - it('applies the first positive evidence and replays exact evidence', async () => { - await expect(applyFounderEligibilityProjection('listener-1', eligibility, NOW)) - .resolves.toBe('APPLIED'); - expect(tx.earlyBirdFounderEligibilityProjection.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - accountId: 'listener-1', - currency: 'USD', - amountMinor: 500, - billingPeriod: 'MONTHLY', - eligibilityHash: founderEligibilityHash(eligibility), - }), - }); - - tx.earlyBirdFounderEligibilityProjection.findUnique.mockResolvedValue(row()); - await expect(applyFounderEligibilityProjection('listener-1', eligibility, NOW)) - .resolves.toBe('REPLAYED'); - }); - - it('replays equivalent timestamp spellings after UTC millisecond normalization', async () => { - tx.earlyBirdFounderEligibilityProjection.findUnique.mockResolvedValue(row()); - const equivalents = [ - '2026-08-06T09:00:00-03:00', - '2026-08-06T12:00:00.000Z', - '2026-08-06T12:00:00.000000000Z', - ]; - for (const grantedAt of equivalents) { - await expect(applyFounderEligibilityProjection('listener-1', { - ...eligibility, - granted_at: grantedAt, - }, NOW)).resolves.toBe('REPLAYED'); - } - }); - - it('preserves positive evidence against null or a conflicting positive payload', async () => { - tx.earlyBirdFounderEligibilityProjection.findUnique.mockResolvedValue(row()); - await expect(applyFounderEligibilityProjection('listener-1', null, NOW)) - .rejects.toBeInstanceOf(FounderEligibilityConflictError); - await expect(applyFounderEligibilityProjection('listener-1', { - ...eligibility, - offer: { ...eligibility.offer, revision: 2 }, - }, NOW)).rejects.toBeInstanceOf(FounderEligibilityConflictError); - expect(tx.earlyBirdFounderEligibilityProjection.create).not.toHaveBeenCalled(); - }); - - it('fails before any evidence lookup when the local account is absent', async () => { - tx.$queryRaw.mockResolvedValue([]); - await expect(applyFounderEligibilityProjection('missing', eligibility, NOW)) - .rejects.toBeInstanceOf(FounderEligibilityAccountNotFoundError); - expect(tx.earlyBirdFounderEligibilityProjection.findUnique).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/early-birds/__tests__/membership-gateway.test.ts b/src/lib/early-birds/__tests__/membership-gateway.test.ts index 9839e8ea..6b738f2c 100644 --- a/src/lib/early-birds/__tests__/membership-gateway.test.ts +++ b/src/lib/early-birds/__tests__/membership-gateway.test.ts @@ -14,7 +14,7 @@ import { const TOKEN = 'ebi_v1.AAAAAAAAAAAAAAAAAAAAAA.synthetic_nonce_00000000000000000000.synthetic_signature_0000000000000000000000000000000'; const authorityMembership = { - schema_version: 'early-bird-authority.membership.v1', + schema_version: 'early-bird-authority.membership.v3', account_id: 'listener-1', membership_revision: 1, state: 'ACTIVE', @@ -28,6 +28,7 @@ const authorityMembership = { current_price: null, free_entitlement_consumed: false, reason_code: 'INVITATION_REDEEMED', + founder_continuity: null, }; afterEach(() => vi.restoreAllMocks()); @@ -67,7 +68,7 @@ describe('canonical EarlyBird membership HTTP gateway', () => { invitation_token: TOKEN, }); expect(applyMembershipProjection).toHaveBeenCalledWith(expect.objectContaining({ - schema_version: 'early-bird-membership.command.v1', + schema_version: 'early-bird-membership.command.v2', account_id: 'listener-1', membership_revision: 1, })); diff --git a/src/lib/early-birds/__tests__/membership-presentation.test.ts b/src/lib/early-birds/__tests__/membership-presentation.test.ts index 558d4427..3ffe90e0 100644 --- a/src/lib/early-birds/__tests__/membership-presentation.test.ts +++ b/src/lib/early-birds/__tests__/membership-presentation.test.ts @@ -16,13 +16,25 @@ function projection(overrides: Partial = {}): Ear offerCode: 'EARLY_BIRDS_FOUNDERS_V1', offerRevision: 1, effectiveAt: NOW, - paidThrough: null, + paidThrough: new Date('2026-09-07T12:00:00.000Z'), graceUntil: null, provider: 'paypal', - amountMinor: 200, + amountMinor: 500, currency: 'USD', reasonCode: 'PAYMENT_CONFIRMED', synthetic: false, + founderContinuityEpisodeId: '00000000-0000-4000-8000-000000000101', + founderContinuityRevision: 1, + founderContinuityState: 'ACTIVE', + founderContinuityOfferCode: 'EARLY_BIRDS_FOUNDERS_V1', + founderContinuityOfferRevision: 1, + founderContinuityCurrency: 'USD', + founderContinuityAmountMinor: 500, + founderContinuityBillingPeriod: 'MONTHLY', + founderContinuityActivatedAt: NOW, + founderContinuityServiceThrough: new Date('2026-09-07T12:00:00.000Z'), + founderContinuityEndedAt: null, + founderContinuityTerminalReason: null, createdAt: NOW, updatedAt: NOW, ...overrides, @@ -33,48 +45,66 @@ describe('public Listener membership presentation', () => { it('exposes only a normalized Founder provider and lifecycle state', () => { const result = listenerMembershipPresentation(projection({ state: 'CANCELLED_PENDING_END', + founderContinuityState: 'CANCELLED_PENDING_END', provider: 'provider-internal-value', reasonCode: 'PRIVATE_REASON', - })); + }), NOW); expect(result).toEqual({ kind: 'founder', provider: 'paypal', state: 'ending' }); expect(JSON.stringify(result)).not.toMatch(/PRIVATE_REASON|provider-internal-value|PAYPAL/); }); it.each([ - ['ACTIVE', 'active'], - ['GRACE', 'grace'], - ['CANCELLED_PENDING_END', 'ending'], - ['EXPIRED', 'expired'], - ['REFUNDED', 'refunded'], - ['REVOKED', 'revoked'], - ['PENDING', 'pending'], - ] as const)('normalizes the %s lifecycle without exposing the raw enum', (state, expected) => { - expect(listenerMembershipPresentation(projection({ state }))).toEqual({ + ['ACTIVE', 'ACTIVE', 'active'], + ['GRACE', 'GRACE', 'grace'], + ['CANCELLED_PENDING_END', 'CANCELLED_PENDING_END', 'ending'], + ] as const)('normalizes current %s continuity without exposing the raw enum', (state, continuityState, expected) => { + expect(listenerMembershipPresentation(projection({ + state, + founderContinuityState: continuityState, + graceUntil: state === 'GRACE' ? new Date('2026-09-07T12:00:00.000Z') : null, + }), NOW)).toEqual({ kind: 'founder', provider: 'paypal', state: expected, }); }); + it.each(['EXPIRED', 'REFUNDED', 'REVOKED', 'PENDING'] as const)( + 'removes the Founder badge for terminal or non-authoritative %s membership', + (state) => { + expect(listenerMembershipPresentation(projection({ + state, + founderContinuityState: state === 'PENDING' ? null : 'ENDED', + }), NOW)).toEqual({ kind: 'none', state: 'none' }); + }, + ); + it('distinguishes invitation and preview access without exposing FREE or synthetic internals', () => { - expect(listenerMembershipPresentation(projection({ source: 'FREE' }))) + expect(listenerMembershipPresentation(projection({ + source: 'FREE', + founderContinuityState: null, + }), NOW)) .toEqual({ kind: 'invitation', state: 'active' }); - expect(listenerMembershipPresentation(projection({ source: null, synthetic: true }))) + expect(listenerMembershipPresentation(projection({ + source: null, + synthetic: true, + founderContinuityState: null, + }), NOW)) .toEqual({ kind: 'preview', state: 'active' }); }); it('does not infer Founder status from offer or price fields', () => { - expect(listenerMembershipPresentation(projection({ source: null, synthetic: false }))) + expect(listenerMembershipPresentation(projection({ source: null, synthetic: false }), NOW)) .toEqual({ kind: 'none', state: 'none' }); expect(listenerMembershipPresentation(projection({ source: 'PAYPAL', offerCode: 'FUTURE_PRODUCT', - }))).toEqual({ kind: 'none', state: 'none' }); + }), NOW)).toEqual({ kind: 'none', state: 'none' }); expect(listenerMembershipPresentation(projection({ source: 'MERCADO_PAGO', offerCode: null, - }))).toEqual({ kind: 'none', state: 'none' }); - expect(listenerMembershipPresentation(null)).toEqual({ kind: 'none', state: 'none' }); + }), NOW)).toEqual({ kind: 'none', state: 'none' }); + expect(listenerMembershipPresentation(null, NOW)).toEqual({ kind: 'none', state: 'none' }); }); }); diff --git a/src/lib/early-birds/__tests__/membership.test.ts b/src/lib/early-birds/__tests__/membership.test.ts index 2f5cc1f0..e13c5385 100644 --- a/src/lib/early-birds/__tests__/membership.test.ts +++ b/src/lib/early-birds/__tests__/membership.test.ts @@ -58,7 +58,7 @@ function projection(overrides: Record = {}) { function command(overrides: Partial = {}) { return { - schema_version: 'early-bird-membership.command.v1', + schema_version: 'early-bird-membership.command.v2', account_id: 'listener-1', membership_revision: 1, state: 'ACTIVE', @@ -70,6 +70,7 @@ function command(overrides: Partial = {}) provider: null, current_price: null, reason_code: 'INVITATION_REDEEMED', + founder_continuity: null, ...overrides, } satisfies EarlyBirdMembershipProjectionCommand; } @@ -96,6 +97,36 @@ describe('EarlyBird membership read model', () => { expect(membershipAccessDecision(projection({ state: 'REFUNDED' }), NOW)).toMatchObject({ allowed: false }); }); + it('never authorizes paid state from the retired eligibility model or an ended episode', () => { + const boundary = new Date(NOW.getTime() + 60_000); + const paid = { + source: 'PAYPAL', + provider: 'paypal', + paidThrough: boundary, + amountMinor: 500, + currency: 'USD', + }; + expect(membershipAccessDecision(projection(paid), NOW)).toMatchObject({ allowed: false, reason: 'ended' }); + expect(membershipAccessDecision(projection({ + ...paid, + state: 'EXPIRED', + founderContinuityState: 'ENDED', + }), NOW)).toMatchObject({ allowed: false, reason: 'ended' }); + expect(membershipAccessDecision(projection({ + ...paid, + founderContinuityEpisodeId: '00000000-0000-4000-8000-000000000101', + founderContinuityRevision: 1, + founderContinuityState: 'ACTIVE', + founderContinuityOfferCode: 'EARLY_BIRDS_FOUNDERS_V1', + founderContinuityOfferRevision: 1, + founderContinuityCurrency: 'USD', + founderContinuityAmountMinor: 500, + founderContinuityBillingPeriod: 'MONTHLY', + founderContinuityActivatedAt: NOW, + founderContinuityServiceThrough: boundary, + }), NOW)).toMatchObject({ allowed: true, reason: 'active' }); + }); + it('hashes the canonical command independently from object identity', () => { expect(membershipCommandHash(command())).toBe(membershipCommandHash({ ...command() })); expect(membershipCommandHash(command({ membership_revision: 2 }))).not.toBe(membershipCommandHash(command())); @@ -132,4 +163,99 @@ describe('EarlyBird membership read model', () => { tx.earlyBirdMembershipProjection.findUnique.mockResolvedValueOnce(projection({ commandHash: 'b'.repeat(64) })); await expect(applyMembershipProjection(command())).rejects.toBeInstanceOf(EarlyBirdProjectionConflictError); }); + + it('rejects immutable Founder episode mutation behind a higher membership revision', async () => { + const boundary = '2026-09-06T12:00:00.000Z'; + const founder = { + episode_id: '00000000-0000-4000-8000-000000000101', + revision: 2, + state: 'ACTIVE' as const, + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1' as const, revision: 1 }, + canonical_price: { currency: 'USD' as const, amount_minor: 500 as const }, + billing_period: 'MONTHLY' as const, + activated_at: NOW.toISOString(), + service_through: boundary, + ended_at: null, + terminal_reason: null, + }; + tx.earlyBirdMembershipProjection.findUnique.mockResolvedValueOnce(projection({ + revision: 1, + source: 'PAYPAL', + provider: 'paypal', + paidThrough: new Date(boundary), + amountMinor: 500, + currency: 'USD', + founderContinuityEpisodeId: founder.episode_id, + founderContinuityRevision: 1, + founderContinuityState: 'ACTIVE', + founderContinuityOfferCode: 'EARLY_BIRDS_FOUNDERS_V1', + founderContinuityOfferRevision: 1, + founderContinuityCurrency: 'USD', + founderContinuityAmountMinor: 500, + founderContinuityBillingPeriod: 'MONTHLY', + founderContinuityActivatedAt: NOW, + founderContinuityServiceThrough: new Date(boundary), + founderContinuityEndedAt: null, + founderContinuityTerminalReason: null, + })); + + await expect(applyMembershipProjection(command({ + membership_revision: 2, + source: 'PAYPAL', + provider: 'paypal', + paid_through: boundary, + current_price: { currency: 'USD', amount_minor: 500 }, + reason_code: 'RENEWED', + founder_continuity: { + ...founder, + activated_at: '2026-08-07T12:00:00.000Z', + }, + }))).rejects.toBeInstanceOf(EarlyBirdProjectionConflictError); + expect(tx.earlyBirdMembershipProjection.update).not.toHaveBeenCalled(); + }); + + it('never reopens an ENDED Founder episode', async () => { + const episodeId = '00000000-0000-4000-8000-000000000101'; + const boundary = '2026-09-06T12:00:00.000Z'; + tx.earlyBirdMembershipProjection.findUnique.mockResolvedValueOnce(projection({ + revision: 2, + state: 'EXPIRED', + source: 'PAYPAL', + provider: 'paypal', + paidThrough: new Date(boundary), + founderContinuityEpisodeId: episodeId, + founderContinuityRevision: 2, + founderContinuityState: 'ENDED', + founderContinuityOfferCode: 'EARLY_BIRDS_FOUNDERS_V1', + founderContinuityOfferRevision: 1, + founderContinuityCurrency: 'USD', + founderContinuityAmountMinor: 500, + founderContinuityBillingPeriod: 'MONTHLY', + founderContinuityActivatedAt: NOW, + founderContinuityServiceThrough: new Date(boundary), + founderContinuityEndedAt: new Date(boundary), + founderContinuityTerminalReason: 'PERIOD_ENDED', + })); + + await expect(applyMembershipProjection(command({ + membership_revision: 3, + source: 'PAYPAL', + provider: 'paypal', + paid_through: '2026-10-06T12:00:00.000Z', + current_price: { currency: 'USD', amount_minor: 500 }, + reason_code: 'REACTIVATED', + founder_continuity: { + episode_id: episodeId, + revision: 3, + state: 'ACTIVE', + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1', revision: 1 }, + canonical_price: { currency: 'USD', amount_minor: 500 }, + billing_period: 'MONTHLY', + activated_at: NOW.toISOString(), + service_through: '2026-10-06T12:00:00.000Z', + ended_at: null, + terminal_reason: null, + }, + }))).rejects.toBeInstanceOf(EarlyBirdProjectionConflictError); + }); }); diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 6f6d9760..ef86c824 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -51,9 +51,9 @@ export const earlyBirdCopy = { privacy: 'Tu cuenta y membresía administran el acceso. Guardamos únicamente tu ciclo y saldo de escucha, no una cronología de reproducciones. La presencia pública es regional, amplia y efímera; nunca revela tu ubicación exacta.', membershipInvitation: 'Acceso por invitación', membershipPreview: 'Acceso de prueba', - membershipFounderActive: 'Founder activo', - membershipFounderGrace: 'Founder · acceso temporal', - membershipFounderEnding: 'Founder · activo hasta el final del período', + membershipFounderActive: 'Founding Listener · USD 5/mes', + membershipFounderGrace: 'Founding Listener · USD 5/mes · período de gracia', + membershipFounderEnding: 'Founding Listener · USD 5/mes · activo hasta fin del período', membershipFounderPending: 'La membresía Founder todavía no está confirmada.', membershipFounderExpired: 'La membresía Founder finalizó.', membershipFounderRefunded: 'El pago fue reembolsado y el acceso Founder finalizó.', @@ -111,9 +111,9 @@ export const earlyBirdCopy = { privacy: 'Your account and membership manage access. We keep only your listening cycle and allowance, not a playback timeline. Public presence is broad, regional and ephemeral, and never reveals your exact location.', membershipInvitation: 'Invitation access', membershipPreview: 'Preview access', - membershipFounderActive: 'Founder active', - membershipFounderGrace: 'Founder · temporary access', - membershipFounderEnding: 'Founder · active until the end of the period', + membershipFounderActive: 'Founding Listener · USD 5/month', + membershipFounderGrace: 'Founding Listener · USD 5/month · grace period', + membershipFounderEnding: 'Founding Listener · USD 5/month · active through period end', membershipFounderPending: 'Founder membership is not confirmed yet.', membershipFounderExpired: 'Founder membership has ended.', membershipFounderRefunded: 'The payment was refunded and Founder access has ended.', diff --git a/src/lib/early-birds/founder-eligibility.ts b/src/lib/early-birds/founder-eligibility.ts deleted file mode 100644 index 7e00540b..00000000 --- a/src/lib/early-birds/founder-eligibility.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { createHash } from 'node:crypto'; - -import { Prisma } from '@prisma/client'; - -import { prisma } from '@/lib/db'; - -import { - canonicalRfc3339Instant, - type FounderPriceEligibility, -} from './membership-contract'; -import { jcsCanonicalize } from './membership'; - -export type FounderEligibilityProjectionOutcome = 'ABSENT' | 'APPLIED' | 'REPLAYED'; - -export class FounderEligibilityConflictError extends Error { - constructor() { - super('Canonical Founder price eligibility conflicts with durable local evidence'); - this.name = 'FounderEligibilityConflictError'; - } -} - -export class FounderEligibilityAccountNotFoundError extends Error { - constructor() { - super('Listener account does not exist'); - this.name = 'FounderEligibilityAccountNotFoundError'; - } -} - -function normalizedEligibility(value: FounderPriceEligibility): FounderPriceEligibility { - if ( - value.offer.code !== 'EARLY_BIRDS_FOUNDERS_V1' - || !Number.isSafeInteger(value.offer.revision) - || value.offer.revision < 1 - || value.canonical_price.currency !== 'USD' - || value.canonical_price.amount_minor !== 500 - || value.billing_period !== 'MONTHLY' - ) { - throw new FounderEligibilityConflictError(); - } - try { - return { - ...value, - granted_at: canonicalRfc3339Instant(value.granted_at, 'Founder granted_at'), - }; - } catch { - throw new FounderEligibilityConflictError(); - } -} - -export function founderEligibilityHash(eligibility: FounderPriceEligibility): string { - return createHash('sha256') - .update(jcsCanonicalize(normalizedEligibility(eligibility))) - .digest('hex'); -} - -export async function applyFounderEligibilityProjection( - accountId: string, - rawEligibility: FounderPriceEligibility | null, - observedAt = new Date(), -): Promise { - return prisma.$transaction(async (tx) => { - const accounts = await tx.$queryRaw>( - Prisma.sql`SELECT "id" FROM "early_bird_users" WHERE "id" = ${accountId} FOR UPDATE`, - ); - if (accounts.length !== 1) throw new FounderEligibilityAccountNotFoundError(); - - const existing = await tx.earlyBirdFounderEligibilityProjection.findUnique({ - where: { accountId }, - }); - if (rawEligibility === null) { - if (existing) throw new FounderEligibilityConflictError(); - return 'ABSENT'; - } - - const eligibility = normalizedEligibility(rawEligibility); - const eligibilityHash = founderEligibilityHash(eligibility); - if (existing) { - const exact = existing.eligibilityHash === eligibilityHash - && existing.offerCode === eligibility.offer.code - && existing.offerRevision === eligibility.offer.revision - && existing.currency === eligibility.canonical_price.currency - && existing.amountMinor === eligibility.canonical_price.amount_minor - && existing.billingPeriod === eligibility.billing_period - && existing.grantedAt.toISOString() === new Date(eligibility.granted_at).toISOString(); - if (!exact) throw new FounderEligibilityConflictError(); - return 'REPLAYED'; - } - - await tx.earlyBirdFounderEligibilityProjection.create({ - data: { - accountId, - offerCode: eligibility.offer.code, - offerRevision: eligibility.offer.revision, - currency: eligibility.canonical_price.currency, - amountMinor: eligibility.canonical_price.amount_minor, - billingPeriod: eligibility.billing_period, - grantedAt: new Date(eligibility.granted_at), - eligibilityHash, - observedAt, - }, - }); - return 'APPLIED'; - }); -} diff --git a/src/lib/early-birds/membership-contract.ts b/src/lib/early-birds/membership-contract.ts index 61746495..06c734be 100644 --- a/src/lib/early-birds/membership-contract.ts +++ b/src/lib/early-birds/membership-contract.ts @@ -1,16 +1,18 @@ -import type { EarlyBirdMembershipProjectionCommand } from './membership'; +import type { + EarlyBirdFounderContinuity, + EarlyBirdMembershipProjectionCommand, +} from './membership'; import { isEarlyBirdAccountId } from './account-id'; const COMMAND_KEYS = [ 'account_id', 'current_price', 'effective_at', 'grace_until', 'membership_revision', 'offer', - 'paid_through', 'provider', 'reason_code', 'schema_version', 'source', 'state', + 'paid_through', 'provider', 'reason_code', 'schema_version', 'source', 'state', 'founder_continuity', ] as const; const AUTHORITY_KEYS = [ 'access_allowed', 'account_id', 'current_price', 'effective_at', 'free_entitlement_consumed', 'grace_until', 'membership_revision', 'offer', 'paid_through', 'provider', 'reason_code', - 'schema_version', 'source', 'state', + 'schema_version', 'source', 'state', 'founder_continuity', ] as const; -const AUTHORITY_V2_KEYS = [...AUTHORITY_KEYS, 'founder_price_eligibility'] as const; const STATES = [ 'PENDING', 'ACTIVE', 'GRACE', 'CANCELLED_PENDING_END', 'EXPIRED', 'REFUNDED', 'REVOKED', ] as const; @@ -24,25 +26,12 @@ export class EarlyBirdMembershipContractError extends Error { } } -export type CanonicalAuthorityMembership = Omit & { - schema_version: 'early-bird-authority.membership.v1'; +export type CanonicalAuthorityMembershipV3 = Omit & { + schema_version: 'early-bird-authority.membership.v3'; access_allowed: boolean; free_entitlement_consumed: boolean; }; -export type FounderPriceEligibility = { - offer: { code: 'EARLY_BIRDS_FOUNDERS_V1'; revision: number }; - canonical_price: { currency: 'USD'; amount_minor: 500 }; - billing_period: 'MONTHLY'; - granted_at: string; -}; - -export type CanonicalAuthorityMembershipV2 = - Omit & { - schema_version: 'early-bird-authority.membership.v2'; - founder_price_eligibility: FounderPriceEligibility | null; - }; - function record(value: unknown, label: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new EarlyBirdMembershipContractError(`${label} must be an object`); @@ -138,14 +127,28 @@ function price(value: unknown): EarlyBirdMembershipProjectionCommand['current_pr }; } -function founderPriceEligibility(value: unknown): FounderPriceEligibility | null { +function founderContinuity(value: unknown): EarlyBirdFounderContinuity | null { if (value === null) return null; - const input = record(value, 'founder_price_eligibility'); - exactKeys(input, ['billing_period', 'canonical_price', 'granted_at', 'offer']); + const input = record(value, 'founder_continuity'); + exactKeys(input, [ + 'activated_at', 'billing_period', 'canonical_price', 'ended_at', 'episode_id', + 'offer', 'revision', 'service_through', 'state', 'terminal_reason', + ]); + + if (typeof input.episode_id !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.episode_id)) { + throw new EarlyBirdMembershipContractError('Founder continuity episode_id is invalid'); + } + if (!Number.isSafeInteger(input.revision) || (input.revision as number) < 1) { + throw new EarlyBirdMembershipContractError('Founder continuity revision is invalid'); + } + if (!['ACTIVE', 'CANCELLED_PENDING_END', 'GRACE', 'ENDED'].includes(String(input.state))) { + throw new EarlyBirdMembershipContractError('Founder continuity state is invalid'); + } const parsedOffer = offer(input.offer); if (parsedOffer === null) { - throw new EarlyBirdMembershipContractError('Founder price eligibility offer is invalid'); + throw new EarlyBirdMembershipContractError('Founder continuity offer is invalid'); } const canonicalPrice = record(input.canonical_price, 'founder canonical_price'); exactKeys(canonicalPrice, ['amount_minor', 'currency']); @@ -155,12 +158,30 @@ function founderPriceEligibility(value: unknown): FounderPriceEligibility | null if (input.billing_period !== 'MONTHLY') { throw new EarlyBirdMembershipContractError('Founder billing_period is invalid'); } - const grantedAt = canonicalRfc3339Instant(input.granted_at, 'Founder granted_at'); + const state = input.state as EarlyBirdFounderContinuity['state']; + const endedAt = canonicalNullableRfc3339Instant(input.ended_at, 'Founder ended_at'); + const terminalReason = input.terminal_reason; + if (state === 'ENDED') { + if (endedAt === null || typeof terminalReason !== 'string' + || terminalReason.length < 1 || terminalReason.length > 64) { + throw new EarlyBirdMembershipContractError('Ended Founder continuity lacks terminal evidence'); + } + } else if (endedAt !== null || terminalReason !== null) { + throw new EarlyBirdMembershipContractError('Current Founder continuity carries terminal evidence'); + } else if (input.service_through === null) { + throw new EarlyBirdMembershipContractError('Current Founder continuity lacks a service boundary'); + } return { + episode_id: input.episode_id, + revision: input.revision as number, + state, offer: parsedOffer, canonical_price: { currency: 'USD', amount_minor: 500 }, billing_period: 'MONTHLY', - granted_at: grantedAt, + activated_at: canonicalRfc3339Instant(input.activated_at, 'Founder activated_at'), + service_through: canonicalNullableRfc3339Instant(input.service_through, 'Founder service_through'), + ended_at: endedAt, + terminal_reason: terminalReason as string | null, }; } @@ -193,7 +214,7 @@ function common(input: Record) { } function canonicalAccessAllowed( - membership: Pick, + membership: Pick, now = new Date(), ): boolean { if (membership.state === 'ACTIVE') { @@ -208,50 +229,72 @@ function canonicalAccessAllowed( return false; } +function assertFounderContinuityConsistent( + membership: ReturnType, + continuity: EarlyBirdFounderContinuity | null, +): void { + const paid = membership.source === 'PAYPAL' || membership.source === 'MERCADO_PAGO'; + const currentlyServing = membership.state === 'ACTIVE' + || membership.state === 'GRACE' + || membership.state === 'CANCELLED_PENDING_END'; + if (continuity === null) { + if (paid && currentlyServing) { + throw new EarlyBirdMembershipContractError('Paid service is missing canonical Founder continuity'); + } + return; + } + if (!paid || membership.offer === null + || membership.offer.code !== continuity.offer.code + || membership.offer.revision !== continuity.offer.revision) { + throw new EarlyBirdMembershipContractError('Founder continuity contradicts current membership'); + } + if (continuity.state === 'ENDED') { + if (currentlyServing) { + throw new EarlyBirdMembershipContractError('Ended Founder continuity cannot accompany current service'); + } + return; + } + if (membership.state !== continuity.state) { + throw new EarlyBirdMembershipContractError('Current Founder continuity contradicts membership state'); + } + const expectedThrough = continuity.state === 'GRACE' + ? membership.grace_until + : membership.paid_through; + if (continuity.service_through === null || expectedThrough === null + || Date.parse(continuity.service_through) !== Date.parse(expectedThrough)) { + throw new EarlyBirdMembershipContractError('Founder continuity boundary contradicts membership'); + } +} + export function parseMembershipProjectionCommand(value: unknown): EarlyBirdMembershipProjectionCommand { const input = record(value, 'membership command'); exactKeys(input, COMMAND_KEYS); - if (input.schema_version !== 'early-bird-membership.command.v1') { + if (input.schema_version !== 'early-bird-membership.command.v2') { throw new EarlyBirdMembershipContractError('Unsupported membership command schema'); } - return { schema_version: input.schema_version, ...common(input) }; -} - -export function parseCanonicalAuthorityMembership(value: unknown): CanonicalAuthorityMembership { - const input = record(value, 'authority membership'); - exactKeys(input, AUTHORITY_KEYS); - if (input.schema_version !== 'early-bird-authority.membership.v1') { - throw new EarlyBirdMembershipContractError('Unsupported authority membership schema'); - } - if (typeof input.access_allowed !== 'boolean' || typeof input.free_entitlement_consumed !== 'boolean') { - throw new EarlyBirdMembershipContractError('Authority membership booleans are invalid'); - } - const membership: CanonicalAuthorityMembership = { + const shared = common(input); + const continuity = founderContinuity(input.founder_continuity); + assertFounderContinuityConsistent(shared, continuity); + return { schema_version: input.schema_version, - ...common(input), - access_allowed: input.access_allowed, - free_entitlement_consumed: input.free_entitlement_consumed, + ...shared, + founder_continuity: continuity, }; - if (membership.access_allowed !== canonicalAccessAllowed(membership)) { - throw new EarlyBirdMembershipContractError( - 'Authority access decision contradicts membership state or time bounds', - ); - } - return membership; } -export function parseCanonicalAuthorityMembershipV2(value: unknown): CanonicalAuthorityMembershipV2 { - const input = record(value, 'authority membership v2'); - exactKeys(input, AUTHORITY_V2_KEYS); - if (input.schema_version !== 'early-bird-authority.membership.v2') { +export function parseCanonicalAuthorityMembershipV3(value: unknown): CanonicalAuthorityMembershipV3 { + const input = record(value, 'authority membership v3'); + exactKeys(input, AUTHORITY_KEYS); + if (input.schema_version !== 'early-bird-authority.membership.v3') { throw new EarlyBirdMembershipContractError('Unsupported authority membership schema'); } if (typeof input.access_allowed !== 'boolean' || typeof input.free_entitlement_consumed !== 'boolean') { throw new EarlyBirdMembershipContractError('Authority membership booleans are invalid'); } - const eligibility = founderPriceEligibility(input.founder_price_eligibility); + const continuity = founderContinuity(input.founder_continuity); const shared = common(input); - const membership: CanonicalAuthorityMembershipV2 = { + assertFounderContinuityConsistent(shared, continuity); + const membership: CanonicalAuthorityMembershipV3 = { schema_version: input.schema_version, ...shared, effective_at: canonicalRfc3339Instant(input.effective_at, 'effective_at'), @@ -259,30 +302,32 @@ export function parseCanonicalAuthorityMembershipV2(value: unknown): CanonicalAu grace_until: canonicalNullableRfc3339Instant(input.grace_until, 'grace_until'), access_allowed: input.access_allowed, free_entitlement_consumed: input.free_entitlement_consumed, - founder_price_eligibility: eligibility, + founder_continuity: continuity, }; if (membership.access_allowed !== canonicalAccessAllowed(membership)) { throw new EarlyBirdMembershipContractError( 'Authority access decision contradicts membership state or time bounds', ); } - if ( - membership.access_allowed - && (membership.source === 'PAYPAL' || membership.source === 'MERCADO_PAGO') - && eligibility === null - ) { - throw new EarlyBirdMembershipContractError( - 'Paid access is missing canonical Founder price eligibility', - ); + if (continuity !== null) { + if (continuity.state === 'ENDED') { + if (membership.access_allowed) { + throw new EarlyBirdMembershipContractError('Ended Founder continuity cannot authorize access'); + } + } else { + if (!membership.access_allowed) { + throw new EarlyBirdMembershipContractError('Current Founder continuity contradicts access state'); + } + } } return membership; } export function authorityMembershipCommand( - membership: CanonicalAuthorityMembership, + membership: CanonicalAuthorityMembershipV3, ): EarlyBirdMembershipProjectionCommand { return { - schema_version: 'early-bird-membership.command.v1', + schema_version: 'early-bird-membership.command.v2', account_id: membership.account_id, membership_revision: membership.membership_revision, state: membership.state, @@ -294,5 +339,6 @@ export function authorityMembershipCommand( provider: membership.provider, current_price: membership.current_price, reason_code: membership.reason_code, + founder_continuity: membership.founder_continuity, }; } diff --git a/src/lib/early-birds/membership-gateway.ts b/src/lib/early-birds/membership-gateway.ts index 05348b0d..5d86dbcc 100644 --- a/src/lib/early-birds/membership-gateway.ts +++ b/src/lib/early-birds/membership-gateway.ts @@ -3,9 +3,8 @@ import { createHash } from 'node:crypto'; import { isEarlyBirdAccountId } from './account-id'; import { authorityMembershipCommand, - parseCanonicalAuthorityMembership, - parseCanonicalAuthorityMembershipV2, - type CanonicalAuthorityMembershipV2, + parseCanonicalAuthorityMembershipV3, + type CanonicalAuthorityMembershipV3, } from './membership-contract'; import { applyMembershipProjection, @@ -78,7 +77,7 @@ export interface EarlyBirdMembershipGateway { } export type CanonicalMembershipReadResult = - | { ok: true; membership: CanonicalAuthorityMembershipV2 } + | { ok: true; membership: CanonicalAuthorityMembershipV3 } | { ok: false; reason: 'not-found' }; export interface EarlyBirdMembershipReader { @@ -157,7 +156,7 @@ export class HttpEarlyBirdMembershipGateway implements EarlyBirdMembershipGatewa if (response.status === 409) return { ok: false, reason: 'unavailable' }; throw new EarlyBirdMembershipGatewayUnavailableError(); } - const membership = parseCanonicalAuthorityMembership(await response.json()); + const membership = parseCanonicalAuthorityMembershipV3(await response.json()); if (membership.account_id !== accountId) throw new EarlyBirdMembershipGatewayUnavailableError(); const projection = authorityMembershipCommand(membership); return { @@ -180,7 +179,7 @@ export class HttpEarlyBirdMembershipGateway implements EarlyBirdMembershipGatewa const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const response = await this.request( - `${this.config.baseUrl}/api/internal/v2/early-bird-memberships/${encodeURIComponent(accountId)}`, + `${this.config.baseUrl}/api/internal/v3/early-bird-memberships/${encodeURIComponent(accountId)}`, { method: 'GET', redirect: 'error', @@ -213,7 +212,7 @@ export class HttpEarlyBirdMembershipGateway implements EarlyBirdMembershipGatewa } catch { throw new EarlyBirdMembershipGatewayUnavailableError(); } - const membership = parseCanonicalAuthorityMembershipV2(body); + const membership = parseCanonicalAuthorityMembershipV3(body); if (membership.account_id !== accountId) { throw new EarlyBirdMembershipGatewayUnavailableError(); } diff --git a/src/lib/early-birds/membership-presentation.ts b/src/lib/early-birds/membership-presentation.ts index 6f035878..231045fd 100644 --- a/src/lib/early-birds/membership-presentation.ts +++ b/src/lib/early-birds/membership-presentation.ts @@ -1,6 +1,6 @@ import type { EarlyBirdMembershipProjection } from '@prisma/client'; -import { EARLY_BIRDS_FOUNDERS_OFFER } from './membership'; +import { EARLY_BIRDS_FOUNDERS_OFFER, membershipAccessDecision } from './membership'; export type ListenerMembershipPresentationState = | 'pending' @@ -42,20 +42,33 @@ function presentationState( */ export function listenerMembershipPresentation( projection: EarlyBirdMembershipProjection | null, + now = new Date(), ): ListenerMembershipPresentation { if (!projection) return { kind: 'none', state: 'none' }; const state = presentationState(projection.state); - if (projection.synthetic) return { kind: 'preview', state }; - if (projection.source === 'FREE') return { kind: 'invitation', state }; + const accessAllowed = membershipAccessDecision(projection, now).allowed; + if (projection.synthetic) return accessAllowed ? { kind: 'preview', state } : { kind: 'none', state: 'none' }; + if (projection.source === 'FREE') { + return accessAllowed ? { kind: 'invitation', state } : { kind: 'none', state: 'none' }; + } + const continuityCurrent = projection.founderContinuityState === 'ACTIVE' + || projection.founderContinuityState === 'CANCELLED_PENDING_END' + || projection.founderContinuityState === 'GRACE'; if ( - projection.offerCode === EARLY_BIRDS_FOUNDERS_OFFER + accessAllowed + && continuityCurrent + && projection.founderContinuityOfferCode === EARLY_BIRDS_FOUNDERS_OFFER + && projection.offerCode === EARLY_BIRDS_FOUNDERS_OFFER && projection.source === 'PAYPAL' ) { return { kind: 'founder', provider: 'paypal', state }; } if ( - projection.offerCode === EARLY_BIRDS_FOUNDERS_OFFER + accessAllowed + && continuityCurrent + && projection.founderContinuityOfferCode === EARLY_BIRDS_FOUNDERS_OFFER + && projection.offerCode === EARLY_BIRDS_FOUNDERS_OFFER && projection.source === 'MERCADO_PAGO' ) { return { kind: 'founder', provider: 'mercado-pago', state }; diff --git a/src/lib/early-birds/membership.ts b/src/lib/early-birds/membership.ts index f3513cd8..6dcce8d3 100644 --- a/src/lib/early-birds/membership.ts +++ b/src/lib/early-birds/membership.ts @@ -18,8 +18,21 @@ import { export const EARLY_BIRDS_FOUNDERS_OFFER = 'EARLY_BIRDS_FOUNDERS_V1' as const; +export type EarlyBirdFounderContinuity = { + episode_id: string; + revision: number; + state: 'ACTIVE' | 'CANCELLED_PENDING_END' | 'GRACE' | 'ENDED'; + offer: { code: typeof EARLY_BIRDS_FOUNDERS_OFFER; revision: number }; + canonical_price: { currency: 'USD'; amount_minor: 500 }; + billing_period: 'MONTHLY'; + activated_at: string; + service_through: string | null; + ended_at: string | null; + terminal_reason: string | null; +}; + export type EarlyBirdMembershipProjectionCommand = { - schema_version: 'early-bird-membership.command.v1'; + schema_version: 'early-bird-membership.command.v2'; account_id: string; membership_revision: number; state: EarlyBirdMembershipState; @@ -31,6 +44,7 @@ export type EarlyBirdMembershipProjectionCommand = { provider: 'paypal' | 'mercado_pago' | null; current_price: { currency: 'USD' | 'ARS'; amount_minor: number } | null; reason_code: string; + founder_continuity: EarlyBirdFounderContinuity | null; }; export type EarlyBirdProjectionOutcome = 'APPLIED' | 'REPLAYED' | 'STALE'; @@ -48,6 +62,47 @@ export class EarlyBirdProjectionConflictError extends Error { } } +function assertFounderContinuityTransition( + existing: EarlyBirdMembershipProjection, + next: EarlyBirdFounderContinuity | null, +): void { + if (existing.founderContinuityEpisodeId === null) return; + if (next === null || next.episode_id !== existing.founderContinuityEpisodeId + || existing.founderContinuityRevision === null + || next.revision < existing.founderContinuityRevision) { + throw new EarlyBirdProjectionConflictError(); + } + const sameInstant = (stored: Date | null, wire: string | null) => ( + stored === null ? wire === null : wire !== null && stored.toISOString() === new Date(wire).toISOString() + ); + const immutableEpisodeFacts = existing.founderContinuityOfferCode === next.offer.code + && existing.founderContinuityOfferRevision === next.offer.revision + && existing.founderContinuityCurrency === next.canonical_price.currency + && existing.founderContinuityAmountMinor === next.canonical_price.amount_minor + && existing.founderContinuityBillingPeriod === next.billing_period + && sameInstant(existing.founderContinuityActivatedAt, next.activated_at); + if (!immutableEpisodeFacts) throw new EarlyBirdProjectionConflictError(); + + if (existing.founderContinuityState === 'ENDED') { + const exactTombstone = next.state === 'ENDED' + && sameInstant(existing.founderContinuityServiceThrough, next.service_through) + && sameInstant(existing.founderContinuityEndedAt, next.ended_at) + && existing.founderContinuityTerminalReason === next.terminal_reason; + if (!exactTombstone) throw new EarlyBirdProjectionConflictError(); + return; + } + if (next.state === 'ENDED' && next.revision === existing.founderContinuityRevision) { + throw new EarlyBirdProjectionConflictError(); + } + if (next.revision === existing.founderContinuityRevision) { + const exact = existing.founderContinuityState === next.state + && sameInstant(existing.founderContinuityServiceThrough, next.service_through) + && sameInstant(existing.founderContinuityEndedAt, next.ended_at) + && existing.founderContinuityTerminalReason === next.terminal_reason; + if (!exact) throw new EarlyBirdProjectionConflictError(); + } +} + function normalizedInstant(value: string, field: string): string { if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(value)) { throw new Error(`${field} must be an RFC 3339 date-time`); @@ -60,7 +115,7 @@ function normalizedInstant(value: string, field: string): string { } function normalizedCommand(command: EarlyBirdMembershipProjectionCommand): EarlyBirdMembershipProjectionCommand { - if (command.schema_version !== 'early-bird-membership.command.v1') { + if (command.schema_version !== 'early-bird-membership.command.v2') { throw new Error('Unsupported membership command schema'); } if (!isEarlyBirdAccountId(command.account_id)) throw new Error('account_id is invalid'); @@ -78,7 +133,40 @@ function normalizedCommand(command: EarlyBirdMembershipProjectionCommand): Early )) throw new Error('current_price is invalid'); if (!command.reason_code || command.reason_code.length > 64) throw new Error('reason_code is invalid'); - return { + const continuity = command.founder_continuity; + if (continuity !== null) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(continuity.episode_id)) { + throw new Error('founder_continuity.episode_id is invalid'); + } + if (!Number.isSafeInteger(continuity.revision) || continuity.revision < 1) { + throw new Error('founder_continuity.revision is invalid'); + } + if (!['ACTIVE', 'CANCELLED_PENDING_END', 'GRACE', 'ENDED'].includes(continuity.state)) { + throw new Error('founder_continuity.state is invalid'); + } + if ( + continuity.offer.code !== EARLY_BIRDS_FOUNDERS_OFFER + || !Number.isSafeInteger(continuity.offer.revision) + || continuity.offer.revision < 1 + || continuity.canonical_price.currency !== 'USD' + || continuity.canonical_price.amount_minor !== 500 + || continuity.billing_period !== 'MONTHLY' + ) { + throw new Error('founder_continuity offer is invalid'); + } + if (continuity.state === 'ENDED') { + if (continuity.ended_at === null || continuity.terminal_reason === null + || continuity.terminal_reason.length < 1 || continuity.terminal_reason.length > 64) { + throw new Error('ended founder_continuity is incomplete'); + } + } else if (continuity.ended_at !== null || continuity.terminal_reason !== null) { + throw new Error('current founder_continuity cannot carry terminal evidence'); + } else if (continuity.service_through === null) { + throw new Error('current founder_continuity lacks a service boundary'); + } + } + + const normalized: EarlyBirdMembershipProjectionCommand = { ...command, effective_at: normalizedInstant(command.effective_at, 'effective_at'), paid_through: command.paid_through === null @@ -87,7 +175,45 @@ function normalizedCommand(command: EarlyBirdMembershipProjectionCommand): Early grace_until: command.grace_until === null ? null : normalizedInstant(command.grace_until, 'grace_until'), + founder_continuity: continuity === null ? null : { + ...continuity, + activated_at: normalizedInstant(continuity.activated_at, 'founder_continuity.activated_at'), + service_through: continuity.service_through === null + ? null + : normalizedInstant(continuity.service_through, 'founder_continuity.service_through'), + ended_at: continuity.ended_at === null + ? null + : normalizedInstant(continuity.ended_at, 'founder_continuity.ended_at'), + }, }; + const paid = normalized.source === 'PAYPAL' || normalized.source === 'MERCADO_PAGO'; + const current = normalized.state === 'ACTIVE' + || normalized.state === 'GRACE' + || normalized.state === 'CANCELLED_PENDING_END'; + if (normalized.founder_continuity === null) { + if (paid && current) throw new Error('Paid service is missing canonical Founder continuity'); + return normalized; + } + if (!paid || normalized.offer === null + || normalized.offer.code !== normalized.founder_continuity.offer.code + || normalized.offer.revision !== normalized.founder_continuity.offer.revision) { + throw new Error('Founder continuity contradicts current membership'); + } + if (normalized.founder_continuity.state === 'ENDED') { + if (current) throw new Error('Ended Founder continuity cannot accompany current service'); + return normalized; + } + if (normalized.state !== normalized.founder_continuity.state) { + throw new Error('Current Founder continuity contradicts membership state'); + } + const boundary = normalized.founder_continuity.state === 'GRACE' + ? normalized.grace_until + : normalized.paid_through; + if (normalized.founder_continuity.service_through === null || boundary === null + || Date.parse(normalized.founder_continuity.service_through) !== Date.parse(boundary)) { + throw new Error('Founder continuity boundary contradicts membership'); + } + return normalized; } /** RFC 8785 is intentionally small here: the contract contains only objects, strings, nulls and integers. */ @@ -122,6 +248,24 @@ export function membershipAccessDecision( return { allowed: false, reason: 'pending', projection }; } + const paid = projection.source === 'PAYPAL' || projection.source === 'MERCADO_PAGO'; + if (paid) { + const currentContinuity = projection.founderContinuityState === projection.state + && projection.founderContinuityEpisodeId !== null + && projection.founderContinuityOfferCode === EARLY_BIRDS_FOUNDERS_OFFER + && projection.founderContinuityOfferCode === projection.offerCode + && projection.founderContinuityOfferRevision === projection.offerRevision + && projection.founderContinuityCurrency === 'USD' + && projection.founderContinuityAmountMinor === 500 + && projection.founderContinuityBillingPeriod === 'MONTHLY' + && projection.founderContinuityActivatedAt !== null; + if (!currentContinuity) return { allowed: false, reason: 'ended', projection }; + const boundary = projection.state === 'GRACE' ? projection.graceUntil : projection.paidThrough; + if (projection.founderContinuityServiceThrough?.getTime() !== boundary?.getTime()) { + return { allowed: false, reason: 'ended', projection }; + } + } + if (projection.state === 'ACTIVE') { const allowed = projection.paidThrough === null || projection.paidThrough > now; return { allowed, reason: allowed ? 'active' : 'ended', projection }; @@ -185,6 +329,9 @@ export async function applyMembershipProjection( if (existing.commandHash !== commandHash) throw new EarlyBirdProjectionConflictError(); return { projection: existing, outcome: 'REPLAYED' }; } + if (existing && !existing.synthetic) { + assertFounderContinuityTransition(existing, command.founder_continuity); + } const data = { revision: command.membership_revision, @@ -200,6 +347,24 @@ export async function applyMembershipProjection( amountMinor: command.current_price?.amount_minor ?? null, currency: command.current_price?.currency ?? null, reasonCode: command.reason_code, + founderContinuityEpisodeId: command.founder_continuity?.episode_id ?? null, + founderContinuityRevision: command.founder_continuity?.revision ?? null, + founderContinuityState: command.founder_continuity?.state ?? null, + founderContinuityOfferCode: command.founder_continuity?.offer.code ?? null, + founderContinuityOfferRevision: command.founder_continuity?.offer.revision ?? null, + founderContinuityCurrency: command.founder_continuity?.canonical_price.currency ?? null, + founderContinuityAmountMinor: command.founder_continuity?.canonical_price.amount_minor ?? null, + founderContinuityBillingPeriod: command.founder_continuity?.billing_period ?? null, + founderContinuityActivatedAt: command.founder_continuity + ? new Date(command.founder_continuity.activated_at) + : null, + founderContinuityServiceThrough: command.founder_continuity?.service_through + ? new Date(command.founder_continuity.service_through) + : null, + founderContinuityEndedAt: command.founder_continuity?.ended_at + ? new Date(command.founder_continuity.ended_at) + : null, + founderContinuityTerminalReason: command.founder_continuity?.terminal_reason ?? null, synthetic, }; const projection = existing @@ -230,7 +395,7 @@ export async function issueSyntheticMembership(accountId: string, now = new Date }); if (existing && !existing.synthetic) throw new Error('Synthetic access cannot replace a canonical membership'); return applyMembershipProjection({ - schema_version: 'early-bird-membership.command.v1', + schema_version: 'early-bird-membership.command.v2', account_id: accountId, membership_revision: (existing?.revision ?? 0) + 1, state: 'ACTIVE', @@ -242,5 +407,6 @@ export async function issueSyntheticMembership(accountId: string, now = new Date provider: null, current_price: null, reason_code: 'SYNTHETIC_TEST_ACCESS', + founder_continuity: null, }, { synthetic: true }); } From 18ea603fe341455d1cb293a070c5186bd7b9e7ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Mon, 10 Aug 2026 23:56:08 -0300 Subject: [PATCH 193/376] fix(listener): validate Founder provider pricing --- contracts/early-bird-authority/v3/README.md | 7 +++ contracts/early-bird-authority/v3/SHA256SUMS | 2 +- contracts/early-bird-membership/v2/README.md | 7 +++ contracts/early-bird-membership/v2/SHA256SUMS | 2 +- .../founder-continuity-contract.test.ts | 32 ++++++++++++++ .../early-birds/__tests__/membership.test.ts | 43 +++++++++++++++++++ src/lib/early-birds/membership-contract.ts | 19 ++++++++ src/lib/early-birds/membership.ts | 19 ++++++++ 8 files changed, 129 insertions(+), 2 deletions(-) diff --git a/contracts/early-bird-authority/v3/README.md b/contracts/early-bird-authority/v3/README.md index 91b5aaa2..69a7e48a 100644 --- a/contracts/early-bird-authority/v3/README.md +++ b/contracts/early-bird-authority/v3/README.md @@ -10,3 +10,10 @@ 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 index 807a5546..a7e1d1c9 100644 --- a/contracts/early-bird-authority/v3/SHA256SUMS +++ b/contracts/early-bird-authority/v3/SHA256SUMS @@ -1,3 +1,3 @@ -e92e9b616d4b0ebb7699c25628772fdf2c8b33baafaf2cdb737ba0dea1534741 README.md +eb02e9516f52a7e18740b45716abb9f08ac059a61aa3b6b16ac513b58f549816 README.md c762752472c4604f9363cb329f66f7064b6b07590d447b26968877115589d7c3 membership.fixture.json f0acbb2662130e477a0809ecc6011a49d3d3ae14e165b02b6e78a5ff662516b9 membership.schema.json diff --git a/contracts/early-bird-membership/v2/README.md b/contracts/early-bird-membership/v2/README.md index 70b9cfc5..4dd16e86 100644 --- a/contracts/early-bird-membership/v2/README.md +++ b/contracts/early-bird-membership/v2/README.md @@ -8,3 +8,10 @@ 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 index 1d044c59..a5244434 100644 --- a/contracts/early-bird-membership/v2/SHA256SUMS +++ b/contracts/early-bird-membership/v2/SHA256SUMS @@ -1,4 +1,4 @@ -199256c185cb977f109802984a7674cac887f5edb664ebfa637c06fbdf39bd45 README.md +61855b5e731159d8c6943262e6bf6a87ca84d403031ffb2fa8abce8256ba18b4 README.md 03efd74e0e9384a170618c19c3f7f2253032d8eaad1a29cec21c858253eebca9 command.fixture.json 6ad90479301a060ce282449e54642ddc5492c09cacc778eda2c961169e5c3f58 command.schema.json a870cb5590a582ae5408c41cc4414c7bd3804b7efbd11f4e82a3fe769707f682 result.fixture.json diff --git a/src/lib/early-birds/__tests__/founder-continuity-contract.test.ts b/src/lib/early-birds/__tests__/founder-continuity-contract.test.ts index 8277b006..e92b5240 100644 --- a/src/lib/early-birds/__tests__/founder-continuity-contract.test.ts +++ b/src/lib/early-birds/__tests__/founder-continuity-contract.test.ts @@ -92,6 +92,17 @@ describe('canonical Founder continuity contracts', () => { current_price: null, founder_continuity: null, }).founder_continuity).toBeNull(); + + expect(parseCanonicalAuthorityMembershipV3({ + ...active, + source: 'MERCADO_PAGO', + provider: 'mercado_pago', + current_price: { currency: 'ARS', amount_minor: 7_250 }, + })).toMatchObject({ + source: 'MERCADO_PAGO', + current_price: { currency: 'ARS', amount_minor: 7_250 }, + founder_continuity: { canonical_price: { currency: 'USD', amount_minor: 500 } }, + }); }); it.each([ @@ -102,6 +113,11 @@ describe('canonical Founder continuity contracts', () => { ['ended without tombstone', { ...active, state: 'EXPIRED', access_allowed: false, founder_continuity: { ...continuity, state: 'ENDED' } }], ['current with terminal evidence', { ...active, founder_continuity: { ...continuity, ended_at: '2026-08-09T12:00:00Z', terminal_reason: 'ENDED' } }], ['paid access without continuity', { ...active, founder_continuity: null }], + ['PayPal price mismatch', { ...active, current_price: { currency: 'USD', amount_minor: 200 } }], + ['Mercado Pago currency mismatch', { ...active, source: 'MERCADO_PAGO', provider: 'mercado_pago' }], + ['paid provider mismatch', { ...active, provider: 'mercado_pago' }], + ['Free provider mismatch', { ...active, source: 'FREE', provider: 'paypal', current_price: null, founder_continuity: null }], + ['null source provider mismatch', { ...active, source: null, provider: 'paypal', current_price: null, founder_continuity: null }], ['continuity on Free', { ...active, source: 'FREE', provider: null }], ['state mismatch', { ...active, state: 'GRACE', grace_until: '2027-08-08T12:00:00Z' }], ['boundary mismatch', { ...active, paid_through: '2027-09-08T12:00:00Z' }], @@ -122,6 +138,22 @@ describe('canonical Founder continuity contracts', () => { ...command, schema_version: 'early-bird-membership.command.v1', })).toThrow(EarlyBirdMembershipContractError); + expect(() => parseMembershipProjectionCommand({ + ...command, + current_price: { currency: 'USD', amount_minor: 200 }, + })).toThrow('PayPal current price contradicts Founder continuity'); + + const mercadoPago = authorityMembershipCommand(parseCanonicalAuthorityMembershipV3({ + ...active, + source: 'MERCADO_PAGO', + provider: 'mercado_pago', + current_price: { currency: 'ARS', amount_minor: 7_250 }, + })); + expect(parseMembershipProjectionCommand(mercadoPago)).toEqual(mercadoPago); + expect(() => parseMembershipProjectionCommand({ + ...mercadoPago, + current_price: { currency: 'USD', amount_minor: 500 }, + })).toThrow('Mercado Pago current price must be positive ARS'); }); it('uses the exact private v3 GET and rejects mismatches or oversized bodies', async () => { diff --git a/src/lib/early-birds/__tests__/membership.test.ts b/src/lib/early-birds/__tests__/membership.test.ts index e13c5385..e23e269d 100644 --- a/src/lib/early-birds/__tests__/membership.test.ts +++ b/src/lib/early-birds/__tests__/membership.test.ts @@ -134,6 +134,49 @@ describe('EarlyBird membership read model', () => { .not.toBe(membershipCommandHash(command())); }); + it('enforces provider-specific Founder prices before persistence', () => { + const serviceThrough = '2026-09-06T12:00:00.000Z'; + const founderContinuity = { + episode_id: '00000000-0000-4000-8000-000000000101', + revision: 1, + state: 'ACTIVE' as const, + offer: { code: 'EARLY_BIRDS_FOUNDERS_V1' as const, revision: 1 }, + canonical_price: { currency: 'USD' as const, amount_minor: 500 as const }, + billing_period: 'MONTHLY' as const, + activated_at: NOW.toISOString(), + service_through: serviceThrough, + ended_at: null, + terminal_reason: null, + }; + const paypal = command({ + source: 'PAYPAL', + provider: 'paypal', + paid_through: serviceThrough, + current_price: { currency: 'USD', amount_minor: 500 }, + reason_code: 'PAYMENT_SUCCEEDED', + founder_continuity: founderContinuity, + }); + expect(() => membershipCommandHash(paypal)).not.toThrow(); + expect(() => membershipCommandHash({ + ...paypal, + current_price: { currency: 'USD', amount_minor: 200 }, + })).toThrow('PayPal current price contradicts Founder continuity'); + + const mercadoPago = { + ...paypal, + source: 'MERCADO_PAGO' as const, + provider: 'mercado_pago' as const, + current_price: { currency: 'ARS' as const, amount_minor: 7_250 }, + }; + expect(() => membershipCommandHash(mercadoPago)).not.toThrow(); + expect(() => membershipCommandHash({ + ...mercadoPago, + current_price: { currency: 'USD', amount_minor: 500 }, + })).toThrow('Mercado Pago current price must be positive ARS'); + expect(() => membershipCommandHash({ ...paypal, provider: 'mercado_pago' })) + .toThrow('Membership source contradicts provider'); + }); + it.each([ 'listener/1', '-listener', diff --git a/src/lib/early-birds/membership-contract.ts b/src/lib/early-birds/membership-contract.ts index 06c734be..c151ba9d 100644 --- a/src/lib/early-birds/membership-contract.ts +++ b/src/lib/early-birds/membership-contract.ts @@ -233,6 +233,14 @@ function assertFounderContinuityConsistent( membership: ReturnType, continuity: EarlyBirdFounderContinuity | null, ): void { + if ( + (membership.source === 'PAYPAL' && membership.provider !== 'paypal') + || (membership.source === 'MERCADO_PAGO' && membership.provider !== 'mercado_pago') + || ((membership.source === 'FREE' || membership.source === null) + && membership.provider !== null) + ) { + throw new EarlyBirdMembershipContractError('Membership source contradicts provider'); + } const paid = membership.source === 'PAYPAL' || membership.source === 'MERCADO_PAGO'; const currentlyServing = membership.state === 'ACTIVE' || membership.state === 'GRACE' @@ -248,6 +256,17 @@ function assertFounderContinuityConsistent( || membership.offer.revision !== continuity.offer.revision) { throw new EarlyBirdMembershipContractError('Founder continuity contradicts current membership'); } + if (membership.source === 'PAYPAL' && ( + membership.current_price === null + || membership.current_price.currency !== continuity.canonical_price.currency + || membership.current_price.amount_minor !== continuity.canonical_price.amount_minor + )) { + throw new EarlyBirdMembershipContractError('PayPal current price contradicts Founder continuity'); + } + if (membership.source === 'MERCADO_PAGO' + && membership.current_price?.currency !== 'ARS') { + throw new EarlyBirdMembershipContractError('Mercado Pago current price must be positive ARS'); + } if (continuity.state === 'ENDED') { if (currentlyServing) { throw new EarlyBirdMembershipContractError('Ended Founder continuity cannot accompany current service'); diff --git a/src/lib/early-birds/membership.ts b/src/lib/early-birds/membership.ts index 6dcce8d3..427d99aa 100644 --- a/src/lib/early-birds/membership.ts +++ b/src/lib/early-birds/membership.ts @@ -190,6 +190,14 @@ function normalizedCommand(command: EarlyBirdMembershipProjectionCommand): Early const current = normalized.state === 'ACTIVE' || normalized.state === 'GRACE' || normalized.state === 'CANCELLED_PENDING_END'; + if ( + (normalized.source === 'PAYPAL' && normalized.provider !== 'paypal') + || (normalized.source === 'MERCADO_PAGO' && normalized.provider !== 'mercado_pago') + || ((normalized.source === 'FREE' || normalized.source === null) + && normalized.provider !== null) + ) { + throw new Error('Membership source contradicts provider'); + } if (normalized.founder_continuity === null) { if (paid && current) throw new Error('Paid service is missing canonical Founder continuity'); return normalized; @@ -199,6 +207,17 @@ function normalizedCommand(command: EarlyBirdMembershipProjectionCommand): Early || normalized.offer.revision !== normalized.founder_continuity.offer.revision) { throw new Error('Founder continuity contradicts current membership'); } + if (normalized.source === 'PAYPAL' && ( + normalized.current_price === null + || normalized.current_price.currency !== normalized.founder_continuity.canonical_price.currency + || normalized.current_price.amount_minor !== normalized.founder_continuity.canonical_price.amount_minor + )) { + throw new Error('PayPal current price contradicts Founder continuity'); + } + if (normalized.source === 'MERCADO_PAGO' + && normalized.current_price?.currency !== 'ARS') { + throw new Error('Mercado Pago current price must be positive ARS'); + } if (normalized.founder_continuity.state === 'ENDED') { if (current) throw new Error('Ended Founder continuity cannot accompany current service'); return normalized; From 0e8ae6678f10ff3b48a0ff24d2257415a62e956b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Tue, 11 Aug 2026 00:41:13 -0300 Subject: [PATCH 194/376] fix(listener): terminate orphan projection retries --- scripts/early-birds-preview/rehearse-migration.sh | 4 ++++ .../[accountId]/__tests__/route.test.ts | 11 +++++++++++ .../v2/early-bird-memberships/[accountId]/route.ts | 4 ++++ src/lib/early-birds/membership.ts | 9 ++++++++- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/early-birds-preview/rehearse-migration.sh b/scripts/early-birds-preview/rehearse-migration.sh index 890b572d..a531392d 100755 --- a/scripts/early-birds-preview/rehearse-migration.sh +++ b/scripts/early-birds-preview/rehearse-migration.sh @@ -4,5 +4,9 @@ set -eu 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/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 index 5f88e0de..a5fc276d 100644 --- 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 @@ -19,6 +19,7 @@ vi.mock('@/lib/db', () => ({ })); import { GET, PUT } from '../route'; +import { EarlyBirdProjectionAccountMissingError } from '@/lib/early-birds/membership'; const ACCOUNT = 'listener-1'; const continuity = { @@ -148,4 +149,14 @@ describe('private EarlyBird membership projection v2 route', () => { 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 index b005d518..66df886f 100644 --- a/src/app/api/internal/v2/early-bird-memberships/[accountId]/route.ts +++ b/src/app/api/internal/v2/early-bird-memberships/[accountId]/route.ts @@ -7,6 +7,7 @@ import { } from '@/lib/early-birds/membership-contract'; import { applyMembershipProjection, + EarlyBirdProjectionAccountMissingError, EarlyBirdProjectionConflictError, membershipAccessDecision, type EarlyBirdProjectionOutcome, @@ -87,6 +88,9 @@ export async function PUT( 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); } diff --git a/src/lib/early-birds/membership.ts b/src/lib/early-birds/membership.ts index 427d99aa..9590503d 100644 --- a/src/lib/early-birds/membership.ts +++ b/src/lib/early-birds/membership.ts @@ -62,6 +62,13 @@ export class EarlyBirdProjectionConflictError extends Error { } } +export class EarlyBirdProjectionAccountMissingError extends Error { + constructor() { + super('EarlyBird account does not exist'); + this.name = 'EarlyBirdProjectionAccountMissingError'; + } +} + function assertFounderContinuityTransition( existing: EarlyBirdMembershipProjection, next: EarlyBirdFounderContinuity | null, @@ -321,7 +328,7 @@ export async function applyMembershipProjection( const accountRows = await tx.$queryRaw>( Prisma.sql`SELECT "id" FROM "early_bird_users" WHERE "id" = ${command.account_id} FOR UPDATE`, ); - if (accountRows.length !== 1) throw new Error('EarlyBird account does not exist'); + if (accountRows.length !== 1) throw new EarlyBirdProjectionAccountMissingError(); await assertListenerQuotaPolicyCompatible(tx); // The account lock must be acquired before observing authoritative time; From 20d8eaedbee6efddb0d31ba982c10b84eddc805b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Tue, 11 Aug 2026 00:56:56 -0300 Subject: [PATCH 195/376] fix(listener): preserve workbench release provenance --- scripts/listener-ui-preview.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/listener-ui-preview.sh b/scripts/listener-ui-preview.sh index 321a60a1..abb4ddde 100755 --- a/scripts/listener-ui-preview.sh +++ b/scripts/listener-ui-preview.sh @@ -67,6 +67,7 @@ if [ "$PREVIEW_PAYPAL_CHECKOUT" = 1 ] || [ "$PREVIEW_MERCADO_PAGO_CHECKOUT" = 1 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" @@ -85,7 +86,6 @@ docker run -d \ --init \ --env-file "$env_file" \ -e NEXT_TELEMETRY_DISABLED=1 \ - -e BEACON_GIT_SHA=ui-dev \ -e EARLY_BIRDS_ENABLED=1 \ -e BEACON_LISTENER_ENABLED=1 \ -e EARLY_BIRDS_FREE_FOR_ALL="$PREVIEW_FREE_FOR_ALL" \ From 6e2e6ee1bb950c408efd7abafe501dad8a6e7dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Tue, 11 Aug 2026 01:04:41 -0300 Subject: [PATCH 196/376] docs(listener): record Founder continuity staging cutover --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 6037d437..0e034d0c 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,5 +1,45 @@ # EarlyBirds isolated staging runtime +## 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 From 3d4afa577219470f6a2176e9e897d08de6e1ea2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Tue, 11 Aug 2026 14:56:45 -0300 Subject: [PATCH 197/376] fix(listener): keep staging OAuth state on payment workbench --- docs/operations/LISTENER_UI_FAST_LOOP.md | 8 ++++++++ .../test/preview-contract.test.mjs | 9 +++++++++ scripts/listener-ui-preview.sh | 16 +++++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/operations/LISTENER_UI_FAST_LOOP.md b/docs/operations/LISTENER_UI_FAST_LOOP.md index 6c6d2d8c..7f0092a8 100644 --- a/docs/operations/LISTENER_UI_FAST_LOOP.md +++ b/docs/operations/LISTENER_UI_FAST_LOOP.md @@ -33,6 +33,14 @@ 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: diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 527b7393..9296fc99 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -93,6 +93,15 @@ test('synthetic guard accepts the example and rejects unsafe effective values', }); }); +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"/); + assert.match(source, /if \[ "\$PREVIEW_PAYPAL_CHECKOUT" = 1 \] \|\| \[ "\$PREVIEW_MERCADO_PAGO_CHECKOUT" = 1 \]/); + assert.doesNotMatch(source, /PREVIEW_ORIGIN="https:\/\/listen\.harmonicbeacon\.com"/); +}); + 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'); diff --git a/scripts/listener-ui-preview.sh b/scripts/listener-ui-preview.sh index abb4ddde..2dd2cf49 100755 --- a/scripts/listener-ui-preview.sh +++ b/scripts/listener-ui-preview.sh @@ -15,6 +15,7 @@ RELEASE_CONTAINER="earlybirds-preview-listener-1" PREVIEW_FREE_FOR_ALL="${LISTENER_UI_PREVIEW_FREE_FOR_ALL:-1}" 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_ORIGIN="https://earlybirds-staging.harmonicbeacon.com" case "$PREVIEW_FREE_FOR_ALL:$PREVIEW_PAYPAL_CHECKOUT:$PREVIEW_MERCADO_PAGO_CHECKOUT" in 0:0:0|0:1:0|0:0:1|1:0:0) ;; @@ -39,7 +40,7 @@ sync_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_PAYPAL_CHECKOUT='$PREVIEW_PAYPAL_CHECKOUT' PREVIEW_MERCADO_PAGO_CHECKOUT='$PREVIEW_MERCADO_PAGO_CHECKOUT' bash -s" <<'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_PAYPAL_CHECKOUT='$PREVIEW_PAYPAL_CHECKOUT' PREVIEW_MERCADO_PAGO_CHECKOUT='$PREVIEW_MERCADO_PAGO_CHECKOUT' PREVIEW_ORIGIN='$PREVIEW_ORIGIN' bash -s" <<'REMOTE' set -euo pipefail image="$(docker inspect "$RELEASE_CONTAINER" --format '{{.Config.Image}}')" @@ -49,6 +50,13 @@ 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" +} + 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" @@ -62,6 +70,12 @@ command_args=() if [ "$PREVIEW_PAYPAL_CHECKOUT" = 1 ] || [ "$PREVIEW_MERCADO_PAGO_CHECKOUT" = 1 ]; then # Synthetic team entry is deliberately unavailable under NODE_ENV=development. # Payment rehearsal therefore runs the exact built release artifact. + # OAuth state and session cookies are host-only. The workbench must initiate + # and receive the callback on its own origin; inheriting the persistent + # Listener base URL sends Google back to listen.harmonicbeacon.com, where + # the staging state cookie is absent and Better Auth correctly rejects it. + set_env_file_value BEACON_LISTENER_AUTH_BASE_URL "$PREVIEW_ORIGIN" + set_env_file_value EARLY_BIRDS_AUTH_BASE_URL "$PREVIEW_ORIGIN" runtime_args=(-e NODE_ENV=production) command_args=(node server.js) else From 657c3349d4d8cd9df6f09b9b9635f7820c4a68b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 02:18:06 -0300 Subject: [PATCH 198/376] feat(listener): prepare commercial membership launch --- contracts/early-bird-authority/v2/README.md | 5 +- contracts/early-bird-authority/v2/SHA256SUMS | 2 +- contracts/listener-checkout/v1/README.md | 14 ++ contracts/listener-checkout/v1/SHA256SUMS | 5 + .../v1/checkout-create.fixture.json | 8 ++ .../v1/checkout-create.schema.json | 33 +++++ .../v1/checkout.fixture.json | 9 ++ .../listener-checkout/v1/checkout.schema.json | 36 +++++ .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 87 +++++++++++ docs/plans/EARLY_BIRDS.md | 7 + ops/early-birds-preview/compose.yml | 4 + ...s-staging.harmonicbeacon.com.conf.template | 49 ++++++- .../listen.harmonicbeacon.com.conf.template | 107 ++++++++++++++ .../preview.env.synthetic.example | 2 + .../test/preview-contract.test.mjs | 40 +++++- scripts/early-birds-preview/lib.sh | 2 + scripts/verify-early-bird-contracts.py | 1 + .../listener/checkout/__tests__/route.test.ts | 30 +++- src/app/api/listener/checkout/route.ts | 26 ++-- .../membership/cancel/__tests__/route.test.ts | 104 ++++++++++++++ .../api/listener/membership/cancel/route.ts | 87 +++++++++++ src/app/early-birds/__tests__/page.test.tsx | 1 + src/app/early-birds/page.tsx | 10 +- src/app/globals.css | 62 ++++++++ src/app/listener/privacy/page.tsx | 7 + src/app/listener/terms/page.tsx | 7 + src/components/early-birds/EarlyBirdHome.tsx | 11 +- .../early-birds/EarlyBirdLanding.tsx | 3 +- .../early-birds/FoundingListenerCheckout.tsx | 23 ++- .../FoundingListenerMembershipActions.tsx | 85 +++++++++++ src/components/early-birds/ListenerLegal.tsx | 27 ++++ ...FoundingListenerMembershipActions.test.tsx | 72 ++++++++++ .../__tests__/checkout-contract.test.ts | 5 +- .../early-birds/__tests__/checkout.test.ts | 55 +++++++ .../__tests__/membership-actions.test.ts | 92 ++++++++++++ .../__tests__/membership-presentation.test.ts | 8 +- src/lib/early-birds/checkout.ts | 101 +++++++++++-- src/lib/early-birds/copy.ts | 113 +++++++++++++++ src/lib/early-birds/membership-actions.ts | 135 ++++++++++++++++++ .../early-birds/membership-presentation.ts | 15 +- 40 files changed, 1441 insertions(+), 49 deletions(-) create mode 100644 contracts/listener-checkout/v1/README.md create mode 100644 contracts/listener-checkout/v1/SHA256SUMS create mode 100644 contracts/listener-checkout/v1/checkout-create.fixture.json create mode 100644 contracts/listener-checkout/v1/checkout-create.schema.json create mode 100644 contracts/listener-checkout/v1/checkout.fixture.json create mode 100644 contracts/listener-checkout/v1/checkout.schema.json create mode 100644 docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md create mode 100644 src/app/api/listener/membership/cancel/__tests__/route.test.ts create mode 100644 src/app/api/listener/membership/cancel/route.ts create mode 100644 src/app/listener/privacy/page.tsx create mode 100644 src/app/listener/terms/page.tsx create mode 100644 src/components/early-birds/FoundingListenerMembershipActions.tsx create mode 100644 src/components/early-birds/ListenerLegal.tsx create mode 100644 src/components/early-birds/__tests__/FoundingListenerMembershipActions.test.tsx create mode 100644 src/lib/early-birds/__tests__/membership-actions.test.ts create mode 100644 src/lib/early-birds/membership-actions.ts diff --git a/contracts/early-bird-authority/v2/README.md b/contracts/early-bird-authority/v2/README.md index 560a18c2..4711209f 100644 --- a/contracts/early-bird-authority/v2/README.md +++ b/contracts/early-bird-authority/v2/README.md @@ -1,4 +1,7 @@ -# EarlyBird authority membership read contract v2 +# 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: diff --git a/contracts/early-bird-authority/v2/SHA256SUMS b/contracts/early-bird-authority/v2/SHA256SUMS index e4946113..cc6cb1a8 100644 --- a/contracts/early-bird-authority/v2/SHA256SUMS +++ b/contracts/early-bird-authority/v2/SHA256SUMS @@ -1,3 +1,3 @@ -6db01b88df755b52f5e70ed07e847ad8888c2cd475824eeaf56ca10e4a3e6e62 README.md +62834291a980d19864e39604b31cb3451b030d836702c7e4a49ab2b4064f30a4 README.md 361768014bc0b46570c257c9cdec4db2ed0749a953c0994a288fd6f31ff8642f membership.fixture.json f9c6928ffd79f46a82ddd2ec81d28e79391d09053a1251d518bc41dbb644f0bf membership.schema.json 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/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md new file mode 100644 index 00000000..2a174e59 --- /dev/null +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -0,0 +1,87 @@ +# 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. +- 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. + +## 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 cancel request: exact same-origin `POST /api/listener/membership/cancel`. +- 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 only provider plus a random attempt ID for checkout, and only a random + attempt ID for cancellation. Account, email, current provider and provider subscription ID are + server-derived. Provider IDs never enter the browser response. + +## 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 new-sales flags OFF. Enable one provider lifecycle and validate private readiness, + catalog/merchant identity, signed-webhook negative cases and reconciliation. +4. Install the reviewed Listener nginx template and verify exact routes plus final 404. Do not + reload nginx unless `nginx -t` is green. +5. Enable the matching Listener checkout flag only after the authority reports that Live provider + ready and the public copy/terms have human approval. +6. 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. +7. Request cancellation in the profile. Confirm provider cancellation, pending-end projection and + access through paid-through. Use a separate controlled account to rehearse failure/refund. +8. 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. +- Listener regression: roll back only the Listener image while keeping a contract-compatible + authority. 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. +- Refund/dispute: follow the canonical provider event. Support records the provider operation and + opaque account in the private ledger; no card/bank data enters GitHub or application logs. + +## Human release gates still required + +- PayPal Live Business account/app/product/plan/webhook and root-only Live secrets. +- Mercado Pago productive merchant credentials/webhook and root-only Live secrets. +- 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. diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index ef3f5393..b2c0ca28 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -482,6 +482,13 @@ The webapp vendors byte-exact copies of the canonical backend contracts under Mandatory external gate before any paid activation: real Mercado Pago TEST proof that `/preapproval/search?q=hb_` returns the exact created preapproval. +- `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. Backend PR #69 / merge + `7bc2dabd05aa68602bbfe9faa6e29c4e1c081017` is CI-green. The Listener Live + surface, cancellation and exact webhook ingress remain disabled by default; + see `docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md`. ## 11. Fast Forward development lane diff --git a/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index d9ac75d8..5c84d159 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -94,6 +94,10 @@ services: BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED: ${BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED:-0} 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} BEACON_LISTENER_GEOIP_DB_PATH: /data/geoip/dbip-country-lite.mmdb TRUSTED_PROXY_HOPS: "1" ports: 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 index 944cb659..251dff33 100644 --- a/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -215,6 +215,26 @@ server { 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 = /early-birds/redeem { access_log off; add_header Cache-Control "private, no-store" always; @@ -288,9 +308,9 @@ server { proxy_read_timeout 30s; } - # Authenticated browser checkout exists only in the sandbox workbench and - # is independently fail-closed inside the application. The public Listener - # vhost has no matching location until a separately approved sales cutover. + # 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; @@ -312,6 +332,29 @@ server { add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; } + # Account-derived sandbox cancellation. The browser cannot name a provider + # or subscription; the Listener resolves the current canonical projection. + location = /api/listener/membership/cancel { + 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; + } + # Renderer-neutral frames for the disposable visual workbench. The public # Listener edge intentionally has no matching location. location = /api/listener/analysis/frame { diff --git a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template index cdca46b5..2bbff5fd 100644 --- a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -4,6 +4,9 @@ # 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; server { listen 80; @@ -52,6 +55,14 @@ server { 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; + } + # 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 { @@ -197,6 +208,26 @@ server { 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; + } + location = / { # The root may receive a legacy invitation bearer before the internal # Listener rewrite can scrub it. Never persist that first request. @@ -256,6 +287,82 @@ server { proxy_read_timeout 30s; } + # New sales and cancellation 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/cancel { + 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 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. diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example index 77c0a6f7..909131cf 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -40,6 +40,8 @@ EARLY_BIRDS_STAGING_TEAM_ENTRY_ENABLED=0 BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED=0 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 EARLY_BIRDS_STAGING_TEAM_ENTRY_HOSTS=earlybirds-staging.harmonicbeacon.com # Local country-only GeoIP data. The reviewed July 2026 DB-IP Lite artifact is diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 9296fc99..04cb4a6e 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -33,6 +33,8 @@ test('synthetic guard accepts the example and rejects unsafe effective values', ['unsafe team-entry switch', 'EARLY_BIRDS_STAGING_TEAM_ENTRY_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/], ['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/], @@ -126,6 +128,8 @@ test('compose gates the loopback Listener on a forward-only isolated database mi assert.match(source, /BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED: \$\{BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED:-0\}/); 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, /NODE_ENV: production/); assert.match(source, /preview_db:[\s\S]*internal: true/); assert.match(source, /listener_egress:/); @@ -190,20 +194,28 @@ test('nginx templates isolate staging, stream and the constrained public Listene assert.match(app, /location \^~ \/api\/early-birds\//); assert.equal( (app.match(/X-Harmonic-Beacon-Environment "early-birds-staging"/g) ?? []).length, - 8, - 'server plus seven sensitive HTTPS staging locations retain the environment attestation when add_header inheritance stops', + 9, + 'server plus eight 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, - 7, - 'server plus six sensitive HTTPS locations retain the environment attestation when add_header inheritance stops', + 9, + 'server plus eight 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); 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.doesNotMatch(listener, /location = \/api\/listener\/checkout/); + assert.match(app, /location = \/api\/listener\/membership\/cancel \{[^}]*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\/cancel \{[^}]*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, /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;/); @@ -223,7 +235,20 @@ test('nginx templates isolate staging, stream and the constrained public Listene 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); - assert.doesNotMatch(listener, /127\.0\.0\.1:18876|\/v1\/webhooks\/early-birds\/(paypal|mercado-pago)/); + 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); @@ -266,7 +291,8 @@ test('nginx templates isolate staging, stream and the constrained public Listene 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|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;/); diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index b816ce92..2d31bd93 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -105,6 +105,8 @@ require_synthetic_env() { 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 EARLY_BIRDS_TEST_ACCESS_ENABLED 1 "$env_file" authority_network=$(preview_env_value EARLYBIRDS_PREVIEW_AUTHORITY_NETWORK "$env_file") diff --git a/scripts/verify-early-bird-contracts.py b/scripts/verify-early-bird-contracts.py index 6797339c..97ff75a1 100644 --- a/scripts/verify-early-bird-contracts.py +++ b/scripts/verify-early-bird-contracts.py @@ -13,6 +13,7 @@ ROOT / "contracts/early-bird-checkout/v2", ROOT / "contracts/early-bird-membership/v1", ROOT / "contracts/early-bird-membership/v2", + ROOT / "contracts/listener-checkout/v1", ) diff --git a/src/app/api/listener/checkout/__tests__/route.test.ts b/src/app/api/listener/checkout/__tests__/route.test.ts index de310acb..18dd7831 100644 --- a/src/app/api/listener/checkout/__tests__/route.test.ts +++ b/src/app/api/listener/checkout/__tests__/route.test.ts @@ -43,6 +43,8 @@ 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' }, }); @@ -74,11 +76,37 @@ describe('Listener sandbox checkout route', () => { attemptId: ATTEMPT, returnUrl: `https://${HOST}/?checkout=returned`, cancelUrl: `https://${HOST}/?checkout=cancelled`, + environment: 'staging', }); }); + 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([ - ['canonical public host', 'https://listen.harmonicbeacon.com', 'listen.harmonicbeacon.com'], ['event host', 'https://live.harmonicbeacon.com', 'live.harmonicbeacon.com'], ['cross origin', 'https://attacker.invalid', HOST], ])('rejects %s before auth', async (_label, origin, host) => { diff --git a/src/app/api/listener/checkout/route.ts b/src/app/api/listener/checkout/route.ts index 97770a4c..144feaa2 100644 --- a/src/app/api/listener/checkout/route.ts +++ b/src/app/api/listener/checkout/route.ts @@ -6,9 +6,10 @@ import { listenerCheckoutAvailability, ListenerCheckoutUnavailableError, type ListenerCheckoutProvider, + type ListenerCheckoutEnvironment, } from '@/lib/early-birds/checkout'; import { earlyBirdsEnabled } from '@/lib/early-birds/enabled'; -import { isListenerStagingHost } from '@/lib/listener/public-discovery'; +import { isCanonicalListenerHost, isListenerStagingHost } from '@/lib/listener/public-discovery'; export const dynamic = 'force-dynamic'; @@ -21,12 +22,18 @@ function json(body: Record, status: number): NextResponse { return response; } -function requestOrigin(request: NextRequest): string | null { +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(); - if (!host || protocol !== 'https' || !isListenerStagingHost(request.headers)) return null; + 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 ? expected : null; + return request.headers.get('origin') === expected ? { origin: expected, environment } : null; } function providerFrom(value: unknown): ListenerCheckoutProvider | null { @@ -35,8 +42,8 @@ function providerFrom(value: unknown): ListenerCheckoutProvider | null { export async function POST(request: NextRequest): Promise { if (!earlyBirdsEnabled()) return json({ error: 'Checkout unavailable.' }, 404); - const origin = requestOrigin(request); - if (!origin) return json({ error: 'Invalid request.' }, 403); + 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)) { @@ -63,7 +70,7 @@ export async function POST(request: NextRequest): Promise { return json({ error: 'Invalid request.' }, 400); } - const available = listenerCheckoutAvailability(); + const available = listenerCheckoutAvailability(process.env, context.environment); if ((provider === 'paypal' && !available.paypal) || (provider === 'mercado_pago' && !available.mercadoPago)) { return json({ error: 'Checkout unavailable.' }, 404); @@ -77,8 +84,9 @@ export async function POST(request: NextRequest): Promise { email: session.user.email, provider, attemptId, - returnUrl: `${origin}/?checkout=returned`, - cancelUrl: `${origin}/?checkout=cancelled`, + returnUrl: `${context.origin}/?checkout=returned`, + cancelUrl: `${context.origin}/?checkout=cancelled`, + environment: context.environment, }); return json({ provider: result.provider, approvalUrl: result.approvalUrl }, 200); } catch (error) { diff --git a/src/app/api/listener/membership/cancel/__tests__/route.test.ts b/src/app/api/listener/membership/cancel/__tests__/route.test.ts new file mode 100644 index 00000000..e5475686 --- /dev/null +++ b/src/app/api/listener/membership/cancel/__tests__/route.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const currentEarlyBirdSession = vi.hoisted(() => vi.fn()); +const cancel = 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 { + cancel = cancel; + }, + }; +}); + +import { POST } from '../route'; + +const HOST = 'listen.harmonicbeacon.com'; +const ATTEMPT = '123e4567-e89b-42d3-a456-426614174000'; + +function request(body: unknown = { attemptId: ATTEMPT }, host = HOST, origin = `https://${HOST}`) { + const serialized = JSON.stringify(body); + return new NextRequest(`https://${host}/api/listener/membership/cancel`, { + 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' }, + }); + cancel.mockResolvedValue(undefined); + getEarlyBirdListeningAccess.mockResolvedValue({ + membership: { projection: { source: 'PAYPAL' } }, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('Listener membership cancellation route', () => { + it('derives the account from session and returns no provider identifiers', async () => { + const response = await POST(request()); + expect(response.status).toBe(202); + await expect(response.json()).resolves.toEqual({ status: 'queued' }); + expect(cancel).toHaveBeenCalledWith({ + accountId: 'opaqueBetterAuthId', + attemptId: ATTEMPT, + 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(undefined, host, `https://${host}`)); + expect(response.status).toBe(202); + expect(cancel).toHaveBeenCalledWith({ + accountId: 'opaqueBetterAuthId', + attemptId: ATTEMPT, + 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(cancel).not.toHaveBeenCalled(); + }); + + it('rejects unauthenticated and client-supplied fields', async () => { + currentEarlyBirdSession.mockResolvedValue(null); + expect((await POST(request())).status).toBe(401); + expect((await POST(request({ attemptId: ATTEMPT, provider: 'paypal' }))).status).toBe(400); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('returns a generic failure without changing browser authority', async () => { + cancel.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/cancel/route.ts b/src/app/api/listener/membership/cancel/route.ts new file mode 100644 index 00000000..3a864fe8 --- /dev/null +++ b/src/app/api/listener/membership/cancel/route.ts @@ -0,0 +1,87 @@ +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, +} 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; +} + +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') !== ['attemptId'].join('\0')) { + return json({ error: 'Invalid request.' }, 400); + } + const attemptId = (input as Record).attemptId; + if (typeof attemptId !== 'string' || + !/^[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().cancel({ + accountId: session.user.id, + attemptId, + 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/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx index dab36488..0e5ac104 100644 --- a/src/app/early-birds/__tests__/page.test.tsx +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -203,6 +203,7 @@ describe('EarlyBird Listener page', () => { kind: 'founder', provider: 'mercado-pago', state: 'ending', + serviceThrough: '2026-08-31T00:00:00.000Z', }); expect(JSON.stringify(result.props.membership)).not.toMatch(/PRIVATE_REASON|internal-provider-value|MERCADO_PAGO/); }); diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index eb9a801d..1f6166f7 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -46,17 +46,19 @@ export default async function EarlyBirdsPage({ if (!earlyBirdsEnabled()) return ; const incomingHeaders = new Headers(await requestHeaders()); const listenerStagingHost = isListenerStagingHost(incomingHeaders); + const canonicalListenerHost = isCanonicalListenerHost(incomingHeaders); const reactiveVisualizationAvailable = listenerStagingHost || isCanonicalListenerHost(incomingHeaders); const reactiveFieldLabAvailable = listenerStagingHost && process.env.BEACON_LISTENER_REACTIVE_FIELD_LAB_ENABLED === '1'; - const checkoutAvailability = listenerStagingHost - ? listenerCheckoutAvailability() + const checkoutEnvironment = canonicalListenerHost ? 'live' : 'staging'; + const checkoutAvailability = (listenerStagingHost || canonicalListenerHost) + ? listenerCheckoutAvailability(process.env, checkoutEnvironment) : { paypal: false, mercadoPago: false }; const params = await searchParams; const paypalReturn = params.paypal; const checkoutReturn = params.checkout; - if (listenerStagingHost && ( + if ((listenerStagingHost || canonicalListenerHost) && ( paypalReturn === 'success' || paypalReturn === 'cancel' || checkoutReturn === 'returned' @@ -108,6 +110,7 @@ export default async function EarlyBirdsPage({ accessKind={access.kind === 'free-quota' ? 'free-quota' : 'membership'} quota={access.quota ? serializeEarlyBirdQuotaSnapshot(access.quota) : null} checkoutAvailability={checkoutAvailability} + checkoutEnvironment={checkoutEnvironment} serverNow={access.serverNow.toISOString()} dropIns={{ es: configuredEarlyBirdDropIn('es'), @@ -140,6 +143,7 @@ export default async function EarlyBirdsPage({ syntheticTeamEntryAvailable={syntheticTeamEntryAvailable} quota={access?.quota ? serializeEarlyBirdQuotaSnapshot(access.quota) : null} checkoutAvailability={checkoutAvailability} + checkoutEnvironment={checkoutEnvironment} membership={listenerMembershipPresentation(access?.membership.projection ?? null)} serverNow={access?.serverNow.toISOString() ?? new Date().toISOString()} /> diff --git a/src/app/globals.css b/src/app/globals.css index 29adaffa..2a920719 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1859,6 +1859,68 @@ body { .listener-checkout__options p { line-height: 1.45; } .listener-checkout__options [role="alert"] { color: #fecaca; } +.listener-checkout__legal { + color: var(--text-muted); + font-size: 0.68rem; +} + +.listener-checkout__legal a, +.listener-legal a { color: var(--gold); 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: 1.2rem; + background: rgba(9, 27, 21, 0.92); + box-shadow: var(--shadow-deep); +} + +.listener-legal > p:first-of-type { margin-top: 2rem; color: var(--gold); letter-spacing: 0.12em; font-size: 0.7rem; } +.listener-legal h1 { margin: 0.6rem 0; color: var(--paper); font-size: clamp(1.7rem, 6vw, 3.4rem); 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(--paper); font-size: 1rem; } +.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; diff --git a/src/app/listener/privacy/page.tsx b/src/app/listener/privacy/page.tsx new file mode 100644 index 00000000..c4b10645 --- /dev/null +++ b/src/app/listener/privacy/page.tsx @@ -0,0 +1,7 @@ +import ListenerLegal from '@/components/early-birds/ListenerLegal'; + +export const dynamic = 'force-dynamic'; + +export default function ListenerPrivacyPage() { + return ; +} diff --git a/src/app/listener/terms/page.tsx b/src/app/listener/terms/page.tsx new file mode 100644 index 00000000..8fb3777f --- /dev/null +++ b/src/app/listener/terms/page.tsx @@ -0,0 +1,7 @@ +import ListenerLegal from '@/components/early-birds/ListenerLegal'; + +export const dynamic = 'force-dynamic'; + +export default function ListenerTermsPage() { + return ; +} diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index d3be0a71..1eaa2db2 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -10,6 +10,7 @@ import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; import ListenerPlayer from './ListenerPlayer'; import FreeQuotaStatus from './FreeQuotaStatus'; import FoundingListenerCheckout from './FoundingListenerCheckout'; +import FoundingListenerMembershipActions from './FoundingListenerMembershipActions'; import type { SerializedEarlyBirdQuotaSnapshot } from './free-quota'; export default function EarlyBirdHome({ @@ -23,6 +24,7 @@ export default function EarlyBirdHome({ reactiveFieldLabAvailable = false, quota = null, checkoutAvailability = { paypal: false, mercadoPago: false }, + checkoutEnvironment = 'staging', }: { displayName: string; membership: ListenerMembershipPresentation; @@ -34,6 +36,7 @@ export default function EarlyBirdHome({ reactiveFieldLabAvailable?: boolean; quota?: SerializedEarlyBirdQuotaSnapshot | null; checkoutAvailability?: { paypal: boolean; mercadoPago: boolean }; + checkoutEnvironment?: 'staging' | 'live'; }) { const { locale } = useLocale(); const copy = earlyBirdHomeCopy[locale]; @@ -68,6 +71,9 @@ export default function EarlyBirdHome({ {accessKind === 'membership' && membership.kind === 'founder' && ( )} + {accessKind === 'membership' && membership.kind === 'founder' && ( + + )}
} @@ -87,7 +93,10 @@ export default function EarlyBirdHome({ compact showMembershipLink={!checkoutAvailability.paypal && !checkoutAvailability.mercadoPago} /> - + )}
diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index e8e4a547..609bcad2 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -28,6 +28,7 @@ type Props = { membership: ListenerMembershipPresentation; serverNow: string; checkoutAvailability?: { paypal: boolean; mercadoPago: boolean }; + checkoutEnvironment?: 'staging' | 'live'; }; export default function EarlyBirdLanding(props: Props) { @@ -180,7 +181,7 @@ export default function EarlyBirdLanding(props: Props) { + }} environment={props.checkoutEnvironment ?? 'staging'} /> )}
); diff --git a/src/components/early-birds/FoundingListenerMembershipActions.tsx b/src/components/early-birds/FoundingListenerMembershipActions.tsx new file mode 100644 index 00000000..ac55bff6 --- /dev/null +++ b/src/components/early-birds/FoundingListenerMembershipActions.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useRef, useState } from 'react'; + +import { useLocale } from '@/context/LocaleContext'; +import { earlyBirdCopy } from '@/lib/early-birds/copy'; +import type { ListenerMembershipPresentation } from '@/lib/early-birds/membership-presentation'; + +export default function FoundingListenerMembershipActions({ + membership, +}: { + membership: Extract; +}) { + const { locale } = useLocale(); + const copy = earlyBirdCopy[locale]; + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [status, setStatus] = useState<'idle' | 'busy' | 'queued' | 'failed'>('idle'); + const attempt = useRef(null); + + const boundary = membership.serviceThrough + ? new Intl.DateTimeFormat(locale === 'es' ? 'es-AR' : 'en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(membership.serviceThrough)) + : null; + + async function cancelMembership() { + if (status === 'busy') return; + setStatus('busy'); + const attemptId = attempt.current ?? crypto.randomUUID(); + attempt.current = attemptId; + try { + const response = await fetch('/api/listener/membership/cancel', { + method: 'POST', + cache: 'no-store', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ attemptId }), + }); + const body = await response.json() as unknown; + if (response.status !== 202 || !body || typeof body !== 'object' || + Array.isArray(body) || (body as Record).status !== 'queued') { + throw new Error(); + } + setStatus('queued'); + setConfirming(false); + window.setTimeout(() => router.refresh(), 2_000); + window.setTimeout(() => router.refresh(), 8_000); + } catch { + setStatus('failed'); + } + } + + return ( +
+ {boundary && ( + {membership.state === 'ending' + ? copy.membershipAccessThrough.replace('{date}', boundary) + : copy.membershipCurrentPeriodThrough.replace('{date}', boundary)} + )} + {membership.state !== 'ending' && status !== 'queued' && !confirming && ( + + )} + {confirming && status !== 'queued' && ( +
+

{copy.membershipCancelConfirmDetail}

+ + +
+ )} + {status === 'queued' &&

{copy.membershipCancelQueued}

} + {status === 'failed' &&

{copy.membershipCancelFailed}

} +
+ ); +} diff --git a/src/components/early-birds/ListenerLegal.tsx b/src/components/early-birds/ListenerLegal.tsx new file mode 100644 index 00000000..abd60749 --- /dev/null +++ b/src/components/early-birds/ListenerLegal.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useLocale } from '@/context/LocaleContext'; +import { earlyBirdLegalCopy } from '@/lib/early-birds/copy'; +import Link from 'next/link'; + +export default function ListenerLegal() { + const { locale } = useLocale(); + const copy = earlyBirdLegalCopy[locale]; + + return ( +
+
+ ← {copy.back} +

{copy.eyebrow}

+

{copy.title}

+

{copy.updated}

+ {copy.sections.map((section) => ( +
+

{section.title}

+ {section.paragraphs.map((paragraph) =>

{paragraph}

)} +
+ ))} +
+
+ ); +} diff --git a/src/components/early-birds/__tests__/FoundingListenerMembershipActions.test.tsx b/src/components/early-birds/__tests__/FoundingListenerMembershipActions.test.tsx new file mode 100644 index 00000000..0368bb9c --- /dev/null +++ b/src/components/early-birds/__tests__/FoundingListenerMembershipActions.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocaleProvider } from '@/context/LocaleContext'; + +const refresh = vi.hoisted(() => vi.fn()); +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh }) })); + +import FoundingListenerMembershipActions from '../FoundingListenerMembershipActions'; + +beforeEach(() => { + vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValue('123e4567-e89b-42d3-a456-426614174000'); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + refresh.mockReset(); +}); + +describe('Founding Listener membership actions', () => { + it('requires explicit confirmation and sends no provider identity', async () => { + const fetchMock = vi.fn().mockResolvedValue(Response.json({ status: 'queued' }, { status: 202 })); + vi.stubGlobal('fetch', fetchMock); + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel membership' })); + expect(screen.getByText(/Founder pricing ends/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Yes, cancel at period end' })); + await screen.findByText(/We received the request/); + const sent = JSON.parse(fetchMock.mock.calls[0][1].body) as Record; + expect(sent).toEqual({ attemptId: '123e4567-e89b-42d3-a456-426614174000' }); + expect(JSON.stringify(sent)).not.toMatch(/paypal|subscription|account/i); + }); + + it('does not offer cancellation twice once canonical state is ending', () => { + render( + + + , + ); + expect(screen.queryByRole('button', { name: 'Cancel membership' })).not.toBeInTheDocument(); + }); + + it('keeps the action retryable after a generic failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({}, { status: 503 }))); + render( + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Cancel membership' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, cancel at period end' })); + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + }); +}); diff --git a/src/lib/early-birds/__tests__/checkout-contract.test.ts b/src/lib/early-birds/__tests__/checkout-contract.test.ts index 1c003f84..632a615f 100644 --- a/src/lib/early-birds/__tests__/checkout-contract.test.ts +++ b/src/lib/early-birds/__tests__/checkout-contract.test.ts @@ -11,6 +11,9 @@ const CONTRACT_DIRS = [ 'contracts/early-bird-authority/v2', 'contracts/early-bird-checkout/v2', 'contracts/early-bird-membership/v1', + 'contracts/early-bird-authority/v3', + 'contracts/early-bird-membership/v2', + 'contracts/listener-checkout/v1', ] as const; const AUTHORITY_DIR = `${REPO_ROOT}${CONTRACT_DIRS[0]}`; @@ -119,7 +122,7 @@ describe('Listener authority contract manifests', () => { }); const BACKEND_REPO = process.env.EARLY_BIRDS_BACKEND_REPO ?? '/home/nicolas/Projects/proyecciones-mito'; -const BACKEND_COMMIT = 'cad4eded2e08ee46da49e54ee94e1ab8601d9495'; +const BACKEND_COMMIT = '7bc2dabd05aa68602bbfe9faa6e29c4e1c081017'; const backendAvailable = existsSync(`${BACKEND_REPO}/.git`); // Git hooks (e.g. the pre-commit suite run) export GIT_DIR and friends, which diff --git a/src/lib/early-birds/__tests__/checkout.test.ts b/src/lib/early-birds/__tests__/checkout.test.ts index a89f5a21..c7629b63 100644 --- a/src/lib/early-birds/__tests__/checkout.test.ts +++ b/src/lib/early-birds/__tests__/checkout.test.ts @@ -46,6 +46,11 @@ describe('Listener sandbox checkout gateway', () => { BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED: '1', BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED: 'true', } as NodeJS.ProcessEnv)).toEqual({ paypal: true, mercadoPago: false }); + expect(listenerCheckoutAvailability({ + NODE_ENV: 'test', + BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED: '1', + BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED: 'true', + } as NodeJS.ProcessEnv, 'live')).toEqual({ paypal: true, mercadoPago: false }); }); it('creates PayPal through v1 without sending email and keeps retries idempotent', async () => { @@ -119,4 +124,54 @@ describe('Listener sandbox checkout gateway', () => { await expect(gateway.create({ ...base, provider: 'paypal' })) .rejects.toBeInstanceOf(ListenerCheckoutUnavailableError); }); + + it('uses the unified Live contract and strips provider subscription identity', async () => { + const request = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const captured = new Request(input, init); + expect(captured.url).toBe('http://pmp-myth-api:8765/api/internal/v1/listener-checkouts'); + await expect(captured.json()).resolves.toEqual({ + schema_version: 'listener-checkout.checkout-create.v1', + account_id: base.accountId, + provider: 'paypal', + payer_email: null, + return_url: base.returnUrl, + cancel_url: base.cancelUrl, + }); + return Response.json({ + schema_version: 'listener-checkout.checkout.v1', + account_id: base.accountId, + provider: 'paypal', + approval_url: 'https://www.paypal.com/checkoutnow?token=live', + currency: 'USD', + amount_minor: 500, + environment: 'live', + }); + }); + const gateway = new HttpListenerCheckoutGateway(config, request as typeof fetch); + await expect(gateway.create({ ...base, provider: 'paypal', environment: 'live' })).resolves.toEqual({ + provider: 'paypal', + approvalUrl: 'https://www.paypal.com/checkoutnow?token=live', + }); + }); + + it.each([ + ['sandbox response', { environment: 'staging' }], + ['wrong PayPal price', { amount_minor: 700 }], + ['sandbox PayPal host', { approval_url: 'https://www.sandbox.paypal.com/checkout' }], + ['provider identity leak', { external_subscription_id: 'I-secret' }], + ])('rejects %s on the Live boundary', async (_label, override) => { + const request = vi.fn(async () => Response.json({ + schema_version: 'listener-checkout.checkout.v1', + account_id: base.accountId, + provider: 'paypal', + approval_url: 'https://www.paypal.com/checkoutnow?token=live', + currency: 'USD', + amount_minor: 500, + environment: 'live', + ...override, + })); + const gateway = new HttpListenerCheckoutGateway(config, request as typeof fetch); + await expect(gateway.create({ ...base, provider: 'paypal', environment: 'live' })) + .rejects.toBeInstanceOf(ListenerCheckoutUnavailableError); + }); }); diff --git a/src/lib/early-birds/__tests__/membership-actions.test.ts b/src/lib/early-birds/__tests__/membership-actions.test.ts new file mode 100644 index 00000000..68f37897 --- /dev/null +++ b/src/lib/early-birds/__tests__/membership-actions.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + HttpListenerMembershipActionsGateway, + ListenerMembershipActionUnavailableError, +} from '../membership-actions'; + +const config = { + baseUrl: 'http://pmp-myth-api:8765', + keyId: 'beacon-listener-v1', + token: 't'.repeat(43), +}; +const input = { + accountId: 'betterAuthOpaqueId_123', + attemptId: '123e4567-e89b-42d3-a456-426614174000', +}; + +describe('Listener membership actions gateway', () => { + it('queues cancellation with opaque account identity and a stable idempotency key', async () => { + const requests: Request[] = []; + const request = vi.fn(async (target: string | URL | Request, init?: RequestInit) => { + requests.push(new Request(target, init)); + return Response.json({ + schema_version: 'listener-membership.action-result.v1', + status: 'queued', + account_id: input.accountId, + provider: 'paypal', + action: 'cancel', + job_id: 'private-job-id', + }, { status: 202 }); + }); + const gateway = new HttpListenerMembershipActionsGateway(config, request as typeof fetch); + await gateway.cancel(input); + await gateway.cancel(input); + + expect(requests).toHaveLength(2); + expect(requests[0].url).toBe('http://pmp-myth-api:8765/api/internal/v1/listener-membership-actions'); + await expect(requests[0].json()).resolves.toEqual({ + schema_version: 'listener-membership.action.v1', + account_id: input.accountId, + action: 'cancel', + }); + expect(requests[0].headers.get('idempotency-key')) + .toBe(requests[1].headers.get('idempotency-key')); + }); + + it.each([ + ['wrong account', { account_id: 'another-account' }], + ['wrong action', { action: 'reactivate' }], + ['wrong status', { status: 'done' }], + ['unknown field', { external_subscription_id: 'private' }], + ])('fails closed on %s', async (_label, override) => { + const request = vi.fn(async () => Response.json({ + schema_version: 'listener-membership.action-result.v1', + status: 'queued', + account_id: input.accountId, + provider: 'paypal', + action: 'cancel', + job_id: 'private-job-id', + ...override, + }, { status: 202 })); + const gateway = new HttpListenerMembershipActionsGateway(config, request as typeof fetch); + await expect(gateway.cancel(input)).rejects + .toBeInstanceOf(ListenerMembershipActionUnavailableError); + }); + + it('uses the server-selected provider only for the isolated staging lifecycle', async () => { + const request = vi.fn(async (target: string | URL | Request, init?: RequestInit) => { + const captured = new Request(target, init); + expect(captured.url).toBe('http://pmp-myth-api:8765/api/internal/v1/early-bird-mercado-pago-actions'); + await expect(captured.json()).resolves.toEqual({ + schema_version: 'early-bird-mercado-pago-lifecycle.command.v1', + account_id: input.accountId, + action: 'cancel', + }); + return Response.json({ + schema_version: 'early-bird-mercado-pago-lifecycle.response.v1', + status: 'queued', + account_id: input.accountId, + action: 'cancel', + external_subscription_id: 'private-and-discarded', + job_id: 'private-job-id', + }, { status: 202 }); + }); + const gateway = new HttpListenerMembershipActionsGateway(config, request as typeof fetch); + await expect(gateway.cancel({ + ...input, + environment: 'staging', + provider: 'mercado_pago', + })).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/early-birds/__tests__/membership-presentation.test.ts b/src/lib/early-birds/__tests__/membership-presentation.test.ts index 3ffe90e0..84a1b017 100644 --- a/src/lib/early-birds/__tests__/membership-presentation.test.ts +++ b/src/lib/early-birds/__tests__/membership-presentation.test.ts @@ -50,7 +50,12 @@ describe('public Listener membership presentation', () => { reasonCode: 'PRIVATE_REASON', }), NOW); - expect(result).toEqual({ kind: 'founder', provider: 'paypal', state: 'ending' }); + expect(result).toEqual({ + kind: 'founder', + provider: 'paypal', + state: 'ending', + serviceThrough: '2026-09-07T12:00:00.000Z', + }); expect(JSON.stringify(result)).not.toMatch(/PRIVATE_REASON|provider-internal-value|PAYPAL/); }); @@ -67,6 +72,7 @@ describe('public Listener membership presentation', () => { kind: 'founder', provider: 'paypal', state: expected, + serviceThrough: '2026-09-07T12:00:00.000Z', }); }); diff --git a/src/lib/early-birds/checkout.ts b/src/lib/early-birds/checkout.ts index 0d267590..8e4d2074 100644 --- a/src/lib/early-birds/checkout.ts +++ b/src/lib/early-birds/checkout.ts @@ -3,13 +3,14 @@ import { createHash } from 'node:crypto'; import { isEarlyBirdAccountId } from './account-id'; export type ListenerCheckoutProvider = 'paypal' | 'mercado_pago'; +export type ListenerCheckoutEnvironment = 'staging' | 'live'; const REQUEST_TIMEOUT_MS = 10_000; const MAX_RESPONSE_BYTES = 64 * 1024; export class ListenerCheckoutUnavailableError extends Error { constructor() { - super('Listener sandbox checkout is unavailable'); + super('Listener checkout is unavailable'); this.name = 'ListenerCheckoutUnavailableError'; } } @@ -19,7 +20,16 @@ export type ListenerCheckoutResult = { approvalUrl: string; }; -export function listenerCheckoutAvailability(environment: NodeJS.ProcessEnv = process.env) { +export function listenerCheckoutAvailability( + environment: NodeJS.ProcessEnv = process.env, + target: ListenerCheckoutEnvironment = 'staging', +) { + if (target === 'live') { + return { + paypal: environment.BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED === '1', + mercadoPago: environment.BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED === '1', + } as const; + } return { paypal: environment.BEACON_LISTENER_PAYPAL_SANDBOX_CHECKOUT_ENABLED === '1', mercadoPago: environment.BEACON_LISTENER_MERCADO_PAGO_TEST_CHECKOUT_ENABLED === '1', @@ -35,7 +45,7 @@ function normalizedEmail(value: string): string { return normalized; } -function authorityConfig(environment: NodeJS.ProcessEnv = process.env) { +export function listenerAuthorityConfig(environment: NodeJS.ProcessEnv = process.env) { const rawBaseUrl = environment.EARLY_BIRDS_AUTHORITY_BASE_URL?.trim(); const keyId = environment.EARLY_BIRDS_AUTHORITY_SERVICE_KEY_ID?.trim(); const token = environment.EARLY_BIRDS_AUTHORITY_SERVICE_TOKEN?.trim(); @@ -54,7 +64,7 @@ function authorityConfig(environment: NodeJS.ProcessEnv = process.env) { return { baseUrl: baseUrl.toString().replace(/\/$/, ''), keyId, token }; } -async function boundedJson(response: Response): Promise { +export async function listenerBoundedJson(response: Response): Promise { const declared = response.headers.get('content-length'); if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > MAX_RESPONSE_BYTES)) { await response.body?.cancel().catch(() => undefined); @@ -91,7 +101,7 @@ async function boundedJson(response: Response): Promise { } } -function checkoutResult( +function sandboxCheckoutResult( raw: unknown, accountId: string, provider: ListenerCheckoutProvider, @@ -129,16 +139,60 @@ function checkoutResult( return { provider, approvalUrl: approval.toString() }; } -function idempotencyKey(accountId: string, provider: ListenerCheckoutProvider, attemptId: string): string { +function liveCheckoutResult( + raw: unknown, + accountId: string, + provider: ListenerCheckoutProvider, +): ListenerCheckoutResult { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new ListenerCheckoutUnavailableError(); + } + const input = raw as Record; + const expectedKeys = [ + 'account_id', 'amount_minor', 'approval_url', 'currency', + 'environment', 'provider', 'schema_version', + ]; + if (Object.keys(input).sort().join('\0') !== expectedKeys.sort().join('\0') || + input.schema_version !== 'listener-checkout.checkout.v1' || + input.account_id !== accountId || input.provider !== provider || input.environment !== 'live' || + !Number.isSafeInteger(input.amount_minor) || Number(input.amount_minor) <= 0 || + (provider === 'paypal' + ? input.currency !== 'USD' || input.amount_minor !== 500 + : input.currency !== 'ARS') || + typeof input.approval_url !== 'string') { + throw new ListenerCheckoutUnavailableError(); + } + let approval: URL; + try { + approval = new URL(input.approval_url); + } catch { + throw new ListenerCheckoutUnavailableError(); + } + const allowedHost = provider === 'paypal' + ? approval.hostname === 'paypal.com' || approval.hostname === 'www.paypal.com' + : approval.hostname === 'www.mercadopago.com.ar'; + if (approval.protocol !== 'https:' || approval.username || approval.password || !allowedHost || + input.approval_url.length > 2048) { + throw new ListenerCheckoutUnavailableError(); + } + return { provider, approvalUrl: approval.toString() }; +} + +function idempotencyKey( + accountId: string, + provider: ListenerCheckoutProvider, + attemptId: string, + environment: ListenerCheckoutEnvironment, +): string { const digest = createHash('sha256') - .update(`listener-checkout-v1\n${accountId}\n${provider}\n${attemptId}`) + .update(`listener-checkout-v2\n${environment}\n${accountId}\n${provider}\n${attemptId}`) .digest('hex'); return `listener-checkout:${digest}`; } export class HttpListenerCheckoutGateway { constructor( - private readonly config = authorityConfig(), + private readonly config = listenerAuthorityConfig(), private readonly request: typeof fetch = fetch, ) {} @@ -149,15 +203,26 @@ export class HttpListenerCheckoutGateway { attemptId: string; returnUrl: string; cancelUrl: string; + environment?: ListenerCheckoutEnvironment; }): Promise { if (!isEarlyBirdAccountId(input.accountId) || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.attemptId)) { throw new ListenerCheckoutUnavailableError(); } - const endpoint = input.provider === 'paypal' - ? '/api/internal/v1/early-bird-checkouts' - : '/api/internal/v2/early-bird-checkouts'; - const payload = input.provider === 'paypal' ? { + const environment = input.environment ?? 'staging'; + const endpoint = environment === 'live' + ? '/api/internal/v1/listener-checkouts' + : input.provider === 'paypal' + ? '/api/internal/v1/early-bird-checkouts' + : '/api/internal/v2/early-bird-checkouts'; + const payload = environment === 'live' ? { + schema_version: 'listener-checkout.checkout-create.v1', + account_id: input.accountId, + provider: input.provider, + payer_email: input.provider === 'mercado_pago' ? normalizedEmail(input.email) : null, + return_url: input.returnUrl, + cancel_url: input.cancelUrl, + } : input.provider === 'paypal' ? { schema_version: 'early-bird-authority.checkout-create.v1', account_id: input.accountId, provider: input.provider, @@ -183,7 +248,12 @@ export class HttpListenerCheckoutGateway { accept: 'application/json', authorization: `Bearer ${this.config.token}`, 'content-type': 'application/json', - 'idempotency-key': idempotencyKey(input.accountId, input.provider, input.attemptId), + 'idempotency-key': idempotencyKey( + input.accountId, + input.provider, + input.attemptId, + environment, + ), 'x-hb-service-key-id': this.config.keyId, }, body: JSON.stringify(payload), @@ -192,7 +262,10 @@ export class HttpListenerCheckoutGateway { await response.body?.cancel().catch(() => undefined); throw new ListenerCheckoutUnavailableError(); } - return checkoutResult(await boundedJson(response), input.accountId, input.provider); + const raw = await listenerBoundedJson(response); + return environment === 'live' + ? liveCheckoutResult(raw, input.accountId, input.provider) + : sandboxCheckoutResult(raw, input.accountId, input.provider); } catch (error) { if (error instanceof ListenerCheckoutUnavailableError) throw error; throw new ListenerCheckoutUnavailableError(); diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index ef86c824..bfd6ab14 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -36,10 +36,16 @@ export const earlyBirdCopy = { freeQuotaMembershipCta: 'Hazte miembro para tener acceso completo', checkoutSandboxTitle: 'Probar membresía Founder', checkoutSandboxDetail: 'Checkout de prueba. No usa dinero real.', + checkoutLiveTitle: 'Founding Listener', + checkoutLiveDetail: 'USD 5 por mes, cobro recurrente. Sin período de prueba. Puedes cancelar cuando quieras; si el servicio se interrumpe, termina el precio Founder.', checkoutPayPal: 'Continuar con PayPal', checkoutMercadoPago: 'Continuar con Mercado Pago', checkoutOpening: 'Abriendo checkout…', checkoutUnavailable: 'El checkout de prueba no está disponible ahora.', + checkoutLiveUnavailable: 'La membresía no está disponible para compra en este momento.', + checkoutAgreement: 'Al continuar aceptas las condiciones del servicio y el tratamiento de datos descrito aquí.', + checkoutTerms: 'Condiciones', + checkoutPrivacy: 'Privacidad', freeQuotaFounder: 'Acceso Founder', freeQuotaFreeForAll: 'Acceso libre', freeQuotaUnlimited: 'Puedes escuchar sin límite de tiempo.', @@ -54,6 +60,16 @@ export const earlyBirdCopy = { membershipFounderActive: 'Founding Listener · USD 5/mes', membershipFounderGrace: 'Founding Listener · USD 5/mes · período de gracia', membershipFounderEnding: 'Founding Listener · USD 5/mes · activo hasta fin del período', + membershipCurrentPeriodThrough: 'Período actual hasta {date}.', + membershipAccessThrough: 'Acceso Founder hasta {date}.', + membershipCancel: 'Cancelar membresía', + membershipCancelConfirmTitle: 'Confirmar cancelación', + membershipCancelConfirmDetail: 'Conservarás acceso hasta el fin del período ya pagado. Después perderás el precio Founder.', + membershipCancelConfirm: 'Sí, cancelar al fin del período', + membershipCancelWorking: 'Solicitando cancelación…', + membershipKeep: 'Conservar membresía', + membershipCancelQueued: 'Recibimos la solicitud. El estado se actualizará cuando el proveedor la confirme.', + membershipCancelFailed: 'No pudimos solicitar la cancelación. Tu membresía no cambió.', membershipFounderPending: 'La membresía Founder todavía no está confirmada.', membershipFounderExpired: 'La membresía Founder finalizó.', membershipFounderRefunded: 'El pago fue reembolsado y el acceso Founder finalizó.', @@ -96,10 +112,16 @@ export const earlyBirdCopy = { freeQuotaMembershipCta: 'Become a member for full access', checkoutSandboxTitle: 'Try Founder membership', checkoutSandboxDetail: 'Test checkout. No real money is used.', + checkoutLiveTitle: 'Founding Listener', + checkoutLiveDetail: 'USD 5 per month, billed recurrently. No trial. Cancel anytime; if service lapses, Founder pricing ends.', checkoutPayPal: 'Continue with PayPal', checkoutMercadoPago: 'Continue with Mercado Pago', checkoutOpening: 'Opening checkout…', checkoutUnavailable: 'Test checkout is unavailable right now.', + checkoutLiveUnavailable: 'Membership is not available for purchase right now.', + checkoutAgreement: 'By continuing, you accept the service terms and the data practices described here.', + checkoutTerms: 'Terms', + checkoutPrivacy: 'Privacy', freeQuotaFounder: 'Founder access', freeQuotaFreeForAll: 'Open access', freeQuotaUnlimited: 'You can listen without a time limit.', @@ -114,6 +136,16 @@ export const earlyBirdCopy = { membershipFounderActive: 'Founding Listener · USD 5/month', membershipFounderGrace: 'Founding Listener · USD 5/month · grace period', membershipFounderEnding: 'Founding Listener · USD 5/month · active through period end', + membershipCurrentPeriodThrough: 'Current period through {date}.', + membershipAccessThrough: 'Founder access through {date}.', + membershipCancel: 'Cancel membership', + membershipCancelConfirmTitle: 'Confirm cancellation', + membershipCancelConfirmDetail: 'You will keep access through the paid period. After that, Founder pricing ends.', + membershipCancelConfirm: 'Yes, cancel at period end', + membershipCancelWorking: 'Requesting cancellation…', + membershipKeep: 'Keep membership', + membershipCancelQueued: 'We received the request. Status will update after the provider confirms it.', + membershipCancelFailed: 'We could not request cancellation. Your membership did not change.', membershipFounderPending: 'Founder membership is not confirmed yet.', membershipFounderExpired: 'Founder membership has ended.', membershipFounderRefunded: 'The payment was refunded and Founder access has ended.', @@ -124,6 +156,87 @@ export const earlyBirdCopy = { }, } satisfies Record>; +export const earlyBirdLegalCopy = { + es: { + back: 'Volver a Listener', + eyebrow: 'HARMONIC BEACON · FOUNDING LISTENER', + title: 'Condiciones y privacidad del servicio Listener', + updated: 'Versión de lanzamiento · 12 de agosto de 2026', + sections: [ + { + title: 'Oferta', + paragraphs: [ + 'Founding Listener cuesta USD 5 por mes, con cobro recurrente y sin período de prueba. Mercado Pago puede cobrar el equivalente en ARS informado por el checkout. El acceso se activa únicamente después de la confirmación canónica del proveedor.', + 'El precio Founder se conserva mientras el servicio permanezca ininterrumpido, incluyendo el período ya pagado o de gracia aprobado. Cuando el servicio termina, también terminan la categoría y el precio Founder.', + ], + }, + { + title: 'Cancelación, fallos y reembolsos', + paragraphs: [ + 'Puedes solicitar la cancelación desde tu perfil. Conservas acceso hasta el final del período ya pagado; el proveedor confirma el cambio y la app actualiza el estado canónico. Una nueva alta posterior usa la oferta pública vigente.', + 'Un reembolso, contracargo, disputa, fraude o terminación administrativa puede finalizar inmediatamente el acceso. Para pedir ayuda o revisar un cobro escribe a nicoechaniz@harmonicbeacon.com. La política legal aplicable y los derechos irrenunciables del consumidor prevalecen.', + ], + }, + { + title: 'Servicio', + paragraphs: [ + 'Harmonic Beacon ofrece un stream de audio continuo sujeto a mantenimiento, capacidad y disponibilidad de Internet. No garantizamos disponibilidad ininterrumpida y el servicio no sustituye atención médica, psicológica ni de emergencia.', + 'El uso debe ser lícito y no debe intentar eludir límites de cuenta, interferir con el servicio o acceder a datos de otras personas.', + ], + }, + { + title: 'Datos y privacidad', + paragraphs: [ + 'Guardamos un identificador opaco de cuenta, identidad de acceso, sesión, estado de membresía, cuota y leases de reproducción. Los proveedores de pago procesan los datos financieros; Harmonic Beacon no recibe ni almacena números completos de tarjeta.', + 'Conservamos evidencia de pago y eventos de membresía necesaria para seguridad, soporte, contabilidad e idempotencia. No enviamos información personal a la visualización pública ni vendemos datos personales. Puedes solicitar acceso, corrección o eliminación escribiendo a nicoechaniz@harmonicbeacon.com, sujeto a obligaciones legales de conservación.', + ], + }, + ], + }, + en: { + back: 'Back to Listener', + eyebrow: 'HARMONIC BEACON · FOUNDING LISTENER', + title: 'Listener service terms and privacy', + updated: 'Launch version · August 12, 2026', + sections: [ + { + title: 'Offer', + paragraphs: [ + 'Founding Listener costs USD 5 per month, billed recurrently with no trial. Mercado Pago may charge the ARS equivalent shown at checkout. Access starts only after canonical provider confirmation.', + 'Founder pricing continues while service remains uninterrupted, including an already-paid period or approved grace. When service ends, Founder status and pricing end as well.', + ], + }, + { + title: 'Cancellation, failures and refunds', + paragraphs: [ + 'You can request cancellation from your profile. Access continues through the paid period; the provider confirms the change and the app updates canonical status. A later signup uses the public offer available then.', + 'A refund, chargeback, dispute, fraud finding or administrative termination may end access immediately. For billing help or review, contact nicoechaniz@harmonicbeacon.com. Applicable law and non-waivable consumer rights prevail.', + ], + }, + { + title: 'Service', + paragraphs: [ + 'Harmonic Beacon provides a continuous audio stream subject to maintenance, capacity and Internet availability. We do not guarantee uninterrupted availability, and the service is not a substitute for medical, psychological or emergency care.', + 'Use must be lawful and must not attempt to evade account limits, interfere with the service or access another person’s data.', + ], + }, + { + title: 'Data and privacy', + paragraphs: [ + 'We keep an opaque account identifier, sign-in identity, session, membership state, allowance and playback leases. Payment providers process financial details; Harmonic Beacon does not receive or store full card numbers.', + 'We retain payment evidence and membership events needed for security, support, accounting and idempotency. We do not send personal data to the public visualization or sell personal data. You may request access, correction or deletion at nicoechaniz@harmonicbeacon.com, subject to legal retention duties.', + ], + }, + ], + }, +} satisfies Record; +}>; + export const earlyBirdSyntheticEntryCopy = { es: { title: 'Acceso de equipo · staging', diff --git a/src/lib/early-birds/membership-actions.ts b/src/lib/early-birds/membership-actions.ts new file mode 100644 index 00000000..e29e665f --- /dev/null +++ b/src/lib/early-birds/membership-actions.ts @@ -0,0 +1,135 @@ +import { createHash } from 'node:crypto'; + +import { isEarlyBirdAccountId } from './account-id'; +import { + listenerAuthorityConfig, + listenerBoundedJson, + type ListenerCheckoutEnvironment, + type ListenerCheckoutProvider, +} from './checkout'; + +const REQUEST_TIMEOUT_MS = 10_000; + +export class ListenerMembershipActionUnavailableError extends Error { + constructor() { + super('Listener membership action is unavailable'); + this.name = 'ListenerMembershipActionUnavailableError'; + } +} + +function idempotencyKey( + accountId: string, + attemptId: string, + environment: ListenerCheckoutEnvironment, + provider: ListenerCheckoutProvider | null, +): string { + const digest = createHash('sha256') + .update(`listener-membership-cancel-v1\n${environment}\n${provider ?? 'canonical'}\n${accountId}\n${attemptId}`) + .digest('hex'); + return `listener-membership:${digest}`; +} + +export class HttpListenerMembershipActionsGateway { + constructor( + private readonly config = listenerAuthorityConfig(), + private readonly request: typeof fetch = fetch, + ) {} + + async cancel(input: { + accountId: string; + attemptId: string; + environment?: ListenerCheckoutEnvironment; + provider?: ListenerCheckoutProvider | null; + }): Promise { + if (!isEarlyBirdAccountId(input.accountId) || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.attemptId)) { + throw new ListenerMembershipActionUnavailableError(); + } + const environment = input.environment ?? 'live'; + const provider = input.provider ?? null; + if (environment === 'staging' && provider === null) { + throw new ListenerMembershipActionUnavailableError(); + } + const endpoint = environment === 'live' + ? '/api/internal/v1/listener-membership-actions' + : provider === 'paypal' + ? '/api/internal/v1/early-bird-paypal-actions' + : '/api/internal/v1/early-bird-mercado-pago-actions'; + const payload = environment === 'live' ? { + schema_version: 'listener-membership.action.v1', + account_id: input.accountId, + action: 'cancel', + } : provider === 'paypal' ? { + schema_version: 'early-bird-paypal-lifecycle.command.v1', + account_id: input.accountId, + action: 'cancel', + } : { + schema_version: 'early-bird-mercado-pago-lifecycle.command.v1', + account_id: input.accountId, + action: 'cancel', + }; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await this.request( + `${this.config.baseUrl}${endpoint}`, + { + method: 'POST', + redirect: 'error', + cache: 'no-store', + signal: controller.signal, + headers: { + accept: 'application/json', + authorization: `Bearer ${this.config.token}`, + 'content-type': 'application/json', + 'idempotency-key': idempotencyKey( + input.accountId, + input.attemptId, + environment, + provider, + ), + 'x-hb-service-key-id': this.config.keyId, + }, + body: JSON.stringify(payload), + }, + ); + if (response.status !== 202 || + response.headers.get('content-type')?.split(';', 1)[0] !== 'application/json') { + await response.body?.cancel().catch(() => undefined); + throw new ListenerMembershipActionUnavailableError(); + } + const raw = await listenerBoundedJson(response); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new ListenerMembershipActionUnavailableError(); + } + const body = raw as Record; + const liveExpected = ['account_id', 'action', 'job_id', 'provider', 'schema_version', 'status']; + const stagingExpected = [ + 'account_id', 'action', 'external_subscription_id', 'job_id', 'schema_version', 'status', + ]; + const schema = provider === 'paypal' + ? 'early-bird-paypal-lifecycle.response.v1' + : 'early-bird-mercado-pago-lifecycle.response.v1'; + const commonInvalid = body.account_id !== input.accountId || body.action !== 'cancel' || + body.status !== 'queued' || typeof body.job_id !== 'string' || !body.job_id; + const liveInvalid = environment === 'live' && ( + Object.keys(body).sort().join('\0') !== liveExpected.sort().join('\0') || + body.schema_version !== 'listener-membership.action-result.v1' || + (body.provider !== 'paypal' && body.provider !== 'mercado_pago') + ); + const stagingInvalid = environment === 'staging' && ( + Object.keys(body).sort().join('\0') !== stagingExpected.sort().join('\0') || + body.schema_version !== schema || + typeof body.external_subscription_id !== 'string' || !body.external_subscription_id + ); + if (commonInvalid || liveInvalid || stagingInvalid) { + throw new ListenerMembershipActionUnavailableError(); + } + } catch (error) { + if (error instanceof ListenerMembershipActionUnavailableError) throw error; + throw new ListenerMembershipActionUnavailableError(); + } finally { + clearTimeout(timeout); + } + } +} diff --git a/src/lib/early-birds/membership-presentation.ts b/src/lib/early-birds/membership-presentation.ts index 231045fd..4fa5c52b 100644 --- a/src/lib/early-birds/membership-presentation.ts +++ b/src/lib/early-birds/membership-presentation.ts @@ -19,6 +19,7 @@ export type ListenerMembershipPresentation = kind: 'founder'; provider: 'paypal' | 'mercado-pago'; state: ListenerMembershipPresentationState; + serviceThrough?: string | null; }; function presentationState( @@ -62,7 +63,12 @@ export function listenerMembershipPresentation( && projection.offerCode === EARLY_BIRDS_FOUNDERS_OFFER && projection.source === 'PAYPAL' ) { - return { kind: 'founder', provider: 'paypal', state }; + return { + kind: 'founder', + provider: 'paypal', + state, + serviceThrough: projection.founderContinuityServiceThrough?.toISOString() ?? null, + }; } if ( accessAllowed @@ -71,7 +77,12 @@ export function listenerMembershipPresentation( && projection.offerCode === EARLY_BIRDS_FOUNDERS_OFFER && projection.source === 'MERCADO_PAGO' ) { - return { kind: 'founder', provider: 'mercado-pago', state }; + return { + kind: 'founder', + provider: 'mercado-pago', + state, + serviceThrough: projection.founderContinuityServiceThrough?.toISOString() ?? null, + }; } // Unknown and incomplete projections fail closed in presentation just as From 4a9eff2756cdf1be75d53ffaab6cddc5f122eb58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 02:19:24 -0300 Subject: [PATCH 199/376] docs(listener): reconcile commercial preview boundaries --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 0e034d0c..af9907be 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -367,10 +367,11 @@ No deployment, DNS change, certificate request, nginx installation, host firewall change, OAuth registration, or provider call is performed by these files or lifecycle scripts. -Free acceptance is the only membership flow in this staging milestone. The -runtime defines no checkout service and supplies no PayPal, Mercado Pago, or -other paid-provider configuration. Paid acceptance remains disabled even when -the public Listener kill switch is opened. +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 @@ -484,10 +485,11 @@ 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, event and checkout surfaces. +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 schedule endpoint, stream leases/manifests and configured +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 @@ -498,7 +500,12 @@ 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. +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 From ba5f74ace8be1973860e7e25e93478d8d086e51c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 02:39:44 -0300 Subject: [PATCH 200/376] docs(listener): record commercial staging deploy --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index af9907be..8570f211 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,5 +1,37 @@ # EarlyBirds isolated staging runtime +## 2026-08-12 commercial lane deployed with Live sales OFF + +The isolated Listener now runs merge SHA +`f571495ff51591fdca5bf51b2c9ab27c1e1eeeb3`, image +`harmonic-beacon/earlybirds-preview-listener:f571495` and unchanged Prisma +head `20260810223000_listener_founder_continuity`. The isolated membership +authority runs merge SHA `7bc2dabd05aa68602bbfe9faa6e29c4e1c081017` +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 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`. + Binary rollback selects Listener image `0e8ae66` and authority image + `ceed7b1e8edc1961c64a809b67f808119857b696`, restores the matched protected + environments/nginx files, recreates only the isolated services and reruns + health smoke. No schema rollback is required or authorized. + ## 2026-08-11 Founder continuity cutover The isolated Listener runs runtime SHA From b6d7470abfbae378f2c62ec042fd0dc830da7bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 03:00:36 -0300 Subject: [PATCH 201/376] feat(listener): support membership reactivation --- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 9 ++- ...s-staging.harmonicbeacon.com.conf.template | 4 +- .../listen.harmonicbeacon.com.conf.template | 4 +- .../test/preview-contract.test.mjs | 6 +- .../__tests__/route.test.ts | 63 ++++++++++------- .../membership/{cancel => action}/route.ts | 13 +++- .../FoundingListenerMembershipActions.tsx | 49 +++++++++---- ...FoundingListenerMembershipActions.test.tsx | 18 ++++- .../__tests__/membership-actions.test.ts | 68 +++++++++++++++++-- src/lib/early-birds/copy.ts | 8 +++ src/lib/early-birds/membership-actions.ts | 21 ++++-- 11 files changed, 202 insertions(+), 61 deletions(-) rename src/app/api/listener/membership/{cancel => action}/__tests__/route.test.ts (61%) rename src/app/api/listener/membership/{cancel => action}/route.ts (87%) diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 2a174e59..096addc1 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -41,14 +41,19 @@ deleting bindings, events, jobs or projections. ## Public boundaries - Browser checkout: exact same-origin `POST /api/listener/checkout`. -- Browser cancel request: exact same-origin `POST /api/listener/membership/cancel`. +- 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 only provider plus a random attempt ID for checkout, and only a random - attempt ID for cancellation. Account, email, current provider and provider subscription ID are + attempt ID plus canonical action for membership management. Account, email, current provider and provider subscription ID are server-derived. 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. 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 index 251dff33..dcd619d7 100644 --- a/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -332,9 +332,9 @@ server { add_header X-Harmonic-Beacon-Environment "early-birds-staging" always; } - # Account-derived sandbox cancellation. The browser cannot name a provider + # Account-derived sandbox membership action. The browser cannot name a provider # or subscription; the Listener resolves the current canonical projection. - location = /api/listener/membership/cancel { + location = /api/listener/membership/action { access_log off; client_max_body_size 256; limit_req zone=listener_checkout burst=2 nodelay; diff --git a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template index 2bbff5fd..65d630bc 100644 --- a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -287,7 +287,7 @@ server { proxy_read_timeout 30s; } - # New sales and cancellation are exact, authenticated, same-origin app + # 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; @@ -310,7 +310,7 @@ server { add_header X-Harmonic-Beacon-Environment "listener-public-free" always; } - location = /api/listener/membership/cancel { + location = /api/listener/membership/action { access_log off; limit_req zone=listener_membership_action burst=2 nodelay; limit_req_status 429; diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 04cb4a6e..97f72c0c 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -207,11 +207,13 @@ test('nginx templates isolate staging, stream and the constrained public Listene assert.match(app, /location \/_next\/static\/ \{[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;[^}]*Cache-Control "private, no-store"/s); 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\/membership\/cancel \{[^}]*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 = \/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\/cancel \{[^}]*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, /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.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;/); diff --git a/src/app/api/listener/membership/cancel/__tests__/route.test.ts b/src/app/api/listener/membership/action/__tests__/route.test.ts similarity index 61% rename from src/app/api/listener/membership/cancel/__tests__/route.test.ts rename to src/app/api/listener/membership/action/__tests__/route.test.ts index e5475686..d1854f91 100644 --- a/src/app/api/listener/membership/cancel/__tests__/route.test.ts +++ b/src/app/api/listener/membership/action/__tests__/route.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; const currentEarlyBirdSession = vi.hoisted(() => vi.fn()); -const cancel = vi.hoisted(() => vi.fn()); +const requestAction = vi.hoisted(() => vi.fn()); const getEarlyBirdListeningAccess = vi.hoisted(() => vi.fn()); vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession })); @@ -12,7 +12,7 @@ vi.mock('@/lib/early-birds/membership-actions', async (importOriginal) => { return { ...actual, HttpListenerMembershipActionsGateway: class { - cancel = cancel; + requestAction = requestAction; }, }; }); @@ -22,9 +22,13 @@ import { POST } from '../route'; const HOST = 'listen.harmonicbeacon.com'; const ATTEMPT = '123e4567-e89b-42d3-a456-426614174000'; -function request(body: unknown = { attemptId: ATTEMPT }, host = HOST, origin = `https://${HOST}`) { +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/cancel`, { + return new NextRequest(`https://${host}/api/listener/membership/action`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -42,7 +46,7 @@ beforeEach(() => { currentEarlyBirdSession.mockResolvedValue({ user: { id: 'opaqueBetterAuthId', email: 'listener@example.com', name: 'Listener' }, }); - cancel.mockResolvedValue(undefined); + requestAction.mockResolvedValue(undefined); getEarlyBirdListeningAccess.mockResolvedValue({ membership: { projection: { source: 'PAYPAL' } }, }); @@ -53,26 +57,35 @@ afterEach(() => { vi.unstubAllEnvs(); }); -describe('Listener membership cancellation route', () => { - it('derives the account from session and returns no provider identifiers', async () => { - const response = await POST(request()); - expect(response.status).toBe(202); - await expect(response.json()).resolves.toEqual({ status: 'queued' }); - expect(cancel).toHaveBeenCalledWith({ - accountId: 'opaqueBetterAuthId', - attemptId: ATTEMPT, - environment: 'live', - provider: null, - }); - }); +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(undefined, host, `https://${host}`)); + const response = await POST(request( + { action: 'reactivate', attemptId: ATTEMPT }, + host, + `https://${host}`, + )); expect(response.status).toBe(202); - expect(cancel).toHaveBeenCalledWith({ + expect(requestAction).toHaveBeenCalledWith({ accountId: 'opaqueBetterAuthId', attemptId: ATTEMPT, + action: 'reactivate', environment: 'staging', provider: 'paypal', }); @@ -85,18 +98,20 @@ describe('Listener membership cancellation route', () => { const response = await POST(request(undefined, host, origin)); expect(response.status).toBe(403); expect(currentEarlyBirdSession).not.toHaveBeenCalled(); - expect(cancel).not.toHaveBeenCalled(); + expect(requestAction).not.toHaveBeenCalled(); }); - it('rejects unauthenticated and client-supplied fields', async () => { + it('rejects unauthenticated, unknown actions and client-supplied fields', async () => { currentEarlyBirdSession.mockResolvedValue(null); expect((await POST(request())).status).toBe(401); - expect((await POST(request({ attemptId: ATTEMPT, provider: 'paypal' }))).status).toBe(400); - expect(cancel).not.toHaveBeenCalled(); + 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 () => { - cancel.mockRejectedValue(new Error('provider leaked a subscription id')); + 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/cancel/route.ts b/src/app/api/listener/membership/action/route.ts similarity index 87% rename from src/app/api/listener/membership/cancel/route.ts rename to src/app/api/listener/membership/action/route.ts index 3a864fe8..f5a26e0b 100644 --- a/src/app/api/listener/membership/cancel/route.ts +++ b/src/app/api/listener/membership/action/route.ts @@ -6,6 +6,7 @@ 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'; @@ -29,6 +30,10 @@ function requestEnvironment(request: NextRequest): 'live' | 'staging' | null { 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); @@ -48,11 +53,12 @@ export async function POST(request: NextRequest): Promise { return json({ error: 'Invalid request.' }, 400); } if (!input || typeof input !== 'object' || Array.isArray(input) || - Object.keys(input).sort().join('\0') !== ['attemptId'].join('\0')) { + Object.keys(input).sort().join('\0') !== ['action', 'attemptId'].join('\0')) { return json({ error: 'Invalid request.' }, 400); } const attemptId = (input as Record).attemptId; - if (typeof attemptId !== 'string' || + 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); } @@ -71,9 +77,10 @@ export async function POST(request: NextRequest): Promise { } try { - await new HttpListenerMembershipActionsGateway().cancel({ + await new HttpListenerMembershipActionsGateway().requestAction({ accountId: session.user.id, attemptId, + action, environment, provider, }); diff --git a/src/components/early-birds/FoundingListenerMembershipActions.tsx b/src/components/early-birds/FoundingListenerMembershipActions.tsx index ac55bff6..66fbe637 100644 --- a/src/components/early-birds/FoundingListenerMembershipActions.tsx +++ b/src/components/early-birds/FoundingListenerMembershipActions.tsx @@ -1,7 +1,7 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useLocale } from '@/context/LocaleContext'; import { earlyBirdCopy } from '@/lib/early-birds/copy'; @@ -17,7 +17,14 @@ export default function FoundingListenerMembershipActions({ const router = useRouter(); const [confirming, setConfirming] = useState(false); const [status, setStatus] = useState<'idle' | 'busy' | 'queued' | 'failed'>('idle'); - const attempt = useRef(null); + const [action, setAction] = useState<'cancel' | 'reactivate' | null>(null); + const attempt = useRef<{ action: 'cancel' | 'reactivate'; id: string } | null>(null); + const refreshTimers = useRef([]); + + useEffect(() => () => { + for (const timer of refreshTimers.current) window.clearTimeout(timer); + refreshTimers.current = []; + }, []); const boundary = membership.serviceThrough ? new Intl.DateTimeFormat(locale === 'es' ? 'es-AR' : 'en-US', { @@ -26,17 +33,20 @@ export default function FoundingListenerMembershipActions({ }).format(new Date(membership.serviceThrough)) : null; - async function cancelMembership() { + async function requestMembershipAction(requestedAction: 'cancel' | 'reactivate') { if (status === 'busy') return; setStatus('busy'); - const attemptId = attempt.current ?? crypto.randomUUID(); - attempt.current = attemptId; + setAction(requestedAction); + const attemptId = attempt.current?.action === requestedAction + ? attempt.current.id + : crypto.randomUUID(); + attempt.current = { action: requestedAction, id: attemptId }; try { - const response = await fetch('/api/listener/membership/cancel', { + const response = await fetch('/api/listener/membership/action', { method: 'POST', cache: 'no-store', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ attemptId }), + body: JSON.stringify({ action: requestedAction, attemptId }), }); const body = await response.json() as unknown; if (response.status !== 202 || !body || typeof body !== 'object' || @@ -45,8 +55,8 @@ export default function FoundingListenerMembershipActions({ } setStatus('queued'); setConfirming(false); - window.setTimeout(() => router.refresh(), 2_000); - window.setTimeout(() => router.refresh(), 8_000); + refreshTimers.current = [2_000, 5_000, 10_000, 20_000] + .map((delay) => window.setTimeout(() => router.refresh(), delay)); } catch { setStatus('failed'); } @@ -64,10 +74,21 @@ export default function FoundingListenerMembershipActions({ {copy.membershipCancel} )} + {membership.state === 'ending' && status !== 'queued' && ( + + )} {confirming && status !== 'queued' && (

{copy.membershipCancelConfirmDetail}

-
)} - {status === 'queued' &&

{copy.membershipCancelQueued}

} - {status === 'failed' &&

{copy.membershipCancelFailed}

} + {status === 'queued' &&

{action === 'reactivate' + ? copy.membershipReactivateQueued + : copy.membershipCancelQueued}

} + {status === 'failed' &&

{action === 'reactivate' + ? copy.membershipReactivateFailed + : copy.membershipCancelFailed}

}
); } diff --git a/src/components/early-birds/__tests__/FoundingListenerMembershipActions.test.tsx b/src/components/early-birds/__tests__/FoundingListenerMembershipActions.test.tsx index 0368bb9c..485ebf2b 100644 --- a/src/components/early-birds/__tests__/FoundingListenerMembershipActions.test.tsx +++ b/src/components/early-birds/__tests__/FoundingListenerMembershipActions.test.tsx @@ -41,11 +41,17 @@ describe('Founding Listener membership actions', () => { fireEvent.click(screen.getByRole('button', { name: 'Yes, cancel at period end' })); await screen.findByText(/We received the request/); const sent = JSON.parse(fetchMock.mock.calls[0][1].body) as Record; - expect(sent).toEqual({ attemptId: '123e4567-e89b-42d3-a456-426614174000' }); + expect(fetchMock).toHaveBeenCalledWith('/api/listener/membership/action', expect.any(Object)); + expect(sent).toEqual({ + action: 'cancel', + attemptId: '123e4567-e89b-42d3-a456-426614174000', + }); expect(JSON.stringify(sent)).not.toMatch(/paypal|subscription|account/i); }); - it('does not offer cancellation twice once canonical state is ending', () => { + it('offers provider-neutral reactivation while canonical state is ending', async () => { + const fetchMock = vi.fn().mockResolvedValue(Response.json({ status: 'queued' }, { status: 202 })); + vi.stubGlobal('fetch', fetchMock); render( { , ); expect(screen.queryByRole('button', { name: 'Cancel membership' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Reactivate membership' })); + await screen.findByText(/provider confirms reactivation/); + const sent = JSON.parse(fetchMock.mock.calls[0][1].body) as Record; + expect(sent).toEqual({ + action: 'reactivate', + attemptId: '123e4567-e89b-42d3-a456-426614174000', + }); + expect(JSON.stringify(sent)).not.toMatch(/paypal|mercado|subscription|account/i); }); it('keeps the action retryable after a generic failure', async () => { diff --git a/src/lib/early-birds/__tests__/membership-actions.test.ts b/src/lib/early-birds/__tests__/membership-actions.test.ts index 68f37897..b0d39bf8 100644 --- a/src/lib/early-birds/__tests__/membership-actions.test.ts +++ b/src/lib/early-birds/__tests__/membership-actions.test.ts @@ -30,8 +30,8 @@ describe('Listener membership actions gateway', () => { }, { status: 202 }); }); const gateway = new HttpListenerMembershipActionsGateway(config, request as typeof fetch); - await gateway.cancel(input); - await gateway.cancel(input); + await gateway.requestAction({ ...input, action: 'cancel' }); + await gateway.requestAction({ ...input, action: 'cancel' }); expect(requests).toHaveLength(2); expect(requests[0].url).toBe('http://pmp-myth-api:8765/api/internal/v1/listener-membership-actions'); @@ -60,7 +60,7 @@ describe('Listener membership actions gateway', () => { ...override, }, { status: 202 })); const gateway = new HttpListenerMembershipActionsGateway(config, request as typeof fetch); - await expect(gateway.cancel(input)).rejects + await expect(gateway.requestAction({ ...input, action: 'cancel' })).rejects .toBeInstanceOf(ListenerMembershipActionUnavailableError); }); @@ -71,22 +71,78 @@ describe('Listener membership actions gateway', () => { await expect(captured.json()).resolves.toEqual({ schema_version: 'early-bird-mercado-pago-lifecycle.command.v1', account_id: input.accountId, - action: 'cancel', + action: 'pause', }); return Response.json({ schema_version: 'early-bird-mercado-pago-lifecycle.response.v1', status: 'queued', account_id: input.accountId, - action: 'cancel', + action: 'pause', external_subscription_id: 'private-and-discarded', job_id: 'private-job-id', }, { status: 202 }); }); const gateway = new HttpListenerMembershipActionsGateway(config, request as typeof fetch); - await expect(gateway.cancel({ + await expect(gateway.requestAction({ ...input, + action: 'cancel', environment: 'staging', provider: 'mercado_pago', })).resolves.toBeUndefined(); }); + + it.each([ + ['paypal', 'activate', 'early-bird-paypal-lifecycle.command.v1', 'early-bird-paypal-lifecycle.response.v1'], + ['mercado_pago', 'reactivate', 'early-bird-mercado-pago-lifecycle.command.v1', 'early-bird-mercado-pago-lifecycle.response.v1'], + ] as const)( + 'maps canonical reactivation to the %s staging lifecycle', + async (provider, providerAction, commandSchema, responseSchema) => { + const request = vi.fn(async (target: string | URL | Request, init?: RequestInit) => { + const captured = new Request(target, init); + await expect(captured.json()).resolves.toEqual({ + schema_version: commandSchema, + account_id: input.accountId, + action: providerAction, + }); + return Response.json({ + schema_version: responseSchema, + status: 'queued', + account_id: input.accountId, + action: providerAction, + external_subscription_id: 'private-and-discarded', + job_id: 'private-job-id', + }, { status: 202 }); + }); + const gateway = new HttpListenerMembershipActionsGateway(config, request as typeof fetch); + await expect(gateway.requestAction({ + ...input, + action: 'reactivate', + environment: 'staging', + provider, + })).resolves.toBeUndefined(); + }, + ); + + it('uses distinct idempotency keys for cancel and reactivate', async () => { + const keys: string[] = []; + const request = vi.fn(async (_target: string | URL | Request, init?: RequestInit) => { + const captured = new Request('http://pmp-myth-api:8765', init); + keys.push(captured.headers.get('idempotency-key') ?? ''); + const action = JSON.parse(String(init?.body)).action as 'cancel' | 'reactivate'; + return Response.json({ + schema_version: 'listener-membership.action-result.v1', + status: 'queued', + account_id: input.accountId, + provider: 'paypal', + action, + job_id: 'private-job-id', + }, { status: 202 }); + }); + const gateway = new HttpListenerMembershipActionsGateway(config, request as typeof fetch); + await gateway.requestAction({ ...input, action: 'cancel' }); + await gateway.requestAction({ ...input, action: 'reactivate' }); + expect(keys[0]).toBeTruthy(); + expect(keys[1]).toBeTruthy(); + expect(keys[0]).not.toBe(keys[1]); + }); }); diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index bfd6ab14..98110f2c 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -70,6 +70,10 @@ export const earlyBirdCopy = { membershipKeep: 'Conservar membresía', membershipCancelQueued: 'Recibimos la solicitud. El estado se actualizará cuando el proveedor la confirme.', membershipCancelFailed: 'No pudimos solicitar la cancelación. Tu membresía no cambió.', + membershipReactivate: 'Reactivar membresía', + membershipReactivateWorking: 'Solicitando reactivación…', + membershipReactivateQueued: 'Recibimos la solicitud. Tu membresía se actualizará cuando el proveedor confirme la reactivación.', + membershipReactivateFailed: 'No pudimos solicitar la reactivación. Tu membresía no cambió.', membershipFounderPending: 'La membresía Founder todavía no está confirmada.', membershipFounderExpired: 'La membresía Founder finalizó.', membershipFounderRefunded: 'El pago fue reembolsado y el acceso Founder finalizó.', @@ -146,6 +150,10 @@ export const earlyBirdCopy = { membershipKeep: 'Keep membership', membershipCancelQueued: 'We received the request. Status will update after the provider confirms it.', membershipCancelFailed: 'We could not request cancellation. Your membership did not change.', + membershipReactivate: 'Reactivate membership', + membershipReactivateWorking: 'Requesting reactivation…', + membershipReactivateQueued: 'We received the request. Your membership will update after the provider confirms reactivation.', + membershipReactivateFailed: 'We could not request reactivation. Your membership did not change.', membershipFounderPending: 'Founder membership is not confirmed yet.', membershipFounderExpired: 'Founder membership has ended.', membershipFounderRefunded: 'The payment was refunded and Founder access has ended.', diff --git a/src/lib/early-birds/membership-actions.ts b/src/lib/early-birds/membership-actions.ts index e29e665f..ee30b9c6 100644 --- a/src/lib/early-birds/membership-actions.ts +++ b/src/lib/early-birds/membership-actions.ts @@ -10,6 +10,8 @@ import { const REQUEST_TIMEOUT_MS = 10_000; +export type ListenerMembershipAction = 'cancel' | 'reactivate'; + export class ListenerMembershipActionUnavailableError extends Error { constructor() { super('Listener membership action is unavailable'); @@ -20,11 +22,12 @@ export class ListenerMembershipActionUnavailableError extends Error { function idempotencyKey( accountId: string, attemptId: string, + action: ListenerMembershipAction, environment: ListenerCheckoutEnvironment, provider: ListenerCheckoutProvider | null, ): string { const digest = createHash('sha256') - .update(`listener-membership-cancel-v1\n${environment}\n${provider ?? 'canonical'}\n${accountId}\n${attemptId}`) + .update(`listener-membership-action-v1\n${action}\n${environment}\n${provider ?? 'canonical'}\n${accountId}\n${attemptId}`) .digest('hex'); return `listener-membership:${digest}`; } @@ -35,9 +38,10 @@ export class HttpListenerMembershipActionsGateway { private readonly request: typeof fetch = fetch, ) {} - async cancel(input: { + async requestAction(input: { accountId: string; attemptId: string; + action: ListenerMembershipAction; environment?: ListenerCheckoutEnvironment; provider?: ListenerCheckoutProvider | null; }): Promise { @@ -55,18 +59,21 @@ export class HttpListenerMembershipActionsGateway { : provider === 'paypal' ? '/api/internal/v1/early-bird-paypal-actions' : '/api/internal/v1/early-bird-mercado-pago-actions'; + const providerAction = provider === 'paypal' + ? input.action === 'cancel' ? 'suspend' : 'activate' + : input.action === 'cancel' ? 'pause' : 'reactivate'; const payload = environment === 'live' ? { schema_version: 'listener-membership.action.v1', account_id: input.accountId, - action: 'cancel', + action: input.action, } : provider === 'paypal' ? { schema_version: 'early-bird-paypal-lifecycle.command.v1', account_id: input.accountId, - action: 'cancel', + action: providerAction, } : { schema_version: 'early-bird-mercado-pago-lifecycle.command.v1', account_id: input.accountId, - action: 'cancel', + action: providerAction, }; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); @@ -85,6 +92,7 @@ export class HttpListenerMembershipActionsGateway { 'idempotency-key': idempotencyKey( input.accountId, input.attemptId, + input.action, environment, provider, ), @@ -110,7 +118,8 @@ export class HttpListenerMembershipActionsGateway { const schema = provider === 'paypal' ? 'early-bird-paypal-lifecycle.response.v1' : 'early-bird-mercado-pago-lifecycle.response.v1'; - const commonInvalid = body.account_id !== input.accountId || body.action !== 'cancel' || + const expectedResponseAction = environment === 'live' ? input.action : providerAction; + const commonInvalid = body.account_id !== input.accountId || body.action !== expectedResponseAction || body.status !== 'queued' || typeof body.job_id !== 'string' || !body.job_id; const liveInvalid = environment === 'live' && ( Object.keys(body).sort().join('\0') !== liveExpected.sort().join('\0') || From e531ca8c06deb273f070d27ca73d82c872f952d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 03:36:01 -0300 Subject: [PATCH 202/376] ops(listener): alert on paid lifecycle health --- ops/early-birds/docker-compose.yml | 5 ++- ops/early-birds/prometheus/alerts.yml | 52 +++++++++++++++++++++++ ops/early-birds/prometheus/prometheus.yml | 7 +++ ops/early-birds/runbook/README.md | 34 +++++++++++++++ ops/early-birds/test/config.test.mjs | 15 +++++++ 5 files changed, 112 insertions(+), 1 deletion(-) diff --git a/ops/early-birds/docker-compose.yml b/ops/early-birds/docker-compose.yml index aac21b93..6c9ac575 100644 --- a/ops/early-birds/docker-compose.yml +++ b/ops/early-birds/docker-compose.yml @@ -44,7 +44,7 @@ services: - ./prometheus/alerts.yml:/etc/prometheus/rules/alerts.yml:ro - prometheus-data:/prometheus ports: [127.0.0.1:9090:9090] - networks: [observability, ops_edge] + networks: [observability, ops_edge, authority_private] deploy: resources: limits: { cpus: "1.0", memory: 1G } @@ -144,6 +144,9 @@ networks: # a wildcard host port on this bridge. ops_edge: name: earlybirds_observability_edge + authority_private: + external: true + name: earlybirds_authority_private volumes: prometheus-data: diff --git a/ops/early-birds/prometheus/alerts.yml b/ops/early-birds/prometheus/alerts.yml index 98054bd2..710b7499 100644 --- a/ops/early-birds/prometheus/alerts.yml +++ b/ops/early-birds/prometheus/alerts.yml @@ -1,4 +1,56 @@ groups: + - 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_provider_ready{environment=~"sandbox|test"} == 0 + for: 5m + labels: { severity: warning, service: listener-payments } + annotations: { summary: "A Listener sandbox/test provider is unavailable", runbook: "listener-paid-provider" } + - alert: ListenerLiveProviderUnavailableDuringSales + expr: pmp_listener_new_sales_enabled == 1 and on() sum(pmp_listener_provider_ready{environment="live"}) < 2 + 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{status="failed"}) > 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{kind="early_birds.beacon.project",status="failed"} > 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 diff --git a/ops/early-birds/prometheus/prometheus.yml b/ops/early-birds/prometheus/prometheus.yml index 9f4a13dc..c54db2c1 100644 --- a/ops/early-birds/prometheus/prometheus.yml +++ b/ops/early-birds/prometheus/prometheus.yml @@ -23,6 +23,13 @@ scrape_configs: 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] diff --git a/ops/early-birds/runbook/README.md b/ops/early-birds/runbook/README.md index e513a061..f9fd7624 100644 --- a/ops/early-birds/runbook/README.md +++ b/ops/early-birds/runbook/README.md @@ -58,6 +58,40 @@ 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. + +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 `container_start_time_seconds` and `container_oom_events_total` diff --git a/ops/early-birds/test/config.test.mjs b/ops/early-birds/test/config.test.mjs index 811f69d4..0b489a93 100644 --- a/ops/early-birds/test/config.test.mjs +++ b/ops/early-birds/test/config.test.mjs @@ -15,7 +15,9 @@ test('keeps all metrics and Alertmanager listeners off public interfaces', async 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. @@ -48,9 +50,22 @@ test('references Telegram and canary credentials as mounted secret files only', test('scrapes node-exporter by the internal Docker DNS name', async () => { const prometheus = await read('prometheus/prometheus.yml'); assert.match(prometheus, /targets: \[node-exporter:9100\]/); + assert.match(prometheus, /job_name: listener-authority[\s\S]*targets: \[pmp-myth-api:8765\]/); assert.doesNotMatch(prometheus, /host\.docker\.internal/); }); +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('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/); From 83609e3f58ca6a1656883d0c107830f855b76940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 03:45:32 -0300 Subject: [PATCH 203/376] fix(listener): scope paid alerts to actionable state --- ops/early-birds/prometheus/alerts.yml | 6 +++--- ops/early-birds/runbook/README.md | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ops/early-birds/prometheus/alerts.yml b/ops/early-birds/prometheus/alerts.yml index 710b7499..1cc5097e 100644 --- a/ops/early-birds/prometheus/alerts.yml +++ b/ops/early-birds/prometheus/alerts.yml @@ -12,7 +12,7 @@ groups: labels: { severity: warning, service: listener-payments } annotations: { summary: "A Listener sandbox/test provider is unavailable", runbook: "listener-paid-provider" } - alert: ListenerLiveProviderUnavailableDuringSales - expr: pmp_listener_new_sales_enabled == 1 and on() sum(pmp_listener_provider_ready{environment="live"}) < 2 + 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" } @@ -27,12 +27,12 @@ groups: 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{status="failed"}) > 0 + 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{kind="early_birds.beacon.project",status="failed"} > 0 + 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" } diff --git a/ops/early-birds/runbook/README.md b/ops/early-birds/runbook/README.md index f9fd7624..a1713726 100644 --- a/ops/early-birds/runbook/README.md +++ b/ops/early-birds/runbook/README.md @@ -72,7 +72,10 @@ 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. +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: From 78474f956440b739fba6fffc88ab5e9534946245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 04:22:44 -0300 Subject: [PATCH 204/376] fix(listener): show terminal membership status --- src/components/early-birds/EarlyBirdHome.tsx | 4 ++-- .../__tests__/EarlyBirdHome.test.tsx | 18 +++++++++++++++ .../__tests__/membership-presentation.test.ts | 13 +++++++---- .../early-birds/membership-presentation.ts | 22 +++++++++++++++++-- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index 1eaa2db2..919d26a3 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -62,10 +62,10 @@ export default function EarlyBirdHome({

{displayName}

- {accessKind === 'membership' && ( + {membershipCopy && ( {membershipCopy?.title ?? copy.active} )} - {accessKind === 'membership' && membership.kind === 'founder' && membershipCopy?.detail && ( + {membership.kind !== 'none' && membershipCopy?.detail && ( {membershipCopy.detail} )} {accessKind === 'membership' && membership.kind === 'founder' && ( diff --git a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx index ae1abcb7..a10babf0 100644 --- a/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx +++ b/src/components/early-birds/__tests__/EarlyBirdHome.test.tsx @@ -126,6 +126,24 @@ describe('EarlyBird Listener home access chrome', () => { expect(screen.queryByText('MERCADO_PAGO')).not.toBeInTheDocument(); }); + it('shows a terminal paid status while keeping the account on Free access', () => { + render( + + + , + ); + + expect(screen.getByText('The payment was refunded and Founder access has ended.')).toBeInTheDocument(); + expect(screen.getByText('You can continue with the Free listening available to your account.')).toBeInTheDocument(); + expect(screen.queryByText('Founder access')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel membership' })).not.toBeInTheDocument(); + }); + it('places Free allowance and membership action below the listening surface', () => { render( diff --git a/src/lib/early-birds/__tests__/membership-presentation.test.ts b/src/lib/early-birds/__tests__/membership-presentation.test.ts index 84a1b017..bc1e5dc4 100644 --- a/src/lib/early-birds/__tests__/membership-presentation.test.ts +++ b/src/lib/early-birds/__tests__/membership-presentation.test.ts @@ -76,13 +76,18 @@ describe('public Listener membership presentation', () => { }); }); - it.each(['EXPIRED', 'REFUNDED', 'REVOKED', 'PENDING'] as const)( - 'removes the Founder badge for terminal or non-authoritative %s membership', - (state) => { + it.each([ + ['EXPIRED', 'expired'], + ['REFUNDED', 'refunded'], + ['REVOKED', 'revoked'], + ['PENDING', 'pending'], + ] as const)( + 'removes the Founder badge while preserving the informational %s paid state', + (state, expected) => { expect(listenerMembershipPresentation(projection({ state, founderContinuityState: state === 'PENDING' ? null : 'ENDED', - }), NOW)).toEqual({ kind: 'none', state: 'none' }); + }), NOW)).toEqual({ kind: 'paid-status', provider: 'paypal', state: expected }); }, ); diff --git a/src/lib/early-birds/membership-presentation.ts b/src/lib/early-birds/membership-presentation.ts index 4fa5c52b..779ceac0 100644 --- a/src/lib/early-birds/membership-presentation.ts +++ b/src/lib/early-birds/membership-presentation.ts @@ -15,6 +15,11 @@ export type ListenerMembershipPresentation = | { kind: 'none'; state: 'none' } | { kind: 'invitation'; state: ListenerMembershipPresentationState } | { kind: 'preview'; state: ListenerMembershipPresentationState } + | { + kind: 'paid-status'; + provider: 'paypal' | 'mercado-pago'; + state: 'pending' | 'expired' | 'refunded' | 'revoked'; + } | { kind: 'founder'; provider: 'paypal' | 'mercado-pago'; @@ -56,6 +61,11 @@ export function listenerMembershipPresentation( const continuityCurrent = projection.founderContinuityState === 'ACTIVE' || projection.founderContinuityState === 'CANCELLED_PENDING_END' || projection.founderContinuityState === 'GRACE'; + const provider = projection.source === 'PAYPAL' + ? 'paypal' + : projection.source === 'MERCADO_PAGO' + ? 'mercado-pago' + : null; if ( accessAllowed && continuityCurrent @@ -85,8 +95,16 @@ export function listenerMembershipPresentation( }; } + if ( + provider + && projection.offerCode === EARLY_BIRDS_FOUNDERS_OFFER + && (state === 'pending' || state === 'expired' || state === 'refunded' || state === 'revoked') + ) { + return { kind: 'paid-status', provider, state }; + } + // Unknown and incomplete projections fail closed in presentation just as - // they do in authorization. Never infer Founder status from an offer, - // price, redirect or reason code. + // they do in authorization. A terminal paid status is informational only; + // it never restores the Founder badge or listening authority. return { kind: 'none', state: 'none' }; } From cb5f33d56c73488fce6c080ca8c9f3c3816fffd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 04:50:58 -0300 Subject: [PATCH 205/376] docs(listener): reconcile commercial launch state --- docs/MONETIZATION.md | 23 ++++---- docs/PRODUCT_PRINCIPLES.md | 10 ++-- docs/VISION.md | 2 +- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 21 ++++--- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 17 ++++++ .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 59 ++++++++++++------- docs/plans/EARLY_BIRDS.md | 14 ++--- 7 files changed, 93 insertions(+), 53 deletions(-) diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md index 3447e103..b9f86c36 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 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/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/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 8570f211..51d91010 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,12 +1,12 @@ # EarlyBirds isolated staging runtime -## 2026-08-12 commercial lane deployed with Live sales OFF +## 2026-08-12 terminal-status checkpoint deployed with Live sales OFF The isolated Listener now runs merge SHA -`f571495ff51591fdca5bf51b2c9ab27c1e1eeeb3`, image -`harmonic-beacon/earlybirds-preview-listener:f571495` and unchanged Prisma +`fcdde37948e7f826641d5e4438f7666765aeda22`, image +`harmonic-beacon/earlybirds-preview-listener:fcdde37` and unchanged Prisma head `20260810223000_listener_founder_continuity`. The isolated membership -authority runs merge SHA `7bc2dabd05aa68602bbfe9faa6e29c4e1c081017` +authority runs merge SHA `60584936603525027c9891e0865efc58055a3d5d` at unchanged Alembic head `7b4c1e9a2d60`. - PayPal Live, Mercado Pago Live and both public Listener checkout flags are @@ -21,16 +21,21 @@ at unchanged Alembic head `7b4c1e9a2d60`. - `/`, `/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`. - Binary rollback selects Listener image `0e8ae66` and authority image - `ceed7b1e8edc1961c64a809b67f808119857b696`, restores the matched protected - environments/nginx files, recreates only the isolated services and reruns - health smoke. No schema rollback is required or authorized. + 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 diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 096addc1..d479edb7 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -17,6 +17,23 @@ LiveKit, Ticket Tailor, playlist-bot, tapestry, event audio or `live.harmonicbea 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. + +Backend magic-link delivery is merged at `c443a7ec9b387fa54ff16904e1a5d561613ec102` but remains +inert until an event-safe PMP maintenance window. The other remaining gates are human/external: +final legal/copy acceptance, protected Live credentials, Google OAuth secret rotation, one +supervised low-value Live lifecycle per enabled provider and explicit approval to open public +checkout. + ## Independent switches Listener app, all default OFF: diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index 68b3db5e..08bec1df 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -12,6 +12,23 @@ 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 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 real sales. Backend magic-link delivery is merged at +`SairaAsua/proyecciones-mito@c443a7ec9b387fa54ff16904e1a5d561613ec102` but still needs an event-safe +runtime rollout. Remaining gates also include final ES/EN legal/copy review, controlled rotation of the exposed Google OAuth +client secret, protected PayPal/MP Live credentials, one supervised low-value Live lifecycle per +provider and explicit main/public-sales approval. 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 @@ -137,16 +154,17 @@ deployed image; later documentation-only commits do not require rebuilding it. - #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 was reopened after the 2026-08-10 product amendment. The continuity-bound - authority/Listener correction is implemented in coordinated review branches; - production providers remain disabled until both sides merge and staging proves - activation, paid-through/grace and terminal badge removal. Provider acceptance - remains separately tracked by #199/#200/#261. +- #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 on the external Gmail delivery endpoint #44. +- #217 remains open while backend #44 is merged but not yet deployed to the + shared PMP email runtime. Its rollout needs an event-safe maintenance window, + a dedicated service token and one controlled Gmail callback smoke. - #218 is closed/Done with deployed runtime evidence. - #219 is closed/Done after positive physical iPhone acceptance of the deployed gesture-safe handoff. @@ -155,26 +173,27 @@ deployed image; later documentation-only commits do not require rebuilding it. - #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 remain open for real sandbox lifecycle evidence. Both adapters are - merged backend-side and default-off. Backend PR #57 and the paired - Beacon slice version isolated egress, exact staging webhook ingress and a - session-derived checkout that is visible only on staging when its provider - flag is explicitly enabled. No provider credential, checkout flag or sale is - active in the deployed Listener. +- #199/#200 have fresh provider evidence: PayPal Sandbox completed USD 5 + activation, cancel-pending-end, reversal and terminal refund; Mercado Pago TEST + completed checkout, pause, reactivation and reconciliation. Both adapters and + their production lanes remain default-off. No Live credential, public checkout + flag or real sale is active. ## Remaining human sequence Use `docs/operations/EARLY_BIRDS_FREE_ACCEPTANCE.md` as the authoritative worksheet. -1. Complete one real Google sign-in through a valid synthetic invitation. -2. Confirm Stop halts the visible balance and a simulated/real cycle boundary - updates without reload. -3. Complete the remaining physical Chrome, Firefox and Android Chrome rows; - retain the accepted iPhone result. -4. Run one 60-minute physical listen with intro, handoff, background/foreground, - network transition and Stop/rejoin. -5. Run stepwise load from external generators before claiming measured scale. +1. Review and accept the final ES/EN offer, seller, cancellation/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. Deploy backend #44 in an event-safe maintenance window and 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. diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index b2c0ca28..e7309ed9 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -477,17 +477,17 @@ The webapp vendors byte-exact copies of the canonical backend contracts under 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 advance to USD 5 + The canonical Founder price and all shared contract bytes advanced to USD 5 in backend PR #59 / `cad4eded2e08ee46da49e54ee94e1ab8601d9495`. - Mandatory external gate before any paid activation: real Mercado Pago TEST - proof that `/preapproval/search?q=hb_` returns the exact - created preapproval. + 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. Backend PR #69 / merge - `7bc2dabd05aa68602bbfe9faa6e29c4e1c081017` is CI-green. The Listener Live - surface, cancellation and exact webhook ingress remain disabled by default; + gate from provider lifecycle. The deployed authority runtime + `60584936603525027c9891e0865efc58055a3d5d` is CI-green and includes canonical + cancellation/reactivation plus paid-lifecycle metrics. The Listener Live + surface and exact webhook ingress remain disabled by default; see `docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md`. ## 11. Fast Forward development lane From be4b8924648975419ac4e9355222bc2e353225ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 04:56:52 -0300 Subject: [PATCH 206/376] docs(listener): track final release blockers --- docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 6 ++++-- docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index d479edb7..acd62d2d 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -30,9 +30,10 @@ truthful launch baseline, not a substitute for counsel review. Human owner: Nico Backend magic-link delivery is merged at `c443a7ec9b387fa54ff16904e1a5d561613ec102` but remains inert until an event-safe PMP maintenance window. The other remaining gates are human/external: -final legal/copy acceptance, protected Live credentials, Google OAuth secret rotation, one +final legal/copy acceptance, protected Live credentials, Google OAuth secret rotation (#328), one supervised low-value Live lifecycle per enabled provider and explicit approval to open public -checkout. +checkout. Production font builds must also be made hermetic under #327; retries against Google +Fonts are evidence of a release defect, not a durable build strategy. ## Independent switches @@ -105,5 +106,6 @@ converted back into a reversible action. - PayPal Live Business account/app/product/plan/webhook and root-only Live secrets. - Mercado Pago productive merchant credentials/webhook and root-only Live secrets. - Counsel/merchant review of public terms, privacy, refund and tax/invoicing obligations. +- Hermetic production fonts (#327) and controlled Google OAuth secret rotation (#328). - One supervised real purchase and cancellation per provider. - Explicit approval to turn on real sales. The checked-in defaults remain OFF. diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index 08bec1df..cd7f01d0 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -26,7 +26,7 @@ The release is not yet authorized for real sales. Backend magic-link delivery is `SairaAsua/proyecciones-mito@c443a7ec9b387fa54ff16904e1a5d561613ec102` but still needs an event-safe runtime rollout. Remaining gates also include final ES/EN legal/copy review, controlled rotation of the exposed Google OAuth client secret, protected PayPal/MP Live credentials, one supervised low-value Live lifecycle per -provider and explicit main/public-sales approval. See +provider, a hermetic font build and explicit main/public-sales approval. See `docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and issue #315 for the current checklist. ## Status: weekly Free deployed for acceptance @@ -188,11 +188,12 @@ worksheet. and support copy. 2. Rotate the exposed Google OAuth client secret through the protected store and re-run callback/logout without printing it. -3. Deploy backend #44 in an event-safe maintenance window and prove one controlled +3. Self-host the approved fonts so the release build has no Google Fonts network dependency (#327). +4. Deploy backend #44 in an event-safe maintenance window and prove one controlled magic-link request, email, callback and Free entry. -4. Install protected PayPal and Mercado Pago Live credentials with all sales +5. 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 +6. 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, From c4c67ce1fedb391eced62d5f4c8dafc77248ed4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 05:17:05 -0300 Subject: [PATCH 207/376] fix(build): self-host application fonts --- src/app/__tests__/layout-locale.test.tsx | 6 +- src/app/fonts/README.md | 22 +++++ .../CormorantGaramond-Italic-wght.woff2 | Bin 0 -> 64000 bytes .../CormorantGaramond-wght.woff2 | Bin 0 -> 89812 bytes src/app/fonts/cormorant-garamond/OFL.txt | 93 ++++++++++++++++++ src/app/fonts/space-mono/OFL.txt | 93 ++++++++++++++++++ src/app/fonts/space-mono/SpaceMono-Bold.woff2 | Bin 0 -> 23816 bytes .../fonts/space-mono/SpaceMono-Regular.woff2 | Bin 0 -> 23356 bytes src/app/fonts/syne/OFL.txt | 93 ++++++++++++++++++ src/app/fonts/syne/Syne-wght.woff2 | Bin 0 -> 44032 bytes src/app/layout.tsx | 42 +++++--- src/lib/__tests__/self-hosted-fonts.test.ts | 41 ++++++++ 12 files changed, 375 insertions(+), 15 deletions(-) create mode 100644 src/app/fonts/README.md create mode 100644 src/app/fonts/cormorant-garamond/CormorantGaramond-Italic-wght.woff2 create mode 100644 src/app/fonts/cormorant-garamond/CormorantGaramond-wght.woff2 create mode 100644 src/app/fonts/cormorant-garamond/OFL.txt create mode 100644 src/app/fonts/space-mono/OFL.txt create mode 100644 src/app/fonts/space-mono/SpaceMono-Bold.woff2 create mode 100644 src/app/fonts/space-mono/SpaceMono-Regular.woff2 create mode 100644 src/app/fonts/syne/OFL.txt create mode 100644 src/app/fonts/syne/Syne-wght.woff2 create mode 100644 src/lib/__tests__/self-hosted-fonts.test.ts diff --git a/src/app/__tests__/layout-locale.test.tsx b/src/app/__tests__/layout-locale.test.tsx index 58e4d950..1598d420 100644 --- a/src/app/__tests__/layout-locale.test.tsx +++ b/src/app/__tests__/layout-locale.test.tsx @@ -7,10 +7,8 @@ const mocks = vi.hoisted(() => ({ vi.mock('next/headers', () => ({ headers: mocks.headers })); vi.mock('@/lib/i18n-server', () => ({ requestLocale: mocks.requestLocale })); -vi.mock('next/font/google', () => ({ - Cormorant_Garamond: () => ({ variable: 'cormorant' }), - Syne: () => ({ variable: 'syne' }), - Space_Mono: () => ({ variable: 'mono' }), +vi.mock('next/font/local', () => ({ + default: () => ({ variable: 'local-font' }), })); vi.mock('@/context/LocaleContext', () => ({ LocaleProvider: ({ children }: { children: React.ReactNode }) => children, 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 0000000000000000000000000000000000000000..984e3c5aeeca7977d9200f4e4eb8311367618fc7 GIT binary patch literal 64000 zcmV)9K*hgzPew8T0RR910Qvv`6aWAK1jIxD0Qryr1OP$+00000000000000000000 z0000QihCQrWE{2_KS)+VQipE_U_Vn-K~#JvA^?dFFIEu<3W~uzh>ZX+hURbqHUcCA zn=}iXH~<771(9S2APfgv_!^W0rE>S*nso$3bsIudHuGWAuMtM7fJcH{9tpr;y#i?Ot=mwnLWO@}@p3>3DlgTj8v8Hnn_Gih-v{$QBavUBLUta(6w06V zQ&-|kt{-v5nxC3J>Ot8)GAG=nnQ{rWr@RDkhf~B~glR^>EaC`x#=phyM;qJs|GA@H z0^SH!y_1MU;w2;nu@J@EWs)CHYx_6v?d|Q|?nS}TM@aaoJi$P)z(5g9Xd?q{B0?;{ zY((t*3YbCU@&EK=|LEH1zW>7{G)q(jI+jo;MKr2P0Lw&md~K(?iUv?O)1(F3rlm>$ zfdYjdemQOFr2b&sk=WHaAyO+?_2xNt*Y+np#?y-!T=@5$GBJ*hc+h`_%9H8o|%Rj}zdVhDq z7y`?SHPjkGIMmmelpl}c*$s0Zi+p!pa5HT?DaW~!tHTnorr zch7iBL^JcdSx}hy;NDu<7g9n&cYEBnXFlU;-yRN!%_`eNumF5Jd?ZsBXQspvije== zGP_U?e$xLEw7|$pkPPXDy?ZAeOfoZB*7Gc<+jgEITUzyKxT%o4c}OzR-G!f~UOiEi zf0S`dxM{UB9m1k`k$vU%&+(AUb(AHnuRd z4=W|EBt#DI=T8+(bvs<^-zI@5jEUSPN9rzXtZ zeZW$hCI$Mar>3z300agSrPosFsIp1nnTQbG2pfmVl7PojDfI1!p~hnV0fcbLujmJ7d7LD7NzhLCX!29Vp11>`vB%5Kc~1r|9

8N zf^>DO5_V5z)S#9cR5;0Pj$I&cG?O?W)`-zw(&l3TG$Nr=cY%;nI|uYRVphu$m0 zFoeE3h7w9FF_apH?)ByI`{a4P*8Kk*r~*-Q-Pi#JBzeuS8@=_%|B$(g(SW;pCD(Lu zML(zE1p{q@xE+wL8h%GmO7(zUup!b*+=LK06}=9FnLt0S?h>y6xY| zFyLJH#5Cn1=FvF&)$9FUEgx>l$@V&5TP2tQ;Z>)I5y;>WQ0|4vM7ck`D0Z*e#h zq!=S2BB4lA|MhLJmPues_55#XHhOVLtH@Z=mOFb8A%{p^{wo~y6j*P-4*0K7AbZ2X zIoSX-+eVjSm53qwB1Xj}_U?(~OtgSX+-Sq$m2u5&tghp@#M2O&+O$Z#T1p&u# z;lOd77C7#U1Wupnf#bJC;PhKEa0bi-oPe2uGkQMYM63i*E6O8HerO57&0uT1KI@(2 zhzC6GMQ`}XH-7SuU3+_69ZeyBY$#2XIz!GfD{f-lO>1X)9q2}{`ZxL1bFX^Mo3f-; z?&9t}N#P6Z-hMqH%bj4Vc08)oD<=Sjqyrkl&=0{vcU-sXggifW`t}-j4J#ImCO>%h zt#C2qO#`?SB>?Uww9Q#azukm(*C*$47+4hDET5{hd$GuN&9VBiO+p>SrCf^+(NzIF z&mi<{DJ6_7eunt$)wwR1vcV97SG5c}zsd&*wg2i_)p8jCTI(W#+Pe8jZGL%u*kUoC zM7d8GQ~FkDD0RVX6 zG17CpAuB*A5dD&$)Fx6{ACOwLUfmFqSUIbg7RSDBmW+bV4SwB0CP6otRvojy%bMU z&-DTR2f&q>gp`b_Ai;(kVWd$)j5bE7vBHEKXS@j}i?G8^yX=-C%WEICY1g4sm+mAW zl`dO;U-zh|ed|Z9V+n`wqOe^hzF<)x(0Y~tCjb#SXpfK=GfKQErBnG6+WccJ>#jfP zS#{ggyOo=_udT-A+U%xiN|4fkDQSHEZR+wE1TNn4ATNQ|8pwl}ybA!XG2G@^LNwE* z0zJT;%-+X8C_X)GiY~>cUN0w7Vyg7RJ^2N{X)u6O1D&w)rtFznLzmhI%@>*r#jU@* z22R4n00KWwHy>JYjTck;6ujY^8faug^RaHam5S3EeXOEI z6WG&*10mNu%UvIM_hXeRA2@B}vzCJzyw}A#(0a(;;~!H=xz+gusUFx2Zt31i$p)!} zdjtGI&3-k?Rz*FyI0@q;N0@PO6z1a~4azGYmkkVq>#)C(`o_FBVM`$s-}v45H^(MD%k5D>I3 z0kCNOsHu-l$2fz845Vz$dJjH^%pCjZW+LJ~_STrq1l8I;+<|+1b6V!#RTp1xrUHruIxcu?uE9 zr3+_2m5b-Kc!xK_)X|OE$z@XK&q&KYJPD$}gCZ0>=1pIL~iG0QMeF$SJNj6yn>l=&CE3{@@n zz_hd0Bk!syCS89fT=#td?y2wK*Jujvy+Zn_kp3#T)?``Q?U=9A0LlPGCIc1G<0+!X zS42;!h?-cD{sKP;@h43PnI;jY6DLD%h8n0Cg_^xW&qbl;rciV5hc-q46<|_;e{=?9 zq2Wn;qX|+Y1&9Nf049J55Eo!5c0xzrpCN*2^n&%yorxe5VG;Xck>V(9Fd5)CiUdecd0XyOvAJ*gp?3?d!*n~r{eWhW6 zK?IQJqQssdVw-(&GHDR=L~)KYltMDfz((2`r!f?g{aR_^hBw-2Upi-F;hQS%qBSA4 z7Bn1e4h_zJXoDp#Lv^M$g9VVl2XV`5k6ja8V1C0)YHlL_UwMWGn1 zOfg+~LVBNp85p=)Fmt;nsBL^2AlO}R=0)F8L))voanxpx3ZjCj022fVqJmz5uk=5} z78i5`LqIXcE~gG9?tpyj?&{$D<}TB?Gi|hQCvOcbd~`b?to(bF#@5_X zTz)J;=VaXH7-{@I2&@oC?^>C>(XH0+F?p@tU^JO6R@*Je4o_D}bYyC3ydn7^nqfI! z5G5IECOsvQk9tgrCLAunGdu`JP>j#Yp-4CBDN(*SDbe0%5N7aeqslG!(B#oADNXBH zrFgT}&Hakbq;h1|w^`W;VUwoKYBuQ@nN-`Zv@I-5#-q{G);2w)G};dmieN}Ep$LUB z!H_@8sBUFO9a=d-N<(U@-$0kcz#nIhmyim$B2Q0!T5 z%}h1s>+<30u6x{dyUZd`yP?sS3AGks$K@=@yB^sT0kQ#RjOdDIvJ zLM)Dmm?0y^OqhD&`OI0cWTh8tHf%{6V`jAE>};vCWzLp6Tj6Y_vsKR4bGE*-)r~RO z#qJ%9HEH`40f4&~X92(1SDpYI`@2Q6qM22@;#hSnku`FqczdH(VQ+EZf%Z*E4zd6C zX%Lb=P}cmO55f5<3zl{m=FN zw|xh~h27t2i)M9DqFWu8$kiE%SzTHhySi~_t8Vq}?_^p7cfF+l!LRKdkMCL?gqL*o z#@TEp)8HXiA}kV<0y5+%P{M?Tm=JjQLBSsq0l<(6ga{r23=?t-*}&jKBMcScM4(0k z6*_{$LSX<6x|)BMzW(o{`v(OF?tjzGaazD0@b}F7+P_@jTl|d=ohyU{LOWqpc#y>s zbBPDJFj6L|n=~jI5siy>ip~VCmmHLSht{L>(bv@7)W(ICpeIw7 zY`OB}D^RFNu@b3Dl~ur7@4V8Whc3AagQOX(Qq&Mb4;%y|C{B=jKi>BY;PBzbL$bd#P+q*mumwaq@qoJ*Z+ zX>%`q-uYI#vQ@4MVwS&if#c73C!JU8hI^iA^2IM5A-EBwG88B?M77Z-Y1XCBT#K!= z-ex8dI?||nqWyj!IJ%iWokjB$kK-Qz~O+T zVA-Kz$3GmqhLS2zeH6m-T}KqS5f2fCZbl=l$Sp*%+up!R+<}$43oCOER_;Ec!h`q- z+v6dk(xVuJ?e!S8&lA{wPhkfQs- zfxF`ea1DM1H{ILd9{3mBBmaSGwuReaT-N(-)UpTw*Suzvj!gu*X*TOQB#_%?i-Ai9 zyKA-@c@&8I<_i;_3iZ%zGYe=ikIiSgb*Yk+}&8CSmTI1 z5JU(vgd~2MVtt8E_ln3zL%3tQe*^geF7Ajmez9iU#$P-eZeT(Jb_FO;>$1XGCR*j`JfAa7dXIhb%eUiQna%si(cL7lGx_p@dXh1Ct5_i* z+`wD=-;iHjl)VVrtAKtiid6ixmU0s(nxsX(?4VkihR(7ns|^^1pCrd%7450Az3pp% z2Rc|))pgbdhBvutP_0KI+_+y2(Xa#Ux~U<=5VF;G2IWY&>g{`&7qV{|2hQxI^XSDK z1sH{3NU1jtS!3~yE!RZ1hL0R;cZyeG4dgLxB3e}4w zE0X|rmy|{c-J4WF@`doigB;1p!D3pK;&W%v97^cnC&z&O+me}ZpTW6z-WkBCO|Ay? zBofa)eFV9E4T$I-ttYjt@sW9{p$Cs!Yj7sWEeI%k|0w9u%9>xcNgxvH)QR!xZ~3?u z-ySA`a<>Ikd8Ik_8^DaCeBh^M$>8Wu$d4cWs@(_D&r7gA?>l-*i_>>ASBd%7~F4NVbrq_X~s!iy-&7|ZrsYJbfpYx!EaV%QAldWRWv&V=&8O|!Eevpss1wvArQQ5&WKvqQSe!VOl;QB16u zt5Y;s2q-4iD3gcDS^GjRfK5MJaMHnN%g0>J(CwQVyk(Bk9S}^yOG;Ii7)>$WTsZB&Rc$ zvzf@bOyzuLofn=I%hoGXhc#i!G?;C3+}TNHy{YW6S@~Mm&pnwENNd1OvHY1u2D{S7 z64{!5IIU5ug}x^HW#Us=gm)-A^4#Sn@$xVcrkG=q71l|xbLaA~fpaT{^M<7j7?RJg$E*>T9&3Q@5!Zv-jdQdA5Y^_e5Oqq|7V}tthTm| zDjn?G+9XW_wG8FTlTSlni~3tvqEwl3751pK*FO6la8Q+MH8i3AVvrz=SI}osA6gAF z+<66iT~tRIM`B>jz;*mwQ|~%8g1yz3d&gb(-1opkk39CoQ_nnSx<7d`QQDA)5~P&& zlZ9)AC^t_ICa~>;*ILJ`So$q`TqcNLslUm{*cuDw%HTeZD5@s#+M5)-igmoL(WcFc z6K{Wr`HG!#I}hi3WN|t#ZAH?1KhVH3eO>X*ZI!J~CLs3pP;nZB@kS;!^OBW9G-{&j z4gIvL`bh)R5^49sAc7V}fdU@Tzy}5doCza=qwi(ptPrf?f2o2Lq&&NK{PgCx;$V#keJXMgco&h9aYTwv*jt|^n#{_xeJ#(U1bIP?~>Ea&{mL-Tq1 zv#fL5jvJf_BM<0!_qn8BKGu&+g>j#N*VE?8*Hm>-fXx`X4`Vhd81GhK0M@JWfoqE! z&J+{Z%5A%%U=f&jO+$c5>tbJsG8PenDP~3n!Bi?EO=W;AwsN{In#11&R=cuLH)X~WwV^UssXP45>(Rwv5i>`Ds6`dAtqJB zwcB}D$beyUL0$NZA^^DDlsjF1sOT^j$DhmL)|G+C?H|AJhfXvv~J3_ zY}elG{9s$v_}$@9x_}ab6S6_eo7qv^Y~hE#QrBjs$OCbA?aV0DmwjqRX<`M_7JAl` z9kP-iuhvSxb5oY}Fpj8Nw1)@j*NwZ1pg*sgz7^x@6U^KX9K$bzJ%>lF#>sk{eo3?0luKHNVCd2! zNCN4%=AVB%{M&u#9e7;Jo!THg_#i-ki1Z&BA)0X<_w@_LU$@}+VG`B}$SQWa%{%cJAoLO5o20(!R}X6p5)l^cfauo` z({RL191@eeggI*>ZU$O@1_}@q@ZEk%{pMz$ay(%Y_JGjd^O0QV!^>j_h0PpRoldf{I3WhAbH|w>U z1ucyn`$r}=Ws>f0TDzJww6O{+?&TK}c6%LgvdwW=!ZB)$SZNC_JavJ-<;zui`FU@b zTW1Y5)zWlDHWs~Q`+=SN4xT6@SLu1HucGQ|vp1s!&1!B-o4<~hbg&)mYENg=op+A4 zug0-)@GUqYWrT=g7Ge(1CYnWTYxuU%Es@$MwoC4i%zMdHFRQ3MT3h*%sY zfvHoHa|XSVT`=lnGKs}xHq%&5Z3?HEvJFWyTc$+{j5^IYW6oEsQfpOMuiQFi#?|^l zjjgI}(eH#_M>YD=B*%3-tj{r1o<==~Hv1*QEBKdNkYS-NhwQJV;&7W|qMbS%&}y$X z`?TB7uO_iaPs=gPZn_ioaFskG4vutW45k%a8zOjgA|CT(S?sKN=Qz!F>v2S-4eD$& zSm=6FV>1(vEo{uK?JZqNo!2aI412FpF_t_9a&$b0UL(FWl3U$NV&4&+Hf@}1t^UNc z!XT=>D#1RqL9aIoyi5@kjb4uHF)V;TEsY&|DWc!9Zk)hD*YlrxWNoiOpuNWj)^`5p za=PWJoBZIv4p{4&AhSSTtGeS|$cvq^2ovY@A_Z9RL~fjh6;ZSvIEbkbO%Yw>Y~p}% zSDdiw&lN9JIpj}MEISL(@yht|0K>qPh$cVAL13cp)Qlcs7MIP(bp?lIDO4+fJ>(-n zY%%JlTjWtYD1mHPUsRDT%uHp_h@z+tnBfXRG*9JW-`k{7Jz$;M+3=VES0D$x3eseZ z>Nm2@xcL;N$z3e8Kk5#2XsGFIsWI(YH{0s79s22phr((F@-dpTZ8MwPXtOu5joJah zbSI08%qSaf~kWds1sftbl{8*j9U{e~VU zjWOc|a*n9Cw}^dOz%S>pVq-zkoz>1TfdrOi&uGJ^;M=z}NAoV#^KKDx0x%JW9~(36 zfI_t<_3$)dvGSxC1}BP?G6WiL6!JXvB~N1r4ly_>_5`h1ZRjg4SWpyz=C}q9!{9yc zuVvdulcf6kw#>T?)i(eZv~aymH+79XtsxYo67YWrL)WT_fdFHo004B_kFCVdLVRF! zyHsCI9Ncj zU`*5ofD7K_fHxyw9>Wwf4vBpcj-geB+ZhlLuq<>Jb8~|mx(RcP{n?Z%7E7rBf{hJD zC;*I!wg8r6zzEY2yg|4@#7zLsqOe>3{F8z)Ra&J`mdK*>JwMwl^epbERot;>tv?W+ zqK@&R5|i2)qb35@M)=?Z0Qmvhw5p7>#zWEf!v}mYA4mkip@a{|jE{v}0RG^<-j$5! zR-uExU38in?TYgiPt;Axg_#-DcWNIGiEvjBf)}YB3Wso%#rW4HSLKvQBi#Ah!4*0dCe>F$C^j4b zGb^bL053xoRcRBD^Xv;ZA@EGgIAIK_Est2jq~?MTk%Q;yfC8GVbOWV9TAd!>lwEgP zClnT1zrKB#PQ_fEXF6P_WRlI37=ZWpov->HO0Qcerf#i-xC}zT0;N=b_p``OE>^Mv6%-Mowl+j=R(cixdkH0=B zKSc)Zv9G+vd7#K#+6YJ?11$A%`xfdvlyt4^dx|>SDc~ndGNk}ipdhlLSYxEgN41Lr zFqrt>Xn>VfeJ0v$EqXXT$f{Bdpx#jr0bV&2DmB`K&S58^1EHv_@i^Ph`7Dek&FyG4 zc;})?kB`q@n>BFgZQqhO(e+N$)ZQ6^ub6IxM4FGnq9E5=`)N$@cpG0LAA4;2Sc8HY zIZcB|_r3bql z35y8QdNdXQ_U)X3+MH8g96Sc?!W=^?>PJid?V*H5@R4@A-|i~`lPU-W+tvidE#nkO zk-k!Ft+yFXo9OlmU{^T;9GQw$BS;uC{0%?z_N9=dh(g zYp#8^5?0sfb~9)|MLk%-_6^nY)olrfhmEDn{IT2pK!cJQFrC?3-e{;2dsu47wq?uR zkeKz#*xagxtmO=-)#=v8Zg^f_YS{Q-iN_BGyLT^z5qIQ6?+|9z_3&@IICr4j>8I*? zF}I7;eLI;Hbx6a#$l|>=iZu`)HN&*2@P*L+wJIi$jx(irYyvKBw&T%^xHZ<<{hMoTr>W`_?+mO_RUO_#9}>V{;e7 zcY>=N`|JQ+KXp_Pto7^uzZkwdj+$o!NST7ZM#czNc@ShYI9u`V?GA`dcxROg zG4Ms8CbqtUcx3lh^=tj5-@m0mGd30!Jkm+cxh8yl5vU--R}lQZXYt~2oOy;D2%7P4 z&yY*2qoCrtdaI)`B7m!f#fdGR3pVQooD&PrIMXP4!dncJy-_;YtiMz(JhEmEf*CELh&o@y_P=sY!NnLSkCyQXIf~hFJF?fBz=QEcW-PY30C>n-SXi zC90+1@pMuO%b)gyY|ppEFlaXi2719hRN?MI;!d#seIOp5-&C72I?d7f+L9@zw(X4gEOns zffFg4k10J2kg+@WOAwU_U0ef45m<^$iol6hb#X*H#Vk>)vp)S+xzOU(6;oW}BLGM7 z+$g3ayNjC9dGl~S{rvEgjg3$0=xoXesUyvS50e}Y&%1>#`qp=u7o|^AKHU)JBS1{C z0&MEH994zoHR<*v_8*8{_IVM;5_xO(Nhm#9Y;>!X{eq(pqW(k$5=zMHwK4RN)m zs2)7uh<-s*{hO-u!@`mI8QB93Fgo~9aYU#w(TqGtD_cq{CJe3h07SOqh5~WWmX!%| z>hKd#RKqE&Cl2f8T|li{HDnSTI}aH<8%3Nawqnx9Vp@}4r=3K=+NyjM_62%iTqX|}evpHGfkz}31Oa`0 zd>pKSUe8yZax@%8G%0}j+7G@j$PC0l?86uv+=o@2O(s$wP6EJV-!hyF%ttD)I!tgn zkR5ngd3-dc>cB-apNdZ(CRkog(TlUJK74wYrdOG|dRgL&`#2@t%KccULy=So>fiw@ zszCd0?|*RtbvZWPcsLN`)lj=b9r(Ix&|&|m!(^x9qz);LJIwa4LXF-Ew-hXsq00-0 z0YspOODlESWeQqlq^p-XzmMerI{1ra;b0t+yA`_BnVf#m9rdSqV7qc-P-_BiBzyK3 zCWUT zrk#ejm@NXlu7_TE3w0Xn*x_o!4_nJ0T3i@&vMqPH`tUAygia+kfs-{ef&eG&f}MV} zD~T#GTx+|%6cP}?pug)RphgzeGyfFo&hb27 zeo#l+bn&fKqwnjL)&QSsLsgBDcgI|C8|FndeL@cpaCC2wNRRoMAf*O?osbteLXD8d zDim@bg9x(fNv)vfpPtR*@bmH)f+MZVR>%@=fwDt+B0I4A91S<1z2C=pwPfx6Y$~D6 zkeG0ttH#Iwb;(AXv>>4q_Q6t&eiezl$hM3bs#fk4a;#i~BM$7K0aKZtxHW3XpoK~% zlG+89`^23?UHT)x6swo8JcO;nlOo4&9@L}PSu&(RndoArW=mF@8LePhudWU-R2*Lq zF{t+aJ|{eCHs1`o1DoNq!5{e27vMoB{-PoJC4@!lAxFH%e;i3v^@Xf1=E4W_#DG+2 zL=0!)fNrdzT;#E_l*nCN6J#Ng4EmGED>cLvhbnf_qRR%~f#$MkNH>A-m)IC~0Mw@R z_@W^aN@au_cd(tpLaIP6+dC}2CsSXw@J3ycIttEJ+4$Aje+RL4HeV2cG*w*E>D7**=b=j2&++^4_HS0VG8t2q6 zgP75+@qntjT&@~z(gskzk$@|TT%RkW*&wApXPI{ZB;=xx8mV81F#B?>^&@VunMn)vf#G@GCgL9qc2j*#?FLDY z7HzeOPNEkp?Z=+gg$&0KW0~dBo{s-TuY4}5RRai8ZrFGS3 zxU7rj+AbxV8rebJ@Oso@JRE8Vdu$8S&8zf7dlMj0##!ExaM(CM7Xecbv-xS17AGDG zcuMg|;;ecCTA&`slAu7n^leAZgjfF1~un#z{+hAVE7yC}-=@QhXXELK)( z5yje24VsJZv-BRsUQ_@G&J3-$j&j)RQ`Dq|UeQ*hjc59nlf>?Q54I`p$hVH7`t_OO5ZYVr0NC5UVq5wv+4n)Vwb9Hl za*=BS8kL~|qfkzlXIvVYne#n&n`qv6v~>^m`y>E|o4kp@{H8yPmB6BU$2})o>awTB z*;V3;rzSW;OM;Z=2afKp)DR5W7LT1z9_08vaufaC47sIe6QCs=8(NUU977|LSei2%1Nt9yW15Z*TzgG>5T-pNyee7Ks5EbPLl)5Oj*V#ty<;QY5`wRzk&0bI zQPGmy~>aFQ0j&h=>{$5ia&4+N`27X~JUh1dAmF0%-OlxHO&|U%$EqftwRI9qxrOtgn`VS~ zn+4QC$2jchz;kVLMzNlq!oYl_w|Ig1>ATv=1n1SZyVcE9cq&2j@<@_7G29DKu+lR6ST^!m zEyN9gO`iMUHd=GzkxqggiZ(i+-sY}>%NZvgpQ0|ErAWZs#Tf&l?O1H`cAaKVMXYvU z6~-^D2d|_oD<-z;tV_4<(0QgP?Z@<6yNJzP_bRgbF-YSsLNw zQ<8H7U$*^HN}HGw&@FQCnRuQiL9|$;nj`0fL`2a^7zq?AxRJp_CMW{bRc<;Gj81mQ z;E}VpQNu3k{sPQEcTjRlhMlQOuY=K``L6#G#}n^NrCHw(50tBXSh!Kp-n8(%MdoP zBaNBT)Hxd3-6Uf!`@FDV5{z*ofO8TFBx6vR+We1{ zm5&S02XktbwPyH9mkEeIb9jJ0D1EwO4HEZ{)L`g93l7qc!kWkKRZ0OJGVa|vy#n%RxXJH+S7Fz%j6gjf|dja&#iLzwCmHYWBs$^407 z;^8C)hDSI#;pAMFD=N`=5{^1a2&?Bvf?=CWW+WLa32SZVgu-;gfQipD<8N{cSC}T7 zzVz82$hviInKG7sC_)pKANu|YFsI_~(Ue3=FCNmIe4Gzj%}T>qQ=&K^HTj4`qftm26Ytoi1$lIrh9>)+yuqU)&7_WZ(gH;-w9Rh1 z?O9*wEe>0lws&>QUkxF*J)k(gf6%tLGzrHI-sBy{0fi@CLcDsEO)24yvgHZPVzPL3 znr28fE&w7jd_C$&XOcl`?;EZ#UF64%xmntayx6)r3?|_ufqNxT2mOst&eWF&yg^Pjcnk`nn!8Z7!XK(>d4$5Xz*=YR zeQ+5?x^v*PP!UCAk1J{666#`)HjW8c3nPo#nt3~>F2=`t!F{et{o9`c+W7RJlu_Atn&$? z?fUBBSKI2KoYSyZdfi~-1#yDG2FsP7QMgXV&S{2hT-Vg5lI?9O+XvT3L8UmDAhI#5iph32(vep z79>Ygb+dfJHHMOr5cW~=n`ih)H8cM(lu8?jSjM3+zW=2lBR%k&I@Iw7#JJm`+E#Mn zRV%HXD_7dAi?jIRI#&6yayJ?oX|Hot|Kpg33pUJ;3|CnP>sd)v)&{#hE1gjzb)}nX zI4lzbu3zu!U;GQkO=Sr~Khh!sBsO^*FS+*A#LSy1HCL}R$yq=8&cr&DS$TSZw*vag z-#DdrZcKt;NF$W^5rn>YgZ+{Ga$;0GoJ=fGON6P{z6GVu6$?>hbiKIHJiht+hBVrd zvY^o!Mg{>iwJmdmVb}GNE#JQ7mB|l)We8UkKTeQ(K|2reIF5C;iczG&qo;*DLP>(Q5A|&Ewz}2sh$DS@zC<7sEDH;c-1Mz3N{8EL|if9 z$C3{X!+;sD{EcM8tDj*8yn03f>s{e-b{UfqSH2lW>HE{2dac3AV(Zn2nYq?=)<{Cn zpF;9&X3_DNO4F$t~?(+C|&^I*u*WLqla&aQk z!iij`^@li$T&QBDvtJ(eVNQ=(f~x#m7lyl%ik!&cb2y!#E(L12tOZq!FsmW{lIinf z|2>reS#~v!T#wPXNi8LnDx9%NKtU>^<-Bg>-ZqCi|4R~!{p(MN870QyP4>hzk~Hp3 z!L@=t%qe&OV_R!@Vlrxp-hGoQc;uI-Qu*>PA_EAVkcuKloO+QsSe?2q zZ=Y0-QmVJvqa;!{DV0)-dguP z|G_~(O52#8sUy4oftFa9xXM)w;3`GkWL9c?YOw5G6-|VdikMJAfPjLODQL*f8~7@6 z&XC$y0Rlyh>H!U|!OO5u+1-WD^VaGo_%R3$^i9ewQRNV&%$uru6B3-LW?aPYBnQX* z6EB}dm*JVv%;TvHL%K%gTvgr|8{HY<(EtzfdHv?;0KjqW;iOKu~Dc-e>x3bO>~)3H(T71h@)y z5u5a}{QZcPNK18k^6_9JO47;FbrNZ&i;SpAH@iT$J#qz;E(p$ABN?i@Y=5GYpub{W zs84Ba1$)&+c2AyW1Hcy#>EQ%3 zUNyaH8Wm?FXx1AOw(I-(%xw7Zl4^d$W-^c8aC>~&C8uJuzkpW>I7RHV+`(Gbq5gqg z)FRhwgS0owUb?pQ1ArqfRa^Q2#gTq6Um*>4id;&jN04aCwR+581t~CmaobCaubk}V zP*71btkloxoOLu-0B9OH^@;m*GriubP?sJhxDk6s5Rhx2Az(9ewYT@Nu-QiNP{xqc zVz3R3X;Q={`!CycW7L&Et!2>ALcvo2SC&gRbC0&xl2~K3&7~Yc?CktTbTw^l(^g3@a}J>cmIU7lf*3U8 zv7~xaq`s0kjN_MYK?N(pp8f( zfP6StcMu|(1yeN`?RbC!(`_D*)wPlUCjJknmLKUq#AuWH2Ny)=SWZEh&-j{m`YonX+0)0 z!0r+DEv@!^gaZktFwMWNF3vE}z_A9@va{1wMSGR$;w#)J6YI+lwXasR*Fa^BdgD-Z z=Lz4%lSzoDKHzAE=1zs4hyXu8z`sG`3#q=LAACwylrB9sLPqYSsGo$0(CCN+jqhaE zt@Irw5>%n+@wqWx zEQ{xaiQ#5h$qCY5=~Cy#LszGD^o?Zh3mrxr1Fx-}#|};BhUi0gt(a)U%$YX_JZdJF zgnUSDo9)S&YYZFJzpVq!hsrDvG@EBavU|vE=D$%nO^TcXBQxx2W$0Sr(tTsAf)6D( z@JD0>67oba!=U0`Bliskf+raT^hZeKlaYkvZCf&q7YxWDq8Vn0%^tkRj1csBpR2b| z6=A&-tr+1aSZF%_XVxjR7zl`BUC zH6a!U*V(5;(eF4LH5HwgrmAH@4@ppLaMFid0+y{|U|k3wu(k}60}wZ6!&);C(^8tI zBKoeKK!BFz1{Z^{LpN-Nxk41VPOVZqybgc|ts#7mB@~}sq)Yag$QDqeT>lBuDgBB{JDB}#)!>Z+RoJ(r3&WV z@k(Sv7ox}C$(N8F#$yT$qR3|5af9!qK#uRr9;t{Oc*~f`=O95KSm(wLwv(rDjnrhm zp0oGR!o!0Om;J;~S1w#;pzPuejd6kBM1MxUSdi5cbzkg>u$tZ29U>gx?)g(9%E@|S z=w|x>3Ky@RX5GoC09n3qM#8RxOs?1X{oYDeGjHOVN%+}4gXrb-kk!nQ5yO?5S#4ml zxUqWz)th0;WoXaTxjP%BE_tg(%*n*Cqy#wU)Gas~g&}cQ$(0CXkZ_akxjF4~~EngUS9f z8ADJHOEd;%Tlbh2d&m1J3xqbK2XA=0&OtQ&n7#2dbrOy;TG45kqY|KT9amQ{Lu>{z zo8|e%27h|~EL@BCFNLFX7%G6zL)JypLyJO2w76sWc>cV&^2Xq+p{U}BIzn&4!31hK zdsJzsmG~=z9G&_IqOzyT4fgKyVZnOzqA7w7n%tq!t>u=>hKw{NciAzc4L1q#LORbK z>HnYit#!qu3wu95^FoC^-^eR$$Y$lQD&{t{L6PzitdzPVl)oUa%$$b?(_ z{6^tnCv`$sMGEmciK7`jWM1yNCH4-G0vu>*RhMDIxb?G`8}*#9$Ffb(7*-!^3~e|yF}Mek-uT)m@51)Xfl``wl_cJk<4-ehYyrFxXwJW5WVT)3!s-co9F{xX40Fu8z~F0d}8l=F#(E_puD zlHghD(Ptz?PV))M-;5d)h~|9fXh}j=V){r)oFxH}U|}rB5F#@OLFxn0CyT#+SBQnN z%y&HAIu-;&n*KcdBjCf|&SVG$SQtmm;P6wP_#PZVh2I7pHch*a~5Q~Me?5|SN-G@#`e67I3*fu;% z+*qIj$8cLoL}=LeyoA_`p|0=6nX+VC0JuwDhuYoYqz){M)j6DI>f8xGZ>A^Wh*(N7!(;mzap0qY>vShl_Sc%<@6DA{3`SSio=^NKL)eKL>OtH28g25D6BQL?q z^tW7s!or}}SRM}x1m_%#b%Y6BQ>P|n=4+w^Hk^=OMuT8T>J+4UuhnK^d}5pn%n+YicFF*HeBKqF^Jq_gT4pxhKuopdrvZ!KOrINusrm`f!HYwWtB{Ve=S77Vm*b)_LPYxXE(_M+bI3IVqa8re4`^M6k@3!UgpCN)zVUVWfb00Da`qCrkkh5T?xw|y8MP-0(J*KQ_lp>;$)EtqO z9uX7yVWIm}?*SlE1+iF=9~2M(`822urL&f=>jG@r&IxQ=N6d!U6oR4t0K0JMpj~*n zL{TARKX-X9*5yV_7bn<0Sl1p(jfV6D+DRt0DK|*ACP#Q#IU*h}Xwe?B(dc)lh@T+m)9s_%y0 zqfvJ1=;dGqnnI=OP{q+8uMXvDY4?P?paE~+z~fbO|H@a-49=!fzTII}=Sx@(hQz6VKTHH5=W@(sNNk97jBoQ7e}iK{2B+*`nu7D zX&2W=O~#-A$<__QCb>MNB zX2MMDE~0M|N96+!V#VZ4_2R%h*kIdQCqKbJuBm8rs z>nF)%NzGyH0J?U$im`foNXu_#E@}0-RCS%$Gql*0hVn!b)|LBd%{onXYal|N7~sH3 z8R0>ebU`2?@VIv%?g3Vtl{+CYFCoV@AwGD!wK>B={#z;*#tMipTxu?3`mDKnHWtQ8 z;y$^=r+RyISQyLn@kJ&Y4#r7R6C0m!CH^ht;-YIre6K#{%#9R83LtcD-5{1aSbMh_ zr<8bORW(AiWYzdD1-AL3>(mrnSI zRw2RbBSPhoz5NMg$7j$LX|;`Q0@|W2;x6&5cEBF)*PUsezg3u0@{Pv34^b-7-Ysbn z(-EQ?8E2U0&x4+%RRFkCBjX2Bnxgb*-plgKNbH!ZoKVUd3DWRg<0!T|1Kk09GoB*{NLfN%G*0lA~GTJ6E5Gw-OT$S zVYDkV&9ib9jxRNCgyUe@^|C<-Mcgdi(TNX-PJum$wp^JZJE$$p@t0Q`90;9x_62Bu zhan)`H-17ZAPy@)%_uU>cyEdX0ghLiDWV_xHH$RN#)^br1MxRPLh8q5)*|W{)J!k= z{5ui|gQS}T7=t73nOpyMR2})4g7Xp>rvgD3R6fo#-^kA!CA;~24y!G|^fy9v=EZSGxj>Dm*hS9h>s!nd87%$XY z#5Fz@LmdYn8M8B0%^~StdU8%?jpBzA(2j=ccj5xw;}!Ar=wL~C+K ze)h!hgsl49;Nie**kJD_0CP$a#MJ5yv%%C@utfT>5S#Au1>0934 zfS92rOGy~;U`<`!Z-J1OcBWqhvglU}!#5y)=(khJ8X&B7Z+WnWtk4gDSdv@R$(cKW zuiizX9u~S^z5Azv*!d@;fcwzx@o*E!ls^0~ z^Tc{v!1@OJR|fy)8qPO9yELsdM-6Q z#I80qOzw|$6{(PH`tB}<$M&1&-tbvPUcWPYcv*gYS`WWWnU>01Z7EHE7;9rfxgm~( zhv06|A3HM^fsTQK+eAY-CL+NLN_dZ((8W;V&ixCy;24>{;)04BvbDx@#Z~k~JVkE1 z?1%-~ED(fPY=|2Rj@NX*%>nT!2%%_a*

*zH-I4=8>6F9LW^}=tc!Fmovnpnh+*Q1OjIn4m81r`v}{_pr`wm&)=sHJ zTL0P+wVWTr6FyU%Re#W6qSVNNq$EroZN(t?v9!UGugEPP-fTYI_3+T2WorwE@rrE1 z(U^Ztvma6?`&5RMtU<7$7>1VLlM9y8>oPai2?u^u6x9l7!>rK3)Uf;lj%X1PIVzRz zs>_=;xw^cS9NHPX;1vaV=VBN<{BOmxpP{f&=p&ZffKeYm#x!^t9RO7aBnBa8ssR;z zE@8DA?LwWD%HT%OFZQitZ~vC4C!Y%fT6ophIUo!oK16T}qnPMJMZ>F?6c`g0k#y|U z@>#bSsbhuwbN4w*Af*70fmUAU8Xg2gh!3T|g>f*B+tywG3Z`)+1!|4An0R~xt~}=m zsFX*LB$P?ntnjA6Yc?9CloOUU4Y$xfq4pt_Q<)DK)kx-vB5%~s+ngEeElXl`dW()d z{jG#28u&I)HoQ!d8fPARa%YEJade0&Hclj^(;9+*`{KJ5Yp=!aF>;rg87*S^RGohI zigOdrOW(a^0`dA4zE%QH<-hD++=p@iaBsrw;zWe|!BeWQN7Z7G{#eI{liKwK(8R)4rlT z5graUQyu$q2Ziv&9oZh{qzf<}lA5Xob4O&H;MdH+Tav`-hVIs^rQ}Sai8`RKD%K}! z3)F@(Vk$m`+7(@puG0i|o)z0Qz4grKY)yO)y>$&Kqp*Lv#cD3s>#NWKqLJjV43@RD zWOjz%Hd#KGY|yNWNQHB4_%)7OBDc%3%l;)75QhnfpCOnXK2y~#(9i~_87O&_usUTz zRGXk*HK>{|a47miCEc2*PX79jP$OYL}xq^*Twk>qwpd92nR$mV?e7@YkiS5krq>p)83i9nn;kt`Gj7*_TNv7 zlIjD(V5zXp=xU5pU|}rPLx@k5lRFrNNvoY5X$C-(s&sDf$gr@?DVFCJdXvGy^C(ej7w0Ixt2&yl z3GRQ3JZ#P#Y#PU_XQ#cI!&z*CjFBdBqzvR?nch9;eZXTk zlT>S4YVwAs(N1x=5?%*?@?LWlK0Sp}m}=rP|DdItEU;;bIip-1 zgCB}w-h4x{+2YCO%XD+0g0Yd5;l1KY{9A&BA%FSh;#`$34$Y-68*^OmV)W1chQl|VNd#yf+S2RS{L z8XC$lM37Xip|*;$iBKq}3Bvl(aUcwaEFzHuXW7$wxix__!s~R*7%(&a{cyOPA?|Ka zk+AQ7UIcj&W8gi@pOD`$;Gd?Zk~dZ?-aVWr#{p2&c&yg&(ALIN z&Bt)9mihT6ooPZKTyve#VCUAg&2Wpk+1@k~bjs$z!*&IX_o#Kq)L7e2+KBZjDptid zby7l|+S&o7@|uTsR-+U^V+l;wr?<5ai5Qs!Lw?Pc%KH=s^X44;$X-9-o^KeAfSkOY z8wWP8Z7wiy6QmZd(iDi6qrq|GtY@oR5)e`^THTs~uUllVLZ+j3bJp09BXQQ9Tr>NL zh^sZl)tAr zoZWfam95G&7q$Rr||991p>cI(G@3cj7QC3!#QJ3ztb;x z=qBZ-M-KdJ#LAN+NLJD@EYAE2BZ=u(+s0CnAX|?nt#L7=s&bo|I52y{?iNkR2KhQe zud^OU&v`~516E{|RfQvaS)H%3I<;~s8+#|$C zhK$2L-#J_5EDt3+=VC9GOJP)1FPnE^RMTY9*0t!jh1$a2JKMa`MYA(0;a=&^iQok( z!ybZfjrwC}c1D0Z<$~M9=0Z%Q-UbRKr{{%%V2s&=3Zl~&`jc8AXopW3nTbET zlTSzhOr1aq{BK-ah2&RGk5L35_BylE@2f1j?a>GNw9Vf zbL%K`-fMxw(~)gD%zqy52+f<d`1P z294Vd$4MWcorJw1YcpnCUipDm5jB8-1ELS$80Kuq+5|3vst+_u_9E6Cm3_pLW#2-t z!J*mU9|;r$C8xv3@b}Gb9hH5AG#>L)FAG9G?)rww7<(mKkJX#Pgu;uV?S$Y{m7Yom zTAhRzoLW^=sL_z(#Z`QTKmJ&d#uttfSCmZJW??%zPvbwjaf!RFaay9xvj%@qCi?s` z54=C99(El7H}^@>g`8mnDp)9HwHjjuS}BdeRnsr@sc4oPbK}MFG9z=piXES!q`*63 zLP}n0l#I2Ix-_?w?J##ZP5G<@wL_ZMS3dRiYg4keJ|HDYZ^0@PRb8}dX^|i+JXqN+ zlOHM#&R`PRF7Bu!#G#{w7z*%qxJ*{A;w;i4T_}IWo02^oCAlT4dCxlYS4$VeYIkjI zP|5w@G%se zbTP0J4ypbUN^SckB|AqI!WzgE5>Ancx2Gk1Qja z-}_`Fmjr20?tl3viK+5`)oOAN<834()D$W*89#7H5KsfN=7zN{)0H_>vqHkSFLgm# z0;j#*WBNy8h>G+|vx>X9Y7-ClTYbq_cFi zSYQRLIY-N%0o$!bxhlD2?Jw*pBnQ00d7i70r2Rcpo9t=S&JA47IpNflsywYRBDD~$ zBWEZIZQ&VNr1WK!5;pKuG)0X})l4yJ*ktO}3-Pyh@;w&C3V-FO3uA>P@x;jeXx@^{ z;M8kQd5Dx}w_dVJvRRLKX@q+9(mowsmM5~*ZKmX=gtF77iEAW97IscIbg;{Vgfzcx zegOLT9}g38D5K;-=SGMK=(n|uJYZ<>`Nl>5uFaIbCk~>bf7|HDi*W1Xm5n%@8$bDf z?p}p*ZBJcA3R9NX5m|e}l0q-2IUCH_c}QF<-h^fQ4T)8>2u8)r1q4bU`C7(m80MpS zaGtg_*1D08t|DE884HHB~9+ZCU z>kW{qkiMn&WC%h_kZmr(%@#RdI~ zr?Ww$-g_2p{yRlu0OR*9!Pk%S;hb#gY%RTt%{sg>=z8(zBw|jsu3L+%l96npHjmnW zH-dP7PF4Fd7V_&bR4psxqdufI=culqJHJ{n4(_jWq1yaoNuKL z`yS0*xEwa~(thFU%dwr*L>(J(Q#Q6gA#Z^o~1*^mw<<;ohmkCp!uJYt3kbmE< z@L$jvOxR(VkF*q|_af-$l^<%1Zo7cZXZ&v=?5$Kk5IC8G+qKb6_HU(yYKT=K( z1r!x;QFq^Dz*+HC>l{n7^jiCEARKjgtuYnI3t$}{tz?(%DhJxrz=`zqtm-=%Z=cCj zm22d}D?2e&ImTzg+obKkr^l)1AxNRPoKlR~Xn|&mdS~_J3W(<{_;x@XK)l!f49^$Y zGLp=TsWmy}>frg9um%3SLr(xwIRvfpP(ED6t&^}$qB{GJyN!DYxA#8b3ZbgUW>9P+ zS;Mh>AU$Atd<15DvemLPf7MZv_wzla3)&b!M?+%-|EwwxS_zU~b8qfDzk*rXI!#27 z$sF6=wh_=&`d<*`bk6s6O?t=qy=MC#E~9wtI4hye;Z^9>4;ErPYOMMRpZV@^7!}SY ztM~-sqOpqzNDeI5r07!QuPsMNxB{ zZ#Hy-4@dd)uVZ^YyDGV>o4Nq`TleQ8P{^Z=R4}w+CEt!OzneoO)og0fvd=*aIo;m}&r+*8}L zK@(~aeh8-D8VbY6A`F_KoN7~3Y)C|5%nebuXnlWbLQI4WpHd!HV6eJ^kk&6aUL1DA zwrU4TpiYK6l^61?Z5fMVq~R5)RCBJ=T*gSX_lu*^#nvRd2-u2xiz*tfqWl< zf?pF<9hUVggS6nID(opS@Z=OiV1ztPT*xVSvwir0C)lwd@g6?V|1#2vo)EE-Ai>tq zw|oG%dZ`D3?aK@dgG?8*?Bbv!k{(Q{xUcZ`hZ9T7b2&j)9?SiRC$6hs!sZ*)?p1q6 z<`;YBR&vEZN_d0-_~oy~G%Bb_Te)H5qV%+i)ekA%uMH@T;QhVcgmEfy_J4>G4F$OMgkuP3p@vLsTuG6po7P`|)vUnL507ZzhdV$+@~VB8?k& zx~_SfWd=;>ti2H0!pl7zW*0vcu@*0L*NzF%VMJc>CkGd)7qxf0;kR8sghyp zk9^92YZul#;lvExKO*53q+qk`_%HW7KVFgp9#+@U6k_?K;%oHSCnqw<Xd_Rs@eE7TYR7@9|~d4Sv&K#dxnj-DiDEAAlvF zk&VWFRar<;J~5Ir`dc@39qnn(M}96|8q*otTCYQgk9RGM~Vd0CvN? zu)+kx8;nOAUcrJv$3SF~a*Y2QgcPdpu$D z9z0=n4S=#JMl_30-#Gc7;y8P9aa^S`uQ8vpY?43xe{JqEPN`| z;;QqnI_UIydJ4kY0_{~kwe%Ro1&lIE-#u{-5?ftx)lO#{2$xZWz5=tBd>>s)+i4=E zz&9v;7;4A4dcop(FRjaG*|x*1*K8k=bT9AZj^F95y)fi9z0tzmpb^i z8? z6^CRr0K^M@B{Ny-`ECIl4__bk``@x2E3Cwn7*pp;q3n#uE9*zJx~3s9y6aJ4Chj{u zLM6%n zsH~QDN$Iwg<)$N|YB3`+RM#4sPNu-$yILYs6I%OX)+fu9@cHtOpNU#1@9qktWpAaX7!7jjR5ZHJSA9~#Y(a8Rl`5tiUkmc4`N~Vr0OP0U%2U5nR~&)_1U`N2@1&E( zQe^%jn(ULRcye`;uC9`4EtX@@~!DSCq12OSItPgZ%?E5p{ zQHb>~#hucwyJ&7aX-0ya7)hM5XudDx83%rSKLU+Nq$wxSNR%WBi8PYC8^O?!ASV;a z4iXjaTEgSsFSk(UqdjO!i0LO&+{Mm+9mY6EZ~7t;W9<~= zo%|z_n|2H3B01T|Fm_(DfS=wYmYz{SQXSrmBLjYBRhK?DSN>q9!q0=0yVFqQGkGLy zvE&Bir*3}npy=e)bgYTZ-_P&Z21h0eIm8yC*||a3U6j9 z<@F5(s?R>zayshPms5*$jziMGg|nYcCPG2^yPD?2UavZq{Q8@Uzvi;|;3?GZrNObq zj;|9mT+yGJZ|DUql6!Wn18d0daqG3ld0ct2vS%m4?-8~yLdV}CYFz(jWgf}wvPl0kZ zlVfD6%Fj!wp{$?oGFU80j*2gv`X2tlR_RA2zby)R5o>BG9rI$;s5a|{{O`uFlUSpu zvAlJo)E?*q0p4DSlUVPq`~i33_mh)N*RK2)x!Q!P>IgR)(H(Czu*K?gL8>2rogvEm z#+dP}&jw}ZT>UGv0SA&^2vyLCp|dAQ&bj+H(H|WWdR0|lvb2bGc(f_k%KP>%C{C>T z!*D4}XOJ8yF8U=!nOUYXWnfMZ?FG)=?#r}ZhJ?7VaTo-GXbSxXqrZVqw=KOEc zvo7Wc|E=*mo5JJc$fhDu&!2bxQ3M2vGuPiSEvjdC*hw+m)hr5y;J6|&D| zy?JocMk_V=X7EhXmQnTp<_f@4w|%~tO#2aT$Q}f$(5pi%1;E5^ud4G*e_5ecWYRGoJCBZL4)=vraxqdn4IcK z+|R&t9p|{PR5E?nRpG8=T5o2C(UN7{{~S2Mem!&^Z`7P6S_oNJ-kOA6?o5va-ZGadf`47Ej&jlzT4Ng!#(8}zv~=6 ze4t>7Au~*@Y4|F`UvlAZ()+cN1$-S=+p|GT{+&@8{$P#w$fP#mlY50#M82<>@p-`YF$peau_j9EJ(5ukNJYAxRqRz0xI5SrlMZoWf za?htuzfV6Or*_x2RTDm55K+f#2erDmS5tYMhL_~{+_)shj}}nmNCM?mJ5Co- zQb~BX-e_Wp)!#DW=18x}bcK?v_*eo3*tX;nO?R>}Ws{C$y_g-7J&P{#u`94yQ#%C| z_-9C+<+{9#tZI3pN`{P1U&}(If2Do0l%^(ZapCFQ)gmf=8I7iJ-YZXFp!s);2--c` zlI*81AfZ?ck2X({ill~Yr6!d5@u4^ALRTZXwqf#mY$RL|A%%Q+iE8&F{Aw_kpZ)L- z4_!e05e{@fgJp7B02qv(M0lm+r<{3%i};%o_ILQ?k+N*86toos_uoAKDHn5Zd$gTd z;&Vn(*a~W*vKG(l_vy`A%8TcLRaAAJ*b`dpi}2RgP^}9z*kE+^MF|Fn$Anw_u_3ha zD<}e*VoP8v(K3C?=vihGHBI@l{e%z;Vx_4*GGBU1nuN97tJJArRlgXL&>sv(vw5L&lSj+V>?1gjvFnA-OrHBP*ony`HZe>F zP&R_o63~3Hvl8(pa#He$BShyUkLGi)QxZo@IcFq~#%g}`xWQGfS6`OHQ>puWPDdV{ ziPK(oHu7IQQ0?cxm!y?6_NNsW3zcS08Wxq^V^%~%{@lGCStjB&)EyHb2k=O}s9Q0j4a~T+h8N%`#HAsN* zIx>pnDiKOUMPR{ULzHW!%MS90-~))ugI2{+7Bv6UXwfWY>J`@fq_x0kwQ%YGWYv?S z9P!}4rmCxZW>_H}7yL=bAMEA1e9E+%yIQXB=jIhMFR%}ebVLb?R(+A2U#N?UvPB9C zzgU@ESPeM-BH1X;M7I6j=9p!4w z3xZ&X*&-6I1&V$ua3ve#6ROoR`IVK>9uhl@PKDCz{K%j%?EFIe>DnyoPZ|ki%K)EG zm`xprc;%o4YglB$Ish(J9ZJb&*JkNTYSU($g9sI;MTw#`s?E7_(dDq<4{QUv9@Gz^ zBh;qZeq_2(I^~<<3}uQzGn*S>M-7$z_NvLbz~v(&guH(eP`MRvZNQX(YKWEzy{!T# z13B0rU2Ye;3tJ}VlmiS@?a<_g)Me^2>q*}1!U}?3zl))5cH?v4*{>Yv67*`}UgB0l zELJbE*OFXUs1?t2%KGV_ ziM?9(U;m)hV3OGz_V^cHeYC}^1R#`RLGp{T14-*Zk(@8TzLtyK}<;2CCMp9isNqtk#(B3+Jen35H1d!gXB~%Ow$O z`E2nFbXST*8ty@@C0#KFk32lq5yLGf8i{7p1xkh!ZCiCuO8p~bPQPE!>|+d5N+`vS zp>ki}Y099h_)5K>eO|s@lT{XEPS?!|%n2;?U+m-qEdPCtEFv{=DY#C-v6VR! z!m4Se`Y8!DzN(yjn@ypkU_I5~sxx-%Zi1Gx0wN|%02UH6!(HesH1R)i=no2eLl4Db zRHk47NN<-ERc#J4Xys{&Tk9{FU}0=X*C%(z zPcF118*i=!3PA*fBf`L26<{Bbg^kwb?nZafibcI~DjbYMZ*s*$z%pX!flO#A5v&xe z465MtmJzadAPT`L059;35s|04Dk-Hzv!OUAqPQ8Sfb8!Xk^a5{VC1a%ELGU@shH zQ3>j|@9JRNoPT}@TA5nDy@8Q77AhHE;T_;uqIc<2w~ai}8I`8uI2VoS+k)9ee)>-?JM6Z9mXF(u!O)w|^Gj*t*uV@3lI8$4>-|k8 ztt;yA5rO=Y9@1Sp2^Xvb2j#tsP|+K2hf_{y zZNsPlV*ph#v~b$Hxa|-Pkk7*6f%hrK_wmdbu$s~5y_^0-vW-lr3omAt? z8r{_3xf6`j#$7!er?1ue#E`wqdStz3k;Z6|J5PdhD8W0|@Vt#kowrfbSidx<+X4Q9 z^CEa?qZo!(E!Ax#VBTIx7|PBj4;qp;!q)lc+BdurQ-?S9_Lz;?zxceEJ%1yP&EH7H z!{@=O`IA>M-;YNc2szVn`tP1}SE_k3Ff5&eHN{@3DZ_uY{Tmf%K8{^W0*thgz$IYB z){w|K5Zx#r5shex8rRj2W5+1#lE9Hq$RYpq;-wnG@7%MG7g!@R`e=*0i!S+bL<63n zsh5eQ-9}>fRAJmNo61Df$Sr=pP?&-xaL?*py?eDe?Jz#MWc?orzqc^clPPXStV^zvmazSC78E z1EM`$Q7W($(l+7Vqd0A|GQ?`%Y^dC54nC>$=*3cj6pzhOPQ4V~{z> zyBcRhs)7D*bov3(I%!dNb@uhu0|u!mM}+>M!sGzk+JmksA=i#+ex)=c1d*Ufm620A zbq6!epDc%9NdD)vz;tHyvGa!ek4xYxqI=poDFg%k-yCZ;V1EvEVJ7ZZj;tL=W#Fk% zeiEF>LdC*ZSs&lkxvqC*G!DkmY{1`t$Dzz`YlIfsY2kV+7RGL#GtC9I;wahfWad%8 zwAB`eRg zpc9{sH@2RWKt!NpL*ySzv_?RI6uvkqg_^y7EbYZx^M2_rDhm$vn|y~6!c4;wKfNK` zRc!&(z{?GX2!7hN2Rx8z2^Cjj*9lkQ}6b0(0V*jC=bPv zGx8(C*e9T;figN5R8Bv}txZbH3W+dQBqt0&C4czWIp=km@z-Z3=A`7=oDP*;N*%eo zl1O?9)Aou(v{}B4vh-5hYd^wWg!7EU<#hI28RVz_goT9!;vtrB31!V&$zgYW*~Je0 zH-J!DiuYfLa3=*9!Ub*udAt8rguasIg6*_+ZK$r4<46!zlAMh0@$OFHqaco!frD{D z%4*+T#+0m1pyLSJaYq}23n)YhAn?tO3?_SRx36gwyI^EvPKY7i{@ebvzO?!8N6X3^ z2?kF3Is5H%X%o`szu)NfEx;SNB2L@|u?>eW0tp~+=F1Eg>pN)WpNNl)I8tG0dVB-Hg;c2qQcG#U!4VmT=ky3H{S0BZK%xz`?tL8-3$TA(e33h|7 zAc)d5BEUm82%v@A^OURkzlYQeNOF%9dC}JX0UK12~Y)8L~=frS9 zCdiWmmK+R>kv8H)_t&it0R2Gcz3%NK5C#LpxPY73fUU8n8_grHW6t9s41xRx55gex znv%Uoew7QtAp9DkQ|@43j3`YmIu!L2SF(Q#2F3)nOe3IUkUxm0-eB`Z6eJ@KAz;`Y z<^C8LQ;!q#gptC7QJ+L043b53s^|g+#xQJ-kgI6OrBiYc1_=fd$pai{R~I41#>OQ^ zQE)^m#n7}n!|+V(2zA0{UW!(`a&~z_B%sxp!~G{+FjA-4!Nq8=%E+6BbRvh5fU!>_3O? zb?M0S@X(zDG1%#)*q{lQz=$6T{@;>*L@OEYFDBSMj6lGj4cz{!LKd@tXbQRa z^GM4Bs{&}<_~Q-Ps#Ud<-GcBRHnm_Bw)@Q^cO!xh%#W3pPm{*xOf`rj4(~6;#6)At zM!(R8tm*4pEW`j9?Y7Z!AUYZ>+kf<)=IEpt8Q^Ttd%=XPR-6zEgOoQ2RYS(Y7%9(a z1uKCc7uJMQAs8f$3Gp|G5IO$o@uI{aW9zlV{AqL%2xBM@s8kafBw{44)Q=5ePJepj zoF4n?ugWX^a~7I2XO_5QCs!2pYb;qkW>c#$FP+^f5!Y&(yaGFZ z!bw&nEVZ{6tavRr8=gB;NqqyDbNe%*hXad<2IGV>Pj{G!f}(sA9pZxNfgaa^Dz*aR zLUfG)5%=@IQ>qHl@DHqTV5ybA;;(e!>)`!2OW1tCSG@Xh?k&1$p!QQwuGP;qiDCT3 z{@qkxCe6&tJRA4t#<+f(VTa;iTSm*Theq*9$SGV=!1Ayj?}DcX8?ongR>l3Q%gN& zYyW`rVV2nDloi%bPfJsLn+nSCcgQNA=K<25&yatUMv~%-->l1JI~xnF9MHcq=Dj)M zi?=*is)E~pw4S5&XJUQ0BL4#aRGFD&C3-Nfklgj-`!93$F|C1Kz;!qhajn7rt4PyO}>&5{20e*f4VpU)tGL_OpE_dxmY(x)~%CWB>-*7i%AH#}x{Zivxv@%u~ zZ!)KcS@ECz7%f}6g}-bC0#aM1wSUi36=$}6FCaIlw4?a2>C!hi9;d|@GEJz+vJ=|1 zcLv`rX%z%z*{96#{yuA0BQ!o;1Eq|torWOSHkyAxC60)*xsr{VoY!s(m=f-C4CiX40w7gYluatG=$7_aOKK#o+6+o4C9vecIMKy-D!Dgf`WC zG)5?TJ=N`R-~fG{6aD35rx)i1=Lo`HsSyOV8XMoNG7Ke zOcAizRX7J~fZbKVDS>tzRw@fUdSpwq$|>n{1M2K znU^|@5!8oX$~cpWC=EH`S~s;^dJcrgP4<8^5by^%luy2jngPJil zIyWe|MVwmGj7!J4n-_k{0sQt@O={bXSqqgvI>O1I*t71=53p=D7EnrWt$!$9^3Ts+ zqUJ0qO#U9L48{_0!AjgGO9Pcac`a87xB;^Lb^i#SOjo*Ksl7wVkXY0_kys4v`@jLk zdgSSG%9AI^k5^F$gKU3>Jb8><`w=;C$A)86Gdd{qh#+S*5V;L|jScx1?RaoKmp~B+ zuxmszjcgJ|)}MJK+O#Dmz7c3CGl`|9J)dNhiH&_I5QPG!9-kO;Jhm+q<08>*7`+0h z!fcalQZq4Lp6J6dq#99f;;x|V78edBb~@xL4-PWsV}cw7!mFoS!#9EORr7bo-UGzi~~z3*7d;=5zbLx zUW+h7mAZ7q=E##=8`w@Wh$JGDmP#?s_ibWQjUWT_Q((V<`=HO!)n4!DAOS(>%PI&F zgENU3*|!j&^DE4=}LKcx1zIm1_LNl>~Gw09MMOJ5nqtMh@JOW@`_8cIY#t^P74ILBJ@8 zLVX7?g^)>DB)mps62po0#6|20_BWUWTi`}`h&#r;AR#0Zsg$%_v{Upg871eFcZhEV zf`O{Q`oK}iVaZ#Hf>KS{NO_AyBGZwJ$ZJ%L7NJAvIVyo_r4CT{Q~#z#(Q0VxXt(Jo zJ&V4LexJc-GVivR3kolq#*3 zE|#8?`N{aQC|R3qm+V)0h`dC;P<}~4QKTv6E4~k622}*@4)zcB1&;)OqZBJ^lWk_Rnn+Er=78o;ZHjiP_NtDjYtbFm{TG=OIWO{N6e%hrYH8GiXf)azy*&C+j3uTc zW^>GsvBcQw*!8iG;uLXZaXaI|_{{iq@sIT?eW!kv{;d9G0xcmvVS2*ugx86h#EQh_ ziB}W<2#euRAu_y^ixW7%7m0{DSw-- z<|XEv7L3JX>9lONJhV!zrPg8V_cpT4VVi9`Z-?v-`waV64$zV0nBh3#_{Uk}Jm&m6 zRhQbCde#--N_CC89;F4RwWl3&2e`A{2hvIDdFk6dkSE-e=b7(0;q~*zd7HgkykGlR zzAWDi-x1$`8BrM%GQP-onHii}k~x<7eU><@HtS4wWcKpxe{-61?&OkkZMpMvkLKRX zBj%a&n)6oXUC$55kIk>hUy=W;fKyOfu&Urj!H2@&!p6emMckr_qNzn&i*6MA6^9pR z7H=xPS0X8CFWFxjQ0gpQQ~FC8woFsjP`146Y}r5Mk>zFO%ge7;U@IakCRVJhxL!%A zEUp}_yj4Z4GFQ#4I#^7+B({fw|#66Z!d3O)_$)ew4;sQK_oje%I^WZ{JJb)c5^wKG>e1{qX0HhJCbttI5YhTif}xPsX>0(1&;Qt6sSs zfBUUGaF==acw*drlD0G1@4>By(GUMMN7CGxgXsb64KNM-=`PChcf*&9FzR*m_BLDenqt~_$F`)ic-ti2gWPaS>T$5V8_slVjd<$m+b7-S4)9p87n-bW{d zpYQkin@(KzJJ*mPKbU*+)X9$_*~FnYoOkN*sRv=#&X3Zgf#|c-Cr^KhEn-Jp7cV(; z^vs7umB^E> z5FiXj_)vicv|#{?uC}h4SHr8mmCeqB^G5xZrzWk{ejTop>b$!Af_w2c)0xe3tjl&@ z`~A`@Zt+U*_WAWfy;^@!zqx#SGq$oe<@$r`@^$;VfBpZv`1bn1t6%&qy!lr*^^)Gy zXWx2sOS(0?{^HZ|+-bhZjZeQb?)*9V+V}nJF>&;drFZw!Tc&$1z4z7Z^;NpY?*BP| ze|PN}4{kruALR4N+?$j65WEPD$Y_jX4_AwD!B6i)yLlBe3>uvg2PYm`A^UO<7dCS`6089fq?+HGmYQR&KQZy9Ems39ldk@K%h55 z>(ZJH+3e(R!0&0|L3K9lRgT=({XoAv=UpelN7vuGYZg>>wliPX7LvzW4W5=|o@T-Ie&5qv8{THT)oa z0~W`D|DQxj=6a#$f;Vj0oikVrJi1j5*Oys1tQ$XE&ZnB}dg$!DTBl>g-{$ke2J3S} z%(ZRqw|f~xDlbbZmE8MO$2wTHalNnacS&B9fib{dfpK6I%=|7B5%Jr$aBOaJHdbKX zRw9A#dOg-dPO)}yt1vK9FwMTCl!<}a;ROL8{7coJ&d1B_Dl>$oZx@_$N?w2zO^9`(g^trj3vbE@`@)Wp-D;KV9M7k~L*cYbtPQ{!)ZHOKwxl zT?-Lftr~YcGw83SxNs&!Hg5@LHXVxYutU^YnU7Edk9_8QR3Y%BH6~bRHR_j`k|o#4 zyjr;1@xO)vpHi-f7b9iYH13351Rf#X8^3O~BxV^xba~q+{wM)^(ltIpZ8lDAH#aO~_{pK9nfU# zq~Tj2tC)y_twQT*nhxi&>6=!rC2IZqJtFan@WzN`*E6xGdaVmZM&)SEJ_y`u^A`^tXG5(1-oKXJWGy+VPbt{G4A}q@zLqEr z5K<%OnhaNp-j5{K;}~;Y@kLtI-@D1_=~ZYEDo(ko8Um|T-BVf5!|?sDRc?0RjS<(t z@bJ--OYbyi47FMX2T}R7bCS{$#$5P-d5jGwyb*_)Q*hq;Zy(X})9cr-t*H%~udMU6 z8CMr1S;v%O^!zjtr0)Ak)f|AvV6kW1PVP=MCMVem4=VY2+!t;kY3zuZ#^in9+%lWG zE=)FCmEq;(r$aZ*({voP&(?%Gm}b4N^f_~ z===WuKDk>yx|KD3_d=kkvoWhwXM6mj1l@19!sOg&U z)|y$_YII=iYP7|Dd57T5A6b|O>J8H9et*!DsukLLVHl@r>~*Zfbrhz`ei{pmLm^1EOGq0DmQ2L1Zednc zG}kfOizDSdz=weMh^piMqxU1`CR@Z-d^e${W#^uhJyb7>X1SB$mlVM8t#a4Q(s!!O09T$Nf&bBwnQxiZW9}{ z!AYq&{sk`c|Hln_joROWZZ_DCYPKTZRJZyvE843yZvWb!sw$An=lBbSbdfQazT4Lw zd!MZBaaIm~9k652+544!DzqBMaXJlkS2tdog<~Y;*V_95!J`gNV9`fB{%TY@5qH<+q^+076C`o=Sid@!UL0;?qKSn^aE#h*Vu~(){b_?Fz5* zZN(7IvbS+7gRG;{riq*t@vNONN$b<`Um%3MA6GCvFNrGVYqt`krqknb>3s-HAhU3J zH8!uffdcFK{3GU`a-AC7m z{z)Wx9C}}<)=xA(%sTw2&s+63Qtw>_it+~$7}(fU-+c+8Ql5zKVDe-71BS+A_!jPZ zz9bDo%45JvCLm*YAyPfx=MMP8Dj=$_>#mwJE3Hm2L07$`-~`Gu2y43QjJRD49Rh?a z>UVFLSzqyu_qtl^FicabzxP-3kYQZMceL1HHbP)a1jG2uYso6g?CGKdgQ|^(uQ8|= z{mu@y{1o|r?5d-!p9Brz@Vp&3x(buqFgm~Oy#EDhn8t5+4Cgf!%I^YR8yP8-yRE=^ zT%$A9zf{m5)7cJqg37GiP`neha244X?JfG=0i8N==NY%q1^4lz5R4q7qJm>R%PYpx zuO2>s{^G@P^?g94qpi7(xWgzR6L{1=@WH2rmE3}jKYnxfLL;Q|F3f+9w^x4wkBhf> zhW`a878yPsFvT@mNqA5&bydp4D5WyWx)kE6n?Y6z|E>k`#Xp9trp?=%Zi@g&D8TR2 zW6bkoJdg^u)teL^zBeCW=Ocf5bBU=+?52Gfo31TYFv~3UMIg)F6cKQBqO`7R;ANIZ zCobDxeCB8O2q|Y#)&{wmeU`q+Clv(sng)0JVdxd(MyHO#s$B`q3Qce#55V|8g8MsEO|?e@Wszl{O!RjBHwG`q4l zyDer2T&-J{Xyk>3GqXbk0;8}PaXLz=Lyg^jSm`ZWU>lDK%p>-uPd( zZ~07v;oBae%YuDlDn%2kC$zA;K3?}Sk~+FZT2F{TEJE7|hKOqINghpkch zvamB%x7B)j^7zzxXV!F=Jv5W&k(DY&O66x`DOzo0poZs}>waxovy;mG127U7*sXl#~m2 zxp`PU{W@mv>zZ6Ue{Md8VeDdZT7)nQeB4u6YM|?~c@;@=8fiG;6OuE1)}xy8%7Wqw zZm$m{drnFdoY2VA-BcM_4-6wRR>hR-9;seZt{WpF53@L*hIsH{+j~|8H9^DbRUW%o z?5DX>N_B2jNAMW2W*#_Kgl0MDx(UwDTgJmicH=IHblVi#v3d^kR1H@(vgB@>QB6b2 zG7B2txNb`~w}x~o{cD51nGK z5>61>|AW<3B1Z^9ajZ7^9Z_M+@@FIGGLQ(5P#vr7B_FoUG$x1E7vndoEi1W;3BiQv zD4zt6(_w~;HNyP}`q-X})8|uG%}X5^JA5u_hUBB#jZ<~j3*>9?ZtD3)j*l6K``{(( zH2wx}iML$8@Y_)1dD9eg&U=jaToecw!bl}~)%O`c9563*pUu?3pvihWV1utYjtiF7 zCQ@kT`KTs`7?Kgb{75Jd_kjuDqai+)m@*{-)|hk5l`5++;oWqxF1IW!AvatJp+Y~7 zZLd)=W}t$9nh)IH$c0^!B$J zY%Vuou~*yZ0K)0Z9b~86s(_%@Ms1W^?9)N9e%-d^SLVh=$7;#qN_vNh{In5|4xAC) zU2aMamA6u<^3kCqm+WlRVpa4uL|l0(Y}Z?@Y5<3>oJ}w)mBep#>hr@!^XPZFd3Kqn z0anHUdpb{1AP59<*in(NsjHEQ*D|v2Qm4wT$_GmuHtHxwsSdPCO89^*wC&UjCwl*i zC{Pr;wB6hws>TjNyQ|MZRSS!qjd8|ElVvHtM9FV>^l;4HB!J%@( z!Z~BkMG1FxiZSMvyS9k};#IT`c*Snq!QNYD=B>(-CvAMrS@13(MROX**k3E`xzkDT z$p1CgKC4{4KIaR9Ra-@S3|Emcs_Qt`?cLbyOhmjs@Ef=|cg67Hs#2J;Vbp9|j8{%w zj8$7xR7|@R$q2oO@9V*Q1S_7TQV84nTj%R32nuCWo2tXC_c$+IeA%O8T!bH7T38Xz z+9j@TU&cl&zeXn_p}km~OWB0|*675V&8TB(2w2MJN<`3EwNk1mcDklfR?30V9J+# z>%-5`kByzZR28_nPfxEMH92}ox#LPo)eY0I{#4ppT4ToaXhDiv7lwr=Z zrcL+jX6NvX{Y;9g|AN(mcB@Q+5G^ADXZeFy|2cl0K5>9s<`iZ#96J3c@?)}-XDcfh z2&@s6PuLP!b1K5YT1g&bS=Em)!9fTip~`yiq6~$PU7YS4Ek1?rD~oL|MaZyvo>vst z9MAC1`MaBLCwBR9xob0bC3dMMzt^`vBMTZ%$0- zi5fnOfde}G-adCX>=-99d}%{lhR^Lg|-0kbW#R~)~ z&2mCyS?3iQJ`*Krm%Q}}2m?w6Afw3_fx|B0chx+d+L`Y8+N_s{jM!=W+2Sh35EFj- z|8Kwd-$pA@OJIz$R6erm4oMIu{7aF16Gdwii_yAUb|?1ma^s6pei{e4&7?CNBjWf9 z$Oz9Q>X zMwj4`r^tdQSiP#xeY`8nrfSpPkeo%*;HqO%^3^+qHCjt zjJ`CyE}cd-IrP#Ap<#xH_Q}w2VgBNx4_z(Vx%w3PRZ5SIJ48F~Q!09x>Yd_scRa~Y z5L!!p|8Pj{0#?GWRqDectSXVa-1OcrQZ_@Ak(*;jr~}eep899FUb3rM8I9C1D&M7z zyc=*{WyE+o43C+O?7%5*`D%tpd3+W}I(r6TR{jd(sGRQmo$d8yHYYyxRg002J}I&$ z=k+r#2i9GTIM)}S{tH#76viB55Og-q@J{OTy{!{xhW}2vFn$AfwH4RRH-*-x<2ap%)TKrS4=~<;uM_Qz zpN~^b?~LV{37;hCiM!^^nrY~oAJp2)!o9r8mJ)d^6KToxocD5 z=1To)@@xz)V^D?4TqzJRQS;koA3$mkAtUkY#{=MYT#a2L7B7_Mp&-1k+f=3IRe|uy zi++lKvtWC`%fq8{Qg4Nz!X$=22}`1TvdOL^b*Nc4 z4CZ%-3enLK3S>aWuNaQi@}1QkbDa_D`^Qrc_ijAQMx=}8XDJ3`Z(7NvPN3Rjgh160 z>6&RA&o|^4+lWNhQV|M#SdPZ}B#Uf!GiforkNT)hpItp7)1&pxr9a~y*kd3s0M|MSpT8X#XaruS@r z%0FpuI=#?D6S&$u_P}oX)pV9N&7`FBbY_;PnLOA)yvbcl)_n-E_sTXO8-}xCJ%z;- z@-&ebE90D();K6p4m0y%Q6|SPPm_7D%C7M~C`7#MoUp+P>3*ScA!TIIZ`MzVvX0B) z(ZfTsMlGQ=TDf;-NN$*jpGl|A&*1u%t+ACr`uiqqsY@(gK6Z!(`PirNWqP*!GfL{4 zm3^NapG`HTFV)>qUl)hr{W}o8UEX<$HB4%OKqgl>`kj0Pg3iV1`O?>O9f((Q%A`YR zYnlldr9?6*)aaE=x0hpmxg&*<{XC5&EM7MU$|Rx;4!1NBd^kU$ebeBzGqX!xs5d$) z=`!xA5Hb56WCd2@BA>DS!`O}iLzT{`hr`8mDR0(YV2*1T7PfNlaoxlFI*0-58>I_S zk7E&HL;+(72pH%3k&#H<3-q<#`JJ-+`Ca!{Ln5jpJ%AuRwV>)eC92bOIRs6CL{W=kWj$q8PE7<(K?BU7Nrw0iZt{FUsxii4) zL+B3J+}e_wqSF38t=m-n^rcU3F~O}rcTUR&Q}bc7q0}(9Oq|V;k|A;#@XNm~yU|IO zTjeeN=nmni)13Hdh2%LR(V-l@(H!ORN(_Jtqq?eVu^Q%Sh|BQJaE?94#07&eOjK31 zh40bH^Q}C#UDJcg)2$8P1?@O3Ouy7!HGg+GXDhGgG{PA0wtaq0rA!m`stlVMJ`V2~ zy-kZwp8;A$sfzQNdH8Zx&(pW`dJ( zM!y&{&+zG=|MoxIc;3bbMf*ELeEDn=CyDc)PVmY9#z^`;yM6wReoc=b{<;1!etG>n zg>ZG^h-H+rCM>VdtwO?U#^J}&crbcA&W{8ec3~7`wEWacf4v=rkSf4*g1GuUA!l6D7v9R85flA=hLqc#y@9G2581qMl8@z=nqe; z?j|Iif6~ra2w%mU(xoAqvK4#&3=pkW+`?$_DD}0{Q#P!+<`lG)>1Xa0Lg;Oa>L0@~ z#W<|5sFWT}2I2l0;iu;9eDaxl^_VxNb<3fJ9(M~kZ7#|c(<)eo_!)g|@?sOMPR6m~ zJTGHh8*={k)Zd220bFE7+*T8_A=-Y9NEJFwZ1=6P>mLtyTo)m=XNn9NDc{K9X=s?J ziZ(u_{TKFI7kAY}VVJ!xM*lC>s;Y6+)5Y6>Z=Jg>?EwYdFsqh~@o7D;S9q2R1L-_pW$COX zIm0w!)@)SUiVhQEQJ%IUM0^1u&9*Nsn27_mADZ$F^w+On(`c|+Dw{nhfxYVQRa^-E z9Rq19+>gpIO}4svXISr-jTB-@n3o<+{}kaLW4CHM&e*~_XsVb|qV;97zrDUmW0Kwm zqf`|!AjDzL@P&}-uFvnn!MHZVvcpbOJ%fQ6jxQM-hL|oJ4oe!U&}MFcIwAyAHz6Q(z!W@x)gAOnI!^AhG86t7oY2+25SpsG>2-KTM?WsG|7+(K3thHs;KzAUvR3d(lFT2B3NY~G@B z)jsRI*fDMo&z_a19&Kmrfc_Z>f`lOP-23(PSxvz9VY7H_+jd>wv~6RJeRAl^3|@URi+n%Mrnc)Z zbsrS|R%0f1fSq+h$=Ge3*KxlX>&%KL1ofY$aV2Z{vtClDt-<@!L!{c` zY-7If)<(_EWc2Loxy00bpuiT@VrUotRv~R>kJTDsLiWBWGW2= zHOc5*KeaEmdta*ky;ECf%8Qhvh~6c1xZsQLX^Tx?KB}r*waDMRc|-C?7K)a1Vc8G> z7x!Jtm{QJ36XJuMz6_sH;(^l-F`GA;aNAs8*LMG?DXd{=#4i_-2xAO^u*8D-pvj^T z{s^S+9y}mI&atW7Y-d@glylW)>l0#xW+cb|(OHrqnknq&@YKfAHjULJO2&e+BWTdR}7 zD#+=+Gz#E`DCwQ!dUyqIKWjl%Rx0U`bHqL@V{45z&a`-K^t85ZVYVoVF5n^uq0-7w z>RsXNwovRK6*KM?HC5Hi4s2jyAD+5z&X5mg;tRg1Kf1j8MA1{qc0}&VYN)gq0U*7MWG_^kyVPBEz*->zB6gsO3 z&5h%ReP1fM-fq>duWY#U76`LtC%*;nyQ_ufRK4e{oeS7MpX4^iy-(rZ+I0Qn$3LL8 z@qo8I#?ZxY41Q`tzH(yUr&h{xbM=xSZ7YpQ_SV6=Q`eVje?JF$Yw3N+TQxZ=vE;{O zP2@V!^-+4?M^}U{Z>XN0nB9!o)(?uNIXL+@(44hyu~fl@jK&&5pn68qazUPuOFT{? zcxpdI7C&|`+A84omJ%vfReXl)_CYr%1`FHvyq0i<7>ec5$PDY7jw;SxaDe-s-mEXO zhIUoI{iL%>ts*OAQH<73OYz;jh>bkjB{~ohZjHs-m~a2Viv5c`3g}8M1Y}X3VJGo? z<79hOcIC+xYYY~Ce~}2$MDDAMUkF>zo#bW#4NN>VMwLiGzUivdRSDW`z1Y+fL8SKS zQBOtmdX81jE&zA`S~&M#RI(mX2KA3J;$Y4-_^rZi-3nwb$PT>k|+ut za~_6iF}_+nv{y#37DQ(V3NR&^1nrSFRCYD*78BA&z?dKxo|j^PATefxNl<9D;um@)H%!uCE#K3 zt|vdGe3BO7pR)^$k*x4$6kN2*Eqv5kamtjIilYKlCApOT8|6wP9(!gRLTEo1k7e!0 z(6O*Jw8exRvJ&HJw*YT<1%f56Pg2vi!In{skzfE<%@t&Zfj1qV!Wo9J0TW&Yk6qj5 z&5q%urf?A)($D$s-kIST@|FhSq`U-7i~5C`&CQZCzGCy_ak#KeT2#~=nX+}uj?}Ve z^K@2Bjpb(AnMl{}KStjH&Hb1>8N9BseS^=>-`ci?4$e_AJn=#>IK6e8bT;Fk@#)E(flKWpIq zg$Sv1ZK-*y6AS@~ZwG_Qqhb!$`7GPruh#*;ZLQ!Ha2mv>=op%xy%+$U_@EslC`wb1 z74Cmt6w1)U>;=&1ESd+;cPwNON|IRDjflhg5un{xi5CW`K|ji4YBIrIdL`oY?la>C z`Jkz4VQ6r#Fo^IiWU#4I)*G@{o!xwPxJl!97@Md$;RFuuK+d~969Z=4S%U6#4^aaH z95$X1=p#3(86ROD8)kld285@~^9i~KflF#1;OhL8jIt%t6mr+G93IX)J#sfu6uk~U z+_v~TKs)Q!D818~kz0CuUcrn*5r98y*jr7BlC*Es!$j)2T zT*tmVBNn++`0|n{jjJ^x6|>3YCm_+&EkgT7DSWMCFa!>|nhz!ZnZM5LXs2+Gs+(?E z+--I?Ny#EoZCE4+hOKV7M=N}8N!u9hWWF0#(Z`C!2`%dAr+3%5gvCcL80Li3R7>Xj zX+C-eK@2f%fjA4ha=?~^#zUz!#5#w#GNy{+LM!#%=;?7|Zdwm7AU3AJLc4N-& zsiezVTW>AO2ZybsI-}OKTqM0$bupY>5MA||D=Ag2b}TF>8|IyY0FCd^W@jDU!J=&D z=q4vN&qyLofZ4KxOlZE^S{{MHkrMZsPy1o)_>Mk%{~M19rRTY+LS8V&g^~*x{j@J^TMY9~gFTC?(X8Q5(Hro#cvN31vc29&$9IiItC6SJE2-3c*lvjr)y@iFb z;b4q24&GtOZRvZRCt~FCU>4^XOV)W~C_$JK1BVm5R8e&nxGPsJ>hxM=j0(=2^q056 z6gOWpUXQP%`7`R7gk5+(c9`HRiThr-$_M{S5T{7X3tIxil6J#Kk93pU<3YL3%c!jV{Ei z5h7Gg3a`mhFN_LGSM=NyNve3$hrx|m9SZn_o7G0{5UDSaYwVhRw&q53NYS}iDa|gS zqDp_-pHeWPq@O0^wC}Zmd62_A6Y+kv_3+?hRed0D&5Q03mCXXxxcbz(XiLA~xvBy~7R6vP3UJRHeB{GMf`)RI09?B33HJ1*cv^P|G|FaoK&^CRL^sVhond z$K>fNS9?GLsNT8Q0wBEld@xq5OscVS*vqUJvTU;u;*05Mj~chUj$KcT1V%|s*D&I( zWYXnt6F@b$3}m!pIZrk#BxG@|w$9PLg%$+ZYK^W;kyX_S7YnbnW>Rf-=>`DB{+4(z zBOILLf=l@ZOq`NXo}=9**Uv%vdd&$DdrFq5^2KAt>*@f;$U0Z)s0>-tc?jMr?7qJ_ zrIhGkFHEW3L;2PQ=jx@9QuFNaT5=$U?*4JUCGT~rdp7ywFjBPGc?x=lCcPIkK~K_> zL0K>kjpRE-=>S$$%H3atK{b#L+sf{*L&u&}UJM#b5T?|kAQYnLRsk z^jUM)S*o4_%UxRLp}~eK{qNJw-6!8!nmPQSf41vR6GDicNiiqiA^*lVsY{(nIoqH+ z?iv2D5MnG<#PQWw#nLuAZ*PTFu30#^YaPtU(uTDPmByMWRGSwW*Y-MS4+mb?%NOP% zUx;1Xp*>qNuS9F1Xa)02qPa8EJX-F08-!r2)SAswyo*zs!r06OR+n`ZyrS$Noyo zv4$8bUP}yx-s{r|N?rn45@$Glo|5tDUIGNo3RgG-*|n+qh1kD#@9IndX+#6&T>6Ha zj}#4YYH?HbbyT=aH$CF-Z|rlPa>?yX#3OCHgGh_&&{qaSQ|P5N_TEbH^?JRm?b_py z7?p$3`tx_g%Xr&pYdT8C0ligq9Q~XRH)nP&q>oaE-l*aV^`|&3apO$Cwk)A_L#sUS z#8~JN3{G={xi{dFcU~?xzt2)$Rx7M?FdqDAOG%Z(9xMm1ovSCAb(aCYblG6l?RJI@ z2i%gG42h7Wv8llb7S`PUCI|s;n}x{ULV}lG*a!pR9Wz4*-PZ^Rv)f)mo5QgLhU9eU zjIA7!Yu~N=u3Jes0nW%N=v>_9qvXY=>-t0MQ-ObcX31k3C=MvVklaj(*yEb(W zB!Xh8Z8`)|zK)x`Qi8#~9RHA{koRqOJj}fVUyv-#vH~lMT3mw`UrKw9{Y^%?RaLbh z24^NB@N{GP@(m$i+A$Gy6$OAM>S-gxP&n^&+BpK4&^zO#fzIpCo_j`QdI!O|_PC^4 z?Q+QlKlW(vLjcKc%r;KPB#yjI1d=?<3)+Kt@2wE@3~M5yohMdY5U^9Dy02PKY#5>2 zPwm{%nnCa6ck=_zz8B8YoR{#2G~HX*bSSdwUwa`_kW`*~n|fLR2@IZdoS~9F-CF2! zx6@d=e*5C0^ltRE`OtZvxv~Pf zpzmvx4fimMOcMr}_%vMb^zIeryDt5i;n9rTH2s zZ>@PIA6Z&Q#d2t=vZ1#oTNGJn!{BxoEQjzCo`HT)cDfY46SVy!{g)8-F4INNWkccK z>$lRDvmUGi8A7Fdei(CEAr97u{2PPV1p)V>wCwkTZ709r-{~`ACFi2vnt;{zMGD7V z;(YIKrEP;e*bMS=P|YnM;m!c0-_>7NxBLlCM-5?nru%g3n{ZrYE`X{i1ywp@|y8c zuF3P)Hp{kQmJA!XFux^#>|IXzk#*yNEkay-1`N#2D=x)+I3QYTv_z7*jt_n>Z)UQ# zZx43z!S9G{cGIeLQc;{d9F7NI!GYc=NmaWhPxD-ApzA?v(o`4~FIdiP-EE#3OW;zl z&JfPd7*%sC6t->OHDy_~!!(V3l?wcv($eQ&_?kLzE~CVA9gAFDMa=}Qsk0BlFg7xK zSW;8AauV6=86q?Qy@?0uFOY-+C4a^OExX$V?u*clB1e^2RgOJD8uu zD`zDPpP6W)v{c5~PnkDeo$C->6)mE%dEs=R!q!D8Ee*gn!%r+e-7RXL31KifC3=*k z&}zdxjdOPrpklimnF}c~&?1ar*$V(M*^3R=oKWD_cf8TrH4w1)#% z8CsE+B4t11SL8PlG41~7D&7TYY7&aAI-Hqha?N_xwT1_M0Um-I!R0d>6W-|7=fpl{ew?kU3ojdxZ$E$aV)-zMAoZ^dO9yGbqv0WWqXp8+ ziGs^mdaLR9e4Ao=Xzb?H<~}@0GQxGL-b=6N`7qZiDzcUWq;*&6M!Td-s}^lr&fJh6 z?Cf%S_AEx@8M0aGZ9={p+5387s#CMR<|SCIZKX8*-|$0#8yFU_!_Sh`E*C%xT!$k~ z?vv2)YWNLw@4ZM34$6{oHXv3W<^%4W(C}mE8#obeOo^9xa(MdehVxMi@f$H$@&mZ& zzTQBjTrkuE-C<9~-4*ISO5TuP80a51Ifuv0n~@;VTJlRufp-3pET$XJt6FDL4?yv)8aI>-ZxoyKvNjTC{ z($JqbV<5Fbq8K^u>dZVEA3(is!tFMnXd4WO&}=diK`s`N0s=c!gk+B^Um_x_-B!?= zbXtlS4tH#f4xKy`Ey=AVgeuQzpB(OkViAUw;-N{=t{TYY#XclAKj*>lNf>bDw#qm% zoP8_}adcxZ#`#i!W*+uXt5%I6#zshPqZhRtP{P!M47cbMJ-gJr1Z7Fxbimm<3=_d9 z<((Vi>nCM>I*kKg-Hcj3oiDX{3Q%dFikExwhtr}_W{a0$%;7y zOj@uCIzvcqs;RyeOifA+2Idf)i)@FpO z={R1M2l9oxaD9kMJ|5m>y*BVWK3d*S$ncildGzXKTsX7NE=KPBXj{jyB`Iz#+*Uq0 z&Su&(C0VgSR2^0CUui6#j*ve5YO1wRb^PG}?B-c_T%|M?3PeGO)$CEIjm?q`w75LdnEa4(U_Do8c9Jhj6Kd+-E}kd-&MFvT1WLfvAHQDoQ$lf+9N z!h-bEBM_aKkFn}fsgmS#R#61%gr%!b>+4w9SG{29obL1?@`>Hl+qsNIV1}Qiv1;sg z+E56+!1t)5a& z0yQPfr?rYOEbhW=xCz7d9TgY!DW-%ZZ&rmub7+mNZYZc?dB*1Evjnrcc27(Q;&*oAFFL-g3n1+yC$;-EZ8KjKu~91 zQgyW%wYB^b_d^Sz>kDO>=_#ryOIFW}>AnP!15@e%V#t`YkJaGTPMD z%VYnw_5S*o|B-j^@5AV2`r^eEX#-VgCfSLUfdf|_MPWzdqV_E(1&-ux93^B&lVF$1 z#6xm>KCp;X92$+F1&`OLSq^V;!ObBAfUL*yjT1%ru=ToO%<14y+HShGZM#F0SNU`f zdD#hP+S90zsiE_MU8PHczVb}=R}w~8g`UEu`hz9P<8gWVX$xFerc{!ucTU~LR=Jnw zRZ2=8AOuKO=aOTyCtd=n=u`Ey@2@L(J8{vy}88) zd1uPq;ri?_Lr83t?kI_0s($pr!w0<14&eP5*3CYR(OFCJQ*@7Lg>CU_;``~1Dn)Xb zDo#l@dK2XKPv=X*esC_8Xbz1`f~;YCiktrS{)|+#t$U3z!9S?OqQ2*o;{+3eVQz27 z-1|pL3%O7|_VKH{x&%7wH>CbBE9w%uf14)&Sqh@#p60DK4Uq)YKRo7!UsUvB4H6oPU`~EYN z>%PN8qOv~OME!Hv2A?a9-|Meh454uaxLZzo?7uX-+SK*nXzV6CGuiH>F(wTt0aoJBy!cdNEcHPb(JXjMT2+K#}~0P zWwmHKYI=FteNJ!t^UcjPHmOnCBur6)EYBOam0G}n3Egu0;@I8GGhLl|htNt{1Fw!? zRPFQwnDZUiYSM~U=LB6HlZB`n%O7Xi*ref)z_EjGe&GUE)0$kd)`{TH7byUg8Ps_7 zY&#m<<_b5sAEX<@&q)Xa8b@fLv00Efllew?4n*xlgWIkK->Hnrs?U(@{Fw3=Z7w3v zfQFFx@VCDu`FYRuPA3yC{X}wNzpg5}&?($Ncm4YHQsL~{hL`FVV)0+I)b;GUZqd|3 z8igBtC=(LZbFL3DL+qxo>DrsCA0c9-SMK7l_6&_YWNntI@RRjrm_CHxd9p;)&18;y zvzebhCCpLHp?8$6PkD}$$9Op`fBaF(k1fuS8wHd&HjD4PhYgEtrP9-4!l&Q9$Py?C z5i;%~IQz(e*S;DXMM|qMmkaJ5(bgE3nt3{3Zf?4eQi{QBX(U4Fti}$b<=x&MMfm1k z+x30&b^1}P+a?>ci@}8q1hruTMuShLPaLdnkVW% zZ5O!Wml+H(llTX#!?YAR8@#yv9q`Rt={>`<59B85K>yX z`QekBoAdE-JRVM$(~z=n6k{$_@DF*$R`%bCUgj>^+JC^5N<)+hu$3+dLgUAuhy%5}bxM|-W=BV-BY`w4 ztHV4^jjHlQh@`0Udzoc*s&$si`MZzrg7>|_tkt2(!MLR0LHiVeGjE7$7coA*z9Sl?Y78>C z^Y$PVeUz$xlr$714n)G4^Hd{pXQ^z*zG>@RV%xl~t4hm~x;mU5$KIW6b$wi3CUe#5 z^>>#lPN&QLjflYGyxGIG<`++UtLC=>Dh$SKC&ZKOlE-e*Z+LNpmM?!Neaot2Mk(@j zQ6Ar%yqqt$>jSR^vutbnUI~m-LX_9b^~c!*Zx(6nk<*fw*a_`b)wF=keU`66J*t?3 zi>Fn0f4TQLhmJ?}OzpoVm$Q%7wJ0qMU8DpOKrsn)(-g;tMP%=A-x&s9f0~Z<#?~p$ zeDN@6+Mbzy6`v-|kkC$aSV}i8U;eQKT)@;9MP9UruKqtW74Tv|w4+=u&R#IHD2 zy%ps$&t>ZE7M`nl{|LJ>4mhLEuA{`Vd=~rF^{ZDa=B!WG;N@`LLg_12PpUCP)n%2I=3z>l1M~JT;R1@F<67pvrah7L>otO zxX2AklFD8;oEuwT@pLiR@l>X0FUM3Mvj&ezjh_nHj_VehO%9A>1*z;8)C!6$&#St~ zBd^F_L5pa**#16vfD_raYB1vtL=Nq<6y+%tsW64u@em6ah1B@;Dub)#Drp) zP0MAPdiWvIu4K1^|Dg)0^L{%!xumkvI!{yVr3V#21I%6TT|1&Z_d2(2q8yGm2 zVuJNK`gr>gm*qN12v@{23IYQ!$wEK2R1XFbVRL^tj5!edpM#M`z)&FL?$Z~HU@8i3 zdXR!1bkAZ~9BbFeCRsm>8ko1y`~!j-btx88)VHB~!w!gd-)0=CJ(%KvAHkWgMnWt8hQVHTt0CR5Yp z>W12GZ!PU_0FJiz7yZ~Nm4)sJcVG(e@tJb8)3lCi-BP%TWd!0ao=v^MjrwL?%#pB* z>>*a7Vk?Ul+E~0KX{NNa|7-7J_xu2}f=J^=Pg)0nbe&X z;$-}owJrP6!}(eM)#I*DU6;<>|88){M~FiX%~W0fef?%Eh`6pN79#b+BFn$ugjR3D zB6ge09o;qO&(C$Hx7SRwdVeL5d>l2+~_61vi$qqn|h^)cd7gnhS@j6 z{bfmfHh=3V3bXwCMW`b8)`*6)7=Ip#F17NXASi-}?pkW^aOxmbb8^@+N!)f{aCv$t z94G99gyFgTdocFHcbHGthVqHp6A*CK1?R+e(*1!`e_8#L#CA;`fVNdz#|PD7Ijj1X zN(3tWpwC?Vlb!1nE1?h5(=m@v7^Ca%(2OOg;Avia;*94r77)2AfD(w?No?7pWJJkde zT_s?xes0Rl*FqxCtw*`wYRxd|F}eM6NOA9dzH7hGq>6EWfh~MA!h5J=^1-o9L&;#+D zEYSGwZq!uy#esaRZVNqn%EXsFzBU$rdGzlHQKgFSJt8HtJ^?AhdJ0Q>mpyjK{z>rN z`uS+wj!A|=VO~;be9}1PC958Ok~y$UjZXdFtLmLk0w8m^*dta3cQmMw*KcS$XIJv9 z$3o1dT%QaR`#F3*CsRQcW2N;eO}eK2Lpg`uCzkbM{CVCA>ch4qOgiQgb3FUw?jd+L z9Yw43W{xQ8NHLn|-GS^r1r0`2su0?491Rsh3XYco}0O2u8g0Jf|fA_A_x`6*45i@s9+c8V4>2JCl{z81hU zILy(fouX-Pd3|Y$5=S(07RDoNAD}3e_F)_kmvge?!_Vj><%rUkm@-;xO2A}f+k%6t zfs$+x&5dTfyNvIT-5uq1s0L@S6oNPB;eN5<_YK6A6ow(^c>j=F&aQg!059~kls>W6 zDq3%2J)cJN#~(Cx)4#ag&=#rDa4{~U;Ga1dxok@cCdJx@m6DdV&#jHB<7w#I?&d$8 z5%Zx)`@vKx27mBsO5-PJlnvA(vn|B}A{gdfn)e2Q3^4!46oZ{ZzcP8AG4M1Ok|rul zE%aMWRrs_EOVE6eqL6(rbq`Y-rY#NVjQvr+jIBC=QtF=niEqccUQv!19* z;kjG-Dp)(GXogh-QtfAQE>#N6(O9=MNShCafCl|~>J97jajc7H|K=cg$kXC>zh5s) zR~9^z)%$8-rb@oJ=M%`@x}2ANKXIOD-AsphUM2%~_>d>s;~KK%U`wufBqQ$gBH=vHrs&gmDvbr+eC6cC6vYs?Yz+#2B0{>-iJq@S7 zC{1W&6zir{Mu1KS<{dVaG}&LrfHFJB*W1XP8KV%Ec=P&&e>>o-6$QXFUmx}@@q@I; zl!}>W0b&0zL^$tjz5VK;9?w@#`PdpmWA|}C(>z|NbK0u-P(B|w8+S}!;s0mO)!Gt- zp}nhe&~8e)2C=-VyLmnyJAH1lLaF5kyujlD7kwteAk=-AetEQG&XI}xLmQH}!ia6q zb%~mfN6`iEga7hhTb2~z?milwX%ts`gy^DN%7PAuMlOv~GSS2Ft@hxFZ~lI;wq&!b zxnBD+Wo3llz`sTDJZMW za09XWjjQf->ZLL2PbI}^ldVeP5%2#lqHMZUKe??M>_*w`>5aG>GTs7iXOTa3n;L`#pCjV=J;7+~nGNOSMQ_W>cgotI{U+WoF8^*jyNbj=L zCA_InCraP3E53Ud9N1N=L={s)31%WrlJOg{Z{H9D;971V%+JfFH5&bU5^x|d-?rs^ zZAH{elC$NwHw1YbA0F}<**3;Nlm2}ePL4lm$I#odvu`DD{la~Bl(v-!Ap0|`7@L>O zgNXRD>H0nk7tbp>cYa^F(c}~p~Kk5g( z*|%<7WmBP^yT3fW1LG>KA~wEU-fa>CHP1hh<43)kH~Z!D?$HSDPTV=d7wi#h7w7iw z2X9R3B|l-D(sS6S{@1{)ShE4U{o$f%)dZD@NtaA|4@=@ihqhwXYo?Sp5QU^7mn(W? z%k#pi|1D%IwqgjJ@{f@&$(9GHJdWl?oigG*jO7y@+=~7m6Qj@%BZwJ>$TZJ-uavF4 zrvHi9pXvjz+;mu_J9%FFsmad$Q#kNL_fIOGw^{N3qgq@yRLIyRotl5)_{YeQ5f(ZS zLx%*_+;l_h8>_Q-eE!oy1Bta3wO=`Ob-1sq^e(TavFI>gMjiVkGh_0coeGN&3(W_= zH^ri+F@NMyn{cqvT-7y*DbZU!7NKRD-{$wNuhi5At4q!9M-zq?jQyogml^UI=9E7* zPoN|`=bWmBv-F{!g@e>z7=`a*jHqj4(KLsgUI!ZiTDwLIlO7Fd`lL#ZW#NT3yh|BGuc6C*kA78w`-rNP@-B?eBr#>Vl!bEj#Wdvk2< zHeH=)u5S|;(c->xQ8)|6@^e&0cxPnl^e(}Xcv~9LEvoe}HqQHY+fif#3`(uwBSPG3 zN(qb#A>x}(hCL=4A)RPl?o}~hn@k%m4DDErg=jh$1dmlgXH6Q2zF#|49y6k}HZ%mo z(gpZ^SEO{%%YHmGG)pA`o;FONt57;G(B@N08ffqA2G^A7mC%tX6em>hNA)=8f}KMv zCb#qqU1|Gn4A1U3scxCZA55VUed?;+E=l5-an;!6P3o+1W^X2K%2hgy$NR~<^+Dwn zV;FfOotYt*F2^K?Fj-!jFlT#)?%!ft$*tpuT|fFsAC!>t8$SW!)xA}6C>iHHED(qh z5&9xjT0mH~I#G~cg^*aO&kaJg*K3uks!jxwk~V-sacF7apQx0K4c*oTRU|nf^&%A2 z`d0*sCc;t*lweU=YchJGh@Zw537MK7=flG(BwSc)PmMNZao^v%RZ0rEV(;8Lym!6{ z!z%z1{WKG`*lKTA!wUGh-YxKc!!B_AFq_D|V|Yg)R1i=Iqi(sEsbnmbRaL69uAf@6 zx!q&PYqgz~icM+*LyZTzB(`2lsZW4PII4JrA=jupOB#6%_bOwZw3+srV9qtQtW|Y<(foHM|;6} z0_!H9r!LY8znHovh73$8S}19eP*-*K2KZ&O6g8kLSxp7Dfjd58xL7~I;5+#kz{Ls} zn$sa(`EDW;4q~2D4PcGd}pT@jzEp(3c6`>aAE!E#cv)z^Mo(oym|5B<%@4`g_o~vhUejo zpnHCWvM3A^xh16OSr|rn8K`h!>Vi!@Mg<-a?m8}cpYAixP`4>70o}N`9%!;9j(K*8zJO=PXyf@!l5?2c)mw*JEn|Sghyf>yr=#^5clgJ(vXYNoq0Y8cONf`k1#(lwc-&)5 znhH2fXs0R~_HBX}{yQ?6-Q?KV2(c;e3A2NaLl|se$L%t;z1r^MD(vrHewI>2j6=A4&2VD8M3EGv+z95|^7PkG)e(7TbdEN>M!{~Ig|5n6Mi z%B<^Y8eK7meUiigNt;seA*lu=!+7-yW@^u?lhh`!$b*9!vi|}jn(&nOtpa^>8E4Z= z_qO2M)Kf7sV5`8nPKgZKE5Q6Lt98a2?fKTDB>@r~(KOrq>=j^i*0m;cgPPH)_-zS> zHr+8_dj*E)bS#bvcCL~0h-Z=Rn&;Sw8A?~Jf*LiS9zn}{;lG_~{MgvAnh(lO zhaF!U!}V?yth6cRoSdH+LeY@t=g6? zB{9g_wCT;MA(bOG=H;4X3A*}%Xd*4%&eK?&Mj`5JaxSDvsfaL3V^U5@3Z$}97K6;R zo8^>pju6?_W;P^G`g(6^yzfP#N+C}AwK%5wyHC1xQr8_urLg3@ zFKBrue3;)AVHdfOfT)D;n+UJ-y!kD76W```Blzw~QT05hQA{ZL%0mnC@`VWFr2xIJ zej9e{3mVHEI;|H?rT)a5*9#%Ftnm6sG08Srp2`?w$|*E<=Ql<~Kv`uKt3EXk#hFmZ zLZ$G?!qS`E(OP6hnze++2$ocWw0JkG(WrANlY4VQ8GCiBc%f{>2x0t9u;Jl536b#S zH-6%w*Dizx&~_l0aP6GWl{Lp;O0{@zV>2}|rJQ1n$(w!R40GKO4c2aeA0q9>-vimH zEyFBQ-vAhTOWP9zKD#3XVXLGUimZh}SBJv0k6{+-vnxxh6sNFV-y4r5jEFcgAcAc? zOrwwBUo?pFk~TcS<;ljFQ$;1H&vBD+bDM6Aw|j3gS0dt>d74;t*+}Fjdr?Jv&UYy_ zGW&k3+;<)s1=hH}_gpcjfSj6_f~(x&GG^GH`3!!?23lNED3s4__GFK2qI2hdVPjl_ z5MB#b_at0J4x$$ET4tX{njoF0f*|h^X)Mo}!S#T+3ezo%RW-2t_OHqN%doT&-(pOx zFdNppPAjgIBw?6ac1z8!i>9FJQaoatYe(Fmvv*TH3#xM*3$e}gv`PJ6{jf8+icdX) zxt2hBGOx^K`7rWaVi15Pw8|?Bg^Ybd^trt|{PA6>#@gQa#J6+jY|%zqwg(M)fl*=O zOF>yncqs*dSTKRc@qIv(Y;%~>0!LkKaI08oKITj*O4e#a-C;k>r-bu?h(xQ}*fJTM zSB`5k!y<2zIc33j*+e`Qr2 zuF_ONT#W0`^t#}_vDi^ZsxyR8wURRsBbU;3<*QW>TqHrzs?G4b*-}Bqji)HaKZ*wS zu83mx4{&$Vl53A zYr`_#^Y^_ncme}VRkh`m3TRr}EU%j@7P(m>UevTQJ99dud7c#{_e-E|Q-cN6Z+tk7 z+bUCVpU^GCpoT5@-0`341t7!isU4b_hSSmw&Ub9d`!KRv+k*OyuOZbw4s|L4fSypP zLW}DLkkUUxars%hK6mm;8Tluyhc~3pTA3JOo=<4gtYzI+@5ehVdI-1k?(mK@_e*aN zf;9gwgwKBy1W_D^URsr_wQ-*955@%&)+yx%mO2BoT6PPeuh>=qq1B}thSBWj=|Ie` z9|0Qzox+)qq(PMVS;KI0xxPN1PL~I>0?=GvpISFsFj!RqbV8+SiouETQ{NyqQ@lLf zxmWtpmOuw}ay0~`S|{$HlQyg{ogGPd7_xeW@yg$08K4kIrf4s!MT zx1W3_6&$l&c{JGacJAvRg9BXus;g%Be|A`(GCiLh4fOsb&|Ba=|CG_XK8>iKvvfDZ z$Me`IB)p)T*DWa0nvj?$TxVFz+n6`JthpF*5xHvDw?Q=n;W&`dI1)QH^iSbGO?LRB zxa9k&+h`0>F6l1Aye!!#GsX!3D4=m91uhDIh4Jg1sr@2De_R@rg@6N)VR6pV*&Uke z6F&pY%jd`l5q@>N(gQH4Hh0%GnGa+n_Xn(gzLIsRpsQUv5I#T{TwS_o6W&hjQR5`UoqDmgL;#y~M5(7AnMtle}$TNt%k$IYWC{)pc} z%d-qy>Rz6*@0+(&^wynUd$sGDl-j}9=IT&nDQ&wt1tFfjv(xNIfsQeYr4Dhlr_7LX z{P4s81iV3mR|p7K@ys#emWWb-^LRQda%Zzb*>eP>0Z9qR^+0~H?h>K*lt8$ieQt{-7{0>2^Iyr-+Ku{g(4579BtZuj9^DO|NDnN~I z_;CPUM~LZ?(C`Kk9Nm6l`U1540-D=TjkV4g{4Ejg!Ud-nVR@LDg@?!=5F2~yRJE_p zSzt9+XzeU~^o2sj^XYWBd~IL9ALJe-Y-YU)iFah2LDueT; znr!Q(6dY)Dyh8;gJT+PJg}S!@T489P240?2`Cn(`^4}v+93?p|X=nf;)C;LNaJuOm ztt4Dvfpthy-n+c8cY`K`7yp|9D^EnfmuG|!bW)2=V90l~#(cfNb&7R|5LU5`me*1m zg03*1Fr+%+ogYDjb)6q4SzgR9<+l`#3O9abn3Z%P_OJ1G5*3KB_zaK+jSPh~PTq^6 zAm@(=#^!qIKNppv@1`noiU*2SW4s!F?)(?0?lW|67JAuh_f*qSm~%}e`B)H9<<=V< z;J*Nv)(+lzj!+V&m}}c1k*Z}TogSR#ZtH^$2hmfXQXPhX(uSpGxB?Ty9XiTRnnGRi z&%bblb&fWUgg`dHG0w-tPcnR~Hy|~2sI&yrt-!UT8xyg@`EtSRnGyrJWkr0HChMmS zo6AjmQ?2!v*!!9zj02uLamV zt*);&D-f&+^Iz#{`~Rb7?p}HUV>wNZd#)^0^}!hj9T>}1TRIx%lW`fe^lc73#=f;5 zB*_Q-?@)La6oO{lfP&=zZi&6T_^;y(UPM$y_#}|a&stG7%>D1*m@TthaQ_Q@Tba#S z(hB864KrP@X+9k&ir(PsvE0b|viOQZ(E9aj%`dXD>+weC*T3ZD={#A>vQ>fc2@S{x zC}<}`z2a*4Qtt12hfofjMkxMdD)cnU%WHE{_ogGw`lf8muON0^7wPk8|HUgR2V2OP z2%<6JD1NuR?6$@46qoP3K_jlyN}2mXRfOvkc_#kD8WpSp`yanUHhP1rd%C%A8-Jiq zhZKwvoLnB46gwW|DV~K;`o|I@m7(@y7_DDVuE7 zy4^|Mp3~(uFb8Litiv*;5S_g3z3RK4M?kMJ#Om~@JA6k#*yZI9pRH{32$Vl&ex3>S z0n4^f|BXKK;@}L?- zsE<|~E54MORUdn#JItwgrxqL{$4i$MkVsYDM3kY)V6uxjh16hKL+UWh7Gs^&_m>@JB;gV`%?<63 z@DZ`B`qimnTs?OlgoOhdP*&Jx!_YOG*8z{4 z?x+^c*C2@N(%xx0nwgS0RlKpMnM9GeYEj8<;PZ&{vJ0}B9Ztt&p_7^%4geElFdfgr z#5O{#dlPCb-ZG-GV634NiR)R2D;)YtY{ovx?a>C*=Z@Z*^&PB32P^BmGX_X0lq%s+ z$q1TJKs3KK2AX{u0!bZFgIuboP2nKtIHM_|N_LltsJ%_4?1tS!PXdeLH10(KW7(Lo zlgk%2%tkMZ;1n~LWBiCQgt`Lau02w2gL7jP{`&n6qDs0LLy#%x2!^|3CaV>*nWWBP z@4Oh2GL60{+Ho)dSXyIE$AvX?H_gGDvEd|KiraC*m!Vm!(!$yhZmU_PC$G^`HB}U1 ze?2J*?KoXGfNi9y=6ivOU;kH}^?b&{;jD>*&FU=#Lu7WiFQ)veBf*F-| z!9aI>?Ax_<-GYTRAf^JR$~*AZ38H=kNB<(3n(3eDuf6x*Z3Uf=)rBJh-L^sn26BBD z2W~Ok#%}2O?b|?4ed)YHOVhe11y;wGV5jouqF*oR%nV#viHV|_o=VW}_O?z|rb(xx z_geEx%b{CcP{fcD#(_$??X>;JfDICd1ZA;%dCnY5XXHrT8{GJR@ZY(9U#oXbw`V=r zg{<*iv434Zg<)8m{($)~=>K=L1;3Q|FZIC<+e6y{>w)JpVfJlciAFCV#1^9~4exg@ z*oC?J7|ApY0Mg*`}&_We?PF>?!8<*>rXFOdC#=7C=!^wgt>-XTmWAOI~@aPkcznc%T|64XT z94Q1(_u!EI-4um=AmyW_5AzN{bOgf`T=3Rl=4v~oRy3qYvxVkMww1w4mzr>&Plt}( zKuDx$Q&00A!LpjpgA4ZOZV17=xja4&M1UPWpS(@w3rV!o=|C{k;n)Z1(axR5-^CwD zf^`*U9yuSnAdLti!KO+nW<1}y;0@jAlyM$nFp@LDKLeIl80vW0&k1@}+pOrUUCM5> z`P%T6zmz)fXh3NU4@EIsBR(g`wQO*H@qYwWy}kp+Ie{LCtMX+ph8wHpixf3BRG(Vy zE!3b5J-!h2EoTg_CdR_@+N*4aE{e^;rix1~nR_#>9y%i+0(GoGP>$!L7Lz8=O7hFW zkrmA~lII;DpioW0J~-a>Bj2$$Y^PwWn%Sz2Y=(9l!n&B#jT!Zv+BOy-bv82fdiBzV z*C|vNoFi6oCrx{Tka>Kt8!KJ(eo6-p>V#0Wd`C*wCaxQ~_AXcU${5+xd~jZ-^cIB& zyGaDd#2`t_Jv;qy#-eFKubThl>Ltr9+#pPY(Fb44NVFM?kY>LYtb5k+#ML+Bq^`J) zsrp)U@5^Pf=BbT#bCJsGd77_%TOFo%7)1Q^FtXbW0p3ks z#h7ME)mO7o%vtiraxG6~n5W50mp4kW64(+q>UhZZl8-3;!3<31io42!nv#c=pzC^S zkt!5|+e>$sj9ODwe}4_|P#fB`&;EM#Q<+MJLBsk70-842 zvPZ?a3)ov$U$qEU!5Q2Q_e<8ZCsLbM?L%fPvwFdqf}6Oe;tA6o3jIq|)L3PE}G7T>)5T-X7| zY#f+7W*llM*_j-R6d=NVbQtRqx@^oC*K(==w@70*G3EQ%-~)FtKvf0Bnu=~qTWv## z%p($47x}4|G{q&%%_mP@IEUWtQ;LCe^>tHw92D(qHarrP=gu}9pQkVmbrb=Wrvrre zlX{I37@g2b1j4rWc~uC+i_~glaEjSW7go$>O11r0$FWbwR#I45!i1HOaxF$4?6mrr zzSWBld`?oL9YoDTd}|*~r#|ZoZBdUOAnN0ch(4ivsc)KpRo(rrYrw6z1ZkkG@WS^| z#}EsrcSS0rgIJX6GZDKo*z(@rHct>coNK)@a*LnT3b&Le{r1yW!wz>Rg^vcloX65F z`MJu!U%Q`@b5+s)yA!kpFcTwOB`&vi?J>t>?`LB}Sl#RTejG9>aqXgWE~J>^^Ld){ zX2itm_8uh+K9YrUO@^DDJ1e~79NoJ9C1vqKI|pFog-}EoF~$A>3eJfA(>L<=3}DLe z=L*us>f{TqgHVF4zQ~u0-^*_ zifz|gr+;5Y-y#G8WA^cWEPD=ZkWNR55}`AULeJWxHB3Kd)$0nP5D*StQV`CbLp0&! zTZHZ&{ND@V`>*b26ok+McRZ}b2BeV%Q*gfDX?pO)`|S?jTzcp-J%ssw4`G4Q+C7(B~Oh~;e-Q-2~PmKiSfCp?8! z(9ouM^EhKB<d+NrZpczR!p7!XPD`^H&xakZS;j_VW(R94Nx{N-kSL z4JIDnI+42zt-7s^Y9n7T+n*#|zVf9SW-}MYUN@=E%@hJj&-c#!z>0pA+k66m% z-WEm^qaQg7SH6%@F`tvNomlZBc5-h#3)6}>>!1&C1dPftpOisGhH`nJ? zi6m@LtB9=Wh8CCvpyKwl4hj5wA2@~MSe{SV@N|nyTh9^3T-#;WJ=+>9K-mZJ1sS9gC%T^jW5|GU))^XLc!oD|M=U}7RJxtExgwWs~ycZA8^6(5_< z1@4a5R5Ky;a**{fGg}Gq)zp$n_$nj?Cb+YqBn^+jTcIfOQxC&iQsT>QN;K|nB zE-yDVw0Ea1SpQ;bsRfj7p~78iHxK{Z{Hf=DAEmDz1*!*=d#;vwg|N_PcFDJGNt^$X9e1pqo+tn&me zn<(-TZo#dD=0G$08@f?Am-Me$zcmmcg#WAho8`!&g#V6^PaxpScgZgTe*FAe|NrRm zdvvYi|J>|$1VaC(fBkj;2B3HC1T`u6Cj5n`frXzpW9Cm8(*5<at?MXSC1Pys)G0Nt$ zf(sb1M4CbML{cLRQ8&wubb@4SE|sGA=-5mA{3yjZAx_%Eqm4q?47$U^HkZ)|n&oN+ ziTxxtleLz_A`;y5pt@Tr#2`6)puDH>XzNviPW!pKFT~u zA29@G6Cb8KbwB09J*zbyv^XtIssncoF~Ap+>7jR#Y(Icq8RV z-{~nd2K~Cw5;W5`*XF64etdp(HE^3GGzWYI#vq|k%n?C>bz-os_s8YAzt7dasK&{d zZ6H+;**WQbtxOaPndWWr`IqeXz2B6Z&SDNm9T`6&PWPY9N4hgG8%E60Df$eWFGOQX zB^94guAbcUD?=Pg?yPHF5<63RE#z+Pjz_Q1_UAp(1&NoTcAD4QaD1^aRvaG6J*GEf z1x_ON-Bn^63E8(7gQRdx12^VE%a6O{5=+r*+U-4wQR@z*-uD`Am15b_8{CSp1BlDU zuoJj66K%TQRllCm-!TsQ@P!+OVh9$&6_L;&0$NytKnh!Egnrr$xkDkGy16D834Fu0 z@o>~PJnzujNW37fib;SBR^g}1wvn3-e4xcZ_`}!zaMAaRoKq%2v2mNFB4O)zwA0}q ztQ+};ZR7o{_pSfr-Tg(|jl8%))&D5a)m4c`N+YE}DNqWMf~B!is5D{)DAF59Z%?ye z1CxGU^FP%=i2ug;FA#p=9^T+NpF!BDe*^JNx{l8odULka%DnGO)?S@!!=OU$jyin` zVPGeT+FV?wzqSFFcA-vlKi7o?Ua~3Zh$}{?Vtk*Y_U8tEi*=V~a!G@cyFtk0B*~sd z;#<~m)_9yoM(+2@T_#Hzcj=F^npOVwC=F?zQ@*T5n{S(Ip1MU(z|O?(e!y@H``Md;~NNi+#h0`NXoh z%)~v7ZC2C+Y5FNMFF4go1^bOdkE4f=S<4FT*s>EM`1{Q}eo(5jr?z zw~vHSJFE-1gPOv`7S`s^8PGC3=qY~?#2&V)B$Y1b<1Oh;pyu|JIz#EaqdftRs#k?6AJHT5It)90co;$n^@jc}2i1vbaAjaFX?}YLR0T%&6ceE>qkSL$JqHVrVRG$7@K2U5uSN=6wk4&$&l0p3+7brk_*8vnF z?`y$umIpZ`ybhxXiLaw5M%?urN|5xr7wa}D%=Pxy5sBvi&p1aj+_A@{;!>f|)2NV3 z!5S0X2SqP)2lp$JhN4gI5AG0NcX;sMphMX=G=Fg)l)pUXrS4Z#7m73e`t9P*+x#8T z{05V8nOvyA^EK6`MXOHK#t0ph7*c!@LILe7YN{1F_5hO!!zJz?}0tswLD?e;S8@D8~ zrJEV`;wk$5W3=MVmNN-Z1}YsIT|Fe@urIA(Es|x68`6~Gn;LI`hg7XfMM0(Vq=>EC z4wY)nO7U=!WjL^g^BB@dAoc8e4sv!(4sguK(~?MnPQ?8ovMl literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..db32ef25ce0081d875bc3223d995d0962059a05c GIT binary patch literal 89812 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 literal 0 HcmV?d00001 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/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 0000000000000000000000000000000000000000..5b8eda900aa4db0d924fe041aa75349e330dbd3a GIT binary patch literal 23816 zcmV(}K+wN;Pew8T0RR9109^X30RY7S00000000000000000000 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 P`$B;4Qv>#4`yK!QMmsIE literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2162fd67eb4b127b9a63dfa3e0d207a112ddea97 GIT binary patch literal 23356 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 X_@V*dsIQ#7CGQ!cp7OIdEowFZ0n#|c literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5c2dde169fddd929024034ab8c1f3e9851d7a8c7 GIT binary patch literal 44032 zcmagDLzFH`ur>IVZQHhOyH44*ZQHhO+qP}jDcg05ec$TtfA9v~F^RQulCd+iBhp<# zoEZQF_)ls80M!2!P)-j3P#q5dW?}iC)Bi2lL54Wn^0+yS0+MP%O0gc`1_H_|LMmny z#DH*g1~nu|=)gUbz&04h;2Z-Wc3@JFL?xIAI{+vlSS%0(AuNP;Vvu9dVVG6*kxn}i zK*5%U?1if$(RPmb%XLrf{LBXGKr*Dk5dbLFb$I>#H78GITC-iS4Te;dZTi8q%b8a8bD1_k;f+U%ysthIbs*$Ldp7K-}wmTafG6_o+7H&L#F%`;quE0 z<}4x07!%kOjdX$k9yPKE|or{+GEfG(Mk_nc=sf)Ae z{`iZeJu%T{?4MV5HSc>j>%rV8z0@KXsx$*L?UxkQR8c#qA9a~dE|i9* zthO56UP;Qr!rMRh_FL+FJI7T%7wK4pFucMP!{0V}!L5nX+=>g+Y&1Bg^gJU?OS`4C zvN0L#|6c!D|JwNweNV%BFcbD(XNZay zP&Su-${DNfxVu{$Zhqd9K@lXL5{Uw8GM-W@0#nNGF9j$}Lc)Pk@yiSNYHHhd#d((- zb!b;zc02#W_G05}SDOk$aDMZf08|L5B7PxtmI|b1brp@Z&dJk-t6J|qOSi;|%mxn} zD~la-`v|%Qx<(tBCU}bMLO?gJ?TNdP+aD((p9j+r7)e!HhviRt?xCR2Ay3dOVIq+~ zOps(u>~G3@+t~t{K5G+8?U$u8`3K8HR5@9CkoO4s)~|0nRp{9E_qUXyk%maclH$d2 zF&XRGKUUM4*L|JwFj&D&lI!>xC^GCuOGH{Jbc*usS`Ob5L>Gis=(f4-vkc<0YNBz} zThEj&w^HyFi6iqNQ14WN(<3;NU&oikOXLQmYr^>_nhE>5xqiMc)S z5&Pr&j2kj#Y;ocwRyB3ErLBj2iAhpFDg6HEAWhqf1V$hNDu+r3mAcnY`Psj^*ds=y zg2;qWNMf`VE62PaItIOk4)<0vec+tWHN>uF8!aAEV#Ee9@WQvm805Co4JD)g)v+sAT5;Z#zz^?Kq zTdGFtuRnM5Z?HRyBL$5lsfM?n8qjNZRzaUzrtH9D*QMQ$@@m%+$~&W~TUyHrsAHL; za$$<63afWIu9R|CGKD?QZ6*7CFf&k4i;$9(;F2`ulwY7I4@i=9J4E-6j z2!x4hYm-2g&}OtMWuq}ij2j@jU0-ei?bvne1&LipK*6~Uo~q|v1+=2FCdtLTe!c4e zG@)vm*}BVwZbB9H?yBqk%Q~z43G;;?utGhZt?#Egc1nOFU4Ibc|`p< zGgV;8K^o9y*L{Nj@c|@3#E!0QJ31(~$J$r--)TLy6Eek>wr??|8_FaYg;HCZ4CYvP*_!2;Y{kzs%4S5ny3*k!396ZoDss!T$NjSzq! z3{Mh|02Is!TK6Q!@mM+S8+c()`E94+!N}7V1n82My=QAzdm`lZyD|a=1`=_{DH!K7 zVh2V?4;#?&ussjw!(ZXj1b94r|9g7c46r|~RaQ!OqEv|8Um#?c@)RWM4=*~}UPqtQ;F8;;FYa&zT& zrF4hzLchf{VHMip6q|9?Ls-Mn{&+I?gLg;y<@Lk!1OA2ZgZ@oPkQzX$0EH{fsu7jW zxkqMNCSgpmdh}*SlKx8k;5EfNA&Yw&?!oem>cNZ^EG9e)SPeJ_BqPWNFcv8-g;*G5 z<-sEs6Hv^ha6zHhg!5?=j^lgIF?RTs4dh~o2^CdDB}=Ij8QwB><9*kLGOyCER+^t> z;o$Br4Q`T<`1r68?ew5o+bB6%6Y|@@_>R3&E|!qz2KCfQO&8wI3_RdZOhxL=E1T+L z+_(rJwpzN7E4#4cbUAlQz5H`&j5=3hdZ14)Ik(!xx2(t;dU5J?DV8rPwbQzSbHBVx z?uJwLP%O;HZxi$KD@9>Q01O_K2kR#ZfRduJ!qW0{emM`r|GGF59S4#K6_HLRGOPS9 z_LpOGeg17dMZa?GFuxk+(xc!LL=Vvx2_h0b-)RWmq~J9H7416p+QF#{m(rW z3jCi()%kuW;)ku>LNZ2F2;lz*ifdpgXh8ZXMH<`CNlJoal47-9u((`SYX-MF+zwPf zx;pLugHnkZ&27uHy_eoKb6WRs>Xq6jtmB9s-8Ay78%jX&EQ*#l03MbK{D{$q;+Dt4_rLI7e+0j7Kc>TClpLj-yADD~zw zeEIav19%Ni_MeX0ZAA}IQWI3q$r(BH21kd7`x6vIWGX^(pv0s^WyPkZMrQ{nG&Jq@ zLTa&QaHAuuZKhd*0(d}!WBBFx?$S~`H`7~*48VoyFImU6wBGEjJ>ot;@RXpzOeqE} ze`6;JfN~#HR%DE*P^}j{HjCAosg)n0q?rTA9iJB`kPs3$fa#Gut|y@4PSIFHWPJDH z9q1BN$7S-QnC|qLs${ zh14?~?C|vXeLld##9$+&gi6d#(9&w`Zt(K-Y(__aw!wfeK5-;=2)fD{;EP{ncA{27 zalIdi_+J5(S|CYp+Os!?)#0OeKjj)WVU=&cy-PQAi3@h~-|K8+w6?WgmosfHmD1~v z@1svI>hSdna4PW!k75&sm`qv5o&VU5vhjZ?VRF@x{R)tYq+>XtZh1Y{wt)e@OXfb) z@y8T+8migt+5tN`dE+Z}-#WIkl68tzEB{}<9sBjBcl#zMC!#Xs6jEXxcIqLNZYr|s zGSq52e)aHuczY(hRr#30cM4T1U9)iY0)!ZVjyeRDScIK;2(f0;sZE->bL!f~bNoO+ zMje7mEy7Me1W`^#Rb7NxZpLjNzl{3l?+oTi#RI+%jNYB01rO5;s5Rz#xweGA-QGX| zzCR87PXR3Fx1KOXuMnXOcRV{g+X#n1*nwVGv5+YLD~@GS$#uhoO{Y{VRM1vUv(gLJ zG}o!;xF^<&>Lz|D0AT&st8&h*^Ai{kt4`F$hk%JU;>1nYmiD}LsnPb@mssp80)ZKP z#kXZa4Zc+Wq{~9x4vwdGX;J5)mAvudT1rcg~%wfBCzxT zDF7Ldv>c#F2F>t0GKM}G&|U6}XIHh4dw(pWc=h@i00@W4^VL!}078mnRi-tY(QGg=MX+YSE|pHE7b#>up;oDix@^jVFY{&x zd&4vQiBtSkvNCXq58o66ki)6=^F0_d14Di-GH6R*Ih)i?7yAwd?bEhQIMIA%SgBF=-t!z?v8|> zm5Jb!ZZzw&MSU=~7!UrA?>U%u8l!R75^S$`Rh^1s0z#<)lCoj%sv&3r*N|&W3pnoV zUKJxTitRMtZ3?5sFA5Ur3mpJ6B;RR>-WuttgyR3pTT7p#4gL=dKb@3&@6s1_O(4G1 zU40!@zlF38fr56I!M-prEd;z9m8&9c57nDco)2QmI;h zr>CZG1#_|x{XJVAsPFj7*K*u;(dK*%-Me&Z^=~oLl?We^R3ilN-KMDEqGPe1#9cSD zce}SXcb#dux_CKwINSD>Bo~dK1=|YTNvx4I@n@uaHBiQ3jAb7rPR=2O7FN#W^HNdAEqc!O-o!m_v7`COETl)z6ZsfUIGoc zwfS+N8{ZSMXBJ$l*vAwq`scb(zl`z8#iUma4&6~-nD4-xHf&Oe&)r+I;a3KSL8sW) z&x5kC2b=2-z0~WA-7$n(?v9q%tJNJ!*V#qXj7aysrhqdY=s`)@mTf(dN^iZi*9r$R zSV+X(`=k`g1UwF4GDr|x!Qf}5>!x75laxBJZTwP*0ic%Qw|8jC0P6$p^MoV31vo!! zo1%Br*0sfy`T~|Dhnyu@(-jwn)?NpXPWQ*fozH(WH+_`C)42?{&%3{!>k4FE8}+3Y zck#?FuL_%9Q=?V&z!o5A$FChqL3u7I9p<^D)*Viw#j1%$D z(Qd(9v7(?$AuGgjXbd$D4#4|-IRr0^Y8sjy{t`O@l~w>NErN}#VL+m(3tAWl8bGmO zM3N=xFjJK_G_x+rn7d<{q>8z1{xwPp7sDPBvUwGz8Y=`%SMq^Jao{H!kLXCqNG{1C zF3t&p231%3BR9J`@!=~h0@6Y?WAAt9RqXvwBKGj^il4s!hNed-CSAgL861gdgc(xR zAVx5Q9Fq;CiY)|^9kC3N_y%BXL)NAwIgtQl7!EW93=;AIb=?io8pwMEHUT-Lu9=|6 zR^bT4LcG}{@1%n={A@GPGJL7AMM%Q?9a$sDW@-%m_NG<+bDx`u^{y6{07slyBZMpo zB(;DFZpCCyHRT4mI0RhqYn=&av97vh~oK%2>JMuc{AxWwO2o!|YB8=RB$u*Hi zCPI`HdO<=dhL+0*rA3EEvQN>4t(B0n%Wgdv1sRWsb(1t3+qLE1DAkiDxuFQe)yV!=_mboy_^|Q25 znfDOBvMR4;3kos(MzM^siHy9Ce9j7-Q^fIeHp-UH_7}aT&uN?5d3%qqX|4x-Dv>lE zbyj!ZtLR*N5-65uzFqmB>tRDPpK-CPd-MAeTnFF$mK|^9vM9^Hsj(^Pm!OIMlUz=l zO?#q%Nc0B*%q*nG@sOXt3AAr5<$Q_BO&e$>#_CB@XlR-Owp^Jg>E#3r zuKr>m{ZeA^i~~DVRL}M$^7r_1*xP6#hDdP z>4m-Q=x8-HW6vIz-`|oo@=NfiW~a%{sGW;D%XG%^f0D`%N3pW^QyAv=C+1OAU_PTP zT3a(TdH?jNbiHvhQGdOGifGHYUM*HD^aBN(WgFLdp5inO2uIV70gE&gGUDp+`T%nh zAu&NwX=eU`1|>sdOA1Si%R?HS8JZfK3q1XMbb5GV*rU+2*4v zcyxGtAS6+dnPSCUPv~F8=@nOPN7Bt!-GuAGj5(M@P`T-*m;zwG-Lc}xxv-i%luAX? zvD|c9F6UdL2}s8OF=mYhlhG^^3LE;@t@+$?ke+{!#|(l6h=;_Y(T>x$bXvQv^S%hY zE0r?se!=)Sk2B5;F&-DO?R)>un;H5(56kt-WV4y9_kxV&&zEY$XAiR?F`<;CD3W3P+OT60K&QStg0MH#*%40*0cf8lRmE9hj}a z)#!mvkOxn>A=)Ou9EzWSILP%Kg35xHExA>)r>;Tatk2ogSEaPr3m0sux|Ln7hYGi~ z43V0UNkpv#4}vo;;Ht>*2x zQ3Y);o;q+-L))pV3f|tmI8Yk{TGs>aP$dh})3>PsP@gH=qt!#HlwzetYj>uG0nav$ZClr&e5&m)Q~`j3?6)L_;73`HWv+ znRF1JH3kl$ghk~+-cTXg7+HE5E~V>V!JX+9V1m#4DF1@7x|wq1$?vSMGb?`Qd+*bJ zD;mTLj)$R>rSgaI^)hZ8!)97?CrfS`!{zI~hl7#MGi1qo5xmb2YXXP2?m3dBoUaSn zm&x*(CZ53zz6?k~>o}l+u2)AtFN7zks|-7`P$2AoyG%#|;30Yl#;*E*%qdN*LZS|r zUe{AK=09k{_51I~O55w|`MjR5@4;>nLBW9&ykNlY4kjQfzQ8a0!mR}wyWk40%st$| zJo=KfC-o~ot^Qfh?2%u=G&pomd_w&L1eynB%S{`;b1B9lEgp+LgBIqJLZgWKCN)rC zK}W8owX~PCvkixnKQ+J-q+JYeJux0VG7fohI(l+Gc+fuh(pJ7t9zT@)P2ZqM%qT~m zQ-(UbAicmE`SkpIpPg(U_x&*_{RSMK1qZmD7L;AybsApO zwi~P+!BT3jXstqLm&kx+s=|6hGutihx;s{A>~2MSz2&;q9-Q5c8C~~wF5m4fzMK8S zt@Y7h-b;d3{&E|};MJkNt3zpBZ@G8=r6#9V_oc3=bxGf-d|shHZm`Ug2^il#60pQ} z{_VHcXGh$WuCHEyJq}y97;trMwhBMm!#W#XA{EWp%5~L6p>^5bb6I|ch=gI?S*z`8 zb*k>qTqqEhg`WXbZtY07q~?uWwLraU3I|QZ{l7jt?P@_vCCSFOW3nHtF0bj0Ns)Yc((z-%Ewk|xrth>CaCc_Bv&c0#aPe>@~Ofe60Avhp#k!ZBvES1@-%mN4>VoVwfxO>p`QsQdSvRzl(A}K zVxv^!2gyEXRnPTNF+Cr*V%`~ZfJ980?j?8BWAV5o*NdB3g6*|W6lIw*k8uS7Rt(x$ zQ6uKGFoj+0E%ZX@8oV>Wyq_n3i2nEavm9aTPC$&8%k(LCP9~d&gIhH^5D*KPV_cOY zNY#HUwU=XE-npk`L>;d)1a0^P0eBzW;|MX*`KnBb2G`0L3w*wuB>(E&Q|6ifwNlm( zuS#V$iqX47h?pb^kd9*Ig2iTKx?nMwO5=l=)A58trd7$P7{j@|VSHX(**o(Z|BU44DLAArC-Eh$uf43FQl7?=CY1I~POU__MK1o{`)e>?Q?yMd>0{jp{6abW;+y=e;1$R&Y`r$OTkp3Do z&M3Zc3#@E+P>~BriXtBgbnp%fdLLiOFpEQ37uu z7?n?pf;ybNhZc+WDLuIW1+sLnG4UvgD2ZjD;j#p#3KTWa&srfNry)Qlh=qMA-;6{d zDAH21s7Nle+0e|chGHT`iFb;U+`Q`=)m-D(Ib_uop}9GTs6c*33XvUb8Rf>MD)#ah zv&u7?l=&J-B`a!@MY!(>bwi;Ul%g47AGp3zNMXP^B?B~8(jro*YRFQElZr6JmQcRv zA-lM1os~f`8QL^X@Mhl9QiEoIpG!Yt8B-#uEhDD10jBV|FxTnSxZ9xHLlyk~cd^WHU0a*F>SPRu~ zEBO+R$#JRF7lP+b<9}M;8U7LVMB##cN|Cw&5fwOPd!qx}Sr7yX0d;PclsLeJOVc0U z?X{wl)u{JKTl4yH7{Lm?@%HFUfL)Y%hoX~4eCSUCzR>_cdYOmd&P# ziSE%>ABJ(uN!Qa!dVbT*-R(DXGadc-S!?UXpfZDglE>0RW+R3{DdZ=tD36IGoVp{n zfpxOOhhPoUC2L~OM7$WW6kgL`;a{P9A;JlzhXvZi^dNQ~!4QkDu=t>p0YQT36Q`6s zh|&@i`!+QG@*d?NNT4HA!ix@yp51b(#Nyh{)mn?AR>CcB*>ulpU`!K*WY?%zN)r9;ou*mE^IIXD^MJV44DF( zS}12QijC*Gy$8Y(knMtCpp8&Hs``fDWvv!x9X}+EdwO}X_-_k7o|>SVZ%X+k95pFm zXgMCPxy~z$@rE`_?w9z(l}I0xo~RzCHTu2fq`M%HXHj=05=0Wre?#y`_J$iD)MIM2 zhb;ke)#(yOzG7Wfm|oYEJTUo3AV^eH@WS$RNIES`95Q>&Su7)pSmA{&Wm6E>?bg^E z%~V!Ogy19pm?V|p$+dt)PKc<1g=C=^oxW?$9plfPTfe9YXBOT5h)Q>3y4qK@@1T9( z#N7y|gmFc}S^1%YXTHLJj>RTFD2jSX`{n;oOS9pUNlc^Agyb1*&uh{-#DWoh5QBhE)2KreN9ZT#*6w&-K8;3x03ip_Zir(srL@9=&|d9XOauxNGv<5yEnFlKF! znj7lZmfqrz3|y!CQ?R@!y|`s6Y`X4U4MCu4!C_i%Uuo^3xs>SKf^^=Y)Ls=C7(-P2 zwReO_H?&G(Vu3>~bE!GiSb<3*zY~jvq_jMN9!ZoMbw!@cyj=GEGEe$%!uhGdAr`NE z6eQJ~sp=~7t6u3R=teOy2zg9}Z|YIlqxJpT1XM0sF>UPxO?ZRm3HUHVQj9Cn4}{71 z@W57t;7U<-95cy#12{5>Kj=Q)uwSK{;yCHBizqBdaqDlTH?bne9*_p zC;?}U%03W{>@Y-~qP7&G;4{cdI{q*}QIA}K?>bkYJr%MRI5_a|`Nteqlxlg~qDUH< zWK=!bZQhsGABmg;k;O4(%(|mUa+=k^+VlD@7``xAFfQ-88i-+G(-SY2)gJ$VyaBale@4H=$_nLIRmKMI>s8Zag6b4wRj@JBN`eN z+pqf|le^sY9}x-hF|_3^x+M!GdzMZ))1~dDIMxnbG8qBZDx1FW9CWMaA_WyL7*K_5 zEX$&bf{;7)!FxPKKT_cfl2@Z5r37h}nWOKuUSVtv69i>ftqaZK)7nOCEO4Na)}^fh z!2X#Y-Z6}_Z)Kk}_G1WrE*v8AQFdDlHy;5p%AFW^^DF@dtYHCydSLx*T~jf9VOnDH z*e4))jaQ;{0lW@A+itKPYogzY{{;32*MkFzeE*>^3vtlKQ9H;}ln4a(FCFOl_U>Nf zC1+|@1m43&xX4{Vn_hUE#oks<7hQ;Ad!CsJ0(>OSEpV?uAQOVIhLYDJt4y@aC;x7g z+O%7G)6sP$Z+}A3P0eNrH}k}OXNWlEOxF)1nhD!PANcGnWMcFOrI5w>3Up+fCGhfN z9+*-V>Gn%}Yk4#l&0T-zGh>-`Za+FZ@fi0hcG+6@Su4Me_jCF+QXxa@A630{Q0en` zWAJ)D=*XZI2HlZ@$z-D9l%yuo*b|a5Ns`BJu~Em|(=wf_i^hxFc_DMc>*3mjwcbzJ ziuOr{OA_s^cBmjdRKb+n!DhdWYO36)4uUFjd+%%8*xuO2g_u7K=&0T}Qn|0H$mb1w zXET;=>=0?CE(te6Zu7isu%Vf4ScIVBQ^y3t@^#0^73|tV#qbvs-4L8E+9||~x*OrC z+SN1ro-mssOI7zhcTJfuYI{bCL*x*<31vvAT(%;}(dqV7wW;L_;*tS(OsMkWX;l&J zmhokqpUoN_PC0KS8c!b;zstqSGe6rnVuV%xX zy`gUqhj~ZOGy{)&5CVco>QZNCvymaL4m%0=;y*n1dpy6b1o$S`+1|uGzg`QulZ&5f z)%zK>GiP3Ebe?yMVy9SV#&P8-RevAkkLBi@cciLiaur}15N_cFGs`aX( zIj$3X8k0==nhVjpG_8kdEva3a`=_-|OV?+NQZP3Z(M&u7%erb0cUf}x>Q(D2wTAwE?Hj2Qa0g% zt?OPs=d^Q#hPa;sXn@mtt^7(#U>Di>SVeHT3P&V_Xe> zye|UtU*$W9dqYCsRP!L|6=Ks@O-h$g0=#A@$m1C(W)b$E+AZ_w0~o!P+~y^A@BF;a zq3E}F0nUa2F#!j+RSizH642r8TFs^UQE<}!tp~l)dm!-Mp=vom<}j~A>Mm(swPO^k z{UA;K(#}J0-`MTF5K$>IXP;k7o{3ILH+gN~9Za()y;beuoJ&P&!L1C)R64O}Qb_Fn z%zvQ?5#}&uktZ@W0~PQv_cjXj<h{TRgiB|c@Ft?9gktdkDUrZ4ykiy;fHPV6?V4`+$<3baj_E5Xyxgr zWQsvL%)Kr0==_d_FRE1_HsU=@v~nqTDs!LY5~}(!RhqlGrDaAgU-Ny{_WTBRVEvha z@ntgqr7C1@Kd=PzYG=8)fN~m}MtS`;HO%z!j^2K(<{8;Ohvdtw|F&F8R_mni@i#V> zyeM-eLO>Ee3TkL%bARLBdOq!2QgXJ>9yGM)Xo%iFys=&f`Nre_+;;3pfW8ayXY#e3 zHXO46Z6)}I>oa)viY2e#<}?%+XD0jUQP1!TErP7~(GLOjK;(xP0Lg!t-VJ`;vk~~` zB|1TxK|R?ikXhqDrryfMgo|7VmNWs(l-XNa388u{-g!_&Oa@B@Nw_3F>8X;ul*4TmK4s==4(H;f^TtnYfvdm>5p)=efeO#z6TARmR@b@p6vTKR5l{_Sco2c ziQMHpzZd0zm0A+#PZ1#v3BBA%! zA(=P@W9l1e_!`OSZ2P+&%0j$FM%)h`%aXZEsVpMP<+tGm!-7RS$4=c8AP#}@pm*qbAWR@XlIu@H?REG+zKQKe|=NsJ~Jm{xp`jEHWpeg6U2`lU#00f$3BA#j026r_;ViHa;W4MH;wYJ;%aCzG9Mn3#8&fb3(unkY~eiwkO} zXOf8G{%ISknpCZ+aNcHV+DqULL1gxGGQ)aFoxjZ`f$r!qdlwl@jeBz&mSZKg4FqAW zAW+?!XUO+a&DN|cSv(k-@9-c&>_5`k1*sGx8+6Y994erMrm)#*`E>4fS!7GLc2nTB z0cyn04&oXEBHmoDUI=oRc2r4kLfqm#3s?u!9j?9R8~(gaR5V12{wTQR4}ZQ7V0cIS zYH^$ILQ+@dJKnBca?K(S;;FYt2~Num2tl=a+!W!14PuDvtyQ$1 zzpu`wp^0N?q+cC_LhaW_tx;7$gSuq2=cRwQZz>qBE#E$e?UkL9M$A;HR4*8#Zpk&Q zqe+o1@SlGDS6rkDp^8 zAQ@%zIPSwlae=1N-U)Yh6ox9^-a;7fW3N!nDF{r1noVMrL+blM_UdgM?s|>~_3U=Z z4_ZY%CXOj4b)gooXxJ=LRzI671i1n{(U681qH}pO43mhfmQM>!bZ)wGz(SVaohFtn z+i27sa>dN*CrCnuz(GcN!H5^J)52oEqR`QUq?^ULmcNAT3YNM{{`=wX9bU+9`KPU; z_~Sd`7SZmy=e>@=kxr;RBq1$`k`gnqmpz*X7QfjE{|qA4EtI=9(8Ck>zV`RAp6aVp z;X?6#x3Hz<3~AbpNO9*-)lY0(exY$>ZNz^rxA8k(=1#yhsodeO^Dfuit1s~3r^`_P z*VEZ_d;MX=J34~|iF-ny79YVi8>;zC9f9#T`LO-l)HoTI%2fSkr%s>ypu!SG!xYP- zOXEVUWtn#AKNSi8jGvhb$?-pekS2~*Oi6B6wHOIPpb6F;j2thopW-X3mEo<>g|PnO z_=J1UF$1qF3&&`Rb;>6qE=k57;;Syu2Wc}oX`0)I))HKj485v*Tu=(KX&|xMYyJH_ zUdZ_0h;L-Ws`LITZ@1~S9&|u`nY>xeH|Zl}A291EFUI;h1>|mfARCiSZ+2Xbn#uo; zPbxLH??B!VsL%4;2c`|bJI{3QksBUD$;6!?q18}3^o$wHiJq5gimFp$Ze~X~!hJaO zMG@woS|1G}ho}E0@V_ZAm^tZENd3F*iGp>NtzlrohL*7=qyztbvtHEI#*}Fk*OiLZ zI5vf&@$h-YjDBfKnpf_j?UDBH?y*4HcxKzc5oAm~Miw=iAvH<*IDJ?CYk5))KKIm% zV!mJBSPew+R@h6waVv&TSfwzf3FHjMb&B&B#~M}ACLljFVo!X8ip~kHQ|)D{-6sQq zl%jrc-D;!NW~HK2{&Y6{Ed49ZY~Q~R{hXLuO2sYPGR=F}GTEtHzO5s*`D(W9ue~4+D#Uz}2_V z(6Ne;-zghv47f4PbM_Jc2SzoA1r&o9=QI=f!ThhR4aFKNI(l8+bMsd_HeePsA6H0r zaJya0d5-6Ri1T}udCTxI9c{hnLc6~RL!%WrT@^b*P&zU9@k|Rp9&G`<#_xVV$?|Zr z@@jJ=)j1JE@e9<@sa^{Q6J1Kh^PpYNE}oC3r( z6AV1v4>qj}-a{FB)j~Q0;Is|e+JA+X1c%OmHBfdS_SwPwYvls=ccvgkZWky?1Cb@w zF~!vu1Su^YpU1<`pMw!DTEa1`>C*9)(=k-3O^{NBP%4h9+5YfPf}`yRLW?D)EBnSz zcIposy6>DZ-n9l3^sB2?v1V4AERQ2g_Y&1Dj9r|y9)kEBij^fFdb}_&+CgfQ+=r=gir5Uu_5=Zl(2Ju#*S#p*C{{PxGGKxswzkP- zbYz$R1lq|HWxESXc9OL3rsq1<>1f?;8;?0*w-4>E90Zy$MEV!{O_$VosESzQ^pGO z3I?R)584lOTs(nMXZ7Ga%;=Ky@LehYQwT+qdC7=9$iSp0aJZWsh>T#uAVf$)Z|j5& z$6NcB_6&I)B%Q&(Q20Vf!w{nK2**fKNQlAEOi=Ia!_yXD+vR*>rht4hhXof6kQ)$I zF#f3z)H5LZ3*9u%o4`%6oV%c3|DY)#p|ew4cEp2tX22E991ubU(uglTqo1Iia$^-V zjOL$gE&BHAYMEbT4O8_=+<(N~Y7|W;Fd^)@Y8DVC)|%8t+t)3vz|Z+>XLq#ko?3O% z@^Uy*!>)31tnOt6yXULgGhq+^$5S#K(xy5jpyk9nGLa zHTjDjEMUCkm3TnCi&v|Vac7Nf3_f2Wl`Eq6djO#%;JuhWD!S>w!Apo))iwGsK7^{1uKdbNWC1CYBg13-=93#$>fld957|)yuqFey7PpTF)`df z7P8ACpVu`=Ned~xoU3A)EF*n?xHYFB*}v^nPh{0Wme?Z=HR?##bz^HkhEQzxu%Zq| z^Yze1nNG!h4I7dhmhT5g-&-5YyTJzVv^OQCJw*!Ga_lue*!F097<60`7g9LNGeh=h zwTHVVF1|S&3^){E7#5nv;m*$J_rNSmgm+tB@}?^?z=aaE!g$7ofaZYbE=Tnl;oBw& zB_y5R?SWG%0ShD5PNzu;V0fH3-nA73-I#(Qme1y>U20F0uUka{-UnHrf{F0 zO(23?5%M&;g;flU{bPCx*Sa0#)4pb74o{fJFuK}!=phudS>{Fzm!m1;b&wIW;p=dg zwOQXn>dv)YA+(O|3e6`GnNh?P51bhLCVCpznj4fb8Ry2lJHs%z(sZ;!R79DWw5CV| z0%NSfOF$r`sLumcASzggaUxY%^0*_=O*gig zLD~Ux^EKq`@Ct5FWGtn7f#$CUJpI(`QBt^*kR(eb2ywmR@z_(k>)h(1f^wQOH@G$t*M)T|LIi7;ekGs(F8WFA1ohq?HHT zvWl!*F*u->=h1EZ{qdu#Va>cpL9O|DH;^yg(K&TT1p_-XGh?6^pu5tOCyOWeX1yJ$ zrYzWXJk#~Jli1hns${-?5t|O&UOh#1ivJVYUog&dqtUEl6#ekXdz4K6(PBn@!kae6 z2h`PMwGN8AzVh*@UjOh(q4tTVM)3t~tp?!}4O3hrP;E)a@^QUct{6Yi3FNp*=)3BhjX=nX=q^pCLes83T9-d3*1~o&k z2Abh)2?R+!o`YjRi7GU93E`dqr8;M^{ySaRbxUIs%tf8ey!+0l6%vp->@c%qL#+qA zwnjYrr`ytjbxGw7zX=~8L%|>xlNQipc?IdX>8C(q(G}JkGIEHR-oV8t3Lm~gLgqYa zGH0YjFSwmqM?&B5J1Jc)ZEnmA6q`(QP{ah=gbP%$ymEWMkbZ#Xa5&d56?C(WcpoNG zr|c{Pb6`&Zc?Sukztx~t7E$403j=JwxS9|ue}E+;C$8Kexl!PLu|c;cS?jYPmt#(? zZ4>l4;7P<@&CfLR87?`MGupc^Y^?PFpT^-$RDroNh$DN3#thI6w0e zyXxd{Rf67IS;m}MAGd=if0Kk?sH!8*(W9=>Mu*`$sqNx=V@keu2J`vS_hkv8k&O2v zuAW2yiMdQ0L4U8f_j$*)KMQ%5Dii6?eSAjtdd0%=ut0A)LE)6dV6OQN$fEnw^Rt~> zCI!n`NT?2bKeffx9pk?Ex+k-d(er2=prolgB!A z`08eKl+Y`7j&?(3;%biIu9+_&BRj-jgxLm!-+MdC3hwcVAH?VX9MjI}&-0V%|1{rb zkSa(ers1`XcGur3!1DZJGg20b*YVaLyo${&r(;Aj-w?7^g(EyV%UkTOcm}As5N~W8 zm89Ef>x)B(8zi)S{?au0%H*Tcp@(O|R`rc8N&P1yYL%Fq)a(lo7I;rcdpqEW3%ESe zs&u=5J{4dSey%|MT8e(g$3^QNAxMEOAhUL3^M4Q<0qG<@iVgTXuw3q7fCnYoq8ipC z!hsrjDyl5+nMS(5V7$it30&7c&612lblW?X}uRsW-U`1!{s~TY2ToaA65_8{SudAZx?caHF0k*7)?A!xK^Ep-|GNKghxTc4IMlz>VN1r} z=gi!AXU4jHsuKzSa~_|j0Ey%2l8%P&L*!;0{;9aX+7w>(N#vk;(Z@W`5RzNp@+ovz-LzXpNq9;OxjJdb!R~-ttfHB{0m?W2i z2TJ<&Um$O-h-NFJYvT1L)0$wHVjj2tt6DMT?`N2=&{bb}ZV1Chf(IwUxDAC@-YH%~O7KJOY58fzM!k>k|e#nC;LW z_m|r1R<-5U3$q8&JRH$u0A}!I$1@(vEHI5J=R}{Mp{KPNBYn6y+nEiTL4{0V5JK`B za54e|)(hl?>9;=3Mw>zTOzp9pUFd9tY4GCf0ouYc?N_!Gs`8=CN=E-QPtZ<1I8w6~6fa7}@{^$19? zU^bR5tPu4stQ4Yk7)&5aW1vTqVCOml?gP?1IG4()m~yGMm_jY!Y9(zd5YRjrNu`PT zanR{~7B`{Rd}r2@OU0B+wQp0Xl0HQq@M{Km*26X5nYBnMH?K|25K@!CG`TrtOg2Vf zJ`g}py3eAaHR!-w3`!~L5x;OMrZwMZwMZ$alOVs=$f-Dd%Y&UsM%z^Q8|4n121&*SU_eCSyYdzqO4 z(}E!t1=*xfG`5pB7$B(`(BPvKo6o0$`4KTA2yB27O$p8i%am!#xH$u0FL(|05}PAT zO+vm7mY9D#6cDTiER&z*s&^KW;LMkN!;{cY?KvT7h$|z<*;1np)l;8p&lm+;R z0=y?WA$e?|B}G4Y*bjAt^+L#>zystg=(xaxykSY@R7|;4( z2-}P+c~p*mcwmwNc_YU%+L${yTT*eJ%P7Rjd2L(OQs_)c8=j4+bENukNA zBViE`ngtvcKwj?-)T=DOg0BG@-~1SS8xW+9U1KsbC8a^`A@&kz+;Upu)-xKnY2111 z(4J3B_JaY~;|u`O_k0GZ>he%;ZSn?iG1nQ9n0Es)ybA9mzZ+Ev`1P17(E+d>ApQvPJ-A$!)USa319nwV$+^id!uO^DAiu&N$Y0Ul93tUb3#f!3&?if7AU0g3kP1<%fOsh;zz+)AbvyWctSI+vFN+h=UBqcHP#}$6~^}-%F%0!zPhWF1Dq*6WDVE?o|~v| z1!x0oI_pE5aXoE4{t>UWhj^}aHJ{i*QVn=`v(({y|^Ie$N)~nFwRzCv6{PsPNo{!CrTWng&FM1Htb(xaZXx?BHvN z}r^XQ^By?9@%l9$_N5V$U*=&xYJCnaBz;ZFpVtJdi^RRP=hY zo`I9A>cNNa-ZG%3jR4x%pt<`bUj9YrVJn$>9B+X+1!0Z40al@~-! zj2MGDQP)8=Kil-fDCGAJqf>{84Bt8%1+g~d{oi5dz;63qi zfKKoN%H=$S-?}BlmJv9VerDD=7J_Fz7yuXhF7+^jNAhPM3rF7kW>~2LCRN~!xt=#0 z2W_cwg!BP5qG9FT20sI?1?SkGhcF7<&RRaSxLpb=W3IJ&*dlxAo8d{3JM{7JKu^S_ z3^X}gIhcLEt}hmGnsvh81BJcR;t}8E!OZBSE?5`+9V|Zw0|0>mB6T@zV&6Z)!Kd9` z^ED@_-D~>5P7Cnm9#ew3x;!GXTLUsSH zdsg?gZfVG?mzTzuTD_t4SbA^z3L__QEn~tIWbQOSG|!qhJbhXJ@oe_a$zJ8%@4e`~ zV+C@OIR~su)?I7Lm!3<{Rpfq>dm(octw6ib%hB(mPolp@|Bj(xAdC%DhH1qt#H`2c z#T>v~!raA7Vbig6tOD!8MzNjPrPxi_*RY4MSFrc74{@0|22O!<;wo@$xJ9`2xL0tW z;4a{9;~wB=;i-5T-hr>cx8oP%H{kc-C-7JB_wf%2nFJ<5MQ{_U3EhO{gqH|!5RMS8 z6Mi84pO{T#6E(ySNJ3H(DMV@@%_XfS?IhdCW#m?hiSl7yUY<11p7$p;l{%M3p^0f0 z+FsfL+9ldu+7vyVPNysACmATlLB>_aUB(pC&rC3fm@hE*Fh9=UpU*LGF#pR+WqDW) ztPQMvtb?p8tb42}b{bpEKElCq1RNs=;nZ_3b5&e7x0>6{UCw=p`vxzMC*|3BAMu6! zG5&S_kNp4VXXS6ne>?w};3dK9LbVVP_Rzmhd@1}zWD!L~&7vXEI?*1{C%^vm;`C-Z zF#TX!m}X(6YkEtQbRC_gK3b$QC6Pe^0DJHRO)wwU!Yp{92r`hwSiz>05@d!Ds16#0 zhM_Ic+t6|73+NZmBaFwyhqWbn5S5+*rCKKw<;%96jhb#3$;e=QCF(l)eF>X z)Z5kXsE?|zsqbk*npVwC?PBdGI-~A@?rz~uy;#3izt4a*yl?o`*k$}*5w+;LDQ-Gr z`q}iaImgU4@3p8{OqP)4D{GN8X6>;qw^rF`wjH)1+qd?({R8`$L*UrscP-+N|xHhH$6k(cK+cthTVcRu-x;!1BVtO8r%W_SU-9=;0q z!?%1+-!KPFgt7we^_!O;vIRnG^5N}PAYFLKUn^<{P&8q3Q0w2#mQ({ z^jcM<>Ou9~8f;B@O=Hc{n(CUKn!7bmW3-q)Rvqh(t&G*g@$o&i#kEbfZ)*QYfCMQa zON0_#i5C)g5;I9!vMaeXc_aC%j$5axbJkVYb=EDeJ6(6ZuCs2m?rz zJyM^n@2_88UtWKy{!aZs{nh&0^}pj*TEJokLICBx0OG)l`d$N)z%AMHK}fq-K?WTB z!;L5uD_DbL?8*3E3wT5HSegp~3+zwMxTnng4W2wnt{=Cwf%D{YNFsv_W&-X_j2W~m zpS{W^ju!a)hM!PzTSSX_2@p%E&r1mr!`KAQEI@*_XuK~2`I^krf*@7uummfc(m`h> zRbXLMWD(3-L4KdXq*5NsT?t-$U99B_aNOA!+L$RXsWFQrnBYEgF?tyz zTh?@vh7u6+IFE;Y%>)Rf;xnu{mxdg1Eab$#$7mvbx*5vQ5v@zP!n=77xO&aI`N~XF zWmun=O+>ZWZltYClBzacVHw>*&K4#^6)u9QE6AV*S&uaXjD%!A*sy(t#SQa)(dkSd zi+Nf)!yJdf3C{ zib^?!gc5cE7ikc+uVfR8Gd`9?Q4@zxrrSlCH~RBj8SCnr0{cdSjdGYz$=!-*Ge3X- z2Y;V$y`eu`XI7_LMhNd5v}Xh57+VAPv;=U4WTa|OVXHqa$%0P0gqv-#qIyQ&aX-%?<-)nDLiU} z^QpsV;uhD*!c?2ykfN>{-fl`OUhm_YQXD5VFnbO%G2IkZI+EX*YAYVfH5bMyVihmb zy-VRGt4R^t=!hxa?D6uJ(=a%^lMGd{2sWLk&pnyxb z!XWQSr6O36^+u4RD{jE2govKhsihu*mQ)>!J-mZ)VsXgPJ*8pD zowF1ay3TGs!0X*-6X$V)WyDKCWs;XX3PwCvRrAqC{ z+q-=(*89?U`L#C2mI{)H_b~*#H!MS-EVDM@a8Xc%DzF|d^UE1v^tqt-Aslp*%Fshj zd)X9dM3uxHMQ)b!rbY6bwNEV*$|J^0bequUNJ;o2GK+zwD;Vv`8PvZPBcT;8cr22k z@)rZsSJb#g5`xf}j3+v^9YD(_MopZt;?lJ2i`E)xU{m0m=9O?tH#I|cUsZ;HQuR}V zEYXQDXBy^Z0!7CoyfKFUVEk<6OwiX3a(_JGQ{bt3p?1^?G#;Np3Kg;*CZVOTw$$4Q zbM~1C^!NPKj)sD^DQc&|m|Qx(tm67Gh1#zL`qEw7I8-zCtypOZzyxGvU@+)8`BIbG z^L>Y{G-fhzW;8b+4id@0$`P;AG?J1NtDZGHl9g}`RZ7jbJoVuoPXkid3@>r*Jkmev zvB9}X8&eZ|c2LqBtm&2XVvfB8sLiHupLM*!F*MOkBk7nT@SgM9xvoT5Oc4Xduo{& zH}dmCgSpXFA+?K(a>vMc;!h|!LkTiE*pX@x4#r?#J+`3bgHOxDDBcFI@caDq<={w$u# zw@2B61BhVhcyuWvOldZX%Bd2hgcc=bEjm@UVeO8PvDY54BJ#Cwt0q#B)CZBxzvu74 z6O=T9`t25`C`Ii-#}%(KMxRZ+GV_?&(IE?V^m^f(f$5}`IuLY(GRK_7g6~)Gf+Waq zdvYG>^rW9G>`9yD^TkLkX_oFq@XZP;QzKQ7x7htxvFJ#&8iKbInlJ2~&hNp{;GCM$ z1)Xfug71?rd;`{#n@^RKrgqSr{M!occ=Rkn9KM`UQ@XICJQfQJs7KYO13GP*1wr51 z@tf&WDzk;pun8MSYTmB_B3rz!!)IxO{O)7HK~A8nf8L$a2Hi@dd_o$nmCq=~r07-P z^$TUFs@n-@`|T7$nro&r=syzz zoQ5-SR*I4w>=qXaQ<}r`Jup*pc}fwz{R2zJ{=yO-#U4DUHwh?nD%Hq3>eoy5e4cB? za#XY2a&_cr)iFHwzs%KqS#eNeX|db0f*Odp$yo(a3EAklxh~3%@|7uk_6xSFxoIxh zkxP2xW*LiO@P|-RxO4fZWm_LgmgRXx5A4E!+j>>_bm`J8oX&9gVLJQtmDnU6f+H2sjw>ZOz zNDcKr9nc%r5fabrHWLpts%{EU#cKgR)L1YU=5LR4$%Jh%TU12V>4!Py1!Y>s0FkW=7&#ktMcBL06hu zNruBtz#at-TE_J(EW<&YXeE&%gcGqvg5xtu)X){8Bbj5!b48Y8T}pK{Y&OrtheC06 zvUXU>7Oi}mb>_m3KQURV?5p-URgS)LnMJU61qsUn7OFV!1zFK05&w%mh}L=8tmTqk zACBWtQqF??%PR0#qza?tqGD^*gX*Ox$@Cy$Sxam-s5LIE(s=bO-Hjadr2JbV9LekXR)~C`ske1de-Qd%hTm_?|83Q z&(8oq&dr>8g1mKB< zmUgDTn7(YQVPo|?*}Jcq+opvOXTHY)ktKqWl*49<%3~TQagQ|ecu$$ZZel5jcvRL$ zIKghRVO){OUk;0Rl((1GaRt%Nv4W}&7YcVTonG18_L~JD#FPRjhTQ&i__pwPP>(!V zuKHVOJh^P@l4dB5C@N)Qmhm|J;xSk8XdyTWs^+KnoHJ*^<|0q|9>u5MnQU>Bf(LpK z77`M-+PHsO&}e_;mz9=vC~Lm>vtRN}xKS11{^qr->(H#bZOOW-jq~N|$$CoZXRvzq zTAQNAVK}wL7?4e`071vCoK6))Gt!bU@e2|8m=)&kY8FYK1%Zc!1P>ABMp`8kjv7>v zx*kG7m{%&Y=AX4eS(y^%#IDvrgQ&UhB%fFcD&0u&2ClG9E_;fNVB{V`6BmkQ5{VK8 zmpgKgGjP@DCsFmZrY4+g*sf0HS~@ys=XlX#4NtyXRU9iEcKB!C$QY(6EF&gLm92K4 z?d9kzmRSr;Us)EkZa~5~wFuLx>v5o9?rH6Xf?I2M!me-|QJ3q6$3|Q@IIh-S^Pkc) zi4umDZq8fq5K$v}Hm`^87Z2G$(gHM=O~r~Va&2)E^Sh!PAmsafma1T$Z%8sTZ4{YM zH$A3dqW>l#k7Hdi_NJ;qi-~YlP8?5s8OBnJBx7%RSK0Z*Nl*^9-Nu4iutHkPUSP*m zLHTeriM_mIGvK6l`Ve1W{KB|-FNF{vh~)4LmEH`Qq?j&9_%yrNtdw}Xj2wr5Tl_uT zIj^~^r1e^X9)@)s7ng9St)%)PhS-DP;7K6+zg#M9OjYQ~oujk1%Jntf+-A&Km5q^10by(* z=hu8mpo}Y|(q07c19P%(HM)W^_jvtWyq?uD^ol2V*V}3rTF}KvtZHj31gq*CG=b7v zczNKKR)|QhlFFW#h={aO$znJYpDlY>6iA5&J$a;U z`&@zJ8`?T~B+l8qV2`N9L(HKW(1qHR38Lrlf|yZ^jis`^@#XzCIy&=MHXlSBm#q|$zf_=y$MuU)~E75eS? z4U_SFL%BS$oys5t0fc|{w~&a_K|%ig7vN5NT4n|V?xALL1aLE1g%8nV-}Mo3E@~JmT zrk+ugk|r{8$m^`&^CCyTW}JM6Nl%lPpu-Q7B98>0Za3wmmr9DKQL;rRGfwZ95O~Eo~?tMm*KIK8k+$e^mDT{O6h(#vePlO$cdF zxbHy>b-Be|+*wxb&*8Y}GyW~moosj{*hhZj9-s9QAD+xEddREcsAzf=9i5AMZ^<)^ z!JR-sM_fdw9%k*=K95e#kMq#5T4e_z5 zx%@IT3ThNmW*3ES>gnws&}e#>Y*^?&2u+-#r=HG8aK}It6?WzDyXDt<5Bu5w5CD^_RzqB*k7Js8u z9o#XlCL*4nHF8i2Zq_##w_TWlH0a6u^*FLd3_Hz?%9%GB3vw?b zO1ip`WJXzR1iE}T_WR?(*bjbh#}A)7Z|GtM5jlD!YKclBMStRai}14un5x=$kwUa5 zYQ7L~5u29!lAm}cqWB%3_d(wrmxv0Z;PFN`JAurooDDto3!-F?N47yW0atB9cidF5 zm*c$i)w&5P?meONQr;sjG{(luwM5nijkV77o`ZskYl(Z`@EW}Vj;3c(5nE>~GG#uu zgHelZdz9KIquVgZxWcssU6|9An-&6|FXkzjy&R#Wb~aZC!%|zGZA_tR*-+zX1)q!* zhV(sE$Wp^ByohLQ5-Q$BVX!5>kG1HKUvO};-`Z5S2f$nmzOLlBurJI!MRIHZXuBh! ztqr>1v*@lPsmK)Q3|HW535jve;k))>m-y)LU7>@(iQqSVCJQnhEs|F0%T~40t5IEA z5*$WQyO324Y0guCh^=fDPz$1_&(+5hZo$?Pc~hR#E*&`pxuw`NGej@yj3B5vtpY4u z^lNx2{vK|4ih6>}EXuMqGCO3l5a% z9#{&W%qZGet2dgR5m?m8ndWf*3ex3{#VA?XvQD;I*>1s&;zwXSqd3%GWhEJKWN4 z01(sCIlB^`3ouIn7{5Pjh*r956S9#qf5GHFa;X zH4dv~OA>uJMVEz76iL`r7T3bD2{s|A&|^0er?bL7;(E6OG9bQhlt+%uTxEbUm^6`yw*LE(Vrb0OZkuY}mVl{Cm;X~t26-f4 zq7Q5IEF64+7$H80?Wy16RpTsaH@)@s`$hTxrn99K&#;qa6=z}#ZtHd;zs%14;E$IT z)qH#CKi__ceVC~)!G_Ulf~jO)ji?!*w<&ZXS0n;hc9oKokaNiH2sY^_2AA6mV3I!F z4d#&5p0}!OtXMiCPIJTRrQXYW z{0d7W{n4(@rEX6rsjYP>N-d(?mKEv{>EnIQui0`OTuna9FGC*>Zz!b9E;o|r@}tXB zGSjz9!|85DYK?|BL7^^70P6G6v-j&U?+H$kUbtXFK)+?fJ0?C5gUB#eeJIW z=j$TNa3Ow;C*mM*u3xUswTE<3lR(>L>AJBLdj*I^f^3|qCf0;)#)W+`)sz8!3L7ji zOobwQ8sB~J=byD^X&o&5l?VpUHov}7jd;J;C9_$X2&Th;CskN2`jS?2#(+s;kH1jP z(?-v~hP#NR6b*Jytn9LN@16x0hM`-}lug{K5DLmN4Lib?cjG_Hq( zJI;8KLk)RAHVRmJKbE>=Jq8Jk@{jq@%kiU8&CQi?s<}fm*`IQjLWd4VlFy@pY#*4@XiIy|(5!j>O7>5L?WifsRJ3a8`ewU- z$$~@7P1~wEVgMHIk}Mt*+X_ww0sFs&bcWuRSwuXVpGzInm21VDGW-ZA_NaB^WN_4o z2gS5749C>d598w}Y6}uxW_d@6E~k=JrudT@%;Kch7lu>HpC8p|<7RO`(^sU8(GBxQ z>AdY0!{$!x9h-5+?WjKKp1~D6h4X6t0pxc* zxtI+1WQOI7JrhfIzEHz_Rk>e6;SsGknd$r^WK#7i2#-*cuv#T+G7_g#a;0>7-`Tw} zBYCKLyyduYn-=1^y7S8pU3MvYTxrRdk#fsI&*jXAYXim4P8#m$9;*Jx?5;UE!Uy6@ z_;o3dm~G54rwwVSN9^6l*7F)SNq|CWs=6*9_Z~6cf5R{-pqte|7ZAilSdH-p8}hZY ziMiYDigoR7XTXt%?$bD2>l!r->OGSf_soquP~2?ifB}t=4F#FhykT!0evou0kjfXt zn#+Yj;4=y5mzW!&R|)7bjN+pf1F3~EX2Jydwjcf|a3!SpixJa?ubE`3EU-t{kkx%o6IZdv8 zIFCkoL*{L8^5u~jH_AC8AcP}V&a84`i&~VWtvH!fXU&&Qu$C1E*{V?P2eiTF@G>4_ z;qhx5?kH=#IK&-B1*vINsJ{jzkT_?7Pen+FXJ@;fjTYdlQNB)(Wrao9BwJ)RPo2kYkK5h$w@YE$9SSs9Mtsznk2lLFczQAjDjY+ExE?Im z6jP<5S*ZzFM9alFYh1wo&;n8E9cnfusCj}-Hm6Tyi}k~7BG+@BK*1sc zndD%88Crl;F3fZ!4fLZ=>I^Q$6a__!s#*`SX-1bc=;q&u>&!FjP_n7HYqxbW^f#Zz$4}ct#s*LCI0$-SU z3g=cmz(hb)UyxJ3+gro#N*bpl!j^e_isX|X;EgX3g@55O|NPJYf$pWY242c5^CP;k zJobk9vwd$lL{A_>c41r>x==)4XYUs|pZ8t&pCRx+mE!z|`bCIEZbRx%z%ke~oDrLr zf&Ix~bj1>?m{~vutnvEW>uFvp#YL;1D^(GarHt6=%DmZ}UYdqlDoY2r3%lpxn(!3Ln8XlM z70l66DyLZuO3wR$3@-?U=Zf(v;83qlV3L&9UaMsEg0lL}nHBm0u4D49)enQ(m0g=I zDD?@kjta_vVEacS4-&>|3G4Z2A(b*xqqlaw2ya$Zw+r!3hNI(FYzL;vye@{_PPwJ& zAPp%iR<Vz47r*ChTHd3iow!zO}pzDdZ^kjbUw0AX+V6Kr8omC84#N;{=EMZ@ZNN-+bc2!^*`QwV1CbPouec4gECXM;lYle- z&`C++NUFJDJR&=9Su1}0;jhQ?Cur+dM0VXHqwzhT%%MHCDiaLoqG%?@N*jxdwEC*~ zb}1%?XENnnO=+pOu$8YE zuyGsAGZ07R3f5ohX{~r_OSGwywVa=qL(KJ1#1&fg>+$1Jxq_R!5bbm^p)*hd#=>ms zD5vq7`|`>4L)!^TYMD|Bk?EV-c1uw71ft5@3EVP(7pB5p$&(laYQmb@>k0kNW{CC_ zHGR>IPOd*CniQ}*t@Q;3TW>h_@t-W4#R)Zwn+ND#ll-z7T*$Zi*$DH$mmDzLaxe(G z=Mn2#gF@~9pgj0vVqDP)SJ`o2mF{cNRV$Q-LuVPMIPBXrdD79ZHAg9^%X150kuSni zkwDt{@NXsc|7M$y2;lLZz>}e7$Ka7r_zh{bPc$9~ca!A=GD1vm!@%aaAgZDcz_TZ} zwz}Xqo1Wq!K(MasE@EdUq#a}RHqeA0sXWk(Cz(U&NHiX&Zb=kp63E2;;yqX}vN695 zjfV<_`kQF1g-9c$`>SyWJ=+&@FRxeNIY(FE?fQF;el?18Z8MP<02oqyxgIxvAH|y~ zlAbAtbSQo^>QK6%czx?j@YE+h`4gP&T)HyN0dH}Pmngy#ZPIOMvJgQvttK@Fvy$e2 zaH%=)5dt2cX7uk>i;s1&XUnOtvlnYd^_}UM z3~Sm4%Z5*axAu8Eq`DVkW01f>B1^Q=L8B2(1tqW*FkvxZE;QF`KaaAB@{;m}%2N7R z;(+x7?2_Q@w|iD*IX$)BE^=Kg6KqPN|HN#AsM8reCwPr5($St-lNlqSX9#c>vwSf% zDgxo@B7J3?NxGAzArMKN|H86QEZTWmc5xB%lP}2nUM9~vK(I(eC4f~wn%yT+$P-K@ z$b4aLj~4u{n5*M?mz^iDtEkS|k7tg9NV>02UTy`}Gj(LD@=CLJFYKe74H#$0SToSw%Kt5FL>XLLp zfGn_}spB=n3Sd-ZSt+d-;WVC6$!v8kCM>*5nrDeaZEjd8^4(R%rBVS)H}e1UWF}_s zaKmL(c1;5TF*X7|og6_J2?o$g0>{a=_Cu9dL}KVAL-`rwmvlqWh-$y$9edxrk$T6k zI1ZT#OIF>$qY$0Bq`}A=H13fzVUmaws__uCgqu9+Oad=eq+x`DZ`Cm7pQ=(+61;xE z&VjV&#$9j%>Hq|Qv8%8xta`X0&ERu&gBcUh=7sVfwwicPa?jn{}? z7A0qw@`cI5u14-ii|6WjooOADoYbCgwPYQDRj9Nv%pK7;;2c?!UxNBUel5(IdFA0o zOE4v+{6c(5bjHOJ6xsf}(5Q->>}%oPDQt7FO3n(~ms%3f&D>TKW+%@=xZh$nATF{b z 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 literal 0 HcmV?d00001 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 17e8bd24..0c3a4296 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,5 @@ 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"; @@ -7,24 +7,44 @@ import { isCanonicalListenerHost, listenerLocaleForHeaders } from "@/lib/listene 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 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", }); diff --git a/src/lib/__tests__/self-hosted-fonts.test.ts b/src/lib/__tests__/self-hosted-fonts.test.ts new file mode 100644 index 00000000..6dba7525 --- /dev/null +++ b/src/lib/__tests__/self-hosted-fonts.test.ts @@ -0,0 +1,41 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = process.cwd(); + +const fonts = { + "src/app/fonts/cormorant-garamond/CormorantGaramond-wght.woff2": + "e4c3c3eb566c07afee0b54301b984dc3e5e7e1dd1218a528e61133ed84a7647d", + "src/app/fonts/cormorant-garamond/CormorantGaramond-Italic-wght.woff2": + "14d1519ed9320432e1782e0b90435647827937a41222e99531f449c981090303", + "src/app/fonts/syne/Syne-wght.woff2": + "3426a96623df5fba636f48774ae899f5b9136b67a8418f49c04d110cf30a585b", + "src/app/fonts/space-mono/SpaceMono-Regular.woff2": + "76ba939dbd8fe9d6cb0519633d0e92878e21e6c8cb6cd635f67fc344c242a4c9", + "src/app/fonts/space-mono/SpaceMono-Bold.woff2": + "2ef5a6968e7045c138da05c95e583025c967b698a3c2bd3d9ea177ba7209934b", +} as const; + +describe("self-hosted application fonts", () => { + it("pins every approved binary and retains its OFL license", () => { + for (const [relativePath, expectedHash] of Object.entries(fonts)) { + const bytes = readFileSync(join(root, relativePath)); + expect(createHash("sha256").update(bytes).digest("hex"), relativePath).toBe(expectedHash); + } + + for (const family of ["cormorant-garamond", "syne", "space-mono"]) { + expect( + readFileSync(join(root, `src/app/fonts/${family}/OFL.txt`), "utf8"), + ).toContain("SIL OPEN FONT LICENSE Version 1.1"); + } + }); + + it("uses only local font loading in the root layout", () => { + const layout = readFileSync(join(root, "src/app/layout.tsx"), "utf8"); + expect(layout).toContain('from "next/font/local"'); + expect(layout).not.toContain("next/font/google"); + expect(layout).not.toMatch(/fonts\.(?:googleapis|gstatic)\.com/); + }); +}); From 4d411ad59efb9368bdd0b3cdbff6ed024b0881ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 05:29:30 -0300 Subject: [PATCH 208/376] fix(listener): recover stalled media clock --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 22 ++- docs/operations/LISTENER_AUDIO_CONTINUITY.md | 63 +++++++ .../test/preview-contract.test.mjs | 12 +- scripts/early-birds-preview/rollback.sh | 10 +- scripts/early-birds-preview/start-origin.sh | 10 ++ scripts/early-birds-preview/start.sh | 7 +- src/components/early-birds/ListenerPlayer.tsx | 80 ++++++++- .../__tests__/ListenerPlayer.test.tsx | 86 +++++++++- .../listener/__tests__/media-boundary.test.ts | 12 +- .../__tests__/playback-liveness.test.ts | 118 ++++++++++++++ src/lib/listener/playback-liveness.ts | 154 ++++++++++++++++++ 11 files changed, 551 insertions(+), 23 deletions(-) create mode 100644 docs/operations/LISTENER_AUDIO_CONTINUITY.md create mode 100755 scripts/early-birds-preview/start-origin.sh create mode 100644 src/lib/listener/__tests__/playback-liveness.test.ts create mode 100644 src/lib/listener/playback-liveness.ts diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 51d91010..e3ba82fa 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -491,6 +491,11 @@ 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; @@ -504,6 +509,16 @@ 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 @@ -676,15 +691,16 @@ Normal stop retains all preview data: scripts/early-birds-preview/stop.sh /secure/earlybirds-preview.env ``` -Incident rollback stops the two public-serving components while retaining -PostgreSQL for diagnosis and a forward fix: +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. None of these scripts uses +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 diff --git a/docs/operations/LISTENER_AUDIO_CONTINUITY.md b/docs/operations/LISTENER_AUDIO_CONTINUITY.md new file mode 100644 index 00000000..df56b263 --- /dev/null +++ b/docs/operations/LISTENER_AUDIO_CONTINUITY.md @@ -0,0 +1,63 @@ +# 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 produces one bounded diagnostic and enters the existing three-attempt +recovery path. Recovery: + +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. + +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. + +The browser emits `listener:playback-diagnostic` and a matching console warning +only when recovery begins. The payload is fixed and contains transport/action, +media state, range counts/endpoints, 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, interrupt only the Listener +control plane for less than 30 seconds while the independent origin remains +healthy. The same client must reconnect at the live position with one audible +source and one quota presence interval. Do not perform this exercise while an +event is active and do not restart the origin to simulate it. diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 97f72c0c..d15313a8 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -412,7 +412,7 @@ test('production Listener HTTPS validation remains fail closed', async () => { assert.match(env, /^EARLY_BIRDS_STREAM_ORIGIN=https:\/\/stream\.harmonicbeacon\.com$/m); }); -test('smoke and rollback contracts cover both probes without deleting state', async () => { +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/); @@ -424,8 +424,14 @@ test('smoke and rollback contracts cover both probes without deleting state', as assert.match(smoke, /State\.ExitCode/); const rollback = await readRepository('scripts/early-birds-preview/rollback.sh'); - assert.match(rollback, /stop listener beacon-stream/); - assert.doesNotMatch(rollback, /preview_compose_command[^\n]*stop[^\n]*postgres|\bdown\b|volume rm/); + 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, /up -d --build listener/); + 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 beacon-stream postgres/); assert.doesNotMatch(stop, /\bdown\b|-v\b|volume rm/); diff --git a/scripts/early-birds-preview/rollback.sh b/scripts/early-birds-preview/rollback.sh index 2133255a..e4d27d50 100755 --- a/scripts/early-birds-preview/rollback.sh +++ b/scripts/early-birds-preview/rollback.sh @@ -4,9 +4,11 @@ set -eu env_file=${1:?usage: scripts/early-birds-preview/rollback.sh /secure/preview.env} require_synthetic_env "$env_file" -# Stop public-serving components only. PostgreSQL and its named volume remain -# intact for inspection and an additive forward fix. -preview_compose_command "$env_file" stop listener beacon-stream -echo 'EarlyBirds Listener and stream origin stopped; preview PostgreSQL was retained.' +# 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; 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 index 8d7f2b7f..08853b2c 100755 --- a/scripts/early-birds-preview/start.sh +++ b/scripts/early-birds-preview/start.sh @@ -5,10 +5,13 @@ 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. -preview_compose_command "$env_file" up -d --build listener beacon-stream +# 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" up -d --build listener 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/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index 961c7165..c6021cbb 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -17,6 +17,12 @@ import { type HarmonicAnalysisFrame, type HarmonicAnalysisProvider, } from '@/lib/listener/analysis'; +import { + LISTENER_PLAYBACK_WATCHDOG_INTERVAL_MS, + ListenerPlaybackLivenessWatchdog, + listenerPlaybackObservation, + type ListenerPlaybackDiagnostic, +} from '@/lib/listener/playback-liveness'; import { deriveListenerPresentationPhase } from './listener-presentation'; import { ListenerTabIdentityCoordinator } from './listener-tab-identity'; @@ -61,6 +67,13 @@ const TRANSPORT_FADE_OUT_MS = 650; const DEFAULT_LISTENER_VOLUME = 0.7; export const LISTENER_PLAYBACK_PRESENCE_EVENT = 'listener:playback-presence'; +export const LISTENER_PLAYBACK_DIAGNOSTIC_EVENT = 'listener:playback-diagnostic'; + +function boundedDiagnosticToken(value: unknown): string | null { + return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(value) + ? value + : null; +} export function resolveListenerAnalysisFramesPerSecond({ reducedMotion, @@ -265,6 +278,13 @@ function ListenerPlayerController({ const recoveryTimer = useRef(null); const queuedRecoveryDelay = useRef(null); const nativeSuspendObserved = useRef(false); + const playbackWatchdog = useRef(new ListenerPlaybackLivenessWatchdog()); + const lastPlaybackAction = useRef('mount'); + const lastHlsSignal = useRef({ + type: null, + details: null, + fatal: null, + }); const listenerPresence = useRef<'idle' | 'listening'>('idle'); const automaticRecovery = useRef<(initialDelayMs?: number) => void>(() => undefined); const deferLiveFadeForRecovery = useRef<() => void>(() => undefined); @@ -437,6 +457,7 @@ function ListenerPlayerController({ const audio = liveAudio.current; if (!audio) return; stopHls(); + lastHlsSignal.current = { type: null, details: null, fatal: null }; manifestUrl.current = url; const nativeHlsSupported = Boolean(audio.canPlayType('application/vnd.apple.mpegurl')); @@ -461,6 +482,11 @@ function ListenerPlayerController({ } const instance = new HlsConstructor(LISTENER_HLS_BUFFER_CONFIG); instance.on(HlsConstructor.Events.ERROR, (_event, data) => { + lastHlsSignal.current = { + type: boundedDiagnosticToken(data.type), + details: boundedDiagnosticToken(data.details), + fatal: Boolean(data.fatal), + }; if (!data.fatal) return; deferLiveFadeForRecovery.current(); liveAudio.current?.pause(); @@ -837,9 +863,12 @@ function ListenerPlayerController({ manifestExpiresAt.current = 0; } else if (probe.kind === 'active') { manifestExpiresAt.current = Date.parse(probe.grant.stream.expiresAt); - if (probe.grant.stream.manifestUrl !== manifestUrl.current) { + if (forceRefresh || probe.grant.stream.manifestUrl !== manifestUrl.current) { await attachManifest(probe.grant.stream.manifestUrl); } + // A verified active lease has now been force-reattached or + // was already fresh. Never mint a duplicate lease merely + // to recover the media pipeline. forceRefresh = false; } else { // A newer request/presence report won the race. Keep its @@ -1053,6 +1082,8 @@ function ListenerPlayerController({ const scheduleAutomaticRecovery = useCallback((initialDelayMs = 0) => { if (!wantsLivePlayback.current || liveStateRef.current === 'displaced') return; + lastPlaybackAction.current = 'automatic-recovery'; + if (playbackAttemptRunning.current) { queuedRecoveryDelay.current = queuedRecoveryDelay.current === null ? Math.max(0, initialDelayMs) @@ -1096,6 +1127,45 @@ function ListenerPlayerController({ runAttempt(Math.max(0, initialDelayMs)); }, [attemptLivePlayback, reportPresence, updateLiveState]); + useEffect(() => { + const interval = window.setInterval(() => { + const audio = liveAudio.current; + const eligible = Boolean(audio) + && wantsLivePlayback.current + && activeDrop.current === null + && liveStateRef.current === 'playing' + && document.visibilityState === 'visible'; + if (!audio || !eligible) { + playbackWatchdog.current.reset(); + return; + } + + const diagnostic = playbackWatchdog.current.observe(listenerPlaybackObservation({ + audio, + observedAtMs: performance.now(), + lastAction: lastPlaybackAction.current, + leaseGeneration: leaseGeneration.current, + presenceSequence: presenceSequence.current, + hlsSignal: lastHlsSignal.current, + visibility: document.visibilityState, + })); + if (!diagnostic) return; + + playbackWatchdog.current.reset(); + lastPlaybackAction.current = 'watchdog-recovery'; + // The object is deliberately bounded and contains no account, + // lease ID, token, URL, cookie, IP or device fingerprint. + console.warn('[listener] media-clock recovery', diagnostic); + window.dispatchEvent(new CustomEvent(LISTENER_PLAYBACK_DIAGNOSTIC_EVENT, { + detail: diagnostic, + })); + reportPresence('idle'); + deferLiveFade(); + scheduleAutomaticRecovery(0); + }, LISTENER_PLAYBACK_WATCHDOG_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [deferLiveFade, reportPresence, scheduleAutomaticRecovery]); + useEffect(() => { automaticRecovery.current = scheduleAutomaticRecovery; return () => { @@ -1128,6 +1198,7 @@ function ListenerPlayerController({ }, [armLiveFadeIn, attemptLivePlayback, cancelRecovery, pauseDropIns, reportPresence, scheduleAutomaticRecovery, updateLiveState]); function playBeaconOnly() { + lastPlaybackAction.current = 'listen-beacon'; dropGeneration.current += 1; if (!livePreparedRef.current) return; startReactiveAnalysis('beacon'); @@ -1168,6 +1239,8 @@ function ListenerPlayerController({ } function stopTransport() { + lastPlaybackAction.current = 'stop'; + playbackWatchdog.current.reset(); dropGeneration.current += 1; setTransportStopped(true); setTransportPaused(false); @@ -1213,6 +1286,8 @@ function ListenerPlayerController({ function handleNativePlaying() { if (!wantsLivePlayback.current) return; + lastPlaybackAction.current = 'media-playing'; + playbackWatchdog.current.reset(); nativeSuspendObserved.current = false; cancelRecovery(true); const introduction = activeDrop.current @@ -1382,6 +1457,7 @@ function ListenerPlayerController({ } async function playWithIntro(language: DropLanguage) { + lastPlaybackAction.current = `listen-intro-${language}`; const selected = dropAudio[language].current; if (!selected || !dropIns[language]) return; startReactiveAnalysis(`intro-${language}`); @@ -1481,6 +1557,7 @@ function ListenerPlayerController({ [language]: { current: 0, duration: current[language].duration }, })); audio!.currentTime = 0; + lastPlaybackAction.current = 'intro-handoff'; startReactiveAnalysis('beacon'); if (liveAudio.current && !liveAudio.current.paused) { cancelRecovery(true); @@ -1542,6 +1619,7 @@ function ListenerPlayerController({ } function skipToBeacon() { + lastPlaybackAction.current = 'skip-to-beacon'; dropGeneration.current += 1; setTransportPaused(false); startReactiveAnalysis('beacon'); diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index 1b01886e..b465678d 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -121,6 +121,7 @@ import ListenerPlayer, { getOrCreateEarlyBirdDeviceId, hlsFragmentProgramTimeMs, LISTENER_HLS_BUFFER_CONFIG, + LISTENER_PLAYBACK_DIAGNOSTIC_EVENT, LISTENER_PLAYBACK_PRESENCE_EVENT, nativeHlsProgramTimeMs, nextPresenceSequence, @@ -657,6 +658,81 @@ describe('EarlyBird Listener player', () => { expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument(); }); + it('detects a silent media-clock stall and rebuilds the same lease pipeline', async () => { + const intervals: Array<{ callback: () => void; delay: number }> = []; + vi.spyOn(window, 'setInterval').mockImplementation((callback, delay) => { + if (typeof callback === 'function') { + intervals.push({ callback: callback as () => void, delay: Number(delay) }); + } + return intervals.length as unknown as ReturnType; + }); + vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); + vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); + vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue(''); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + let monotonicNow = 0; + vi.spyOn(performance, 'now').mockImplementation(() => monotonicNow); + const manifestUrl = '/api/early-birds/stream/manifest?leaseId=00000000-0000-4000-8000-000000000003&leaseGeneration=1'; + const grant = { + leaseId: '00000000-0000-4000-8000-000000000003', + leaseGeneration: 1, + presenceSequence: 0, + leaseExpiresAt: '2099-08-06T12:03:00.000Z', + stream: { manifestUrl, expiresAt: '2099-08-06T12:03:00.000Z' }, + }; + const fetchMock = vi.fn().mockImplementation((url, init) => { + if (url === '/api/early-birds/stream/lease') { + return Promise.resolve(new Response(JSON.stringify(grant), { status: 200 })); + } + const body = JSON.parse(String(init?.body ?? '{}')) as { presenceSequence?: number }; + return Promise.resolve(new Response(JSON.stringify({ + leaseGeneration: 1, + presenceSequence: body.presenceSequence ?? 0, + leaseExpiresAt: '2099-08-06T12:04:00.000Z', + stream: { manifestUrl, expiresAt: '2099-08-06T12:04:00.000Z' }, + }), { status: 200 })); + }); + vi.stubGlobal('fetch', fetchMock); + const diagnostics: unknown[] = []; + const onDiagnostic: EventListener = (event) => { + diagnostics.push((event as CustomEvent).detail); + }; + window.addEventListener(LISTENER_PLAYBACK_DIAGNOSTIC_EVENT, onDiagnostic); + + render( + + + , + ); + await waitFor(() => expect(screen.getByRole('button', { name: 'Listen' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Listen' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument()); + const live = screen.getByLabelText('Beacon'); + Object.defineProperties(live, { + currentTime: { value: 120, writable: true, configurable: true }, + paused: { value: false, configurable: true }, + }); + const watchdog = intervals.find(({ delay }) => delay === 5_000); + expect(watchdog).toBeDefined(); + + watchdog!.callback(); + monotonicNow = 15_000; + watchdog!.callback(); + + await waitFor(() => expect(hlsHarness.instances).toHaveLength(2)); + expect(hlsHarness.instances[0].destroy).toHaveBeenCalledOnce(); + expect(hlsHarness.instances[1].loadedSources).toEqual([manifestUrl]); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + schemaVersion: 1, + reason: 'media-clock-stalled', + lease: { generation: 1 }, + }); + expect(JSON.stringify(diagnostics[0])).not.toMatch(/leaseId|account|email|cookie|token|url/i); + await waitFor(() => expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument()); + window.removeEventListener(LISTENER_PLAYBACK_DIAGNOSTIC_EVENT, onDiagnostic); + }); + it('keeps the pre-attached iOS source lease alive before the first play gesture', async () => { const intervalCallbacks: Array<() => void | Promise> = []; vi.spyOn(window, 'setInterval').mockImplementation((callback) => { @@ -1129,10 +1205,12 @@ describe('EarlyBird Listener player', () => { finishFirstPlay?.(); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3), { timeout: 3_000 }); - // Presence promotion/refresh never replaces the prepared generation or - // creates a second audio pipeline. - expect(hlsHarness.instances).toHaveLength(1); + // Recovery preserves the prepared lease generation while rebuilding + // the one hls.js pipeline. A control-plane interruption must not leave + // the original non-progressing instance attached. + expect(hlsHarness.instances).toHaveLength(2); + expect(hlsHarness.instances[0].destroy).toHaveBeenCalledOnce(); await waitFor(() => expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument()); - expect(hlsHarness.instances[0].loadedSources).toEqual([initialGrant.stream.manifestUrl]); + expect(hlsHarness.instances[1].loadedSources).toEqual([initialGrant.stream.manifestUrl]); }); }); diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts index 40bc3ac6..52a21b71 100644 --- a/src/lib/listener/__tests__/media-boundary.test.ts +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -5,13 +5,13 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const MEDIA_FILE_SHA256 = { - // Reviewed and re-pinned for the public server-side harmonic field. The - // browser no longer creates Web Audio nodes, adds crossOrigin or remounts - // media for analysis. This change only hides the tuning lab behind a - // server-side staging flag while leaving the accepted field enabled; - // HLS parameters, source URLs, media assets, element gain/fades and event + // Reviewed and re-pinned for the non-acoustic playback liveness recovery. + // A privacy-safe media-clock watchdog rebuilds the single hls.js instance + // after 15 seconds without progress and ordinary recovery now reattaches a + // verified lease even when its manifest URL is unchanged. HLS parameters, + // source URLs, media assets, element gain/fades, AudioContext and event // audio remain unchanged. - 'src/components/early-birds/ListenerPlayer.tsx': 'fd80bb558317c78c983e1ae623b7697bb778b44def0bafe0d0b4ca4e421cd358', + 'src/components/early-birds/ListenerPlayer.tsx': '8aa13c93b886ccf51aebead3b960fe7f6478933fed101dd6c296a524f0001234', 'src/lib/early-birds/stream.ts': '96a2d9fe798591833327631b59a73a5b2fc5ca06be7081945a0b07450970da84', 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', 'src/app/api/early-birds/stream/lease/route.ts': 'ec0e8780387bc1f493eb33d13a2d90e01cfdb6d899fc6232e04f51aaf2dfc508', diff --git a/src/lib/listener/__tests__/playback-liveness.test.ts b/src/lib/listener/__tests__/playback-liveness.test.ts new file mode 100644 index 00000000..d2acf51a --- /dev/null +++ b/src/lib/listener/__tests__/playback-liveness.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; + +import { + LISTENER_PLAYBACK_STALL_AFTER_MS, + ListenerPlaybackLivenessWatchdog, + listenerPlaybackObservation, +} from '../playback-liveness'; + +function observation(overrides: Partial[0]> = {}) { + return { + transport: 'beacon' as const, + lastAction: 'listen', + observedAtMs: 0, + media: { + currentTimeSeconds: 42, + paused: false, + ended: false, + readyState: 4, + networkState: 1, + muted: false, + volume: 0.7, + playbackRate: 1, + errorCode: null, + bufferedRangeCount: 1, + bufferedEndSeconds: 72, + seekableRangeCount: 1, + seekableEndSeconds: 90, + }, + lease: { generation: 3, presenceSequence: 7 }, + hls: { type: null, details: null, fatal: null }, + visibility: 'visible' as const, + ...overrides, + }; +} + +describe('ListenerPlaybackLivenessWatchdog', () => { + it('accepts slow progress and resets across a live-edge jump', () => { + const watchdog = new ListenerPlaybackLivenessWatchdog(); + expect(watchdog.observe(observation())).toBeNull(); + expect(watchdog.observe(observation({ + observedAtMs: LISTENER_PLAYBACK_STALL_AFTER_MS, + media: { ...observation().media, currentTimeSeconds: 42.1 }, + }))).toBeNull(); + expect(watchdog.observe(observation({ + observedAtMs: LISTENER_PLAYBACK_STALL_AFTER_MS * 2, + media: { ...observation().media, currentTimeSeconds: 8 }, + }))).toBeNull(); + }); + + it('reports a bounded privacy-safe snapshot after a silent media-clock stall', () => { + const watchdog = new ListenerPlaybackLivenessWatchdog(); + expect(watchdog.observe(observation())).toBeNull(); + const diagnostic = watchdog.observe(observation({ + observedAtMs: LISTENER_PLAYBACK_STALL_AFTER_MS, + })); + + expect(diagnostic).toMatchObject({ + schemaVersion: 1, + reason: 'media-clock-stalled', + stalledForMs: LISTENER_PLAYBACK_STALL_AFTER_MS, + lease: { generation: 3, presenceSequence: 7 }, + }); + expect(JSON.stringify(diagnostic)).not.toMatch(/leaseId|account|email|cookie|token|url/i); + }); + + it('classifies paused, ended and media-error failures without changing thresholds', () => { + for (const [field, expected] of [ + ['paused', 'paused-unexpectedly'], + ['ended', 'ended-unexpectedly'], + ] as const) { + const watchdog = new ListenerPlaybackLivenessWatchdog(); + expect(watchdog.observe(observation())).toBeNull(); + expect(watchdog.observe(observation({ + observedAtMs: LISTENER_PLAYBACK_STALL_AFTER_MS, + media: { ...observation().media, [field]: true }, + }))).toMatchObject({ reason: expected }); + } + + const watchdog = new ListenerPlaybackLivenessWatchdog(); + expect(watchdog.observe(observation())).toBeNull(); + expect(watchdog.observe(observation({ + observedAtMs: 1, + media: { ...observation().media, errorCode: 2 }, + }))).toMatchObject({ reason: 'media-error' }); + }); + + it('extracts range summaries without throwing or retaining range contents', () => { + const audio = document.createElement('audio'); + Object.defineProperties(audio, { + currentTime: { value: 12.5, configurable: true }, + buffered: { + value: { length: 1, start: () => 3, end: () => 18 }, + configurable: true, + }, + seekable: { + value: { length: 1, start: () => 0, end: () => 24 }, + configurable: true, + }, + }); + const snapshot = listenerPlaybackObservation({ + audio, + observedAtMs: 100, + lastAction: 'listen', + leaseGeneration: 2, + presenceSequence: 4, + hlsSignal: { type: 'networkError', details: 'manifestLoadError', fatal: false }, + visibility: 'visible', + }); + expect(snapshot.media).toMatchObject({ + currentTimeSeconds: 12.5, + bufferedRangeCount: 1, + bufferedEndSeconds: 18, + seekableRangeCount: 1, + seekableEndSeconds: 24, + }); + }); +}); diff --git a/src/lib/listener/playback-liveness.ts b/src/lib/listener/playback-liveness.ts new file mode 100644 index 00000000..c479e443 --- /dev/null +++ b/src/lib/listener/playback-liveness.ts @@ -0,0 +1,154 @@ +export const LISTENER_PLAYBACK_WATCHDOG_INTERVAL_MS = 5_000; +export const LISTENER_PLAYBACK_STALL_AFTER_MS = 15_000; + +const MEDIA_PROGRESS_EPSILON_SECONDS = 0.05; + +export type ListenerPlaybackStallReason = + | 'media-error' + | 'paused-unexpectedly' + | 'ended-unexpectedly' + | 'media-clock-stalled'; + +export type ListenerPlaybackDiagnostic = { + schemaVersion: 1; + reason: ListenerPlaybackStallReason; + transport: 'beacon'; + lastAction: string; + observedAtMs: number; + stalledForMs: number; + media: { + currentTimeSeconds: number; + paused: boolean; + ended: boolean; + readyState: number; + networkState: number; + muted: boolean; + volume: number; + playbackRate: number; + errorCode: number | null; + bufferedRangeCount: number; + bufferedEndSeconds: number | null; + seekableRangeCount: number; + seekableEndSeconds: number | null; + }; + lease: { + generation: number | null; + presenceSequence: number; + }; + hls: { + type: string | null; + details: string | null; + fatal: boolean | null; + }; + visibility: DocumentVisibilityState; +}; + +type LivenessObservation = Omit; + +function finiteOr(value: number, fallback: number): number { + return Number.isFinite(value) ? value : fallback; +} + +function lastRangeEnd(ranges: TimeRanges): number | null { + if (ranges.length < 1) return null; + try { + const value = ranges.end(ranges.length - 1); + return Number.isFinite(value) ? value : null; + } catch { + return null; + } +} + +/** + * Process-local media clock observer. It stores no identity, URL, token, + * account or raw browser metadata and never changes the audio graph. + */ +export class ListenerPlaybackLivenessWatchdog { + private lastMediaTimeSeconds: number | null = null; + private lastProgressAtMs: number | null = null; + + reset(): void { + this.lastMediaTimeSeconds = null; + this.lastProgressAtMs = null; + } + + observe(input: LivenessObservation): ListenerPlaybackDiagnostic | null { + const currentTimeSeconds = finiteOr(input.media.currentTimeSeconds, 0); + const observedAtMs = finiteOr(input.observedAtMs, 0); + const previousTime = this.lastMediaTimeSeconds; + const progressed = previousTime === null + || Math.abs(currentTimeSeconds - previousTime) >= MEDIA_PROGRESS_EPSILON_SECONDS; + + if (progressed) { + this.lastMediaTimeSeconds = currentTimeSeconds; + this.lastProgressAtMs = observedAtMs; + return null; + } + + if (this.lastProgressAtMs === null) this.lastProgressAtMs = observedAtMs; + const stalledForMs = Math.max(0, observedAtMs - this.lastProgressAtMs); + const reason: ListenerPlaybackStallReason | null = input.media.errorCode !== null + ? 'media-error' + : stalledForMs < LISTENER_PLAYBACK_STALL_AFTER_MS + ? null + : input.media.paused + ? 'paused-unexpectedly' + : input.media.ended + ? 'ended-unexpectedly' + : 'media-clock-stalled'; + + if (!reason) return null; + return { + schemaVersion: 1, + ...input, + reason, + stalledForMs, + media: { ...input.media, currentTimeSeconds }, + }; + } +} + +export function listenerPlaybackObservation({ + audio, + observedAtMs, + lastAction, + leaseGeneration, + presenceSequence, + hlsSignal, + visibility, +}: { + audio: HTMLMediaElement; + observedAtMs: number; + lastAction: string; + leaseGeneration: number | null; + presenceSequence: number; + hlsSignal: { type: string | null; details: string | null; fatal: boolean | null }; + visibility: DocumentVisibilityState; +}): LivenessObservation { + return { + transport: 'beacon', + lastAction: lastAction.slice(0, 48), + observedAtMs, + media: { + currentTimeSeconds: finiteOr(audio.currentTime, 0), + paused: audio.paused, + ended: audio.ended, + readyState: audio.readyState, + networkState: audio.networkState, + muted: audio.muted, + volume: finiteOr(audio.volume, 0), + playbackRate: finiteOr(audio.playbackRate, 1), + errorCode: audio.error?.code ?? null, + bufferedRangeCount: audio.buffered.length, + bufferedEndSeconds: lastRangeEnd(audio.buffered), + seekableRangeCount: audio.seekable.length, + seekableEndSeconds: lastRangeEnd(audio.seekable), + }, + lease: { + generation: leaseGeneration, + presenceSequence, + }, + hls: { ...hlsSignal }, + visibility, + }; +} From a71e02b910ce0cf9ab96064c9c70bc20832839d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 15:43:40 -0300 Subject: [PATCH 209/376] docs(listener): reconcile commercial launch checkpoint --- docs/architecture/EARLY_BIRDS_MAGIC_LINK.md | 22 +++++++++--------- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 23 +++++++++++++++---- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 10 ++++---- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 13 +++++------ 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md b/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md index b8569491..7afc4e66 100644 --- a/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md +++ b/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md @@ -1,7 +1,7 @@ # Founding Listener email magic-link boundary -Status: Listener side implemented; private mail adapter pending in -`SairaAsua/proyecciones-mito`. +Status: Listener and private delivery implemented. The dedicated mail sidecar is +being rolled out independently from the event runtime. This fallback reuses the deployed Google Workspace/Gmail delivery capability without copying its OAuth grant into the Listener container. It is additive to @@ -56,7 +56,7 @@ Body (`listener-magic-link.v1`): The endpoint must: 1. exist only on the private `earlybirds_authority_private` network under the - existing `pmp-myth-api` alias; + 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 @@ -68,24 +68,24 @@ The endpoint must: its current ambiguous-outcome semantics; 7. return a minimal `202 {"status":"accepted"}` for accepted or replayed work. -The current deployed PMP service already owns the Gmail API OAuth grant for the -Google Workspace sender and its worker/durable delivery machinery. It does not -yet expose this purpose/endpoint. That small adapter belongs in -`proyecciones-mito`; mounting the same grant in the Listener would create a -second email authority and is explicitly rejected. +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 -EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL=http://pmp-myth-api:8765/api/internal/v1/listener-magic-links/deliver +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`, configure the -mail adapter first, then install the three protected Listener values and +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. diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index e3ba82fa..84b1da9f 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,6 +1,19 @@ # EarlyBirds isolated staging runtime -## 2026-08-12 terminal-status checkpoint deployed with Live sales OFF +## 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 @@ -621,16 +634,16 @@ Return both switches to `0` after the supervised team window. ### Optional email magic-link fallback -The fallback remains absent until the existing PMP mail authority implements -the exact private contract in +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. After that adapter is deployed on +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 -EARLY_BIRDS_MAGIC_LINK_DELIVERY_URL=http://pmp-myth-api:8765/api/internal/v1/listener-magic-links/deliver +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= ``` diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index acd62d2d..658b55dc 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -28,12 +28,12 @@ truthful launch baseline, not a substitute for counsel review. Human owner: Nico database backup was restored into an isolated rehearsal database and verified. - Production provider and new-sales flags remain OFF. No real payment was attempted. -Backend magic-link delivery is merged at `c443a7ec9b387fa54ff16904e1a5d561613ec102` but remains -inert until an event-safe PMP maintenance window. The other remaining gates are human/external: +Backend magic-link delivery is merged at `c443a7ec9b387fa54ff16904e1a5d561613ec102` and its +dedicated Listener-only sidecar is being deployed without an event maintenance window. The other +remaining gates are human/external: final legal/copy acceptance, protected Live credentials, Google OAuth secret rotation (#328), one supervised low-value Live lifecycle per enabled provider and explicit approval to open public -checkout. Production font builds must also be made hermetic under #327; retries against Google -Fonts are evidence of a release defect, not a durable build strategy. +checkout. Production fonts are now hermetic under #327/#329. ## Independent switches @@ -106,6 +106,6 @@ converted back into a reversible action. - PayPal Live Business account/app/product/plan/webhook and root-only Live secrets. - Mercado Pago productive merchant credentials/webhook and root-only Live secrets. - Counsel/merchant review of public terms, privacy, refund and tax/invoicing obligations. -- Hermetic production fonts (#327) and controlled Google OAuth secret rotation (#328). +- Controlled Google OAuth secret rotation (#328); hermetic fonts are complete (#327/#329). - One supervised real purchase and cancellation per provider. - Explicit approval to turn on real sales. The checked-in defaults remain OFF. diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index cd7f01d0..21f4d406 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -23,10 +23,10 @@ The weekly-Free candidate has advanced to a complete Founding Listener pre-relea - production provider adapters and public checkout present but fail-closed/default-off. The release is not yet authorized for real sales. Backend magic-link delivery is merged at -`SairaAsua/proyecciones-mito@c443a7ec9b387fa54ff16904e1a5d561613ec102` but still needs an event-safe -runtime rollout. Remaining gates also include final ES/EN legal/copy review, controlled rotation of the exposed Google OAuth +`SairaAsua/proyecciones-mito@c443a7ec9b387fa54ff16904e1a5d561613ec102`; its dedicated sidecar rollout +is isolated from the event runtime. Remaining gates include final ES/EN legal/copy review, controlled rotation of the exposed Google OAuth client secret, protected PayPal/MP Live credentials, one supervised low-value Live lifecycle per -provider, a hermetic font build and explicit main/public-sales approval. See +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 @@ -188,12 +188,11 @@ worksheet. and support copy. 2. Rotate the exposed Google OAuth client secret through the protected store and re-run callback/logout without printing it. -3. Self-host the approved fonts so the release build has no Google Fonts network dependency (#327). -4. Deploy backend #44 in an event-safe maintenance window and prove one controlled +3. Prove one controlled magic-link request, email, callback and Free entry. -5. Install protected PayPal and Mercado Pago Live credentials with all sales +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. -6. Approve merge to `main` and public checkout separately; retain the immediate +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, From 9e0c57dff6cfdfdb78cd1af0175edb70476c0094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 16:15:05 -0300 Subject: [PATCH 210/376] docs(listener): record launch-now checkpoint --- docs/ROADMAP.md | 7 +++ docs/architecture/EARLY_BIRDS_MAGIC_LINK.md | 15 ++++- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 15 ++++- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 29 +++++---- docs/operations/LISTENER_LAUNCH_NOW.md | 63 +++++++++++++++++++ docs/plans/EARLY_BIRDS.md | 8 +++ 6 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 docs/operations/LISTENER_LAUNCH_NOW.md 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/architecture/EARLY_BIRDS_MAGIC_LINK.md b/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md index 7afc4e66..ae693b32 100644 --- a/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md +++ b/docs/architecture/EARLY_BIRDS_MAGIC_LINK.md @@ -1,7 +1,9 @@ # Founding Listener email magic-link boundary -Status: Listener and private delivery implemented. The dedicated mail sidecar is -being rolled out independently from the event runtime. +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 @@ -91,7 +93,14 @@ 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 schedule and Listener. Negative checks cover unknown addresses, social +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/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 658b55dc..66d39bea 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -28,13 +28,20 @@ truthful launch baseline, not a substitute for counsel review. Human owner: Nico database backup was restored into an isolated rehearsal database and verified. - Production provider and new-sales flags remain OFF. No real payment was attempted. -Backend magic-link delivery is merged at `c443a7ec9b387fa54ff16904e1a5d561613ec102` and its -dedicated Listener-only sidecar is being deployed without an event maintenance window. The other -remaining gates are human/external: +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 human callback, Free-entry and logout check +remain. The other remaining gates are human/external: final legal/copy acceptance, protected Live credentials, Google OAuth secret rotation (#328), one supervised low-value Live lifecycle per enabled provider and explicit approval to open public checkout. Production fonts are now hermetic under #327/#329. +The exact public Listener image is +`4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a`. Health and readiness attest that SHA. The previous +same-schema Listener image `fcdde379` remains available as the bounded application rollback target; +the weekly-quota database policy itself is forward-only. + ## Independent switches Listener app, all default OFF: @@ -109,3 +116,5 @@ converted back into a reversible action. - Controlled Google OAuth secret rotation (#328); hermetic fonts are complete (#327/#329). - 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`. diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index 21f4d406..fe4c781c 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -22,9 +22,11 @@ The weekly-Free candidate has advanced to a complete Founding Listener pre-relea - 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 real sales. Backend magic-link delivery is merged at -`SairaAsua/proyecciones-mito@c443a7ec9b387fa54ff16904e1a5d561613ec102`; its dedicated sidecar rollout -is isolated from the event runtime. Remaining gates include final ES/EN legal/copy review, controlled rotation of the exposed Google OAuth +The release is not yet authorized for 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. Remaining gates +include final ES/EN legal/copy review, controlled rotation of the exposed Google OAuth client secret, protected PayPal/MP Live credentials, 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. @@ -49,10 +51,12 @@ repair, never restoring daily/welcome authorization. | Artifact | Exact value | |---|---| -| Deployed Listener application | `1f8368d2fda19b30b74c95af884d862838f73305` | -| Operational smoke/documentation head | `8444ed7d06b2764c519f65ce4d32932346a94fdd` | +| Deployed Listener application | `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` | +| Previous same-schema application rollback | `fcdde379` | +| Commercial checkpoint documentation | `78ede811161cf47104f3758e6703d06d2328ea6f` | | Listener database schema | `20260808160000_listener_weekly_quota` | -| Authority application | `21c3637ee0f520ee79d20c247e2914699ed8a73a` | +| Canonical payment authority application | `60584936603525027c9891e0865efc58055a3d5d` | +| 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 | @@ -70,7 +74,7 @@ test-only branch head. | 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 | Listener ready; authority blocked | #221 is merged and hidden/fail-closed until the existing Gmail authority implements `SairaAsua/proyecciones-mito#44` and protected delivery values are installed. | +| 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. | @@ -139,8 +143,8 @@ deployed image; later documentation-only commits do not require rebuilding it. - 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 `1f8368d`. Image `ae1d0ba` remains the same-schema recovery - target for this visual-only release; earlier policy images are historical and +- 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 fixed public-disable command was exercised after deployment. Its first health probe observed the normal Next.js startup connection reset, retried, @@ -162,9 +166,10 @@ deployed image; later documentation-only commits do not require rebuilding it. - #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 while backend #44 is merged but not yet deployed to the - shared PMP email runtime. Its rollout needs an event-safe maintenance window, - a dedicated service token and one controlled Gmail callback smoke. +- #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. diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md new file mode 100644 index 00000000..fc94cea1 --- /dev/null +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -0,0 +1,63 @@ +# Listener launch — current state + +Last reconciled: 2026-08-12 + +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: `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` +- Previous same-schema Listener rollback image: `fcdde379` +- Canonical payment authority: `60584936603525027c9891e0865efc58055a3d5d` +- 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 +- Authority paid checkout/providers: OFF +- Public sales and real charges: not authorized + +PayPal Sandbox has passed activation, pending cancellation, reactivation and +terminal refund. Mercado Pago TEST has passed checkout, activation, pause, +reactivation and reconciliation. Browser redirects never grant membership. + +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 human callback/Free/logout check remains. + +## Remaining blockers to public sales + +1. #217 — open the delivered magic link and prove email-only session → Free → logout. +2. #304 — complete a physical 60-minute listen and record any watchdog recovery. +3. #317 — final mobile/account-menu billing acceptance. +4. #318 — human ES/EN offer/legal/seller/refund/support acceptance. +5. #328 — rotate the exposed Google OAuth client secret in the protected store, + then prove canonical and staging login/logout/relogin without printing it. +6. Install protected PayPal Live and Mercado Pago productive credentials while + every sales/provider flag remains OFF. +7. With explicit approval, execute one supervised low-value activation, + cancellation and refund per provider. +8. 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. +- Listener application regression: roll back only the isolated Listener to + `fcdde379` if contract-compatible; otherwise disable Listener and 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. diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index e7309ed9..238962d7 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -7,6 +7,14 @@ > 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-12):** the public Listener candidate runs exact +> SHA `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a`; canonical payment authority runs +> `60584936603525027c9891e0865efc58055a3d5d`; the isolated mail sidecar runs +> `456ece2b38e203a2d12c54864115e03ebaa1a89c`. PayPal Sandbox and Mercado Pago +> TEST lifecycles are accepted. Live providers, new sales and public checkout +> remain OFF. 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 From aac6c5c40b21661125b037353b5ddd116303e1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 16:29:02 -0300 Subject: [PATCH 211/376] docs(listener): reconcile payment processor truth --- README.md | 2 +- docs/MONETIZATION.md | 13 +++++++++---- docs/RESEARCH_PROTOCOL.md | 15 ++++++++++----- docs/operations/LISTENER_LAUNCH_NOW.md | 10 ++++++++++ 4 files changed, 30 insertions(+), 10 deletions(-) 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/docs/MONETIZATION.md b/docs/MONETIZATION.md index b9f86c36..bd45c0bd 100644 --- a/docs/MONETIZATION.md +++ b/docs/MONETIZATION.md @@ -246,10 +246,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]** - -- **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. +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. + +- **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/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/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index fc94cea1..506f6a4a 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -19,6 +19,7 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and - PayPal Live checkout: OFF - Mercado Pago Live checkout: OFF - Authority paid checkout/providers: OFF +- Authority Sandbox/TEST new-sales gate: ON only inside isolated staging acceptance; Live metrics remain zero/OFF - Public sales and real charges: not authorized PayPal Sandbox has passed activation, pending cancellation, reactivation and @@ -61,3 +62,12 @@ without explicit approval. authority. - Magic delivery incident: clear the three protected magic-link values and recreate only Listener; do not restart the event runtime. + +## 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 Live billing is active; +productive credentials, real charges and public checkout remain OFF. From 9acfb1a0610beaa5577c455f803936f701f4f439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 16:40:02 -0300 Subject: [PATCH 212/376] docs(listener): record Live preflight checkpoint --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 16 ++++++++++- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 28 ++++++++++++++----- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 8 +++++- docs/operations/LISTENER_LAUNCH_NOW.md | 18 ++++++++++-- docs/plans/EARLY_BIRDS.md | 7 +++-- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 84b1da9f..d738bf82 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,5 +1,19 @@ # EarlyBirds isolated staging runtime +## 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 @@ -19,7 +33,7 @@ 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 runs merge SHA `60584936603525027c9891e0865efc58055a3d5d` +authority then ran merge SHA `60584936603525027c9891e0865efc58055a3d5d` at unchanged Alembic head `7b4c1e9a2d60`. - PayPal Live, Mercado Pago Live and both public Listener checkout flags are diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 66d39bea..9ec6071d 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -42,6 +42,13 @@ The exact public Listener image is same-schema Listener image `fcdde379` remains available as the bounded application rollback target; the weekly-quota database policy itself is forward-only. +The exact isolated payment-authority image is +`8e10f16fe3471a097021f7f1ee41eb8f88f4f154`. Its read-only Live preflight is deployed, health is +green and its migration completed with exit `0`. PayPal Live and Mercado Pago Live remain disabled +and report zero provider-readiness/new-sales metrics because productive credentials are absent. +The previous authority image `60584936603525027c9891e0865efc58055a3d5d` and protected backup +`/mnt/beacon-data/staging-backups/authority-live-preflight-20260812T193128Z` are retained for rollback. + ## Independent switches Listener app, all default OFF: @@ -83,17 +90,24 @@ converted back into a reversible action. 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 new-sales flags OFF. Enable one provider lifecycle and validate private readiness, - catalog/merchant identity, signed-webhook negative cases and reconciliation. -4. Install the reviewed Listener nginx template and verify exact routes plus final 404. Do not +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. The + 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. -5. Enable the matching Listener checkout flag only after the authority reports that Live provider +6. Enable the matching Listener checkout flag only after the authority reports that Live provider ready and the public copy/terms have human approval. -6. Execute one supervised real USD 5 membership with an agreed account. Confirm provider event, +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. -7. Request cancellation in the profile. Confirm provider cancellation, pending-end projection and +8. Request cancellation in the profile. Confirm provider cancellation, pending-end projection and access through paid-through. Use a separate controlled account to rehearse failure/refund. -8. Expand availability only after webhook/reconciliation lag and alerts remain healthy. +9. Expand availability only after webhook/reconciliation lag and alerts remain healthy. ## Incident and rollback diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index fe4c781c..40720776 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -55,11 +55,17 @@ repair, never restoring daily/welcome authorization. | Previous same-schema application rollback | `fcdde379` | | Commercial checkpoint documentation | `78ede811161cf47104f3758e6703d06d2328ea6f` | | Listener database schema | `20260808160000_listener_weekly_quota` | -| Canonical payment authority application | `60584936603525027c9891e0865efc58055a3d5d` | +| Canonical payment authority application | `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` | | 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 a read-only Live provider preflight only. Live +credentials are not installed, Live provider/new-sales metrics remain zero and +no real charge is authorized. Authority rollback retains exact previous image +`60584936603525027c9891e0865efc58055a3d5d` plus protected backup +`/mnt/beacon-data/staging-backups/authority-live-preflight-20260812T193128Z`. + Health must attest the deployed application SHA, not the later documentation or test-only branch head. diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index 506f6a4a..0d6bbe36 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -11,7 +11,8 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and - Public candidate: `https://listen.harmonicbeacon.com/` - Listener image/SHA: `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` - Previous same-schema Listener rollback image: `fcdde379` -- Canonical payment authority: `60584936603525027c9891e0865efc58055a3d5d` +- Canonical payment authority: `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` +- Previous authority rollback image: `60584936603525027c9891e0865efc58055a3d5d` - Listener mail sidecar: `456ece2b38e203a2d12c54864115e03ebaa1a89c` - Weekly Free: three hours per server-owned seven-day cycle - Founding Listener: USD 5/month while service remains uninterrupted @@ -22,6 +23,15 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and - Authority Sandbox/TEST new-sales gate: ON only inside isolated staging acceptance; Live metrics remain zero/OFF - Public sales and real charges: not authorized +The authority now includes a read-only Live-provider preflight. It validates the +exact PayPal Live product, plan and webhook event set and the Mercado Pago Live +merchant identity without creating checkouts, subscriptions or payments. The +deployed authority is healthy with migration exit `0`; PayPal Live and Mercado +Pago Live readiness/new-sales metrics are both `0`. Productive credentials are +not installed, so the preflight cannot yet reach either Live provider. The +global staging new-sales gate deliberately remains ON for Sandbox/TEST and makes +the Live preflight fail closed until an operator explicitly disables it. + PayPal Sandbox has passed activation, pending cancellation, reactivation and terminal refund. Mercado Pago TEST has passed checkout, activation, pause, reactivation and reconciliation. Browser redirects never grant membership. @@ -39,7 +49,8 @@ event runtime and have no host ports. A controlled Gmail delivery reached 5. #328 — rotate the exposed Google OAuth client secret in the protected store, then prove canonical and staging login/logout/relogin without printing it. 6. Install protected PayPal Live and Mercado Pago productive credentials while - every sales/provider flag remains OFF. + every Live sales/provider flag remains OFF; temporarily disable the global + staging new-sales gate and run the read-only provider preflight. 7. With explicit approval, execute one supervised low-value activation, cancellation and refund per provider. 8. Obtain separate explicit approvals for merge to `main` and public checkout. @@ -56,6 +67,9 @@ without explicit approval. - Commerce incident: switch OFF Listener checkout flags and authority new-sales; keep webhooks, reconciliation, cancellation and existing access running. +- Authority application regression: restore exact image `60584936603525027c9891e0865efc58055a3d5d` + with the protected pre-deploy configuration/database backup at + `/mnt/beacon-data/staging-backups/authority-live-preflight-20260812T193128Z`. - Listener application regression: roll back only the isolated Listener to `fcdde379` if contract-compatible; otherwise disable Listener and roll forward. - Weekly quota is forward-only. Never restore the retired daily-window/welcome diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 238962d7..12a1269d 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -9,7 +9,7 @@ > **Current launch memory (2026-08-12):** the public Listener candidate runs exact > SHA `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a`; canonical payment authority runs -> `60584936603525027c9891e0865efc58055a3d5d`; the isolated mail sidecar runs +> `8e10f16fe3471a097021f7f1ee41eb8f88f4f154`; the isolated mail sidecar runs > `456ece2b38e203a2d12c54864115e03ebaa1a89c`. PayPal Sandbox and Mercado Pago > TEST lifecycles are accepted. Live providers, new sales and public checkout > remain OFF. See `docs/operations/LISTENER_LAUNCH_NOW.md` for the few remaining @@ -493,10 +493,13 @@ The webapp vendors byte-exact copies of the canonical backend contracts under 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 - `60584936603525027c9891e0865efc58055a3d5d` is CI-green and includes canonical + `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` is CI-green and includes canonical cancellation/reactivation plus paid-lifecycle metrics. 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 remain absent and all Live flags remain OFF. ## 11. Fast Forward development lane From 465b5e66fc537ffcc1cf6af11230094b6aca1c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 18:59:47 -0300 Subject: [PATCH 213/376] docs(listener): record hardened Live authority deploy --- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 18 +++++---- docs/operations/LISTENER_LAUNCH_NOW.md | 38 +++++++++---------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 9ec6071d..982215ec 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -33,7 +33,7 @@ SHA `456ece2b38e203a2d12c54864115e03ebaa1a89c`. The API, worker and PostgreSQL q ports, use separate storage and did not restart or modify any event service. A controlled message reached Gmail with terminal delivery state `SENT`; the human callback, Free-entry and logout check remain. The other remaining gates are human/external: -final legal/copy acceptance, protected Live credentials, Google OAuth secret rotation (#328), one +final legal/copy acceptance, Google OAuth secret rotation (#328), one supervised low-value Live lifecycle per enabled provider and explicit approval to open public checkout. Production fonts are now hermetic under #327/#329. @@ -43,11 +43,15 @@ same-schema Listener image `fcdde379` remains available as the bounded applicati the weekly-quota database policy itself is forward-only. The exact isolated payment-authority image is -`8e10f16fe3471a097021f7f1ee41eb8f88f4f154`. Its read-only Live preflight is deployed, health is -green and its migration completed with exit `0`. PayPal Live and Mercado Pago Live remain disabled -and report zero provider-readiness/new-sales metrics because productive credentials are absent. -The previous authority image `60584936603525027c9891e0865efc58055a3d5d` and protected backup -`/mnt/beacon-data/staging-backups/authority-live-preflight-20260812T193128Z` are retained for rollback. +`b1038ddb579817e39add567c5b7b055e2f716095`. It includes the reviewed Mercado Pago adverse-event +hardening from backend PR #80. 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. No checkout, subscription or payment was created. The previous authority +image `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` and protected pre-deploy backup +`/var/backups/harmonic-beacon/earlybirds-authority-pre-b1038ddb579817e39add567c5b7b055e2f716095.sql.gz` +are retained for rollback. ## Independent switches @@ -124,8 +128,6 @@ converted back into a reversible action. ## Human release gates still required -- PayPal Live Business account/app/product/plan/webhook and root-only Live secrets. -- Mercado Pago productive merchant credentials/webhook and root-only Live secrets. - Counsel/merchant review of public terms, privacy, refund and tax/invoicing obligations. - Controlled Google OAuth secret rotation (#328); hermetic fonts are complete (#327/#329). - One supervised real purchase and cancellation per provider. diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index 0d6bbe36..e6bf07b8 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -11,26 +11,26 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and - Public candidate: `https://listen.harmonicbeacon.com/` - Listener image/SHA: `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` - Previous same-schema Listener rollback image: `fcdde379` -- Canonical payment authority: `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` -- Previous authority rollback image: `60584936603525027c9891e0865efc58055a3d5d` +- Canonical payment authority: `b1038ddb579817e39add567c5b7b055e2f716095` +- Previous authority rollback image: `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` - 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 -- Authority paid checkout/providers: OFF +- Authority Live providers: OFF - Authority Sandbox/TEST new-sales gate: ON only inside isolated staging acceptance; Live metrics remain zero/OFF - Public sales and real charges: not authorized -The authority now includes a read-only Live-provider preflight. It validates the -exact PayPal Live product, plan and webhook event set and the Mercado Pago Live -merchant identity without creating checkouts, subscriptions or payments. The -deployed authority is healthy with migration exit `0`; PayPal Live and Mercado -Pago Live readiness/new-sales metrics are both `0`. Productive credentials are -not installed, so the preflight cannot yet reach either Live provider. The -global staging new-sales gate deliberately remains ON for Sandbox/TEST and makes -the Live preflight fail closed until an operator explicitly disables it. +The authority now includes the reviewed adverse-event hardening and a read-only +Live-provider preflight. The deployed API/worker are healthy at exact revision +`b1038ddb`; 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 normal runtime deliberately remains on +Sandbox/TEST for acceptance, with both Live provider flags OFF. PayPal Sandbox has passed activation, pending cancellation, reactivation and terminal refund. Mercado Pago TEST has passed checkout, activation, pause, @@ -48,11 +48,10 @@ event runtime and have no host ports. A controlled Gmail delivery reached 4. #318 — human ES/EN offer/legal/seller/refund/support acceptance. 5. #328 — rotate the exposed Google OAuth client secret in the protected store, then prove canonical and staging login/logout/relogin without printing it. -6. Install protected PayPal Live and Mercado Pago productive credentials while - every Live sales/provider flag remains OFF; temporarily disable the global - staging new-sales gate and run the read-only provider preflight. -7. With explicit approval, execute one supervised low-value activation, +6. With explicit approval, execute one supervised low-scope activation, cancellation and refund per provider. +7. Confirm Founder activation, terminal Free fallback, metrics, alerts and the + absence of PII/secret leakage against those Live transactions. 8. Obtain separate explicit approvals for merge to `main` and public checkout. ## Non-negotiable isolation @@ -67,9 +66,9 @@ without explicit approval. - Commerce incident: switch OFF Listener checkout flags and authority new-sales; keep webhooks, reconciliation, cancellation and existing access running. -- Authority application regression: restore exact image `60584936603525027c9891e0865efc58055a3d5d` +- Authority application regression: restore exact image `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` with the protected pre-deploy configuration/database backup at - `/mnt/beacon-data/staging-backups/authority-live-preflight-20260812T193128Z`. + `/var/backups/harmonic-beacon/earlybirds-authority-pre-b1038ddb579817e39add567c5b7b055e2f716095.sql.gz`. - Listener application regression: roll back only the isolated Listener to `fcdde379` if contract-compatible; otherwise disable Listener and roll forward. - Weekly quota is forward-only. Never restore the retired daily-window/welcome @@ -83,5 +82,6 @@ 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 Live billing is active; -productive credentials, real charges and public checkout remain OFF. +pre-release processors. Equally, copy must not claim Live billing is active: +productive credentials are installed and verified, but both Live providers, +real charges and public checkout remain OFF. From f4341240202efef1d0380cc527028a3280482c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Wed, 12 Aug 2026 19:17:20 -0300 Subject: [PATCH 214/376] docs(listener): close Google OAuth rotation gate --- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 9 +++++++-- docs/operations/LISTENER_LAUNCH_NOW.md | 16 +++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 982215ec..4e9b7676 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -33,10 +33,16 @@ SHA `456ece2b38e203a2d12c54864115e03ebaa1a89c`. The API, worker and PostgreSQL q ports, use separate storage and did not restart or modify any event service. A controlled message reached Gmail with terminal delivery state `SENT`; the human callback, Free-entry and logout check remain. The other remaining gates are human/external: -final legal/copy acceptance, Google OAuth secret rotation (#328), one +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 `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a`. Health and readiness attest that SHA. The previous same-schema Listener image `fcdde379` remains available as the bounded application rollback target; @@ -129,7 +135,6 @@ converted back into a reversible action. ## Human release gates still required - Counsel/merchant review of public terms, privacy, refund and tax/invoicing obligations. -- Controlled Google OAuth secret rotation (#328); hermetic fonts are complete (#327/#329). - One supervised real purchase and cancellation per provider. - Explicit approval to turn on real sales. The checked-in defaults remain OFF. diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index e6bf07b8..8ceaad1e 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -40,19 +40,22 @@ 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 human callback/Free/logout check remains. +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. #217 — open the delivered magic link and prove email-only session → Free → logout. 2. #304 — complete a physical 60-minute listen and record any watchdog recovery. 3. #317 — final mobile/account-menu billing acceptance. 4. #318 — human ES/EN offer/legal/seller/refund/support acceptance. -5. #328 — rotate the exposed Google OAuth client secret in the protected store, - then prove canonical and staging login/logout/relogin without printing it. -6. With explicit approval, execute one supervised low-scope activation, +5. With explicit approval, execute one supervised low-scope activation, cancellation and refund per provider. -7. Confirm Founder activation, terminal Free fallback, metrics, alerts and the +6. Confirm Founder activation, terminal Free fallback, metrics, alerts and the absence of PII/secret leakage against those Live transactions. -8. Obtain separate explicit approvals for merge to `main` and public checkout. +7. Obtain separate explicit approvals for merge to `main` and public checkout. ## Non-negotiable isolation @@ -75,6 +78,9 @@ without explicit approval. 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 From 5d9af3d8bcbcd637cdca6c57f8b4385485b5ece9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 01:25:47 -0300 Subject: [PATCH 215/376] docs(listener): enforce post-live rollback floor --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 16 +++++++++ .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 23 +++++++++++-- .../FOUNDING_LISTENER_RELEASE_CANDIDATE.md | 34 +++++++++++-------- docs/operations/LISTENER_LAUNCH_NOW.md | 32 ++++++++++------- docs/plans/EARLY_BIRDS.md | 18 ++++++---- 5 files changed, 88 insertions(+), 35 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index d738bf82..746e5feb 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -1,5 +1,21 @@ # 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 diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 4e9b7676..fc14627a 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -54,10 +54,14 @@ hardening from backend PR #80. API and worker are healthy, Alembic is at head `7 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. No checkout, subscription or payment was created. The previous authority -image `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` and protected pre-deploy backup +new sales forced OFF. On 2026-08-13, a supervised PayPal Live approval intent was created for the +USD 5 offer; it is awaiting approval by a buyer account different from the merchant, and created +no subscription or charge. New sales were immediately returned to OFF while PayPal Live lifecycle +ingestion remains ON. The former authority image +`8e10f16fe3471a097021f7f1ee41eb8f88f4f154` and protected pre-deploy backup `/var/backups/harmonic-beacon/earlybirds-authority-pre-b1038ddb579817e39add567c5b7b055e2f716095.sql.gz` -are retained for rollback. +are retained only as pre-Live forensic/disaster-recovery artifacts; they are no longer routine +rollback targets. ## Independent switches @@ -123,7 +127,20 @@ converted back into a reversible action. - 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 the first Live checkout attempt, provider binding or event, + `b1038ddb579817e39add567c5b7b055e2f716095` is the minimum supported authority binary. Do not run + `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` against the current database and do not routinely + restore the pre-`b1038` database backup. The older binary predates required Mercado Pago + adverse-event hardening and a database restore 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 `b1038`-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. `fcdde379` remains the bounded contract-compatible UI rollback for the current authority. 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. diff --git a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md index 40720776..3b114ff5 100644 --- a/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md +++ b/docs/operations/FOUNDING_LISTENER_RELEASE_CANDIDATE.md @@ -22,13 +22,13 @@ The weekly-Free candidate has advanced to a complete Founding Listener pre-relea - 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 real sales. Passwordless email delivery is deployed in an +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. Remaining gates -include final ES/EN legal/copy review, controlled rotation of the exposed Google OAuth -client secret, protected PayPal/MP Live credentials, one supervised low-value Live lifecycle per -provider and explicit main/public-sales approval. Hermetic fonts are complete. See +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 @@ -55,16 +55,18 @@ repair, never restoring daily/welcome authorization. | Previous same-schema application rollback | `fcdde379` | | Commercial checkpoint documentation | `78ede811161cf47104f3758e6703d06d2328ea6f` | | Listener database schema | `20260808160000_listener_weekly_quota` | -| Canonical payment authority application | `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` | +| 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 a read-only Live provider preflight only. Live -credentials are not installed, Live provider/new-sales metrics remain zero and -no real charge is authorized. Authority rollback retains exact previous image -`60584936603525027c9891e0865efc58055a3d5d` plus protected backup -`/mnt/beacon-data/staging-backups/authority-live-preflight-20260812T193128Z`. +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. @@ -152,6 +154,9 @@ deployed image; later documentation-only commits do not require rebuilding it. - 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. @@ -186,9 +191,10 @@ deployed image; later documentation-only commits do not require rebuilding it. 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 refund; Mercado Pago TEST - completed checkout, pause, reactivation and reconciliation. Both adapters and - their production lanes remain default-off. No Live credential, public checkout - flag or real sale is active. + 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 diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index 8ceaad1e..6546c030 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -1,6 +1,6 @@ # Listener launch — current state -Last reconciled: 2026-08-12 +Last reconciled: 2026-08-13 This is the compact operational memory for Founding Listeners. Detailed evidence and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and @@ -12,16 +12,17 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and - Listener image/SHA: `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` - Previous same-schema Listener rollback image: `fcdde379` - Canonical payment authority: `b1038ddb579817e39add567c5b7b055e2f716095` -- Previous authority rollback image: `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` +- Minimum authority after any Live checkout attempt: `b1038ddb579817e39add567c5b7b055e2f716095` - 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 -- Authority Live providers: OFF -- Authority Sandbox/TEST new-sales gate: ON only inside isolated staging acceptance; Live metrics remain zero/OFF -- Public sales and real charges: not authorized +- PayPal Live lifecycle: ON with new sales OFF after creating one supervised approval intent +- Mercado Pago Live provider: OFF +- Mercado Pago TEST lifecycle: ready; global new sales OFF +- Public sales: OFF; only the explicitly supervised Live lifecycle is authorized The authority now includes the reviewed adverse-event hardening and a read-only Live-provider preflight. The deployed API/worker are healthy at exact revision @@ -29,8 +30,11 @@ Live-provider preflight. The deployed API/worker are healthy at exact revision 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 normal runtime deliberately remains on -Sandbox/TEST for acceptance, with both Live provider flags OFF. +subscription, binding or payment. A supervised PayPal Live approval intent was subsequently +created for the exact USD 5 offer and is awaiting a buyer account different from the merchant; it +created no subscription or charge. Global new sales and both public Listener checkout flags are +OFF. PayPal Live lifecycle ingestion remains ON so its callback, signed webhook, reconciliation +and cancellation path stay available. Mercado Pago remains on TEST with Live OFF. PayPal Sandbox has passed activation, pending cancellation, reactivation and terminal refund. Mercado Pago TEST has passed checkout, activation, pause, @@ -51,8 +55,9 @@ backup. Only Listener and the disposable staging workbench were recreated. 2. #304 — complete a physical 60-minute listen and record any watchdog recovery. 3. #317 — final mobile/account-menu billing acceptance. 4. #318 — human ES/EN offer/legal/seller/refund/support acceptance. -5. With explicit approval, execute one supervised low-scope activation, - cancellation and refund per provider. +5. Complete the already-created PayPal approval intent with a non-merchant buyer, then execute its + supervised activation, cancellation and refund evidence. Execute the corresponding supervised + Mercado Pago lifecycle separately. 6. Confirm Founder activation, terminal Free fallback, metrics, alerts and the absence of PII/secret leakage against those Live transactions. 7. Obtain separate explicit approvals for merge to `main` and public checkout. @@ -69,9 +74,12 @@ without explicit approval. - Commerce incident: switch OFF Listener checkout flags and authority new-sales; keep webhooks, reconciliation, cancellation and existing access running. -- Authority application regression: restore exact image `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` - with the protected pre-deploy configuration/database backup at - `/var/backups/harmonic-beacon/earlybirds-authority-pre-b1038ddb579817e39add567c5b7b055e2f716095.sql.gz`. +- Authority application regression after any Live checkout attempt: keep the current database, + keep the affected provider's Live lifecycle flag ON, keep new sales OFF and roll forward with + `b1038ddb` or a newer contract-compatible authority. Never deploy `8e10f16` against the current + database. Never use the protected pre-`b1038` database 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 `fcdde379` if contract-compatible; otherwise disable Listener and roll forward. - Weekly quota is forward-only. Never restore the retired daily-window/welcome diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 12a1269d..38e4ecad 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -7,12 +7,13 @@ > 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-12):** the public Listener candidate runs exact +> **Current launch memory (2026-08-13):** the public Listener candidate runs exact > SHA `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a`; canonical payment authority runs -> `8e10f16fe3471a097021f7f1ee41eb8f88f4f154`; the isolated mail sidecar runs +> `b1038ddb579817e39add567c5b7b055e2f716095`; the isolated mail sidecar runs > `456ece2b38e203a2d12c54864115e03ebaa1a89c`. PayPal Sandbox and Mercado Pago -> TEST lifecycles are accepted. Live providers, new sales and public checkout -> remain OFF. See `docs/operations/LISTENER_LAUNCH_NOW.md` for the few remaining +> TEST lifecycles are accepted. One PayPal Live approval intent exists without a +> subscription or charge; new sales and public checkout remain OFF while its Live +> lifecycle ingestion stays ON. 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 @@ -493,8 +494,9 @@ The webapp vendors byte-exact copies of the canonical backend contracts under 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 - `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` is CI-green and includes canonical - cancellation/reactivation plus paid-lifecycle metrics. The Listener Live + `b1038ddb579817e39add567c5b7b055e2f716095` is CI-green, includes canonical + cancellation/reactivation, paid-lifecycle metrics and reviewed Mercado Pago adverse-event + hardening, and is the minimum authority binary after any 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 @@ -744,6 +746,10 @@ its own explicit approval. 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 the first Live checkout attempt, payment-authority rollback is also forward-only: + `b1038ddb` 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. From 127563cdd1a58f6be5bf25a317b2c1ebcec38f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 01:32:30 -0300 Subject: [PATCH 216/376] feat(listener): add private live checkout workbench --- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 4 + .../LISTENER_PRIVATE_LIVE_WORKBENCH.md | 91 +++++++++ ops/early-birds-preview/compose.yml | 7 + ...s-staging.harmonicbeacon.com.conf.template | 25 +++ .../preview.env.synthetic.example | 6 + .../test/preview-contract.test.mjs | 12 +- scripts/early-birds-preview/lib.sh | 7 + .../api/health/__tests__/ready-route.test.ts | 21 +++ src/app/api/health/ready/route.ts | 8 +- .../live-workbench/__tests__/route.test.ts | 159 ++++++++++++++++ .../listener/checkout/live-workbench/route.ts | 104 +++++++++++ src/app/early-birds/__tests__/page.test.tsx | 37 ++++ src/app/early-birds/page.tsx | 19 ++ src/components/early-birds/EarlyBirdHome.tsx | 6 + .../early-birds/EarlyBirdLanding.tsx | 5 + .../FoundingListenerLiveWorkbench.tsx | 90 +++++++++ .../FoundingListenerLiveWorkbench.test.tsx | 61 +++++++ .../__tests__/live-workbench.test.ts | 106 +++++++++++ src/lib/early-birds/live-workbench.ts | 172 ++++++++++++++++++ 19 files changed, 937 insertions(+), 3 deletions(-) create mode 100644 docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md create mode 100644 src/app/api/listener/checkout/live-workbench/__tests__/route.test.ts create mode 100644 src/app/api/listener/checkout/live-workbench/route.ts create mode 100644 src/components/early-birds/FoundingListenerLiveWorkbench.tsx create mode 100644 src/components/early-birds/__tests__/FoundingListenerLiveWorkbench.test.tsx create mode 100644 src/lib/early-birds/__tests__/live-workbench.test.ts create mode 100644 src/lib/early-birds/live-workbench.ts diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index fc14627a..929b003a 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -156,3 +156,7 @@ converted back into a reversible action. - 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/LISTENER_PRIVATE_LIVE_WORKBENCH.md b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md new file mode 100644 index 00000000..08f9f7e6 --- /dev/null +++ b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md @@ -0,0 +1,91 @@ +# Private Listener Live checkout workbench + +Status: implemented, default-OFF, not deployed by this change. + +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. + +## 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 only a random attempt UUID and a short-lived session-bound CSRF proof. Account, + email, provider, price, environment and callbacks are server-derived. +- 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. Set the workbench gate to `1` and recreate only the isolated staging Listener container. Require + `/api/health/ready` green. 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 + any supervised refund/terminal path are canonical and reconciled. +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/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index 5c84d159..5533f857 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -98,6 +98,13 @@ services: # 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: 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 index dcd619d7..2a8b297e 100644 --- a/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -332,6 +332,31 @@ server { 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 { diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example index 909131cf..9cebb63c 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -42,6 +42,12 @@ 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 diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index d15313a8..468b2038 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -35,6 +35,8 @@ test('synthetic guard accepts the example and rejects unsafe effective values', ['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/], @@ -130,6 +132,10 @@ test('compose gates the loopback Listener on a forward-only isolated database mi 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:/); @@ -194,8 +200,8 @@ test('nginx templates isolate staging, stream and the constrained public Listene assert.match(app, /location \^~ \/api\/early-birds\//); assert.equal( (app.match(/X-Harmonic-Beacon-Environment "early-birds-staging"/g) ?? []).length, - 9, - 'server plus eight sensitive HTTPS staging locations retain the environment attestation when add_header inheritance stops', + 10, + 'server plus nine 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, @@ -207,6 +213,8 @@ test('nginx templates isolate staging, stream and the constrained public Listene assert.match(app, /location \/_next\/static\/ \{[^}]*proxy_pass http:\/\/127\.0\.0\.1:13001;[^}]*Cache-Control "private, no-store"/s); 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); diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index 2d31bd93..efd7376e 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -107,6 +107,13 @@ require_synthetic_env() { 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") diff --git a/src/app/api/health/__tests__/ready-route.test.ts b/src/app/api/health/__tests__/ready-route.test.ts index 20259607..bf5e7a13 100644 --- a/src/app/api/health/__tests__/ready-route.test.ts +++ b/src/app/api/health/__tests__/ready-route.test.ts @@ -51,6 +51,27 @@ describe('GET /api/health/ready', () => { } }); + 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('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 94bee5b1..82ce4b2c 100644 --- a/src/app/api/health/ready/route.ts +++ b/src/app/api/health/ready/route.ts @@ -5,6 +5,10 @@ import { ListenerRuntimeEnvironmentError, validateListenerRuntimeEnvironment, } from '@/lib/listener/runtime-env'; +import { + ListenerLiveWorkbenchConfigurationError, + validateListenerLiveWorkbenchEnvironment, +} from '@/lib/early-birds/live-workbench'; import { OperationTimeoutError, withTimeout } from '@/lib/with-timeout'; export const dynamic = 'force-dynamic'; @@ -24,8 +28,10 @@ export async function GET() { let listenerRuntimeConfigured = false; try { listenerRuntimeConfigured = validateListenerRuntimeEnvironment(); + validateListenerLiveWorkbenchEnvironment(); } catch (error) { - const diagnostic = error instanceof ListenerRuntimeEnvironmentError + const diagnostic = error instanceof ListenerRuntimeEnvironmentError || + error instanceof ListenerLiveWorkbenchConfigurationError ? error.message : 'unexpected validation failure'; console.error('Listener runtime configuration invalid:', diagnostic); 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..2a65210a --- /dev/null +++ b/src/app/api/listener/checkout/live-workbench/__tests__/route.test.ts @@ -0,0 +1,159 @@ +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, email and the single provider from server state', 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, + email: 'listener@example.com', + provider: 'paypal', + attemptId: ATTEMPT, + returnUrl: `${ORIGIN}/?checkout=returned`, + cancelUrl: `${ORIGIN}/?checkout=cancelled`, + environment: 'live', + }); + }); + + 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' }, + ])('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..31dc0061 --- /dev/null +++ b/src/app/api/listener/checkout/live-workbench/route.ts @@ -0,0 +1,104 @@ +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 { + 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 = 256; +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) || + Object.keys(input).join('\0') !== 'attemptId') { + return json({ error: 'Invalid request.' }, 400); + } + const attemptId = (input as Record).attemptId; + if (typeof attemptId !== 'string' || !ATTEMPT_ID.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); + 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, + email: session.user.email, + 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/early-birds/__tests__/page.test.tsx b/src/app/early-birds/__tests__/page.test.tsx index 0e5ac104..9211d14c 100644 --- a/src/app/early-birds/__tests__/page.test.tsx +++ b/src/app/early-birds/__tests__/page.test.tsx @@ -159,6 +159,43 @@ describe('EarlyBird Listener page', () => { }); }); + it('exposes one server-selected Live workbench only to its staging allowlist account', 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: true, + kind: 'free-quota', + membership: { allowed: false, projection: null }, + quota: availableQuota, + allowedUntil: null, + serverNow: new Date('2026-08-13T15:00:00.000Z'), + }); + + const result = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + expect(result.props.liveWorkbench).toEqual({ + provider: 'mercado_pago', + csrfToken: expect.stringMatching(/^\d{10}\.[A-Za-z0-9_-]{32}\.[A-Za-z0-9_-]{43}$/), + }); + expect(result.props.checkoutEnvironment).toBe('staging'); + + mocks.headers.mockResolvedValue(new Headers({ host: 'listen.harmonicbeacon.com' })); + const publicResult = await EarlyBirdsPage({ searchParams: Promise.resolve({}) }); + expect(publicResult.props.liveWorkbench).toBeNull(); + }); + it('derives a sanitized Founder presentation on the server', async () => { vi.stubEnv('EARLY_BIRDS_ENABLED', '1'); vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '0'); diff --git a/src/app/early-birds/page.tsx b/src/app/early-birds/page.tsx index 1f6166f7..d3438e0c 100644 --- a/src/app/early-birds/page.tsx +++ b/src/app/early-birds/page.tsx @@ -18,6 +18,10 @@ import { configuredEarlyBirdDropIn } from '@/lib/early-birds/drop-ins'; import { earlyBirdMagicLinkAvailable } from '@/lib/early-birds/magic-link'; import { serializeEarlyBirdQuotaSnapshot } from '@/lib/early-birds/quota'; import { listenerCheckoutAvailability } from '@/lib/early-birds/checkout'; +import { + createListenerLiveWorkbenchCsrfToken, + listenerLiveWorkbenchConfig, +} from '@/lib/early-birds/live-workbench'; import { listenerMembershipPresentation } from '@/lib/early-birds/membership-presentation'; import { isCanonicalListenerHost, @@ -90,6 +94,19 @@ export default async function EarlyBirdsPage({ .then((session) => ({ session, unavailable: false as const })) .catch(() => ({ session: null, unavailable: true as const })); const session = sessionResolution.session; + const liveWorkbenchConfig = listenerStagingHost + ? listenerLiveWorkbenchConfig() + : null; + const liveWorkbenchToken = session && liveWorkbenchConfig + ? createListenerLiveWorkbenchCsrfToken({ + config: liveWorkbenchConfig, + accountId: session.user.id, + sessionId: session.session.id, + }) + : null; + const liveWorkbench = liveWorkbenchConfig && liveWorkbenchToken + ? { provider: liveWorkbenchConfig.provider, csrfToken: liveWorkbenchToken } + : null; const accessResolution = session ? await getEarlyBirdListeningAccess(session.user.id) .then((access) => ({ access, unavailable: false as const })) @@ -111,6 +128,7 @@ export default async function EarlyBirdsPage({ quota={access.quota ? serializeEarlyBirdQuotaSnapshot(access.quota) : null} checkoutAvailability={checkoutAvailability} checkoutEnvironment={checkoutEnvironment} + liveWorkbench={liveWorkbench} serverNow={access.serverNow.toISOString()} dropIns={{ es: configuredEarlyBirdDropIn('es'), @@ -144,6 +162,7 @@ export default async function EarlyBirdsPage({ quota={access?.quota ? serializeEarlyBirdQuotaSnapshot(access.quota) : null} checkoutAvailability={checkoutAvailability} checkoutEnvironment={checkoutEnvironment} + liveWorkbench={liveWorkbench} membership={listenerMembershipPresentation(access?.membership.projection ?? null)} serverNow={access?.serverNow.toISOString() ?? new Date().toISOString()} /> diff --git a/src/components/early-birds/EarlyBirdHome.tsx b/src/components/early-birds/EarlyBirdHome.tsx index 919d26a3..78d0ddf3 100644 --- a/src/components/early-birds/EarlyBirdHome.tsx +++ b/src/components/early-birds/EarlyBirdHome.tsx @@ -10,6 +10,9 @@ import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; import ListenerPlayer from './ListenerPlayer'; import FreeQuotaStatus from './FreeQuotaStatus'; import FoundingListenerCheckout from './FoundingListenerCheckout'; +import FoundingListenerLiveWorkbench, { + type ListenerLiveWorkbenchClientConfig, +} from './FoundingListenerLiveWorkbench'; import FoundingListenerMembershipActions from './FoundingListenerMembershipActions'; import type { SerializedEarlyBirdQuotaSnapshot } from './free-quota'; @@ -25,6 +28,7 @@ export default function EarlyBirdHome({ quota = null, checkoutAvailability = { paypal: false, mercadoPago: false }, checkoutEnvironment = 'staging', + liveWorkbench = null, }: { displayName: string; membership: ListenerMembershipPresentation; @@ -37,6 +41,7 @@ export default function EarlyBirdHome({ quota?: SerializedEarlyBirdQuotaSnapshot | null; checkoutAvailability?: { paypal: boolean; mercadoPago: boolean }; checkoutEnvironment?: 'staging' | 'live'; + liveWorkbench?: ListenerLiveWorkbenchClientConfig | null; }) { const { locale } = useLocale(); const copy = earlyBirdHomeCopy[locale]; @@ -97,6 +102,7 @@ export default function EarlyBirdHome({ available={checkoutAvailability} environment={checkoutEnvironment} /> + )}

diff --git a/src/components/early-birds/EarlyBirdLanding.tsx b/src/components/early-birds/EarlyBirdLanding.tsx index 609bcad2..6abb3439 100644 --- a/src/components/early-birds/EarlyBirdLanding.tsx +++ b/src/components/early-birds/EarlyBirdLanding.tsx @@ -12,6 +12,9 @@ import { LISTENER_NAMESPACE } from '@/lib/listener/namespace'; import BeaconField from './BeaconField'; import FreeQuotaStatus from './FreeQuotaStatus'; import FoundingListenerCheckout from './FoundingListenerCheckout'; +import FoundingListenerLiveWorkbench, { + type ListenerLiveWorkbenchClientConfig, +} from './FoundingListenerLiveWorkbench'; import SyntheticTeamEntryForm from './SyntheticTeamEntryForm'; import type { SerializedEarlyBirdQuotaSnapshot } from './free-quota'; @@ -29,6 +32,7 @@ type Props = { serverNow: string; checkoutAvailability?: { paypal: boolean; mercadoPago: boolean }; checkoutEnvironment?: 'staging' | 'live'; + liveWorkbench?: ListenerLiveWorkbenchClientConfig | null; }; export default function EarlyBirdLanding(props: Props) { @@ -182,6 +186,7 @@ export default function EarlyBirdLanding(props: Props) { paypal: false, mercadoPago: false, }} environment={props.checkoutEnvironment ?? 'staging'} /> + )} + {failed &&

{copy.checkoutLiveUnavailable}

} +
+ + ); +} diff --git a/src/components/early-birds/__tests__/FoundingListenerLiveWorkbench.test.tsx b/src/components/early-birds/__tests__/FoundingListenerLiveWorkbench.test.tsx new file mode 100644 index 00000000..e0537207 --- /dev/null +++ b/src/components/early-birds/__tests__/FoundingListenerLiveWorkbench.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocaleProvider } from '@/context/LocaleContext'; + +import FoundingListenerLiveWorkbench from '../FoundingListenerLiveWorkbench'; + +const attemptId = '123e4567-e89b-42d3-a456-426614174000'; + +beforeEach(() => { + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue(attemptId); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('Founding Listener private Live workbench', () => { + it('renders nothing unless the server supplied an allowlisted configuration', () => { + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('sends only an attempt and CSRF proof to the separate endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response( + JSON.stringify({ error: 'Checkout unavailable.' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }, + )); + vi.stubGlobal('fetch', fetchMock); + render( + + + , + ); + + fireEvent.click(screen.getByText('Become a member for full access')); + fireEvent.click(screen.getByRole('button', { name: 'Continue with Mercado Pago' })); + await screen.findByRole('alert'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0]; + expect(fetchMock.mock.calls[0][0]).toBe('/api/listener/checkout/live-workbench'); + expect(JSON.parse(init.body)).toEqual({ attemptId }); + expect(init.headers).toEqual(expect.objectContaining({ + 'x-hb-listener-live-csrf': 'browser-csrf-proof', + })); + expect(JSON.stringify({ url: fetchMock.mock.calls[0][0], init })).not.toMatch( + /opaque-account|listener@example|provider.*mercado_pago/i, + ); + }); +}); diff --git a/src/lib/early-birds/__tests__/live-workbench.test.ts b/src/lib/early-birds/__tests__/live-workbench.test.ts new file mode 100644 index 00000000..af62026a --- /dev/null +++ b/src/lib/early-birds/__tests__/live-workbench.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { + createListenerLiveWorkbenchCsrfToken, + listenerLiveWorkbenchConfig, + validateListenerLiveWorkbenchEnvironment, + verifyListenerLiveWorkbenchCsrfToken, +} from '../live-workbench'; + +const environment = { + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED: '1', + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID: 'opaque-account_1', + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER: 'paypal', + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET: 's'.repeat(43), + BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED: '0', + BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED: '0', +}; + +describe('private Listener Live workbench configuration', () => { + it('is default-off and requires one exact account, provider and secret', () => { + expect(listenerLiveWorkbenchConfig({})).toBeNull(); + expect(listenerLiveWorkbenchConfig({ ...environment, BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED: 'true' })).toBeNull(); + expect(listenerLiveWorkbenchConfig({ ...environment, BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID: 'a,b' })).toBeNull(); + expect(listenerLiveWorkbenchConfig({ ...environment, BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER: 'all' })).toBeNull(); + expect(listenerLiveWorkbenchConfig({ ...environment, BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET: 'short' })).toBeNull(); + expect(listenerLiveWorkbenchConfig(environment)).toEqual({ + accountId: 'opaque-account_1', + provider: 'paypal', + csrfSecret: 's'.repeat(43), + }); + }); + + it('cannot coexist with either public Listener Live provider', () => { + expect(listenerLiveWorkbenchConfig({ + ...environment, + BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED: '1', + })).toBeNull(); + expect(listenerLiveWorkbenchConfig({ + ...environment, + BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED: '1', + })).toBeNull(); + }); + + it('makes partial or ambiguous enabled configuration fail readiness', () => { + expect(validateListenerLiveWorkbenchEnvironment({})).toBe(false); + expect(validateListenerLiveWorkbenchEnvironment({ + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED: '0', + })).toBe(false); + expect(() => validateListenerLiveWorkbenchEnvironment({ + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED: 'true', + })).toThrow(/exactly 0 or 1/); + expect(() => validateListenerLiveWorkbenchEnvironment({ + BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED: '1', + })).toThrow(/requires one valid account/); + expect(validateListenerLiveWorkbenchEnvironment(environment)).toBe(true); + }); + + it('binds short-lived CSRF proof to account, session and server-selected provider', () => { + const config = listenerLiveWorkbenchConfig(environment)!; + const now = new Date('2026-08-13T12:00:00.000Z'); + const token = createListenerLiveWorkbenchCsrfToken({ + config, + accountId: config.accountId, + sessionId: 'session-1', + now, + }); + expect(token).not.toBeNull(); + expect(verifyListenerLiveWorkbenchCsrfToken({ + config, + token, + accountId: config.accountId, + sessionId: 'session-1', + now: new Date('2026-08-13T12:14:59.000Z'), + })).toBe(true); + expect(verifyListenerLiveWorkbenchCsrfToken({ + config, + token, + accountId: config.accountId, + sessionId: 'other-session', + now, + })).toBe(false); + expect(verifyListenerLiveWorkbenchCsrfToken({ + config: { ...config, provider: 'mercado_pago' }, + token, + accountId: config.accountId, + sessionId: 'session-1', + now, + })).toBe(false); + expect(verifyListenerLiveWorkbenchCsrfToken({ + config, + token, + accountId: config.accountId, + sessionId: 'session-1', + now: new Date('2026-08-13T12:15:01.000Z'), + })).toBe(false); + }); + + it('does not mint a token for a non-allowlisted account', () => { + const config = listenerLiveWorkbenchConfig(environment)!; + expect(createListenerLiveWorkbenchCsrfToken({ + config, + accountId: 'another-account', + sessionId: 'session-1', + })).toBeNull(); + }); +}); diff --git a/src/lib/early-birds/live-workbench.ts b/src/lib/early-birds/live-workbench.ts new file mode 100644 index 00000000..366995fb --- /dev/null +++ b/src/lib/early-birds/live-workbench.ts @@ -0,0 +1,172 @@ +import { + createHmac, + randomBytes, + timingSafeEqual, +} from 'node:crypto'; + +import { isEarlyBirdAccountId } from './account-id'; +import type { ListenerCheckoutProvider } from './checkout'; + +const TOKEN_VERSION = 'listener-live-workbench-csrf-v1'; +const TOKEN_TTL_SECONDS = 15 * 60; +const MIN_SECRET_LENGTH = 43; +const MAX_SECRET_LENGTH = 512; +const NONCE_BYTES = 24; + +export const LISTENER_LIVE_WORKBENCH_CSRF_HEADER = 'x-hb-listener-live-csrf'; + +type Environment = Record; + +const WORKBENCH_VARIABLES = [ + 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED', + 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID', + 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER', + 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET', +] as const; + +export class ListenerLiveWorkbenchConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ListenerLiveWorkbenchConfigurationError'; + } +} + +export type ListenerLiveWorkbenchConfig = { + accountId: string; + provider: ListenerCheckoutProvider; + csrfSecret: string; +}; + +function configured(value: string | undefined): string | null { + if (!value || value !== value.trim()) return null; + return value; +} + +/** + * Resolve the supervised Live checkout seam. This is deliberately a separate, + * canonical-only configuration generation: the pre-release workbench has no + * legacy aliases and a partial or ambiguous configuration is inert. + */ +export function listenerLiveWorkbenchConfig( + environment: Environment = process.env, +): ListenerLiveWorkbenchConfig | null { + if (environment.BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED !== '1') return null; + + // Public Live checkout and the staging-only workbench must never coexist. + if (environment.BEACON_LISTENER_PAYPAL_LIVE_CHECKOUT_ENABLED === '1' || + environment.BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED === '1') return null; + + const accountId = configured( + environment.BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ACCOUNT_ID, + ); + const provider = configured( + environment.BEACON_LISTENER_STAGING_LIVE_WORKBENCH_PROVIDER, + ); + const csrfSecret = configured( + environment.BEACON_LISTENER_STAGING_LIVE_WORKBENCH_CSRF_SECRET, + ); + if (!isEarlyBirdAccountId(accountId) || + (provider !== 'paypal' && provider !== 'mercado_pago') || + !csrfSecret || csrfSecret.length < MIN_SECRET_LENGTH || + csrfSecret.length > MAX_SECRET_LENGTH) return null; + + return { accountId, provider, csrfSecret }; +} + +/** Validate deployment shape without ever including configured values in diagnostics. */ +export function validateListenerLiveWorkbenchEnvironment( + environment: Environment = process.env, +): boolean { + const present = WORKBENCH_VARIABLES.some((name) => environment[name] !== undefined); + if (!present) return false; + const enabled = environment.BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED; + if (enabled !== '0' && enabled !== '1') { + throw new ListenerLiveWorkbenchConfigurationError( + 'BEACON_LISTENER_STAGING_LIVE_WORKBENCH_ENABLED must be exactly 0 or 1', + ); + } + if (enabled === '0') return false; + if (!listenerLiveWorkbenchConfig(environment)) { + throw new ListenerLiveWorkbenchConfigurationError( + 'Enabled private Live workbench requires one valid account, one provider, one CSRF secret and both public Live flags OFF', + ); + } + return true; +} + +function signatureInput(input: { + accountId: string; + sessionId: string; + provider: ListenerCheckoutProvider; + expiresAtSeconds: number; + nonce: string; +}): string { + return [ + TOKEN_VERSION, + input.accountId, + input.sessionId, + input.provider, + String(input.expiresAtSeconds), + input.nonce, + ].join('\n'); +} + +function signature(secret: string, value: string): Buffer { + return createHmac('sha256', secret).update(value).digest(); +} + +export function createListenerLiveWorkbenchCsrfToken(input: { + config: ListenerLiveWorkbenchConfig; + accountId: string; + sessionId: string; + now?: Date; +}): string | null { + if (input.accountId !== input.config.accountId || !input.sessionId) return null; + const expiresAtSeconds = Math.floor( + (input.now?.getTime() ?? Date.now()) / 1000, + ) + TOKEN_TTL_SECONDS; + const nonce = randomBytes(NONCE_BYTES).toString('base64url'); + const mac = signature(input.config.csrfSecret, signatureInput({ + accountId: input.accountId, + sessionId: input.sessionId, + provider: input.config.provider, + expiresAtSeconds, + nonce, + })).toString('base64url'); + return `${expiresAtSeconds}.${nonce}.${mac}`; +} + +export function verifyListenerLiveWorkbenchCsrfToken(input: { + config: ListenerLiveWorkbenchConfig; + token: string | null; + accountId: string; + sessionId: string; + now?: Date; +}): boolean { + if (input.accountId !== input.config.accountId || !input.sessionId || !input.token || + input.token.length > 256) return false; + const parts = input.token.split('.'); + if (parts.length !== 3 || !/^\d{10}$/.test(parts[0]) || + !/^[A-Za-z0-9_-]{32}$/.test(parts[1]) || + !/^[A-Za-z0-9_-]{43}$/.test(parts[2])) return false; + + const expiresAtSeconds = Number(parts[0]); + const nowSeconds = Math.floor((input.now?.getTime() ?? Date.now()) / 1000); + if (!Number.isSafeInteger(expiresAtSeconds) || expiresAtSeconds < nowSeconds || + expiresAtSeconds > nowSeconds + TOKEN_TTL_SECONDS) return false; + + const expected = signature(input.config.csrfSecret, signatureInput({ + accountId: input.accountId, + sessionId: input.sessionId, + provider: input.config.provider, + expiresAtSeconds, + nonce: parts[1], + })); + let supplied: Buffer; + try { + supplied = Buffer.from(parts[2], 'base64url'); + } catch { + return false; + } + return supplied.length === expected.length && timingSafeEqual(supplied, expected); +} From c3bdeef6ad9d68e21052fa64a6c12f774b600d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 01:43:42 -0300 Subject: [PATCH 217/376] fix(listener): pin private workbench runtime --- .../LISTENER_PRIVATE_LIVE_WORKBENCH.md | 22 ++++- .../test/preview-contract.test.mjs | 11 ++- scripts/listener-ui-preview.sh | 86 ++++++++++++++++--- 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md index 08f9f7e6..5af430a7 100644 --- a/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md +++ b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md @@ -51,9 +51,25 @@ canonical authority runtime; they are never copied into Listener configuration. 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. Set the workbench gate to `1` and recreate only the isolated staging Listener container. Require - `/api/health/ready` green. Install the reviewed staging nginx template only after `nginx -t` is - green; do not change any event vhost. +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. diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 468b2038..8db5d991 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -102,7 +102,16 @@ test('payment workbench keeps OAuth state and callback on the staging origin', a 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"/); - assert.match(source, /if \[ "\$PREVIEW_PAYPAL_CHECKOUT" = 1 \] \|\| \[ "\$PREVIEW_MERCADO_PAGO_CHECKOUT" = 1 \]/); + assert.match(source, /PREVIEW_LIVE_WORKBENCH="\$\{LISTENER_UI_PREVIEW_LIVE_WORKBENCH_ENABLED:-0\}"/); + 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, /sudo stat -c '%u:%g:%a'/); + assert.match(source, /127\.0\.0\.1:13001/); + assert.match(source, /api\/health\/ready/); assert.doesNotMatch(source, /PREVIEW_ORIGIN="https:\/\/listen\.harmonicbeacon\.com"/); }); diff --git a/scripts/listener-ui-preview.sh b/scripts/listener-ui-preview.sh index 2dd2cf49..00fec600 100755 --- a/scripts/listener-ui-preview.sh +++ b/scripts/listener-ui-preview.sh @@ -15,14 +15,37 @@ RELEASE_CONTAINER="earlybirds-preview-listener-1" PREVIEW_FREE_FOR_ALL="${LISTENER_UI_PREVIEW_FREE_FOR_ALL:-1}" 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_EXPECTED_SHA="${LISTENER_UI_PREVIEW_EXPECTED_SHA:-}" +LIVE_WORKBENCH_ENV_FILE="/etc/harmonic-beacon/listener-live-workbench.env" PREVIEW_ORIGIN="https://earlybirds-staging.harmonicbeacon.com" -case "$PREVIEW_FREE_FOR_ALL:$PREVIEW_PAYPAL_CHECKOUT:$PREVIEW_MERCADO_PAGO_CHECKOUT" in - 0:0:0|0:1:0|0:0:1|1:0:0) ;; - 1:1:0|1:0:1) echo "Payment checkout requires Free For All to be disabled." >&2; exit 2 ;; - 0:1:1|1:1:1) echo "Select exactly one payment provider in the workbench." >&2; exit 2 ;; - *) echo "Preview switches must be 0 or 1." >&2; exit 2 ;; -esac +for switch in "$PREVIEW_FREE_FOR_ALL" "$PREVIEW_PAYPAL_CHECKOUT" \ + "$PREVIEW_MERCADO_PAGO_CHECKOUT" "$PREVIEW_LIVE_WORKBENCH"; do + case "$switch" in 0|1) ;; *) echo "Preview switches must be 0 or 1." >&2; exit 2 ;; esac +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 @@ -40,10 +63,17 @@ sync_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_PAYPAL_CHECKOUT='$PREVIEW_PAYPAL_CHECKOUT' PREVIEW_MERCADO_PAGO_CHECKOUT='$PREVIEW_MERCADO_PAGO_CHECKOUT' PREVIEW_ORIGIN='$PREVIEW_ORIGIN' bash -s" <<'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_PAYPAL_CHECKOUT='$PREVIEW_PAYPAL_CHECKOUT' PREVIEW_MERCADO_PAGO_CHECKOUT='$PREVIEW_MERCADO_PAGO_CHECKOUT' PREVIEW_LIVE_WORKBENCH='$PREVIEW_LIVE_WORKBENCH' PREVIEW_EXPECTED_SHA='$PREVIEW_EXPECTED_SHA' LIVE_WORKBENCH_ENV_FILE='$LIVE_WORKBENCH_ENV_FILE' PREVIEW_ORIGIN='$PREVIEW_ORIGIN' bash -s" <<'REMOTE' set -euo pipefail -image="$(docker inspect "$RELEASE_CONTAINER" --format '{{.Config.Image}}')" +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)" cleanup() { rm -f "$env_file"; } trap cleanup EXIT @@ -67,7 +97,7 @@ fi runtime_args=() command_args=() -if [ "$PREVIEW_PAYPAL_CHECKOUT" = 1 ] || [ "$PREVIEW_MERCADO_PAGO_CHECKOUT" = 1 ]; then +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. # OAuth state and session cookies are host-only. The workbench must initiate @@ -94,6 +124,36 @@ else command_args=(npm run dev -- --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" + sudo cat "$LIVE_WORKBENCH_ENV_FILE" >> "$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 + set_env_file_value BEACON_LISTENER_AUTH_BASE_URL "$PREVIEW_ORIGIN" + set_env_file_value EARLY_BIRDS_AUTH_BASE_URL "$PREVIEW_ORIGIN" +fi + docker run -d \ --name "$DEV_CONTAINER" \ --restart unless-stopped \ @@ -117,7 +177,13 @@ docker network connect earlybirds_preview_listener_egress "$DEV_CONTAINER" docker network connect earlybirds_authority_private "$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; then + 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" + fi exit 0 fi sleep 1 From ba675aa6683db71f3e0280a9ecde0f62ebf0f4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 01:44:53 -0300 Subject: [PATCH 218/376] fix(listener): fail closed on workbench launch --- .../test/preview-contract.test.mjs | 4 ++++ scripts/listener-ui-preview.sh | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 8db5d991..47e216f8 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -109,7 +109,11 @@ test('payment workbench keeps OAuth state and callback on the staging origin', a 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, /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, /api\/health\/ready/); assert.doesNotMatch(source, /PREVIEW_ORIGIN="https:\/\/listen\.harmonicbeacon\.com"/); diff --git a/scripts/listener-ui-preview.sh b/scripts/listener-ui-preview.sh index 00fec600..b37ddbab 100755 --- a/scripts/listener-ui-preview.sh +++ b/scripts/listener-ui-preview.sh @@ -75,7 +75,16 @@ else image="$(docker inspect "$RELEASE_CONTAINER" --format '{{.Config.Image}}')" fi env_file="$(mktemp /tmp/listener-ui-dev-env.XXXXXX)" -cleanup() { rm -f "$env_file"; } +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" @@ -152,6 +161,9 @@ if [ "$PREVIEW_LIVE_WORKBENCH" = 1 ]; then set_env_file_value BEACON_LISTENER_MERCADO_PAGO_LIVE_CHECKOUT_ENABLED 0 set_env_file_value BEACON_LISTENER_AUTH_BASE_URL "$PREVIEW_ORIGIN" set_env_file_value EARLY_BIRDS_AUTH_BASE_URL "$PREVIEW_ORIGIN" + # 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 \ @@ -172,6 +184,9 @@ docker run -d \ "${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" @@ -183,6 +198,7 @@ for _ in $(seq 1 90); do 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 From 3913e620db522510f26f305f08802536263c3c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 01:57:48 -0300 Subject: [PATCH 219/376] feat(listener): add public consumer withdrawal flow --- docs/operations/LISTENER_LAUNCH_NOW.md | 6 +- .../LISTENER_WITHDRAWAL_REQUESTS.md | 64 +++++ docs/plans/EARLY_BIRDS.md | 4 + .../migration.sql | 72 +++++ prisma/schema.prisma | 53 ++++ scripts/listener-withdrawal-operator.ts | 148 ++++++++++ .../withdrawal/__tests__/route.test.ts | 100 +++++++ src/app/api/listener/withdrawal/route.ts | 76 ++++++ src/app/globals.css | 98 +++++++ src/app/listener/layout.tsx | 12 +- src/app/listener/withdrawal/page.tsx | 14 + .../early-birds/ConsumerWithdrawalForm.tsx | 147 ++++++++++ .../early-birds/ConsumerWithdrawalLink.tsx | 22 ++ src/components/early-birds/ListenerLegal.tsx | 10 + .../__tests__/ConsumerWithdrawalForm.test.tsx | 46 ++++ .../__tests__/ConsumerWithdrawalLink.test.tsx | 28 ++ .../consumer-withdrawal.postgres.test.ts | 104 ++++++++ .../__tests__/consumer-withdrawal.test.ts | 53 ++++ .../listener/consumer-withdrawal-contract.ts | 4 + src/lib/listener/consumer-withdrawal.ts | 252 ++++++++++++++++++ 20 files changed, 1311 insertions(+), 2 deletions(-) create mode 100644 docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md create mode 100644 prisma/migrations/20260813190000_listener_withdrawal_request/migration.sql create mode 100755 scripts/listener-withdrawal-operator.ts create mode 100644 src/app/api/listener/withdrawal/__tests__/route.test.ts create mode 100644 src/app/api/listener/withdrawal/route.ts create mode 100644 src/app/listener/withdrawal/page.tsx create mode 100644 src/components/early-birds/ConsumerWithdrawalForm.tsx create mode 100644 src/components/early-birds/ConsumerWithdrawalLink.tsx create mode 100644 src/components/early-birds/__tests__/ConsumerWithdrawalForm.test.tsx create mode 100644 src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx create mode 100644 src/lib/listener/__tests__/consumer-withdrawal.postgres.test.ts create mode 100644 src/lib/listener/__tests__/consumer-withdrawal.test.ts create mode 100644 src/lib/listener/consumer-withdrawal-contract.ts create mode 100644 src/lib/listener/consumer-withdrawal.ts diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index 6546c030..ea2b76b8 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -54,7 +54,11 @@ backup. Only Listener and the disposable staging workbench were recreated. 1. #217 — open the delivered magic link and prove email-only session → Free → logout. 2. #304 — complete a physical 60-minute listen and record any watchdog recovery. 3. #317 — final mobile/account-menu billing acceptance. -4. #318 — human ES/EN offer/legal/seller/refund/support acceptance. +4. #318 — deploy and smoke the implemented no-login consumer-withdrawal queue, + then record human ES/EN offer/legal/seller/refund/support acceptance. The + implementation returns an opaque receipt and has no automatic provider + action; operations must install its dedicated secret and own the 24-hour + private queue procedure. 5. Complete the already-created PayPal approval intent with a non-merchant buyer, then execute its supervised activation, cancellation and refund evidence. Execute the corresponding supervised Mercado Pago lifecycle separately. diff --git a/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md b/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md new file mode 100644 index 00000000..5cbc2741 --- /dev/null +++ b/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md @@ -0,0 +1,64 @@ +# Listener consumer-withdrawal requests + +This is the bounded operator flow for the public **BOTÓN DE +ARREPENTIMIENTO**. It receives and tracks a request; it never calls PayPal, +Mercado Pago or the membership authority and it never cancels or refunds by +itself. + +## 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. +- The public API fails closed with `503` when the secret is absent. 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 throttles; + no raw IP, card data or provider transaction ID is accepted. + +## 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. 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/refund, if applicable, using + its normal 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. Adding +that private metric/alert is a deployment operation; do not expose request +details or receipt codes in metrics or logs. + +## Rollback + +Hide the link and route or deploy the previous Listener image. 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/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 38e4ecad..e1e9e48b 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -630,6 +630,8 @@ cross into event/staff privileges. - 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; @@ -660,6 +662,8 @@ explicitly approved. 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 route, dedicated + secret, queue migration and operator runbook are deployed and smoke-tested. ## 14. Post-weekend convergence card 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..ba950b00 --- /dev/null +++ b/prisma/migrations/20260813190000_listener_withdrawal_request/migration.sql @@ -0,0 +1,72 @@ +-- 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 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, + "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" CHAR(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" ~ '^network:[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/schema.prisma b/prisma/schema.prisma index 60b9ed67..921bb9ed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -123,6 +123,18 @@ enum ListenerPresenceState { LISTENING } +enum ListenerWithdrawalProvider { + PAYPAL + MERCADO_PAGO + OTHER +} + +enum ListenerWithdrawalStatus { + RECEIVED + ACKNOWLEDGED + RESOLVED +} + model User { id String @id @default(uuid()) @db.Uuid email String @unique @@ -675,3 +687,44 @@ model EarlyBirdListenerAuthorityPolicy { @@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) + 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 buckets. Raw IP addresses are neither persisted nor +// exposed to operators. +model ListenerWithdrawalThrottle { + key String @id @db.Char(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/scripts/listener-withdrawal-operator.ts b/scripts/listener-withdrawal-operator.ts new file mode 100755 index 00000000..04e5f4ab --- /dev/null +++ b/scripts/listener-withdrawal-operator.ts @@ -0,0 +1,148 @@ +#!/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 ', + ].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 === '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, + 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, + 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/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..252e88c4 --- /dev/null +++ b/src/app/api/listener/withdrawal/__tests__/route.test.ts @@ -0,0 +1,100 @@ +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: '', +}; + +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); + 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('fails closed without its dedicated secret', async () => { + delete process.env.LISTENER_WITHDRAWAL_SECRET; + expect((await POST(request())).status).toBe(503); + 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..1f72a84f --- /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, + listenerWithdrawalSecret, + 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 { + if (!trustedRequest(request)) return json({ error: 'Invalid request.' }, 403); + const secret = listenerWithdrawalSecret(); + if (!secret) return json({ error: 'Request service unavailable.' }, 503); + + 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, + }); + 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/globals.css b/src/app/globals.css index 2a920719..33f64f71 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2043,6 +2043,104 @@ body { line-height: 1.65; } +.listener-withdrawal-link { + position: fixed; + right: max(0.8rem, env(safe-area-inset-right)); + bottom: max(0.8rem, env(safe-area-inset-bottom)); + z-index: 90; + display: grid; + gap: 0.12rem; + min-height: 48px; + max-width: min(18rem, calc(100vw - 1.6rem)); + padding: 0.7rem 0.95rem; + border: 1px solid rgba(255, 216, 117, 0.48); + border-radius: 0.8rem; + color: var(--night); + background: var(--gold); + box-shadow: 0 0.7rem 2.2rem rgba(0, 0, 0, 0.45); + font-family: var(--font-space-mono), monospace; + 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(--paper); + 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(--paper); + background: #0d1917; + 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(--night); + background: var(--lime); + font-size: clamp(0.72rem, 2.5vw, 0.95rem); +} + +@media (max-width: 640px) { + .listener-withdrawal-link { + left: 0.75rem; + right: 0.75rem; + bottom: max(0.75rem, env(safe-area-inset-bottom)); + max-width: none; + } +} + /* -------------------------------------------- LISTENER PAGE SHELL / ACTIONS / ALERTS / FIELDS Listener-scoped mirrors of the event visual diff --git a/src/app/listener/layout.tsx b/src/app/listener/layout.tsx index f37f8bda..dd8f1e1a 100644 --- a/src/app/listener/layout.tsx +++ b/src/app/listener/layout.tsx @@ -1 +1,11 @@ -export { default } from '../early-birds/layout'; +import ConsumerWithdrawalLink from '@/components/early-birds/ConsumerWithdrawalLink'; +import EarlyBirdLayout from '../early-birds/layout'; + +export default function ListenerLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/src/app/listener/withdrawal/page.tsx b/src/app/listener/withdrawal/page.tsx new file mode 100644 index 00000000..5fd61401 --- /dev/null +++ b/src/app/listener/withdrawal/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next'; + +import ConsumerWithdrawalForm from '@/components/early-birds/ConsumerWithdrawalForm'; + +export const dynamic = 'force-dynamic'; + +export const metadata: Metadata = { + title: 'BOTÓN DE ARREPENTIMIENTO · Harmonic Beacon', + robots: { index: true, follow: true }, +}; + +export default function ListenerWithdrawalPage() { + return ; +} diff --git a/src/components/early-birds/ConsumerWithdrawalForm.tsx b/src/components/early-birds/ConsumerWithdrawalForm.tsx new file mode 100644 index 00000000..31a5a810 --- /dev/null +++ b/src/components/early-birds/ConsumerWithdrawalForm.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; + +import { useLocale } from '@/context/LocaleContext'; +import { + LISTENER_WITHDRAWAL_API_PATH, + LISTENER_WITHDRAWAL_RESPONSE_HOURS, +} from '@/lib/listener/consumer-withdrawal-contract'; + +const copy = { + es: { + back: 'Volver a Listener', + eyebrow: 'DERECHO DEL CONSUMIDOR', + title: 'BOTÓN DE ARREPENTIMIENTO', + intro: 'Puedes solicitar la revocación de una compra sin iniciar sesión ni crear una cuenta. Recibirás un código de identificación de inmediato.', + scope: 'Esta solicitud abre un caso para revisión. No cancela ni reembolsa automáticamente: el equipo verifica la operación con el proveedor y la procesa según tus derechos aplicables.', + email: 'Correo usado para la compra', + provider: 'Medio de pago', + paypal: 'PayPal', + mercadoPago: 'Mercado Pago', + other: 'Otro o no lo recuerdo', + purchaseDate: 'Fecha aproximada de compra (opcional)', + submit: 'Enviar solicitud', + submitting: 'Enviando…', + failed: 'No pudimos recibir la solicitud ahora. Conserva los datos e intenta nuevamente.', + rateLimited: 'Recibimos demasiadas solicitudes desde esta conexión. Intenta nuevamente más tarde.', + received: 'Solicitud recibida', + receipt: 'Tu código de identificación es', + receiptHelp: `Guárdalo. El equipo debe revisar la solicitud dentro de ${LISTENER_WITHDRAWAL_RESPONSE_HOURS} horas y puede contactarte en el correo indicado.`, + }, + en: { + back: 'Back to Listener', + eyebrow: 'CONSUMER RIGHT', + title: 'BOTÓN DE ARREPENTIMIENTO', + intro: 'You may request cancellation of a purchase without signing in or creating an account. You will receive an identification code immediately.', + scope: 'This request opens a case for review. It does not cancel or refund automatically: the team verifies the transaction with the provider and processes it under your applicable rights.', + email: 'Email used for the purchase', + provider: 'Payment method', + paypal: 'PayPal', + mercadoPago: 'Mercado Pago', + other: 'Other or I do not remember', + purchaseDate: 'Approximate purchase date (optional)', + submit: 'Send request', + submitting: 'Sending…', + failed: 'We could not receive the request right now. Keep the information and try again.', + rateLimited: 'Too many requests came from this connection. Please try again later.', + received: 'Request received', + receipt: 'Your identification code is', + receiptHelp: `Keep this code. The team must review the request within ${LISTENER_WITHDRAWAL_RESPONSE_HOURS} hours and may contact you at the email supplied.`, + }, +} as const; + +type Receipt = { receiptCode: string; receivedAt: string }; + +export default function ConsumerWithdrawalForm() { + const { locale } = useLocale(); + const text = copy[locale]; + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [receipt, setReceipt] = useState(null); + const [idempotencyKey] = useState(() => globalThis.crypto.randomUUID()); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + const form = new FormData(event.currentTarget); + try { + const response = await fetch(LISTENER_WITHDRAWAL_API_PATH, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Listener-Withdrawal-Intent': '1', + }, + body: JSON.stringify({ + email: form.get('email'), + idempotencyKey, + locale, + provider: form.get('provider'), + purchaseDate: form.get('purchaseDate'), + }), + }); + if (!response.ok) { + setError(response.status === 429 ? text.rateLimited : text.failed); + return; + } + const body = await response.json() as Partial; + if (typeof body.receiptCode !== 'string' || typeof body.receivedAt !== 'string') { + setError(text.failed); + return; + } + setReceipt({ receiptCode: body.receiptCode, receivedAt: body.receivedAt }); + } catch { + setError(text.failed); + } finally { + setBusy(false); + } + } + + return ( +
+
+ ← {text.back} +

{text.eyebrow}

+

{text.title}

+ {receipt ? ( +
+

{text.received}

+

{text.receipt}

+ {receipt.receiptCode} +

{text.receiptHelp}

+
+ ) : ( + <> +

{text.intro}

+

{text.scope}

+
+ + + + {error &&

{error}

} + +
+ + )} +
+
+ ); +} diff --git a/src/components/early-birds/ConsumerWithdrawalLink.tsx b/src/components/early-birds/ConsumerWithdrawalLink.tsx new file mode 100644 index 00000000..cbc4b60c --- /dev/null +++ b/src/components/early-birds/ConsumerWithdrawalLink.tsx @@ -0,0 +1,22 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import { useLocale } from '@/context/LocaleContext'; +import { LISTENER_WITHDRAWAL_PATH } from '@/lib/listener/consumer-withdrawal-contract'; + +export default function ConsumerWithdrawalLink({ inline = false }: { inline?: boolean }) { + const { locale } = useLocale(); + const pathname = usePathname(); + if (!inline && pathname === LISTENER_WITHDRAWAL_PATH) return null; + return ( + + BOTÓN DE ARREPENTIMIENTO + {!inline && {locale === 'es' ? 'Cancelar una compra' : 'Request cancellation'}} + + ); +} diff --git a/src/components/early-birds/ListenerLegal.tsx b/src/components/early-birds/ListenerLegal.tsx index abd60749..1e17e335 100644 --- a/src/components/early-birds/ListenerLegal.tsx +++ b/src/components/early-birds/ListenerLegal.tsx @@ -3,6 +3,7 @@ import { useLocale } from '@/context/LocaleContext'; import { earlyBirdLegalCopy } from '@/lib/early-birds/copy'; import Link from 'next/link'; +import ConsumerWithdrawalLink from './ConsumerWithdrawalLink'; export default function ListenerLegal() { const { locale } = useLocale(); @@ -21,6 +22,15 @@ export default function ListenerLegal() { {section.paragraphs.map((paragraph) =>

{paragraph}

)}
))} +
+

{locale === 'es' ? 'Derecho de revocación' : 'Cancellation right'}

+

+ {locale === 'es' + ? 'Puedes iniciar una solicitud pública sin ingresar a tu cuenta.' + : 'You can open a public request without signing in to your account.'}{' '} + +

+
); diff --git a/src/components/early-birds/__tests__/ConsumerWithdrawalForm.test.tsx b/src/components/early-birds/__tests__/ConsumerWithdrawalForm.test.tsx new file mode 100644 index 00000000..5a673513 --- /dev/null +++ b/src/components/early-birds/__tests__/ConsumerWithdrawalForm.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { LocaleProvider } from '@/context/LocaleContext'; +import ConsumerWithdrawalForm from '../ConsumerWithdrawalForm'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('Listener withdrawal form', () => { + it.each([ + ['es', 'Puedes solicitar la revocación', 'Enviar solicitud'], + ['en', 'You may request cancellation', 'Send request'], + ] as const)('is public, bounded and accessible in %s', (locale, intro, submit) => { + const { container } = render(); + expect(screen.getByRole('heading', { name: 'BOTÓN DE ARREPENTIMIENTO' })).toBeInTheDocument(); + expect(screen.getByText(new RegExp(intro))).toBeInTheDocument(); + expect(screen.getByLabelText(/Correo usado|Email used/)).toHaveAttribute('maxlength', '254'); + expect(screen.getByRole('button', { name: submit })).toHaveClass('listener-button--primary'); + expect(screen.queryByRole('button', { name: /Google|Apple|sign in|iniciar sesión/i })).not.toBeInTheDocument(); + expect(container.querySelector('input[type="password"]')).toBeNull(); + expect(container.querySelector('textarea')).toBeNull(); + }); + + it('shows the immediate receipt and sends no login or provider identifier', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + receiptCode: 'HBW-1234567890ABCDEF1234567890ABCD', + receivedAt: '2026-08-13T19:00:00.000Z', + }), { status: 201, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', fetchMock); + render(); + + await userEvent.type(screen.getByLabelText('Email used for the purchase'), 'buyer@example.com'); + await userEvent.click(screen.getByRole('button', { name: 'Send request' })); + + expect(await screen.findByRole('status')).toHaveTextContent('HBW-1234567890ABCDEF1234567890ABCD'); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.headers).toMatchObject({ 'X-Listener-Withdrawal-Intent': '1' }); + expect(init.body).not.toContain('providerId'); + expect(init.body).not.toContain('accountId'); + }); +}); diff --git a/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx b/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx new file mode 100644 index 00000000..0ea91d40 --- /dev/null +++ b/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { LocaleProvider } from '@/context/LocaleContext'; + +const usePathname = vi.hoisted(() => vi.fn(() => '/listener')); +vi.mock('next/navigation', () => ({ usePathname })); + +import ConsumerWithdrawalLink from '../ConsumerWithdrawalLink'; + +afterEach(() => cleanup()); + +describe('prominent consumer-withdrawal entry', () => { + it.each(['es', 'en'] as const)('keeps the legally named no-login link visible in %s', (locale) => { + usePathname.mockReturnValue('/listener'); + render(); + const link = screen.getByRole('link', { name: /BOTÓN DE ARREPENTIMIENTO/ }); + expect(link).toHaveAttribute('href', '/listener/withdrawal'); + expect(link).toHaveClass('listener-withdrawal-link'); + }); + + it('does not cover the request itself with a self-link', () => { + usePathname.mockReturnValue('/listener/withdrawal'); + render(); + expect(screen.queryByRole('link')).toBeNull(); + }); +}); diff --git a/src/lib/listener/__tests__/consumer-withdrawal.postgres.test.ts b/src/lib/listener/__tests__/consumer-withdrawal.postgres.test.ts new file mode 100644 index 00000000..a190edde --- /dev/null +++ b/src/lib/listener/__tests__/consumer-withdrawal.postgres.test.ts @@ -0,0 +1,104 @@ +import { randomUUID } from 'node:crypto'; + +import type { PrismaClient } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + ListenerWithdrawalConflictError, + ListenerWithdrawalRateLimitError, + listenerWithdrawalNetworkBucketKey, + parseListenerWithdrawalInput, + submitListenerWithdrawal, +} from '../consumer-withdrawal'; + +const databaseUrl = process.env.LISTENER_TEST_DATABASE_URL; +if (databaseUrl) process.env.DATABASE_URL = databaseUrl; +const postgres = databaseUrl ? describe : describe.skip; +let prisma: PrismaClient; +const suffix = randomUUID(); +const secret = `postgres-withdrawal-${suffix}`; +const idempotencyKey = randomUUID(); +const rateIdempotencyKeys = Array.from({ length: 9 }, () => randomUUID()); + +function request(email = `withdrawal-${suffix}@example.invalid`) { + return parseListenerWithdrawalInput({ + email, + idempotencyKey, + locale: 'es', + provider: 'PAYPAL', + purchaseDate: '', + }); +} + +postgres('Listener withdrawal PostgreSQL queue', () => { + beforeAll(async () => { + ({ prisma } = await import('@/lib/db')); + }); + + afterAll(async () => { + await prisma.listenerWithdrawalRequest.deleteMany({ + where: { idempotencyKey: { in: [idempotencyKey, ...rateIdempotencyKeys] } }, + }); + await prisma.listenerWithdrawalThrottle.deleteMany({ + where: { + key: { + in: [ + listenerWithdrawalNetworkBucketKey(suffix, secret), + listenerWithdrawalNetworkBucketKey(`rate-${suffix}`, secret), + ], + }, + }, + }); + await prisma.$disconnect(); + }); + + it('converges concurrent exact submissions to one durable request and receipt', async () => { + const input = { request: request(), networkIdentity: suffix, secret }; + const results = await Promise.all([ + submitListenerWithdrawal(input), + submitListenerWithdrawal(input), + ]); + expect(new Set(results.map((result) => result.receiptCode))).toHaveLength(1); + expect(results.map((result) => result.replayed).sort()).toEqual([false, true]); + expect(await prisma.listenerWithdrawalRequest.count({ where: { idempotencyKey } })).toBe(1); + const persisted = await prisma.listenerWithdrawalRequest.findUniqueOrThrow({ + where: { idempotencyKey }, + }); + expect(persisted.receiptDigest).toMatch(/^[0-9a-f]{64}$/); + expect('receiptCode' in persisted).toBe(false); + }); + + it('rejects conflicting reuse and enforces ordered processing fields', async () => { + await expect(submitListenerWithdrawal({ + request: request(`changed-${suffix}@example.invalid`), + networkIdentity: suffix, + secret, + })).rejects.toBeInstanceOf(ListenerWithdrawalConflictError); + await expect(prisma.listenerWithdrawalRequest.update({ + where: { idempotencyKey }, + data: { status: 'RESOLVED' }, + })).rejects.toThrow(); + }); + + it('enforces the durable network abuse bucket without account lookups', async () => { + for (const [index, rateKey] of rateIdempotencyKeys.entries()) { + const candidate = parseListenerWithdrawalInput({ + email: `rate-${index}-${suffix}@example.invalid`, + idempotencyKey: rateKey, + locale: 'en', + provider: 'OTHER', + purchaseDate: '', + }); + const submission = submitListenerWithdrawal({ + request: candidate, + networkIdentity: `rate-${suffix}`, + secret, + }); + if (index < 8) await expect(submission).resolves.toMatchObject({ replayed: false }); + else await expect(submission).rejects.toBeInstanceOf(ListenerWithdrawalRateLimitError); + } + expect(await prisma.listenerWithdrawalRequest.count({ + where: { idempotencyKey: { in: rateIdempotencyKeys } }, + })).toBe(8); + }); +}); diff --git a/src/lib/listener/__tests__/consumer-withdrawal.test.ts b/src/lib/listener/__tests__/consumer-withdrawal.test.ts new file mode 100644 index 00000000..1c125fa4 --- /dev/null +++ b/src/lib/listener/__tests__/consumer-withdrawal.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { + ListenerWithdrawalInputError, + listenerWithdrawalReceiptCode, + listenerWithdrawalReceiptDigest, + listenerWithdrawalRequestHash, + parseListenerWithdrawalInput, +} from '../consumer-withdrawal'; + +const BASE = { + email: ' Listener@Example.com ', + idempotencyKey: '123e4567-e89b-42d3-a456-426614174000', + locale: 'es', + provider: 'PAYPAL', + purchaseDate: '2026-08-12', +}; + +describe('Listener consumer-withdrawal contract', () => { + it('normalizes only bounded fields needed to locate a purchase', () => { + expect(parseListenerWithdrawalInput(BASE, new Date('2026-08-13T12:00:00Z'))).toEqual({ + email: 'listener@example.com', + idempotencyKey: BASE.idempotencyKey, + locale: 'es', + provider: 'PAYPAL', + purchaseDate: new Date('2026-08-12T00:00:00.000Z'), + }); + }); + + it.each([ + { ...BASE, accepted: false }, + { ...BASE, provider: 'paypal' }, + { ...BASE, email: 'not-an-email' }, + { ...BASE, purchaseDate: '2026-08-14' }, + { ...BASE, idempotencyKey: 'predictable' }, + { ...BASE, detail: 'unrequested free text' }, + { ...BASE, providerId: 'raw-provider-id' }, + ])('rejects malformed, excessive or extra input %#', (input) => { + expect(() => parseListenerWithdrawalInput(input, new Date('2026-08-13T12:00:00Z'))) + .toThrow(ListenerWithdrawalInputError); + }); + + it('derives a replayable opaque receipt without embedding request facts', () => { + const parsed = parseListenerWithdrawalInput(BASE, new Date('2026-08-13T12:00:00Z')); + const receipt = listenerWithdrawalReceiptCode(parsed.idempotencyKey); + expect(receipt).toMatch(/^HBW-[0-9A-F]{30}$/); + expect(receipt).not.toContain('PAYPAL'); + expect(receipt).not.toContain('LISTENER'); + expect(listenerWithdrawalReceiptCode(parsed.idempotencyKey)).toBe(receipt); + expect(listenerWithdrawalReceiptDigest(receipt)).toMatch(/^[0-9a-f]{64}$/); + expect(listenerWithdrawalRequestHash(parsed)).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/src/lib/listener/consumer-withdrawal-contract.ts b/src/lib/listener/consumer-withdrawal-contract.ts new file mode 100644 index 00000000..21ee8c31 --- /dev/null +++ b/src/lib/listener/consumer-withdrawal-contract.ts @@ -0,0 +1,4 @@ +export const LISTENER_WITHDRAWAL_PATH = '/listener/withdrawal'; +export const LISTENER_WITHDRAWAL_API_PATH = '/api/listener/withdrawal'; +export const LISTENER_WITHDRAWAL_MAX_REQUEST_BYTES = 2_048; +export const LISTENER_WITHDRAWAL_RESPONSE_HOURS = 24; diff --git a/src/lib/listener/consumer-withdrawal.ts b/src/lib/listener/consumer-withdrawal.ts new file mode 100644 index 00000000..b50431c0 --- /dev/null +++ b/src/lib/listener/consumer-withdrawal.ts @@ -0,0 +1,252 @@ +import { createHash, createHmac } from 'node:crypto'; + +import { Prisma, type ListenerWithdrawalProvider } from '@prisma/client'; + +import { prisma } from '@/lib/db'; + +const RATE_WINDOW_MS = 60 * 60 * 1_000; +const RATE_WINDOW_MAX = 8; +const RATE_RETENTION_MS = 48 * 60 * 60 * 1_000; +const RECEIPT_HEX_LENGTH = 30; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; +const IDEMPOTENCY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +export type ListenerWithdrawalInput = { + email: string; + provider: ListenerWithdrawalProvider; + purchaseDate: Date | null; + locale: 'es' | 'en'; + idempotencyKey: string; +}; + +export type ListenerWithdrawalReceipt = { + receiptCode: string; + receivedAt: Date; + replayed: boolean; +}; + +export class ListenerWithdrawalInputError extends Error { + constructor() { + super('Invalid withdrawal request'); + this.name = 'ListenerWithdrawalInputError'; + } +} + +export class ListenerWithdrawalConflictError extends Error { + constructor() { + super('Withdrawal request idempotency conflict'); + this.name = 'ListenerWithdrawalConflictError'; + } +} + +export class ListenerWithdrawalRateLimitError extends Error { + constructor() { + super('Withdrawal request rate limit reached'); + this.name = 'ListenerWithdrawalRateLimitError'; + } +} + +export function listenerWithdrawalSecret( + environment: Record = process.env, +): string | null { + const value = environment.LISTENER_WITHDRAWAL_SECRET; + return value && value.length >= 32 ? value : null; +} + +function normalizeEmail(value: unknown): string { + if (typeof value !== 'string') throw new ListenerWithdrawalInputError(); + const email = value.trim().toLowerCase(); + if (email.length < 3 || email.length > 254 || !EMAIL_PATTERN.test(email)) { + throw new ListenerWithdrawalInputError(); + } + return email; +} + +function normalizeProvider(value: unknown): ListenerWithdrawalProvider { + if (value === 'PAYPAL' || value === 'MERCADO_PAGO' || value === 'OTHER') return value; + throw new ListenerWithdrawalInputError(); +} + +function normalizePurchaseDate(value: unknown, now: Date): Date | null { + if (value === '' || value === null) return null; + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new ListenerWithdrawalInputError(); + } + const date = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) { + throw new ListenerWithdrawalInputError(); + } + const tomorrowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1); + if (date.getTime() < Date.UTC(2000, 0, 1) || date.getTime() >= tomorrowUtc) { + throw new ListenerWithdrawalInputError(); + } + return date; +} + +export function parseListenerWithdrawalInput(input: unknown, now = new Date()): ListenerWithdrawalInput { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new ListenerWithdrawalInputError(); + } + const record = input as Record; + const keys = Object.keys(record).sort(); + const expected = ['email', 'idempotencyKey', 'locale', 'provider', 'purchaseDate'].sort(); + if (keys.join('\0') !== expected.join('\0')) { + throw new ListenerWithdrawalInputError(); + } + if (record.locale !== 'es' && record.locale !== 'en') throw new ListenerWithdrawalInputError(); + if (typeof record.idempotencyKey !== 'string' || !IDEMPOTENCY_PATTERN.test(record.idempotencyKey)) { + throw new ListenerWithdrawalInputError(); + } + return { + email: normalizeEmail(record.email), + provider: normalizeProvider(record.provider), + purchaseDate: normalizePurchaseDate(record.purchaseDate, now), + locale: record.locale, + idempotencyKey: record.idempotencyKey.toLowerCase(), + }; +} + +function canonicalRequest(input: ListenerWithdrawalInput): string { + return JSON.stringify({ + email: input.email, + idempotencyKey: input.idempotencyKey, + locale: input.locale, + provider: input.provider, + purchaseDate: input.purchaseDate?.toISOString().slice(0, 10) ?? null, + }); +} + +export function listenerWithdrawalRequestHash(input: ListenerWithdrawalInput): string { + return createHash('sha256').update(canonicalRequest(input), 'utf8').digest('hex'); +} + +export function listenerWithdrawalReceiptCode(idempotencyKey: string): string { + // The UUID supplies 122 random bits. A namespaced one-way derivation keeps + // the receipt opaque and replayable even when the rate-limit secret rotates. + const opaque = createHash('sha256') + .update(`listener-withdrawal-receipt\n${idempotencyKey}`, 'utf8') + .digest('hex') + .slice(0, RECEIPT_HEX_LENGTH) + .toUpperCase(); + return `HBW-${opaque}`; +} + +export function listenerWithdrawalReceiptDigest(receiptCode: string): string { + return createHash('sha256').update(receiptCode, 'utf8').digest('hex'); +} + +export function listenerWithdrawalNetworkBucketKey(networkIdentity: string, secret: string): string { + const digest = createHmac('sha256', secret) + .update(`listener-withdrawal-network\n${networkIdentity}`, 'utf8') + .digest('hex'); + return `network:${digest}`; +} + +async function consumeNetworkBucket( + tx: Prisma.TransactionClient, + key: string, + now: Date, +): Promise { + const current = await tx.listenerWithdrawalThrottle.upsert({ + where: { key }, + create: { key, windowStartedAt: now, attempts: 0 }, + update: {}, + }); + if (current.blockedUntil && current.blockedUntil > now) throw new ListenerWithdrawalRateLimitError(); + const windowEnd = new Date(current.windowStartedAt.getTime() + RATE_WINDOW_MS); + if (windowEnd <= now) { + await tx.listenerWithdrawalThrottle.update({ + where: { key }, + data: { windowStartedAt: now, attempts: 1, blockedUntil: null }, + }); + return; + } + if (current.attempts >= RATE_WINDOW_MAX) { + await tx.listenerWithdrawalThrottle.update({ + where: { key }, + data: { blockedUntil: windowEnd }, + }); + throw new ListenerWithdrawalRateLimitError(); + } + await tx.listenerWithdrawalThrottle.update({ + where: { key }, + data: { attempts: { increment: 1 } }, + }); +} + +function isPrismaCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error + && (error as { code?: unknown }).code === code; +} + +export async function submitListenerWithdrawal(input: { + request: ListenerWithdrawalInput; + networkIdentity: string; + secret: string; + now?: Date; +}): Promise { + const now = input.now ?? new Date(); + const requestHash = listenerWithdrawalRequestHash(input.request); + const receiptCode = listenerWithdrawalReceiptCode(input.request.idempotencyKey); + const receiptDigest = listenerWithdrawalReceiptDigest(receiptCode); + const bucketKey = listenerWithdrawalNetworkBucketKey(input.networkIdentity, input.secret); + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await prisma.$transaction(async (tx) => { + const existing = await tx.listenerWithdrawalRequest.findUnique({ + where: { idempotencyKey: input.request.idempotencyKey }, + select: { requestHash: true, createdAt: true }, + }); + if (existing) { + if (existing.requestHash !== requestHash) throw new ListenerWithdrawalConflictError(); + return { receiptCode, receivedAt: existing.createdAt, replayed: true }; + } + + await tx.listenerWithdrawalThrottle.deleteMany({ + where: { updatedAt: { lt: new Date(now.getTime() - RATE_RETENTION_MS) } }, + }); + await consumeNetworkBucket(tx, bucketKey, now); + const created = await tx.listenerWithdrawalRequest.create({ + data: { + receiptDigest, + receiptLastFour: receiptCode.slice(-4), + idempotencyKey: input.request.idempotencyKey, + requestHash, + contactEmail: input.request.email, + provider: input.request.provider, + purchaseDate: input.request.purchaseDate, + locale: input.request.locale, + }, + select: { createdAt: true }, + }); + return { receiptCode, receivedAt: created.createdAt, replayed: false }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } catch (error) { + if (isPrismaCode(error, 'P2034') && attempt < 2) continue; + if (isPrismaCode(error, 'P2002')) { + const existing = await prisma.listenerWithdrawalRequest.findUnique({ + where: { idempotencyKey: input.request.idempotencyKey }, + select: { requestHash: true, createdAt: true }, + }); + if (existing?.requestHash === requestHash) { + return { receiptCode, receivedAt: existing.createdAt, replayed: true }; + } + throw new ListenerWithdrawalConflictError(); + } + throw error; + } + } + throw new Error('unreachable withdrawal serialization retry'); +} + +export function listenerWithdrawalNetworkIdentity(request: Request): string { + const direct = request.headers.get('x-real-ip')?.trim(); + const forwarded = request.headers.get('x-forwarded-for') + ?.split(',') + .map((value) => value.trim()) + .filter(Boolean) + .at(-1); + const address = direct || forwarded || 'unavailable'; + return address.length <= 128 ? address : 'unavailable'; +} From 4d31f3b5ac7698995de726783770d30db38dadca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 02:20:30 -0300 Subject: [PATCH 220/376] fix(listener): harden consumer request operations --- Dockerfile | 2 + .../LISTENER_WITHDRAWAL_REQUESTS.md | 62 +++++++++++-- docs/plans/EARLY_BIRDS.md | 2 +- ops/early-birds-preview/compose.yml | 4 + ...s-staging.harmonicbeacon.com.conf.template | 69 +++++++++++++++ .../listen.harmonicbeacon.com.conf.template | 71 +++++++++++++++ .../preview.env.synthetic.example | 6 +- .../test/preview-contract.test.mjs | 33 +++++++ ops/early-birds/docker-compose.yml | 1 + ops/early-birds/prometheus/alerts.yml | 22 +++++ ...beacon-listener-withdrawal-metrics.service | 17 ++++ ...c-beacon-listener-withdrawal-metrics.timer | 12 +++ ...c-beacon-listener-withdrawal-prune.service | 16 ++++ ...nic-beacon-listener-withdrawal-prune.timer | 11 +++ ops/early-birds/test/config.test.mjs | 34 ++++++++ .../migration.sql | 6 +- prisma/schema.prisma | 12 ++- scripts/early-birds-preview/lib.sh | 6 ++ scripts/early-birds-preview/validate.mjs | 4 + scripts/listener-withdrawal-export-metrics.sh | 26 ++++++ scripts/listener-withdrawal-operator.ts | 43 +++++++++ .../listener-withdrawal-prune-throttles.sh | 15 ++++ .../api/health/__tests__/ready-route.test.ts | 53 +++++++++++ src/app/api/health/ready/route.ts | 24 ++++- .../withdrawal/__tests__/route.test.ts | 9 +- src/app/api/listener/withdrawal/route.ts | 8 +- src/app/globals.css | 9 +- src/app/listener/cancel-service/page.tsx | 17 ++++ src/app/listener/layout.tsx | 7 +- src/app/listener/privacy/page.tsx | 3 +- src/app/listener/terms/page.tsx | 3 +- src/app/listener/withdrawal/page.tsx | 3 + .../early-birds/ConsumerWithdrawalForm.tsx | 29 ++++++- .../early-birds/ConsumerWithdrawalLink.tsx | 26 ++++-- src/components/early-birds/ListenerLegal.tsx | 12 ++- .../__tests__/ConsumerWithdrawalForm.test.tsx | 19 +++- .../__tests__/ConsumerWithdrawalLink.test.tsx | 7 ++ .../consumer-withdrawal.postgres.test.ts | 64 +++++++++++++- .../__tests__/consumer-withdrawal.test.ts | 18 ++++ .../listener/consumer-withdrawal-contract.ts | 1 + src/lib/listener/consumer-withdrawal.ts | 87 +++++++++++++++---- 41 files changed, 815 insertions(+), 58 deletions(-) create mode 100644 ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-metrics.service create mode 100644 ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-metrics.timer create mode 100644 ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-prune.service create mode 100644 ops/early-birds/systemd/harmonic-beacon-listener-withdrawal-prune.timer create mode 100755 scripts/listener-withdrawal-export-metrics.sh create mode 100755 scripts/listener-withdrawal-prune-throttles.sh create mode 100644 src/app/listener/cancel-service/page.tsx diff --git a/Dockerfile b/Dockerfile index cad5a314..0958ab22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,10 +49,12 @@ 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/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/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 diff --git a/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md b/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md index 5cbc2741..8beb3933 100644 --- a/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md +++ b/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md @@ -1,7 +1,7 @@ # Listener consumer-withdrawal requests This is the bounded operator flow for the public **BOTÓN DE -ARREPENTIMIENTO**. It receives and tracks a request; it never calls PayPal, +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. @@ -13,13 +13,29 @@ itself. `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. -- The public API fails closed with `503` when the secret is absent. Public paid +- 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 throttles; + 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 @@ -34,7 +50,7 @@ npx tsx scripts/listener-withdrawal-operator.ts acknowledge REQUEST_UUID operato Then, outside this application: -1. correlate the email/provider/date against the canonical provider and +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/refund, if applicable, using @@ -52,13 +68,43 @@ 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. Adding -that private metric/alert is a deployment operation; do not expose request -details or receipt codes in metrics or logs. +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-listener-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. + +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 -Hide the link and route or deploy the previous Listener image. Keep the +Set `LISTENER_WITHDRAWAL_ENABLED=0` to hide both links/routes/API, or deploy the previous Listener image. 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/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index e1e9e48b..ece0452a 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -662,7 +662,7 @@ explicitly approved. 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 route, dedicated +- 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 diff --git a/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index 5533f857..9a5505e0 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -92,6 +92,10 @@ services: 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 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 index 2a8b297e..ffd7a39a 100644 --- a/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/earlybirds-staging.harmonicbeacon.com.conf.template @@ -3,6 +3,7 @@ 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; log_format listener_payment_webhook '$remote_addr - $request_method $uri $status $body_bytes_sent'; server { @@ -51,6 +52,22 @@ server { 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; @@ -235,6 +252,58 @@ server { 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; diff --git a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template index 65d630bc..70ac3703 100644 --- a/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/listen.harmonicbeacon.com.conf.template @@ -7,6 +7,7 @@ limit_req_zone $binary_remote_addr zone=listener_public_visual_analysis:1m rate= 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; server { listen 80; @@ -63,6 +64,22 @@ server { 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 { @@ -228,6 +245,60 @@ server { 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. diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example index 9cebb63c..6a7cd182 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -9,7 +9,7 @@ EARLYBIRDS_PREVIEW_APP_PORT=13000 EARLYBIRDS_PREVIEW_IMAGE_TAG=synthetic EARLYBIRDS_PREVIEW_GIT_SHA=synthetic-preview EARLYBIRDS_PREVIEW_BUILD_TIME=synthetic-preview -EARLYBIRDS_PREVIEW_SCHEMA_VERSION=20260807200000_listener_regional_presence +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= @@ -38,6 +38,10 @@ EARLY_BIRDS_TEST_LOGIN_SECRET=synthetic-preview-login-secret-at-least-32-charact # 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 diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 47e216f8..dfa19836 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -31,6 +31,7 @@ test('synthetic guard accepts the example and rejects unsafe effective values', ['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/], @@ -141,6 +142,8 @@ test('compose gates the loopback Listener on a forward-only isolated database mi 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\}/); @@ -163,6 +166,36 @@ test('compose gates the loopback Listener on a forward-only isolated database mi assert.match(source, /BEACON_LISTENER_GEOIP_HOST_PATH[^\n]*:\/data\/geoip\/dbip-country-lite\.mmdb:ro/); }); +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); diff --git a/ops/early-birds/docker-compose.yml b/ops/early-birds/docker-compose.yml index 6c9ac575..08df920d 100644 --- a/ops/early-birds/docker-compose.yml +++ b/ops/early-birds/docker-compose.yml @@ -78,6 +78,7 @@ services: - --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 diff --git a/ops/early-birds/prometheus/alerts.yml b/ops/early-birds/prometheus/alerts.yml index 1cc5097e..522bd2bf 100644 --- a/ops/early-birds/prometheus/alerts.yml +++ b/ops/early-birds/prometheus/alerts.yml @@ -1,4 +1,26 @@ 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 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/config.test.mjs b/ops/early-birds/test/config.test.mjs index 0b489a93..000038d0 100644 --- a/ops/early-birds/test/config.test.mjs +++ b/ops/early-birds/test/config.test.mjs @@ -49,11 +49,45 @@ test('references Telegram and canary credentials as mounted secret files only', 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('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(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(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/); diff --git a/prisma/migrations/20260813190000_listener_withdrawal_request/migration.sql b/prisma/migrations/20260813190000_listener_withdrawal_request/migration.sql index ba950b00..02edef18 100644 --- a/prisma/migrations/20260813190000_listener_withdrawal_request/migration.sql +++ b/prisma/migrations/20260813190000_listener_withdrawal_request/migration.sql @@ -2,6 +2,7 @@ -- 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, @@ -10,6 +11,7 @@ CREATE TABLE "listener_withdrawal_requests" ( "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, @@ -55,7 +57,7 @@ CREATE INDEX "listener_withdrawal_requests_created_at_idx" ON "listener_withdrawal_requests"("created_at"); CREATE TABLE "listener_withdrawal_throttles" ( - "key" CHAR(72) NOT NULL, + "key" VARCHAR(72) NOT NULL, "window_started_at" TIMESTAMP(3) NOT NULL, "attempts" INTEGER NOT NULL DEFAULT 0, "blocked_until" TIMESTAMP(3), @@ -63,7 +65,7 @@ CREATE TABLE "listener_withdrawal_throttles" ( CONSTRAINT "listener_withdrawal_throttles_pkey" PRIMARY KEY ("key"), CONSTRAINT "listener_withdrawal_throttles_key_check" - CHECK ("key" ~ '^network:[0-9a-f]{64}$'), + CHECK ("key" = 'global' OR "key" ~ '^(network|email):[0-9a-f]{64}$'), CONSTRAINT "listener_withdrawal_throttles_attempts_check" CHECK ("attempts" >= 0) ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 921bb9ed..4fac9bf3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -129,6 +129,11 @@ enum ListenerWithdrawalProvider { OTHER } +enum ListenerConsumerRequestKind { + WITHDRAWAL + SERVICE_CANCELLATION +} + enum ListenerWithdrawalStatus { RECEIVED ACKNOWLEDGED @@ -699,6 +704,7 @@ model ListenerWithdrawalRequest { 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) @@ -716,10 +722,10 @@ model ListenerWithdrawalRequest { @@map("listener_withdrawal_requests") } -// HMAC-keyed network buckets. Raw IP addresses are neither persisted nor -// exposed to operators. +// 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.Char(72) + key String @id @db.VarChar(72) windowStartedAt DateTime @map("window_started_at") attempts Int @default(0) blockedUntil DateTime? @map("blocked_until") diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index efd7376e..d1f7dbee 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -101,6 +101,12 @@ require_synthetic_env() { 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") diff --git a/scripts/early-birds-preview/validate.mjs b/scripts/early-birds-preview/validate.mjs index e8dc2360..cabafbef 100644 --- a/scripts/early-birds-preview/validate.mjs +++ b/scripts/early-birds-preview/validate.mjs @@ -33,6 +33,8 @@ const syntheticEnv = [ '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', @@ -104,6 +106,8 @@ try { 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', diff --git a/scripts/listener-withdrawal-export-metrics.sh b/scripts/listener-withdrawal-export-metrics.sh new file mode 100755 index 00000000..28a217f3 --- /dev/null +++ b/scripts/listener-withdrawal-export-metrics.sh @@ -0,0 +1,26 @@ +#!/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-listener-1} +metrics_dir=/var/lib/harmonic-beacon/metrics +metrics_file=$metrics_dir/listener-withdrawal.prom +docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null | grep -Fxq true || { + echo 'Listener container is not running' >&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 index 04e5f4ab..051282b2 100755 --- a/scripts/listener-withdrawal-operator.ts +++ b/scripts/listener-withdrawal-operator.ts @@ -21,6 +21,8 @@ function usage(): never { ' 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')); } @@ -45,6 +47,45 @@ 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]); @@ -57,6 +98,7 @@ async function main() { id: true, receiptLastFour: true, provider: true, + requestKind: true, status: true, createdAt: true, acknowledgedAt: true, @@ -75,6 +117,7 @@ async function main() { receiptLastFour: true, contactEmail: true, provider: true, + requestKind: true, purchaseDate: true, locale: true, status: true, diff --git a/scripts/listener-withdrawal-prune-throttles.sh b/scripts/listener-withdrawal-prune-throttles.sh new file mode 100755 index 00000000..1505580d --- /dev/null +++ b/scripts/listener-withdrawal-prune-throttles.sh @@ -0,0 +1,15 @@ +#!/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-listener-1} +docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null | grep -Fxq true || { + echo 'Listener container is not running' >&2 + exit 1 +} +exec docker exec --user root "$container" \ + npx --no-install tsx scripts/listener-withdrawal-operator.ts prune-throttles 48 diff --git a/src/app/api/health/__tests__/ready-route.test.ts b/src/app/api/health/__tests__/ready-route.test.ts index bf5e7a13..e8def086 100644 --- a/src/app/api/health/__tests__/ready-route.test.ts +++ b/src/app/api/health/__tests__/ready-route.test.ts @@ -72,6 +72,59 @@ describe('GET /api/health/ready', () => { } }); + 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 82ce4b2c..01ca9966 100644 --- a/src/app/api/health/ready/route.ts +++ b/src/app/api/health/ready/route.ts @@ -10,6 +10,10 @@ import { validateListenerLiveWorkbenchEnvironment, } from '@/lib/early-birds/live-workbench'; import { OperationTimeoutError, withTimeout } from '@/lib/with-timeout'; +import { + ListenerWithdrawalConfigurationError, + listenerWithdrawalConfiguration, +} from '@/lib/listener/consumer-withdrawal'; export const dynamic = 'force-dynamic'; @@ -26,12 +30,15 @@ const NO_STORE_HEADERS = { 'Cache-Control': 'no-store' }; */ export async function GET() { let listenerRuntimeConfigured = false; + let listenerWithdrawalConfigured = false; try { listenerRuntimeConfigured = validateListenerRuntimeEnvironment(); validateListenerLiveWorkbenchEnvironment(); + listenerWithdrawalConfigured = listenerWithdrawalConfiguration().enabled; } catch (error) { const diagnostic = error instanceof ListenerRuntimeEnvironmentError || - error instanceof ListenerLiveWorkbenchConfigurationError + error instanceof ListenerLiveWorkbenchConfigurationError || + error instanceof ListenerWithdrawalConfigurationError ? error.message : 'unexpected validation failure'; console.error('Listener runtime configuration invalid:', diagnostic); @@ -44,6 +51,20 @@ export async function GET() { ); } 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( { @@ -51,6 +72,7 @@ export async function GET() { checks: { database: 'ok', ...(listenerRuntimeConfigured ? { listenerRuntime: 'ok' } : {}), + ...(listenerWithdrawalConfigured ? { listenerWithdrawal: 'ok' } : {}), }, }, { headers: NO_STORE_HEADERS }, diff --git a/src/app/api/listener/withdrawal/__tests__/route.test.ts b/src/app/api/listener/withdrawal/__tests__/route.test.ts index 252e88c4..9b68d3aa 100644 --- a/src/app/api/listener/withdrawal/__tests__/route.test.ts +++ b/src/app/api/listener/withdrawal/__tests__/route.test.ts @@ -20,6 +20,7 @@ const BODY = { locale: 'en', provider: 'PAYPAL', purchaseDate: '', + requestKind: 'WITHDRAWAL', }; function request(overrides: { @@ -47,6 +48,7 @@ function request(overrides: { 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', @@ -84,9 +86,12 @@ describe('public Listener withdrawal API', () => { expect(submitListenerWithdrawal).not.toHaveBeenCalled(); }); - it('fails closed without its dedicated secret', async () => { + 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(503); + expect((await POST(request())).status).toBe(404); expect(submitListenerWithdrawal).not.toHaveBeenCalled(); }); diff --git a/src/app/api/listener/withdrawal/route.ts b/src/app/api/listener/withdrawal/route.ts index 1f72a84f..6512162e 100644 --- a/src/app/api/listener/withdrawal/route.ts +++ b/src/app/api/listener/withdrawal/route.ts @@ -5,7 +5,7 @@ import { ListenerWithdrawalInputError, ListenerWithdrawalRateLimitError, listenerWithdrawalNetworkIdentity, - listenerWithdrawalSecret, + listenerWithdrawalPublicConfiguration, parseListenerWithdrawalInput, submitListenerWithdrawal, } from '@/lib/listener/consumer-withdrawal'; @@ -36,9 +36,9 @@ function trustedRequest(request: NextRequest): boolean { } 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 secret = listenerWithdrawalSecret(); - if (!secret) return json({ error: 'Request service unavailable.' }, 503); const declared = request.headers.get('content-length'); if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > LISTENER_WITHDRAWAL_MAX_REQUEST_BYTES)) { @@ -55,7 +55,7 @@ export async function POST(request: NextRequest): Promise { const result = await submitListenerWithdrawal({ request: parsed, networkIdentity: listenerWithdrawalNetworkIdentity(request), - secret, + secret: configuration.secret!, }); return json({ receiptCode: result.receiptCode, diff --git a/src/app/globals.css b/src/app/globals.css index 33f64f71..333ee76f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2043,11 +2043,16 @@ body { line-height: 1.65; } -.listener-withdrawal-link { +.listener-consumer-request-links { position: fixed; right: max(0.8rem, env(safe-area-inset-right)); bottom: max(0.8rem, env(safe-area-inset-bottom)); z-index: 90; + display: grid; + gap: 0.5rem; +} + +.listener-withdrawal-link { display: grid; gap: 0.12rem; min-height: 48px; @@ -2133,7 +2138,7 @@ body { } @media (max-width: 640px) { - .listener-withdrawal-link { + .listener-consumer-request-links { left: 0.75rem; right: 0.75rem; bottom: max(0.75rem, env(safe-area-inset-bottom)); diff --git a/src/app/listener/cancel-service/page.tsx b/src/app/listener/cancel-service/page.tsx new file mode 100644 index 00000000..ac1d0a6c --- /dev/null +++ b/src/app/listener/cancel-service/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import ConsumerWithdrawalForm from '@/components/early-birds/ConsumerWithdrawalForm'; +import { listenerWithdrawalPublicConfiguration } from '@/lib/listener/consumer-withdrawal'; + +export const dynamic = 'force-dynamic'; + +export const metadata: Metadata = { + title: 'BOTÓN DE BAJA DE SERVICIO · Harmonic Beacon', + robots: { index: true, follow: true }, +}; + +export default function ListenerServiceCancellationPage() { + if (!listenerWithdrawalPublicConfiguration()) notFound(); + return ; +} diff --git a/src/app/listener/layout.tsx b/src/app/listener/layout.tsx index dd8f1e1a..f3459d09 100644 --- a/src/app/listener/layout.tsx +++ b/src/app/listener/layout.tsx @@ -1,10 +1,15 @@ import ConsumerWithdrawalLink from '@/components/early-birds/ConsumerWithdrawalLink'; +import { listenerWithdrawalPublicConfiguration } from '@/lib/listener/consumer-withdrawal'; import EarlyBirdLayout from '../early-birds/layout'; export default function ListenerLayout({ children }: { children: React.ReactNode }) { + const withdrawalAvailable = listenerWithdrawalPublicConfiguration() !== null; return ( - + {withdrawalAvailable ?
+ + +
: null} {children}
); diff --git a/src/app/listener/privacy/page.tsx b/src/app/listener/privacy/page.tsx index c4b10645..d32e0bbf 100644 --- a/src/app/listener/privacy/page.tsx +++ b/src/app/listener/privacy/page.tsx @@ -1,7 +1,8 @@ import ListenerLegal from '@/components/early-birds/ListenerLegal'; +import { listenerWithdrawalPublicConfiguration } from '@/lib/listener/consumer-withdrawal'; export const dynamic = 'force-dynamic'; export default function ListenerPrivacyPage() { - return ; + return ; } diff --git a/src/app/listener/terms/page.tsx b/src/app/listener/terms/page.tsx index 8fb3777f..f547194a 100644 --- a/src/app/listener/terms/page.tsx +++ b/src/app/listener/terms/page.tsx @@ -1,7 +1,8 @@ import ListenerLegal from '@/components/early-birds/ListenerLegal'; +import { listenerWithdrawalPublicConfiguration } from '@/lib/listener/consumer-withdrawal'; export const dynamic = 'force-dynamic'; export default function ListenerTermsPage() { - return ; + return ; } diff --git a/src/app/listener/withdrawal/page.tsx b/src/app/listener/withdrawal/page.tsx index 5fd61401..d838de9c 100644 --- a/src/app/listener/withdrawal/page.tsx +++ b/src/app/listener/withdrawal/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; import ConsumerWithdrawalForm from '@/components/early-birds/ConsumerWithdrawalForm'; +import { listenerWithdrawalPublicConfiguration } from '@/lib/listener/consumer-withdrawal'; export const dynamic = 'force-dynamic'; @@ -10,5 +12,6 @@ export const metadata: Metadata = { }; export default function ListenerWithdrawalPage() { + if (!listenerWithdrawalPublicConfiguration()) notFound(); return ; } diff --git a/src/components/early-birds/ConsumerWithdrawalForm.tsx b/src/components/early-birds/ConsumerWithdrawalForm.tsx index 31a5a810..bf90187d 100644 --- a/src/components/early-birds/ConsumerWithdrawalForm.tsx +++ b/src/components/early-birds/ConsumerWithdrawalForm.tsx @@ -28,7 +28,7 @@ const copy = { rateLimited: 'Recibimos demasiadas solicitudes desde esta conexión. Intenta nuevamente más tarde.', received: 'Solicitud recibida', receipt: 'Tu código de identificación es', - receiptHelp: `Guárdalo. El equipo debe revisar la solicitud dentro de ${LISTENER_WITHDRAWAL_RESPONSE_HOURS} horas y puede contactarte en el correo indicado.`, + receiptHelp: `Guárdalo. El equipo procesará la solicitud y adoptará las medidas correspondientes dentro de ${LISTENER_WITHDRAWAL_RESPONSE_HOURS} horas; puede contactarte en el correo indicado.`, }, en: { back: 'Back to Listener', @@ -48,15 +48,35 @@ const copy = { rateLimited: 'Too many requests came from this connection. Please try again later.', received: 'Request received', receipt: 'Your identification code is', - receiptHelp: `Keep this code. The team must review the request within ${LISTENER_WITHDRAWAL_RESPONSE_HOURS} hours and may contact you at the email supplied.`, + receiptHelp: `Keep this code. The team will process the request and take the corresponding measures within ${LISTENER_WITHDRAWAL_RESPONSE_HOURS} hours; it may contact you at the email supplied.`, + }, +} as const; + +const cancellationCopy = { + es: { + title: 'BOTÓN DE BAJA DE SERVICIO', + intro: 'Puedes solicitar la baja del servicio sin iniciar sesión ni crear una cuenta. Recibirás un código de identificación de inmediato.', + scope: 'Esta solicitud abre un caso para procesar la baja. No ejecuta automáticamente acciones en el proveedor: el equipo verifica la operación y adopta las medidas correspondientes dentro de 24 horas.', + }, + en: { + title: 'BOTÓN DE BAJA DE SERVICIO', + intro: 'You may request service cancellation without signing in or creating an account. You will receive an identification code immediately.', + scope: 'This request opens a case to process the cancellation. It does not automatically act at the provider: the team verifies the transaction and takes the corresponding measures within 24 hours.', }, } as const; type Receipt = { receiptCode: string; receivedAt: string }; -export default function ConsumerWithdrawalForm() { +export default function ConsumerWithdrawalForm({ + requestKind = 'WITHDRAWAL', +}: { + requestKind?: 'WITHDRAWAL' | 'SERVICE_CANCELLATION'; +}) { const { locale } = useLocale(); - const text = copy[locale]; + const baseText = copy[locale]; + const text = requestKind === 'SERVICE_CANCELLATION' + ? { ...baseText, ...cancellationCopy[locale] } + : baseText; const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [receipt, setReceipt] = useState(null); @@ -81,6 +101,7 @@ export default function ConsumerWithdrawalForm() { locale, provider: form.get('provider'), purchaseDate: form.get('purchaseDate'), + requestKind, }), }); if (!response.ok) { diff --git a/src/components/early-birds/ConsumerWithdrawalLink.tsx b/src/components/early-birds/ConsumerWithdrawalLink.tsx index cbc4b60c..def53b40 100644 --- a/src/components/early-birds/ConsumerWithdrawalLink.tsx +++ b/src/components/early-birds/ConsumerWithdrawalLink.tsx @@ -4,19 +4,33 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useLocale } from '@/context/LocaleContext'; -import { LISTENER_WITHDRAWAL_PATH } from '@/lib/listener/consumer-withdrawal-contract'; +import { + LISTENER_SERVICE_CANCELLATION_PATH, + LISTENER_WITHDRAWAL_PATH, +} from '@/lib/listener/consumer-withdrawal-contract'; -export default function ConsumerWithdrawalLink({ inline = false }: { inline?: boolean }) { +export default function ConsumerWithdrawalLink({ + inline = false, + kind = 'WITHDRAWAL', +}: { + inline?: boolean; + kind?: 'WITHDRAWAL' | 'SERVICE_CANCELLATION'; +}) { const { locale } = useLocale(); const pathname = usePathname(); - if (!inline && pathname === LISTENER_WITHDRAWAL_PATH) return null; + const serviceCancellation = kind === 'SERVICE_CANCELLATION'; + const path = serviceCancellation ? LISTENER_SERVICE_CANCELLATION_PATH : LISTENER_WITHDRAWAL_PATH; + const title = serviceCancellation ? 'BOTÓN DE BAJA DE SERVICIO' : 'BOTÓN DE ARREPENTIMIENTO'; + if (!inline && pathname === path) return null; return ( - BOTÓN DE ARREPENTIMIENTO - {!inline && {locale === 'es' ? 'Cancelar una compra' : 'Request cancellation'}} + {title} + {!inline && {serviceCancellation + ? (locale === 'es' ? 'Dar de baja el servicio' : 'Cancel the service') + : (locale === 'es' ? 'Revocar una compra' : 'Withdraw from a purchase')}} ); } diff --git a/src/components/early-birds/ListenerLegal.tsx b/src/components/early-birds/ListenerLegal.tsx index 1e17e335..8b945a23 100644 --- a/src/components/early-birds/ListenerLegal.tsx +++ b/src/components/early-birds/ListenerLegal.tsx @@ -5,7 +5,7 @@ import { earlyBirdLegalCopy } from '@/lib/early-birds/copy'; import Link from 'next/link'; import ConsumerWithdrawalLink from './ConsumerWithdrawalLink'; -export default function ListenerLegal() { +export default function ListenerLegal({ withdrawalAvailable = false }: { withdrawalAvailable?: boolean }) { const { locale } = useLocale(); const copy = earlyBirdLegalCopy[locale]; @@ -22,7 +22,7 @@ export default function ListenerLegal() { {section.paragraphs.map((paragraph) =>

{paragraph}

)}
))} -
+ {withdrawalAvailable ?

{locale === 'es' ? 'Derecho de revocación' : 'Cancellation right'}

{locale === 'es' @@ -30,7 +30,13 @@ export default function ListenerLegal() { : 'You can open a public request without signing in to your account.'}{' '}

-
+

+ {locale === 'es' + ? 'También puedes solicitar la baja del servicio sin ingresar a tu cuenta.' + : 'You may also request service cancellation without signing in to your account.'}{' '} + +

+
: null} ); diff --git a/src/components/early-birds/__tests__/ConsumerWithdrawalForm.test.tsx b/src/components/early-birds/__tests__/ConsumerWithdrawalForm.test.tsx index 5a673513..d459d77d 100644 --- a/src/components/early-birds/__tests__/ConsumerWithdrawalForm.test.tsx +++ b/src/components/early-birds/__tests__/ConsumerWithdrawalForm.test.tsx @@ -40,7 +40,22 @@ describe('Listener withdrawal form', () => { expect(await screen.findByRole('status')).toHaveTextContent('HBW-1234567890ABCDEF1234567890ABCD'); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(init.headers).toMatchObject({ 'X-Listener-Withdrawal-Intent': '1' }); - expect(init.body).not.toContain('providerId'); - expect(init.body).not.toContain('accountId'); + expect(String(init.body)).not.toContain('providerId'); + expect(String(init.body)).not.toContain('accountId'); + expect(String(init.body)).toContain('"requestKind":"WITHDRAWAL"'); + }); + + it('shares the bounded queue while fixing service cancellation kind', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + receiptCode: 'HBW-1234567890ABCDEF1234567890ABCD', + receivedAt: '2026-08-13T19:00:00.000Z', + }), { status: 201, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', fetchMock); + render(); + expect(screen.getByRole('heading', { name: 'BOTÓN DE BAJA DE SERVICIO' })).toBeInTheDocument(); + await userEvent.type(screen.getByLabelText('Correo usado para la compra'), 'buyer@example.com'); + await userEvent.click(screen.getByRole('button', { name: 'Enviar solicitud' })); + expect(String((fetchMock.mock.calls[0] as [string, RequestInit])[1].body)) + .toContain('"requestKind":"SERVICE_CANCELLATION"'); }); }); diff --git a/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx b/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx index 0ea91d40..7f45bf3d 100644 --- a/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx +++ b/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx @@ -20,6 +20,13 @@ describe('prominent consumer-withdrawal entry', () => { expect(link).toHaveClass('listener-withdrawal-link'); }); + it('exposes the separately named no-login service cancellation link', () => { + usePathname.mockReturnValue('/listener'); + render(); + expect(screen.getByRole('link', { name: /BOTÓN DE BAJA DE SERVICIO/ })) + .toHaveAttribute('href', '/listener/cancel-service'); + }); + it('does not cover the request itself with a self-link', () => { usePathname.mockReturnValue('/listener/withdrawal'); render(); diff --git a/src/lib/listener/__tests__/consumer-withdrawal.postgres.test.ts b/src/lib/listener/__tests__/consumer-withdrawal.postgres.test.ts index a190edde..5adff0c2 100644 --- a/src/lib/listener/__tests__/consumer-withdrawal.postgres.test.ts +++ b/src/lib/listener/__tests__/consumer-withdrawal.postgres.test.ts @@ -6,6 +6,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { ListenerWithdrawalConflictError, ListenerWithdrawalRateLimitError, + LISTENER_WITHDRAWAL_GLOBAL_BUCKET_KEY, + listenerWithdrawalEmailBucketKey, listenerWithdrawalNetworkBucketKey, parseListenerWithdrawalInput, submitListenerWithdrawal, @@ -19,6 +21,8 @@ const suffix = randomUUID(); const secret = `postgres-withdrawal-${suffix}`; const idempotencyKey = randomUUID(); const rateIdempotencyKeys = Array.from({ length: 9 }, () => randomUUID()); +const emailRateIdempotencyKeys = Array.from({ length: 6 }, () => randomUUID()); +const globalRateIdempotencyKey = randomUUID(); function request(email = `withdrawal-${suffix}@example.invalid`) { return parseListenerWithdrawalInput({ @@ -27,6 +31,7 @@ function request(email = `withdrawal-${suffix}@example.invalid`) { locale: 'es', provider: 'PAYPAL', purchaseDate: '', + requestKind: 'WITHDRAWAL', }); } @@ -37,7 +42,12 @@ postgres('Listener withdrawal PostgreSQL queue', () => { afterAll(async () => { await prisma.listenerWithdrawalRequest.deleteMany({ - where: { idempotencyKey: { in: [idempotencyKey, ...rateIdempotencyKeys] } }, + where: { idempotencyKey: { in: [ + idempotencyKey, + globalRateIdempotencyKey, + ...rateIdempotencyKeys, + ...emailRateIdempotencyKeys, + ] } }, }); await prisma.listenerWithdrawalThrottle.deleteMany({ where: { @@ -45,6 +55,9 @@ postgres('Listener withdrawal PostgreSQL queue', () => { in: [ listenerWithdrawalNetworkBucketKey(suffix, secret), listenerWithdrawalNetworkBucketKey(`rate-${suffix}`, secret), + listenerWithdrawalEmailBucketKey(`withdrawal-${suffix}@example.invalid`, secret), + listenerWithdrawalEmailBucketKey(`shared-${suffix}@example.invalid`, secret), + LISTENER_WITHDRAWAL_GLOBAL_BUCKET_KEY, ], }, }, @@ -88,6 +101,7 @@ postgres('Listener withdrawal PostgreSQL queue', () => { locale: 'en', provider: 'OTHER', purchaseDate: '', + requestKind: 'WITHDRAWAL', }); const submission = submitListenerWithdrawal({ request: candidate, @@ -101,4 +115,52 @@ postgres('Listener withdrawal PostgreSQL queue', () => { where: { idempotencyKey: { in: rateIdempotencyKeys } }, })).toBe(8); }); + + it('limits one normalized email across distributed network identities', async () => { + for (const [index, key] of emailRateIdempotencyKeys.entries()) { + const candidate = parseListenerWithdrawalInput({ + email: ` Shared-${suffix}@Example.Invalid `, + idempotencyKey: key, + locale: 'en', + provider: 'OTHER', + purchaseDate: '', + requestKind: 'SERVICE_CANCELLATION', + }); + const submission = submitListenerWithdrawal({ + request: candidate, + networkIdentity: `distributed-${index}-${suffix}`, + secret, + }); + if (index < 5) await expect(submission).resolves.toMatchObject({ replayed: false }); + else await expect(submission).rejects.toBeInstanceOf(ListenerWithdrawalRateLimitError); + } + expect(await prisma.listenerWithdrawalThrottle.findUnique({ + where: { key: listenerWithdrawalEmailBucketKey(`shared-${suffix}@example.invalid`, secret) }, + })).toMatchObject({ attempts: 5 }); + }); + + it('enforces the fixed global hourly cap before accepting another request', async () => { + await prisma.listenerWithdrawalThrottle.upsert({ + where: { key: LISTENER_WITHDRAWAL_GLOBAL_BUCKET_KEY }, + create: { + key: LISTENER_WITHDRAWAL_GLOBAL_BUCKET_KEY, + windowStartedAt: new Date(), + attempts: 200, + }, + update: { windowStartedAt: new Date(), attempts: 200 }, + }); + const candidate = parseListenerWithdrawalInput({ + email: `global-${suffix}@example.invalid`, + idempotencyKey: globalRateIdempotencyKey, + locale: 'es', + provider: 'PAYPAL', + purchaseDate: '', + requestKind: 'WITHDRAWAL', + }); + await expect(submitListenerWithdrawal({ + request: candidate, + networkIdentity: `global-${suffix}`, + secret, + })).rejects.toBeInstanceOf(ListenerWithdrawalRateLimitError); + }); }); diff --git a/src/lib/listener/__tests__/consumer-withdrawal.test.ts b/src/lib/listener/__tests__/consumer-withdrawal.test.ts index 1c125fa4..28a8999a 100644 --- a/src/lib/listener/__tests__/consumer-withdrawal.test.ts +++ b/src/lib/listener/__tests__/consumer-withdrawal.test.ts @@ -5,6 +5,8 @@ import { listenerWithdrawalReceiptCode, listenerWithdrawalReceiptDigest, listenerWithdrawalRequestHash, + listenerWithdrawalConfiguration, + listenerWithdrawalPublicConfiguration, parseListenerWithdrawalInput, } from '../consumer-withdrawal'; @@ -14,9 +16,23 @@ const BASE = { locale: 'es', provider: 'PAYPAL', purchaseDate: '2026-08-12', + requestKind: 'WITHDRAWAL', }; describe('Listener consumer-withdrawal contract', () => { + it('keeps the public surface dark unless both switch and dedicated secret are valid', () => { + expect(listenerWithdrawalPublicConfiguration({})).toBeNull(); + expect(listenerWithdrawalPublicConfiguration({ + LISTENER_WITHDRAWAL_ENABLED: '1', + LISTENER_WITHDRAWAL_SECRET: 'short', + })).toBeNull(); + expect(listenerWithdrawalPublicConfiguration({ + LISTENER_WITHDRAWAL_ENABLED: '1', + LISTENER_WITHDRAWAL_SECRET: 's'.repeat(32), + })).toEqual({ enabled: true, secret: 's'.repeat(32) }); + expect(() => listenerWithdrawalConfiguration({ LISTENER_WITHDRAWAL_ENABLED: 'true' })) + .toThrow(/must be 0 or 1/); + }); it('normalizes only bounded fields needed to locate a purchase', () => { expect(parseListenerWithdrawalInput(BASE, new Date('2026-08-13T12:00:00Z'))).toEqual({ email: 'listener@example.com', @@ -24,12 +40,14 @@ describe('Listener consumer-withdrawal contract', () => { locale: 'es', provider: 'PAYPAL', purchaseDate: new Date('2026-08-12T00:00:00.000Z'), + requestKind: 'WITHDRAWAL', }); }); it.each([ { ...BASE, accepted: false }, { ...BASE, provider: 'paypal' }, + { ...BASE, requestKind: 'CANCEL' }, { ...BASE, email: 'not-an-email' }, { ...BASE, purchaseDate: '2026-08-14' }, { ...BASE, idempotencyKey: 'predictable' }, diff --git a/src/lib/listener/consumer-withdrawal-contract.ts b/src/lib/listener/consumer-withdrawal-contract.ts index 21ee8c31..134ff2a6 100644 --- a/src/lib/listener/consumer-withdrawal-contract.ts +++ b/src/lib/listener/consumer-withdrawal-contract.ts @@ -1,4 +1,5 @@ export const LISTENER_WITHDRAWAL_PATH = '/listener/withdrawal'; +export const LISTENER_SERVICE_CANCELLATION_PATH = '/listener/cancel-service'; export const LISTENER_WITHDRAWAL_API_PATH = '/api/listener/withdrawal'; export const LISTENER_WITHDRAWAL_MAX_REQUEST_BYTES = 2_048; export const LISTENER_WITHDRAWAL_RESPONSE_HOURS = 24; diff --git a/src/lib/listener/consumer-withdrawal.ts b/src/lib/listener/consumer-withdrawal.ts index b50431c0..a598a20a 100644 --- a/src/lib/listener/consumer-withdrawal.ts +++ b/src/lib/listener/consumer-withdrawal.ts @@ -1,18 +1,20 @@ import { createHash, createHmac } from 'node:crypto'; -import { Prisma, type ListenerWithdrawalProvider } from '@prisma/client'; +import { Prisma, type ListenerConsumerRequestKind, type ListenerWithdrawalProvider } from '@prisma/client'; import { prisma } from '@/lib/db'; const RATE_WINDOW_MS = 60 * 60 * 1_000; -const RATE_WINDOW_MAX = 8; -const RATE_RETENTION_MS = 48 * 60 * 60 * 1_000; +const NETWORK_RATE_WINDOW_MAX = 8; +const EMAIL_RATE_WINDOW_MAX = 5; +const GLOBAL_RATE_WINDOW_MAX = 200; const RECEIPT_HEX_LENGTH = 30; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; const IDEMPOTENCY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; export type ListenerWithdrawalInput = { email: string; + requestKind: ListenerConsumerRequestKind; provider: ListenerWithdrawalProvider; purchaseDate: Date | null; locale: 'es' | 'en'; @@ -46,6 +48,45 @@ export class ListenerWithdrawalRateLimitError extends Error { } } +export class ListenerWithdrawalConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ListenerWithdrawalConfigurationError'; + } +} + +export type ListenerWithdrawalConfiguration = { + enabled: boolean; + secret: string | null; +}; + +export function listenerWithdrawalConfiguration( + environment: Record = process.env, +): ListenerWithdrawalConfiguration { + const enabled = environment.LISTENER_WITHDRAWAL_ENABLED?.trim() || '0'; + if (enabled !== '0' && enabled !== '1') { + throw new ListenerWithdrawalConfigurationError('LISTENER_WITHDRAWAL_ENABLED must be 0 or 1'); + } + const secret = listenerWithdrawalSecret(environment); + if (enabled === '1' && !secret) { + throw new ListenerWithdrawalConfigurationError( + 'LISTENER_WITHDRAWAL_SECRET must contain at least 32 characters when withdrawal is enabled', + ); + } + return { enabled: enabled === '1', secret }; +} + +export function listenerWithdrawalPublicConfiguration( + environment: Record = process.env, +): ListenerWithdrawalConfiguration | null { + try { + const configuration = listenerWithdrawalConfiguration(environment); + return configuration.enabled && configuration.secret ? configuration : null; + } catch { + return null; + } +} + export function listenerWithdrawalSecret( environment: Record = process.env, ): string | null { @@ -67,6 +108,11 @@ function normalizeProvider(value: unknown): ListenerWithdrawalProvider { throw new ListenerWithdrawalInputError(); } +function normalizeRequestKind(value: unknown): ListenerConsumerRequestKind { + if (value === 'WITHDRAWAL' || value === 'SERVICE_CANCELLATION') return value; + throw new ListenerWithdrawalInputError(); +} + function normalizePurchaseDate(value: unknown, now: Date): Date | null { if (value === '' || value === null) return null; if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { @@ -89,7 +135,7 @@ export function parseListenerWithdrawalInput(input: unknown, now = new Date()): } const record = input as Record; const keys = Object.keys(record).sort(); - const expected = ['email', 'idempotencyKey', 'locale', 'provider', 'purchaseDate'].sort(); + const expected = ['email', 'idempotencyKey', 'locale', 'provider', 'purchaseDate', 'requestKind'].sort(); if (keys.join('\0') !== expected.join('\0')) { throw new ListenerWithdrawalInputError(); } @@ -99,6 +145,7 @@ export function parseListenerWithdrawalInput(input: unknown, now = new Date()): } return { email: normalizeEmail(record.email), + requestKind: normalizeRequestKind(record.requestKind), provider: normalizeProvider(record.provider), purchaseDate: normalizePurchaseDate(record.purchaseDate, now), locale: record.locale, @@ -109,6 +156,7 @@ export function parseListenerWithdrawalInput(input: unknown, now = new Date()): function canonicalRequest(input: ListenerWithdrawalInput): string { return JSON.stringify({ email: input.email, + requestKind: input.requestKind, idempotencyKey: input.idempotencyKey, locale: input.locale, provider: input.provider, @@ -142,10 +190,20 @@ export function listenerWithdrawalNetworkBucketKey(networkIdentity: string, secr return `network:${digest}`; } -async function consumeNetworkBucket( +export function listenerWithdrawalEmailBucketKey(normalizedEmail: string, secret: string): string { + const digest = createHmac('sha256', secret) + .update(`listener-withdrawal-email\n${normalizedEmail}`, 'utf8') + .digest('hex'); + return `email:${digest}`; +} + +export const LISTENER_WITHDRAWAL_GLOBAL_BUCKET_KEY = 'global'; + +async function consumeBucket( tx: Prisma.TransactionClient, key: string, now: Date, + maximum: number, ): Promise { const current = await tx.listenerWithdrawalThrottle.upsert({ where: { key }, @@ -161,11 +219,7 @@ async function consumeNetworkBucket( }); return; } - if (current.attempts >= RATE_WINDOW_MAX) { - await tx.listenerWithdrawalThrottle.update({ - where: { key }, - data: { blockedUntil: windowEnd }, - }); + if (current.attempts >= maximum) { throw new ListenerWithdrawalRateLimitError(); } await tx.listenerWithdrawalThrottle.update({ @@ -189,7 +243,8 @@ export async function submitListenerWithdrawal(input: { const requestHash = listenerWithdrawalRequestHash(input.request); const receiptCode = listenerWithdrawalReceiptCode(input.request.idempotencyKey); const receiptDigest = listenerWithdrawalReceiptDigest(receiptCode); - const bucketKey = listenerWithdrawalNetworkBucketKey(input.networkIdentity, input.secret); + const networkBucketKey = listenerWithdrawalNetworkBucketKey(input.networkIdentity, input.secret); + const emailBucketKey = listenerWithdrawalEmailBucketKey(input.request.email, input.secret); for (let attempt = 0; attempt < 3; attempt += 1) { try { @@ -203,10 +258,11 @@ export async function submitListenerWithdrawal(input: { return { receiptCode, receivedAt: existing.createdAt, replayed: true }; } - await tx.listenerWithdrawalThrottle.deleteMany({ - where: { updatedAt: { lt: new Date(now.getTime() - RATE_RETENTION_MS) } }, - }); - await consumeNetworkBucket(tx, bucketKey, now); + // Stable ordering prevents concurrent submissions from taking + // the same bucket locks in different orders. + await consumeBucket(tx, LISTENER_WITHDRAWAL_GLOBAL_BUCKET_KEY, now, GLOBAL_RATE_WINDOW_MAX); + await consumeBucket(tx, emailBucketKey, now, EMAIL_RATE_WINDOW_MAX); + await consumeBucket(tx, networkBucketKey, now, NETWORK_RATE_WINDOW_MAX); const created = await tx.listenerWithdrawalRequest.create({ data: { receiptDigest, @@ -214,6 +270,7 @@ export async function submitListenerWithdrawal(input: { idempotencyKey: input.request.idempotencyKey, requestHash, contactEmail: input.request.email, + requestKind: input.request.requestKind, provider: input.request.provider, purchaseDate: input.request.purchaseDate, locale: input.request.locale, From f0157594588e636f71b5caf48312fadc9f6b359a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 02:28:28 -0300 Subject: [PATCH 221/376] fix(listener): preserve withdrawal ops across rollback --- .../LISTENER_WITHDRAWAL_REQUESTS.md | 27 +++++++++++++++-- ops/early-birds-preview/compose.yml | 29 +++++++++++++++++++ .../preview.env.synthetic.example | 2 ++ .../test/preview-contract.test.mjs | 29 +++++++++++++++++-- ops/early-birds/test/config.test.mjs | 5 ++++ scripts/early-birds-preview/lib.sh | 22 ++++++++++++++ scripts/early-birds-preview/rollback.sh | 2 +- scripts/early-birds-preview/start.sh | 4 ++- scripts/early-birds-preview/stop.sh | 2 +- scripts/early-birds-preview/validate.mjs | 16 ++++++++-- scripts/listener-withdrawal-export-metrics.sh | 7 +++-- .../listener-withdrawal-prune-throttles.sh | 7 +++-- 12 files changed, 137 insertions(+), 15 deletions(-) diff --git a/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md b/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md index 8beb3933..c43119b9 100644 --- a/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md +++ b/docs/operations/LISTENER_WITHDRAWAL_REQUESTS.md @@ -75,7 +75,8 @@ 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-listener-1` +`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 @@ -98,13 +99,35 @@ 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. Keep the +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/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index 9a5505e0..5b36e9ce 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -138,6 +138,35 @@ services: 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:-${EARLYBIRDS_PREVIEW_IMAGE_TAG:-synthetic}} + 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 diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example index 6a7cd182..2ad50113 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -7,6 +7,8 @@ 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 diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index dfa19836..f8577d9a 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -164,6 +164,31 @@ test('compose gates the loopback Listener on a forward-only isolated database mi 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:-\$\{EARLYBIRDS_PREVIEW_IMAGE_TAG:-synthetic\}/); + 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.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/); + assert.match(rollback, /stop listener/); + assert.doesNotMatch(rollback, /stop[^\n]*withdrawal-operator/); }); test('withdrawal edge is exact, private-by-default and isolated from non-Listener vhosts', async () => { @@ -481,13 +506,13 @@ test('smoke covers both probes while ordinary app rollback preserves the origin 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, /up -d --build listener/); + 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 beacon-stream postgres/); + 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'); diff --git a/ops/early-birds/test/config.test.mjs b/ops/early-birds/test/config.test.mjs index 000038d0..36769c83 100644 --- a/ops/early-birds/test/config.test.mjs +++ b/ops/early-birds/test/config.test.mjs @@ -77,10 +77,15 @@ test('schedules an atomic private metric export and out-of-band throttle pruning 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'); diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index d1f7dbee..d88fb3d5 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -39,6 +39,28 @@ require_synthetic_secret() { 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") + test -n "$operator_tag" || operator_tag=$(preview_env_value EARLYBIRDS_PREVIEW_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; then + test "$operator_tag" = synthetic || preview_fail 'synthetic withdrawal operator image tag must be synthetic' + test "$operator_expected_sha" = synthetic-preview || preview_fail 'synthetic withdrawal operator provenance must be synthetic-preview' + else + 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' +} + 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" diff --git a/scripts/early-birds-preview/rollback.sh b/scripts/early-birds-preview/rollback.sh index e4d27d50..8dd596a5 100755 --- a/scripts/early-birds-preview/rollback.sh +++ b/scripts/early-birds-preview/rollback.sh @@ -9,6 +9,6 @@ require_synthetic_env "$env_file" # 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; preview PostgreSQL and Beacon origin were retained.' +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.sh b/scripts/early-birds-preview/start.sh index 08853b2c..578725c0 100755 --- a/scripts/early-birds-preview/start.sh +++ b/scripts/early-birds-preview/start.sh @@ -8,7 +8,9 @@ require_synthetic_env "$env_file" # 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" up -d --build listener +preview_compose_command "$env_file" build listener +require_withdrawal_operator_image "$env_file" +preview_compose_command "$env_file" up -d listener withdrawal-operator 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") diff --git a/scripts/early-birds-preview/stop.sh b/scripts/early-birds-preview/stop.sh index 0051994e..f22229c6 100755 --- a/scripts/early-birds-preview/stop.sh +++ b/scripts/early-birds-preview/stop.sh @@ -3,5 +3,5 @@ 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 beacon-stream postgres +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 index cabafbef..ac6f0ea2 100644 --- a/scripts/early-birds-preview/validate.mjs +++ b/scripts/early-birds-preview/validate.mjs @@ -16,6 +16,8 @@ const syntheticEnv = [ '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', @@ -78,10 +80,10 @@ try { encoding: 'utf8', }); const resolved = JSON.parse(rendered); - const { postgres, migration, listener, 'beacon-stream': stream } = resolved.services; + const { postgres, migration, listener, 'withdrawal-operator': withdrawalOperator, 'beacon-stream': stream } = resolved.services; assert.deepEqual(Object.keys(resolved.services).sort(), [ - 'beacon-stream', 'listener', 'migration', 'postgres', + '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']); @@ -131,6 +133,16 @@ try { 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_edge', 'stream_observability']); diff --git a/scripts/listener-withdrawal-export-metrics.sh b/scripts/listener-withdrawal-export-metrics.sh index 28a217f3..cd02d7ca 100755 --- a/scripts/listener-withdrawal-export-metrics.sh +++ b/scripts/listener-withdrawal-export-metrics.sh @@ -6,11 +6,12 @@ if [[ ${EUID:-$(id -u)} -ne 0 ]]; then exit 1 fi -container=${LISTENER_WITHDRAWAL_CONTAINER:-earlybirds-preview-listener-1} +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}}' "$container" 2>/dev/null | grep -Fxq true || { - echo 'Listener container is not running' >&2 +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 } diff --git a/scripts/listener-withdrawal-prune-throttles.sh b/scripts/listener-withdrawal-prune-throttles.sh index 1505580d..ec738d69 100755 --- a/scripts/listener-withdrawal-prune-throttles.sh +++ b/scripts/listener-withdrawal-prune-throttles.sh @@ -6,9 +6,10 @@ if [[ ${EUID:-$(id -u)} -ne 0 ]]; then exit 1 fi -container=${LISTENER_WITHDRAWAL_CONTAINER:-earlybirds-preview-listener-1} -docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null | grep -Fxq true || { - echo 'Listener container is not running' >&2 +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" \ From 259653c4bdf3b127bd971311d495fccace8e0c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 02:30:42 -0300 Subject: [PATCH 222/376] fix(listener): require independent operator provenance --- ops/early-birds-preview/compose.yml | 2 +- .../test/preview-contract.test.mjs | 23 +++++++++++++++++-- scripts/early-birds-preview/lib.sh | 16 ++++++++++++- scripts/early-birds-preview/start.sh | 1 + 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index 5b36e9ce..9463934f 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -142,7 +142,7 @@ services: # 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:-${EARLYBIRDS_PREVIEW_IMAGE_TAG:-synthetic}} + 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"] diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index f8577d9a..78387643 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -166,7 +166,7 @@ test('compose gates the loopback Listener on a forward-only isolated database mi 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:-\$\{EARLYBIRDS_PREVIEW_IMAGE_TAG:-synthetic\}/); + 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\]/); @@ -181,14 +181,33 @@ test('preview lifecycle pins and preserves the private withdrawal operator', asy 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/); + 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(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' }); + await fs.rm(temporary, { recursive: true, force: true }); + assert.equal(missingTag.status, 2); + assert.match(missingTag.stderr, /EARLYBIRDS_WITHDRAWAL_OPERATOR_IMAGE_TAG is required/); }); test('withdrawal edge is exact, private-by-default and isolated from non-Listener vhosts', async () => { diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index d88fb3d5..ab04716a 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -42,13 +42,14 @@ require_synthetic_secret() { 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") - test -n "$operator_tag" || operator_tag=$(preview_env_value EARLYBIRDS_PREVIEW_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; then test "$operator_tag" = synthetic || preview_fail 'synthetic withdrawal operator image tag must be synthetic' test "$operator_expected_sha" = synthetic-preview || preview_fail 'synthetic withdrawal operator provenance must be synthetic-preview' 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" || \ @@ -61,6 +62,19 @@ require_withdrawal_operator_image() { 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=$(docker inspect "$operator_container" \ + --format '{{.State.Running}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || true) + 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" diff --git a/scripts/early-birds-preview/start.sh b/scripts/early-birds-preview/start.sh index 578725c0..68ba24b8 100755 --- a/scripts/early-birds-preview/start.sh +++ b/scripts/early-birds-preview/start.sh @@ -11,6 +11,7 @@ require_synthetic_env "$env_file" 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") From d6b6043a232ddb51eadd1aee6f6be3a542abd334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 02:31:26 -0300 Subject: [PATCH 223/376] fix(listener): await withdrawal operator health --- .../test/preview-contract.test.mjs | 1 + scripts/early-birds-preview/lib.sh | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 78387643..736963c3 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -190,6 +190,7 @@ test('preview lifecycle pins and preserves the private withdrawal operator', asy 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/); diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index ab04716a..0b003c59 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -66,8 +66,15 @@ 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=$(docker inspect "$operator_container" \ - --format '{{.State.Running}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || true) + 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) From c8c75da86c84d75f9c25ac06b6ad0331ac9b575e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 03:16:27 -0300 Subject: [PATCH 224/376] docs(listener): record consumer request runtime --- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 25 ++++++++---- docs/operations/LISTENER_LAUNCH_NOW.md | 40 ++++++++++--------- docs/plans/EARLY_BIRDS.md | 9 +++-- 3 files changed, 45 insertions(+), 29 deletions(-) diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 929b003a..4fc03210 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -31,8 +31,8 @@ truthful launch baseline, not a substitute for counsel review. Human owner: Nico 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 human callback, Free-entry and logout check -remain. The other remaining gates are human/external: +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. @@ -44,9 +44,18 @@ checks and is recoverable in Google Cloud for 30 days for administrative recover the exposed secret from an environment backup. The exact public Listener image is -`4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a`. Health and readiness attest that SHA. The previous -same-schema Listener image `fcdde379` remains available as the bounded application rollback target; -the weekly-quota database policy itself is forward-only. +`0a475717d45d32cec38afdb8fc35fb772a994017`. Health and readiness attest that SHA. The same exact +image runs the no-port withdrawal operator sidecar and the staging-only Live workbench. The previous +contract-compatible Listener image `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` remains available as +an 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 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 `b1038ddb579817e39add567c5b7b055e2f716095`. It includes the reviewed Mercado Pago adverse-event @@ -140,8 +149,10 @@ converted back into a reversible action. 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. `fcdde379` remains the bounded contract-compatible UI rollback for the current - authority. If compatibility is uncertain, keep Listener disabled and roll forward. + authority. `4ac408f` remains the bounded contract-compatible application rollback for the current + authority. Preserve the `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, diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index ea2b76b8..082747fb 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -9,8 +9,9 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and ## Exact deployed state - Public candidate: `https://listen.harmonicbeacon.com/` -- Listener image/SHA: `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` -- Previous same-schema Listener rollback image: `fcdde379` +- Listener image/SHA: `0a475717d45d32cec38afdb8fc35fb772a994017` +- Previous contract-compatible Listener application image: `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` +- Withdrawal operator sidecar image/SHA: `0a475717d45d32cec38afdb8fc35fb772a994017` - Canonical payment authority: `b1038ddb579817e39add567c5b7b055e2f716095` - Minimum authority after any Live checkout attempt: `b1038ddb579817e39add567c5b7b055e2f716095` - Listener mail sidecar: `456ece2b38e203a2d12c54864115e03ebaa1a89c` @@ -22,6 +23,7 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and - PayPal Live lifecycle: ON with new sales OFF after creating one supervised approval 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 - Public sales: OFF; only the explicitly supervised Live lifecycle is authorized The authority now includes the reviewed adverse-event hardening and a read-only @@ -42,7 +44,7 @@ reactivation and reconciliation. Browser redirects never grant membership. 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 human callback/Free/logout check remains. +`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 @@ -51,20 +53,18 @@ backup. Only Listener and the disposable staging workbench were recreated. ## Remaining blockers to public sales -1. #217 — open the delivered magic link and prove email-only session → Free → logout. -2. #304 — complete a physical 60-minute listen and record any watchdog recovery. -3. #317 — final mobile/account-menu billing acceptance. -4. #318 — deploy and smoke the implemented no-login consumer-withdrawal queue, - then record human ES/EN offer/legal/seller/refund/support acceptance. The - implementation returns an opaque receipt and has no automatic provider - action; operations must install its dedicated secret and own the 24-hour - private queue procedure. -5. Complete the already-created PayPal approval intent with a non-merchant buyer, then execute its +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/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. Complete the already-created PayPal approval intent with a non-merchant buyer, then execute its supervised activation, cancellation and refund evidence. Execute the corresponding supervised Mercado Pago lifecycle separately. -6. Confirm Founder activation, terminal Free fallback, metrics, alerts and the +5. Confirm Founder activation, terminal Free fallback, metrics, alerts and the absence of PII/secret leakage against those Live transactions. -7. Obtain separate explicit approvals for merge to `main` and public checkout. +6. Obtain separate explicit approvals for merge to `main` and public checkout. ## Non-negotiable isolation @@ -85,7 +85,10 @@ without explicit approval. 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 - `fcdde379` if contract-compatible; otherwise disable Listener and roll forward. + `4ac408f` 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 @@ -100,6 +103,7 @@ 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 Live billing is active: -productive credentials are installed and verified, but both Live providers, -real charges and public checkout remain OFF. +pre-release processors. Equally, copy must not claim public Live billing is +active: productive credentials are installed and verified, PayPal Live +lifecycle ingestion is ON only for the supervised pending intent, and authority +new sales, real charges and both public checkout flags remain OFF. diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index ece0452a..6e477660 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -7,14 +7,15 @@ > 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-13):** the public Listener candidate runs exact -> SHA `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a`; canonical payment authority runs +> **Current launch memory (2026-08-13):** the public Listener candidate and the +> private withdrawal operator run exact SHA `0a475717d45d32cec38afdb8fc35fb772a994017`; canonical payment authority runs > `b1038ddb579817e39add567c5b7b055e2f716095`; the isolated mail sidecar runs > `456ece2b38e203a2d12c54864115e03ebaa1a89c`. PayPal Sandbox and Mercado Pago > TEST lifecycles are accepted. One PayPal Live approval intent exists without a > subscription or charge; new sales and public checkout remain OFF while its Live -> lifecycle ingestion stays ON. See `docs/operations/LISTENER_LAUNCH_NOW.md` for the few remaining -> human/external gates. +> lifecycle ingestion stays ON. 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 From 0a0512e1ca17ada205e81cec8eccb30bd248a56d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 03:29:03 -0300 Subject: [PATCH 225/376] feat(listener): identify initial merchant in terms --- src/lib/early-birds/__tests__/copy.test.ts | 15 ++++++++++++++- src/lib/early-birds/copy.ts | 6 ++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/lib/early-birds/__tests__/copy.test.ts b/src/lib/early-birds/__tests__/copy.test.ts index e46a1095..c96361f1 100644 --- a/src/lib/early-birds/__tests__/copy.test.ts +++ b/src/lib/early-birds/__tests__/copy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { earlyBirdCopy, earlyBirdHomeCopy } from '@/lib/early-birds/copy'; +import { earlyBirdCopy, earlyBirdHomeCopy, earlyBirdLegalCopy } from '@/lib/early-birds/copy'; describe('EarlyBirds interface copy', () => { it.each([ @@ -15,4 +15,17 @@ describe('EarlyBirds interface copy', () => { /record(?:ed|ing)?|grabaci[oó]n|grabad[oa]|live instrument|instrumento en vivo/i, ); }); + + it('identifies the initial merchant and billing contact in both public terms locales', () => { + const spanish = earlyBirdLegalCopy.es.sections.flatMap((section) => section.paragraphs).join(' '); + const english = earlyBirdLegalCopy.en.sections.flatMap((section) => section.paragraphs).join(' '); + + for (const visibleCopy of [spanish, english]) { + expect(visibleCopy).toContain('Nicolás Echaniz'); + expect(visibleCopy).toContain('nicoechaniz@harmonicbeacon.com'); + expect(visibleCopy).toMatch(/merchant of record/i); + expect(visibleCopy).toMatch(/PayPal/); + expect(visibleCopy).toMatch(/Mercado Pago/); + } + }); }); diff --git a/src/lib/early-birds/copy.ts b/src/lib/early-birds/copy.ts index 98110f2c..f39f8639 100644 --- a/src/lib/early-birds/copy.ts +++ b/src/lib/early-birds/copy.ts @@ -169,11 +169,12 @@ export const earlyBirdLegalCopy = { back: 'Volver a Listener', eyebrow: 'HARMONIC BEACON · FOUNDING LISTENER', title: 'Condiciones y privacidad del servicio Listener', - updated: 'Versión de lanzamiento · 12 de agosto de 2026', + updated: 'Versión de lanzamiento · 13 de agosto de 2026', sections: [ { title: 'Oferta', paragraphs: [ + 'El vendedor inicial y merchant of record es Nicolás Echaniz, operando Harmonic Beacon desde Argentina. PayPal o Mercado Pago muestran el descriptor aplicable antes de confirmar. Para soporte de facturación o solicitar el comprobante fiscal aplicable escribe a nicoechaniz@harmonicbeacon.com.', 'Founding Listener cuesta USD 5 por mes, con cobro recurrente y sin período de prueba. Mercado Pago puede cobrar el equivalente en ARS informado por el checkout. El acceso se activa únicamente después de la confirmación canónica del proveedor.', 'El precio Founder se conserva mientras el servicio permanezca ininterrumpido, incluyendo el período ya pagado o de gracia aprobado. Cuando el servicio termina, también terminan la categoría y el precio Founder.', ], @@ -205,11 +206,12 @@ export const earlyBirdLegalCopy = { back: 'Back to Listener', eyebrow: 'HARMONIC BEACON · FOUNDING LISTENER', title: 'Listener service terms and privacy', - updated: 'Launch version · August 12, 2026', + updated: 'Launch version · August 13, 2026', sections: [ { title: 'Offer', paragraphs: [ + 'The initial seller and merchant of record is Nicolás Echaniz, operating Harmonic Beacon from Argentina. PayPal or Mercado Pago shows the applicable descriptor before confirmation. For billing support or to request the applicable tax receipt, contact nicoechaniz@harmonicbeacon.com.', 'Founding Listener costs USD 5 per month, billed recurrently with no trial. Mercado Pago may charge the ARS equivalent shown at checkout. Access starts only after canonical provider confirmation.', 'Founder pricing continues while service remains uninterrupted, including an already-paid period or approved grace. When service ends, Founder status and pricing end as well.', ], From 371c175be44462984e8da8b044f596b1839d2f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Thu, 13 Aug 2026 03:42:26 -0300 Subject: [PATCH 226/376] docs(listener): record merchant-copy runtime --- docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 8 ++++---- docs/operations/LISTENER_LAUNCH_NOW.md | 6 +++--- docs/plans/EARLY_BIRDS.md | 5 +++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 4fc03210..572a097a 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -44,9 +44,9 @@ checks and is recoverable in Google Cloud for 30 days for administrative recover the exposed secret from an environment backup. The exact public Listener image is -`0a475717d45d32cec38afdb8fc35fb772a994017`. Health and readiness attest that SHA. The same exact -image runs the no-port withdrawal operator sidecar and the staging-only Live workbench. The previous -contract-compatible Listener image `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` remains available as +`acc90ba35fea52f63ef18337e3a555ef637c552f`. Health and readiness attest that SHA. The same exact +image runs the staging-only Live workbench. The no-port withdrawal operator remains independently +pinned at `0a475717d45d32cec38afdb8fc35fb772a994017`. That previous contract-compatible Listener image remains available as an 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. @@ -149,7 +149,7 @@ converted back into a reversible action. 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. `4ac408f` remains the bounded contract-compatible application rollback for the current + authority. `0a475717` remains the bounded contract-compatible application rollback for the current authority. Preserve the `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. diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index 082747fb..d72a603e 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -9,8 +9,8 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and ## Exact deployed state - Public candidate: `https://listen.harmonicbeacon.com/` -- Listener image/SHA: `0a475717d45d32cec38afdb8fc35fb772a994017` -- Previous contract-compatible Listener application image: `4ac408f4bc43cab85f058fc3d39aa2a2b4b4207a` +- Listener image/SHA: `acc90ba35fea52f63ef18337e3a555ef637c552f` +- Previous contract-compatible Listener application image: `0a475717d45d32cec38afdb8fc35fb772a994017` - Withdrawal operator sidecar image/SHA: `0a475717d45d32cec38afdb8fc35fb772a994017` - Canonical payment authority: `b1038ddb579817e39add567c5b7b055e2f716095` - Minimum authority after any Live checkout attempt: `b1038ddb579817e39add567c5b7b055e2f716095` @@ -85,7 +85,7 @@ without explicit approval. 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 - `4ac408f` if contract-compatible; keep the `0a475717` withdrawal operator and + `0a475717` 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. diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 6e477660..3f189176 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -7,8 +7,9 @@ > 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-13):** the public Listener candidate and the -> private withdrawal operator run exact SHA `0a475717d45d32cec38afdb8fc35fb772a994017`; canonical payment authority runs +> **Current launch memory (2026-08-13):** the public Listener candidate runs exact +> SHA `acc90ba35fea52f63ef18337e3a555ef637c552f`; the private withdrawal operator remains pinned at +> `0a475717d45d32cec38afdb8fc35fb772a994017`; canonical payment authority runs > `b1038ddb579817e39add567c5b7b055e2f716095`; the isolated mail sidecar runs > `456ece2b38e203a2d12c54864115e03ebaa1a89c`. PayPal Sandbox and Mercado Pago > TEST lifecycles are accepted. One PayPal Live approval intent exists without a From 3f0ba8a3f463bd85a00eb7769fd98655e18cf2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 05:31:09 -0300 Subject: [PATCH 227/376] fix(listener): alert only on enabled test sales --- ops/early-birds/prometheus/alerts.yml | 4 ++-- ops/early-birds/test/config.test.mjs | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/ops/early-birds/prometheus/alerts.yml b/ops/early-birds/prometheus/alerts.yml index 522bd2bf..80a98537 100644 --- a/ops/early-birds/prometheus/alerts.yml +++ b/ops/early-birds/prometheus/alerts.yml @@ -29,10 +29,10 @@ groups: labels: { severity: critical, service: listener-payments } annotations: { summary: "Listener membership authority is unreachable", runbook: "listener-paid-authority" } - alert: ListenerSandboxProviderUnavailable - expr: pmp_listener_provider_ready{environment=~"sandbox|test"} == 0 + 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: "A Listener sandbox/test provider is unavailable", runbook: "listener-paid-provider" } + 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 diff --git a/ops/early-birds/test/config.test.mjs b/ops/early-birds/test/config.test.mjs index 36769c83..a0d98ba1 100644 --- a/ops/early-birds/test/config.test.mjs +++ b/ops/early-birds/test/config.test.mjs @@ -105,6 +105,17 @@ test('alerts on paid authority failures without account or provider identifiers' 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/); From 660de29a27d952ad8d85badd91e9d8eac307a5df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 06:14:18 -0300 Subject: [PATCH 228/376] fix(deps): update nanoid advisory floor --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0bc2bcf..d0840719 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8108,9 +8108,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "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", From 0118ddff55e545eb27b9a521164dc50900a5ca6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 06:20:29 -0300 Subject: [PATCH 229/376] docs(listener): reconcile Live launch checkpoint --- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 36 ++++++++------- docs/operations/LISTENER_LAUNCH_NOW.md | 46 +++++++++++-------- docs/plans/EARLY_BIRDS.md | 25 +++++----- 3 files changed, 59 insertions(+), 48 deletions(-) diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 572a097a..1ac47c93 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -58,19 +58,20 @@ rules at 20h/24h; the runtime smoke accepted and resolved one synthetic request returned the open queue and alerts to zero without exporting PII. The exact isolated payment-authority image is -`b1038ddb579817e39add567c5b7b055e2f716095`. It includes the reviewed Mercado Pago adverse-event -hardening from backend PR #80. API and worker are healthy, Alembic is at head `7b4c1e9a2d60`, and +`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-13, a supervised PayPal Live approval intent was created for the -USD 5 offer; it is awaiting approval by a buyer account different from the merchant, and created -no subscription or charge. New sales were immediately returned to OFF while PayPal Live lifecycle -ingestion remains ON. The former authority image -`8e10f16fe3471a097021f7f1ee41eb8f88f4f154` and protected pre-deploy backup -`/var/backups/harmonic-beacon/earlybirds-authority-pre-b1038ddb579817e39add567c5b7b055e2f716095.sql.gz` -are retained only as pre-Live forensic/disaster-recovery artifacts; they are no longer routine -rollback targets. +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. ## Independent switches @@ -136,15 +137,16 @@ converted back into a reversible action. - 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 the first Live checkout attempt, provider binding or event, - `b1038ddb579817e39add567c5b7b055e2f716095` is the minimum supported authority binary. Do not run - `8e10f16fe3471a097021f7f1ee41eb8f88f4f154` against the current database and do not routinely - restore the pre-`b1038` database backup. The older binary predates required Mercado Pago - adverse-event hardening and a database restore could discard canonical checkout/lifecycle - evidence. +- 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 `b1038`-compatible-or-newer image and reconcile + 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. diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index d72a603e..b7cba625 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -1,6 +1,6 @@ # Listener launch — current state -Last reconciled: 2026-08-13 +Last reconciled: 2026-08-15 This is the compact operational memory for Founding Listeners. Detailed evidence and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and @@ -12,31 +12,35 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and - Listener image/SHA: `acc90ba35fea52f63ef18337e3a555ef637c552f` - Previous contract-compatible Listener application image: `0a475717d45d32cec38afdb8fc35fb772a994017` - Withdrawal operator sidecar image/SHA: `0a475717d45d32cec38afdb8fc35fb772a994017` -- Canonical payment authority: `b1038ddb579817e39add567c5b7b055e2f716095` -- Minimum authority after any Live checkout attempt: `b1038ddb579817e39add567c5b7b055e2f716095` +- 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 -- PayPal Live lifecycle: ON with new sales OFF after creating one supervised approval intent +- 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 - Public sales: OFF; only the explicitly supervised Live lifecycle is authorized -The authority now includes the reviewed adverse-event hardening and a read-only -Live-provider preflight. The deployed API/worker are healthy at exact revision -`b1038ddb`; Alembic is at `7b4c1e9a2d60`. Productive credentials are installed +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. A supervised PayPal Live approval intent was subsequently -created for the exact USD 5 offer and is awaiting a buyer account different from the merchant; it -created no subscription or charge. Global new sales and both public Listener checkout flags are -OFF. PayPal Live lifecycle ingestion remains ON so its callback, signed webhook, reconciliation -and cancellation path stay available. Mercado Pago remains on TEST with Live OFF. +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 refund. Mercado Pago TEST has passed checkout, activation, pause, @@ -59,9 +63,10 @@ backup. Only Listener and the disposable staging workbench were recreated. 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. Complete the already-created PayPal approval intent with a non-merchant buyer, then execute its - supervised activation, cancellation and refund evidence. Execute the corresponding supervised - Mercado Pago lifecycle separately. +4. With a new explicit approval, create a fresh PayPal checkout for a + non-merchant buyer and execute supervised activation, cancellation and + refund evidence. Execute the corresponding supervised Mercado Pago Live + lifecycle separately. 5. Confirm Founder activation, terminal Free fallback, metrics, alerts and the absence of PII/secret leakage against those Live transactions. 6. Obtain separate explicit approvals for merge to `main` and public checkout. @@ -78,10 +83,11 @@ without explicit approval. - Commerce incident: switch OFF Listener checkout flags and authority new-sales; keep webhooks, reconciliation, cancellation and existing access running. -- Authority application regression after any Live checkout attempt: keep the current database, +- 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 - `b1038ddb` or a newer contract-compatible authority. Never deploy `8e10f16` against the current - database. Never use the protected pre-`b1038` database backup as a routine rollback: it can lose + `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 @@ -105,5 +111,5 @@ 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 ingestion is ON only for the supervised pending intent, and authority -new sales, real charges and both public checkout flags remain OFF. +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/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 3f189176..f965e34e 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -7,14 +7,15 @@ > 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-13):** the public Listener candidate runs exact +> **Current launch memory (2026-08-15):** the public Listener candidate runs exact > SHA `acc90ba35fea52f63ef18337e3a555ef637c552f`; the private withdrawal operator remains pinned at > `0a475717d45d32cec38afdb8fc35fb772a994017`; canonical payment authority runs -> `b1038ddb579817e39add567c5b7b055e2f716095`; the isolated mail sidecar runs +> `4e5b208e902969285c8f68067f7fd13b7e2eb68d`; the isolated mail sidecar runs > `456ece2b38e203a2d12c54864115e03ebaa1a89c`. PayPal Sandbox and Mercado Pago -> TEST lifecycles are accepted. One PayPal Live approval intent exists without a -> subscription or charge; new sales and public checkout remain OFF while its Live -> lifecycle ingestion stays ON. The public no-login withdrawal and service-cancellation paths, +> 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. @@ -496,14 +497,16 @@ The webapp vendors byte-exact copies of the canonical backend contracts under 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 - `b1038ddb579817e39add567c5b7b055e2f716095` is CI-green, includes canonical - cancellation/reactivation, paid-lifecycle metrics and reviewed Mercado Pago adverse-event - hardening, and is the minimum authority binary after any Live checkout attempt. The Listener Live + `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 remain absent and all Live flags remain OFF. + productive credentials are installed root-only and verified read-only; public + checkout and global new sales remain OFF. ## 11. Fast Forward development lane @@ -752,8 +755,8 @@ its own explicit approval. 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 the first Live checkout attempt, payment-authority rollback is also forward-only: - `b1038ddb` is the minimum supported binary. Stop new sales with flags, keep provider lifecycle +- 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 From 6bc862b98d596f191dc22bd18a2587b26fd7251e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 06:27:49 -0300 Subject: [PATCH 230/376] docs(listener): record dormant Live workbench --- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 6 ++++++ docs/operations/LISTENER_LAUNCH_NOW.md | 1 + .../LISTENER_PRIVATE_LIVE_WORKBENCH.md | 21 ++++++++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 1ac47c93..be7c5f57 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -73,6 +73,12 @@ lifecycle/reconciliation stays ready. The exact pre-deploy backup 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: diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index b7cba625..da8176a1 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -20,6 +20,7 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and - 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 diff --git a/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md index 5af430a7..147863ab 100644 --- a/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md +++ b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md @@ -1,12 +1,31 @@ # Private Listener Live checkout workbench -Status: implemented, default-OFF, not deployed by this change. +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. + +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. + ## Boundary - Exact browser endpoint: `POST /api/listener/checkout/live-workbench` on the staging host only. From c6d34783ae916e3d8aa7d071b511e61721375220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 06:41:29 -0300 Subject: [PATCH 231/376] fix(listener): keep legal actions clear on mobile --- src/app/globals.css | 12 +++++++++--- src/app/listener/layout.tsx | 2 +- .../__tests__/ConsumerWithdrawalLink.test.tsx | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 333ee76f..df2a3b49 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2139,9 +2139,15 @@ body { @media (max-width: 640px) { .listener-consumer-request-links { - left: 0.75rem; - right: 0.75rem; - bottom: max(0.75rem, env(safe-area-inset-bottom)); + position: static; + width: auto; + max-width: none; + margin: 0 0.75rem; + padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); + } + + .listener-consumer-request-links .listener-withdrawal-link { + width: 100%; max-width: none; } } diff --git a/src/app/listener/layout.tsx b/src/app/listener/layout.tsx index f3459d09..113da6c9 100644 --- a/src/app/listener/layout.tsx +++ b/src/app/listener/layout.tsx @@ -6,11 +6,11 @@ export default function ListenerLayout({ children }: { children: React.ReactNode const withdrawalAvailable = listenerWithdrawalPublicConfiguration() !== null; return ( + {children} {withdrawalAvailable ?
: null} - {children}
); } diff --git a/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx b/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx index 7f45bf3d..1c34a27c 100644 --- a/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx +++ b/src/components/early-birds/__tests__/ConsumerWithdrawalLink.test.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom import { cleanup, render, screen } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { LocaleProvider } from '@/context/LocaleContext'; @@ -32,4 +34,18 @@ describe('prominent consumer-withdrawal entry', () => { render(); expect(screen.queryByRole('link')).toBeNull(); }); + + it('keeps mobile consumer actions in document flow after Listener content', () => { + const root = process.cwd(); + const layout = readFileSync(join(root, 'src/app/listener/layout.tsx'), 'utf8'); + const css = readFileSync(join(root, 'src/app/globals.css'), 'utf8'); + + expect(layout.indexOf('{children}')).toBeGreaterThan(-1); + expect(layout.indexOf('{children}')).toBeLessThan( + layout.indexOf('className="listener-consumer-request-links"'), + ); + expect(css).toMatch( + /@media \(max-width: 640px\)[\s\S]*?\.listener-consumer-request-links\s*\{[\s\S]*?position:\s*static;/, + ); + }); }); From 481656cb45fa8f3fe8ab2a60a27e6df3ad0fbb44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 07:01:53 -0300 Subject: [PATCH 232/376] fix(listener): accept pinned withdrawal operator in release lane --- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 15 +++++++++---- docs/operations/LISTENER_LAUNCH_NOW.md | 5 +++-- docs/plans/EARLY_BIRDS.md | 4 +++- .../test/preview-contract.test.mjs | 22 ++++++++++++++++++- scripts/early-birds-preview/lib.sh | 7 +++--- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index be7c5f57..268d544f 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -44,13 +44,20 @@ checks and is recoverable in Google Cloud for 30 days for administrative recover the exposed secret from an environment backup. The exact public Listener image is -`acc90ba35fea52f63ef18337e3a555ef637c552f`. Health and readiness attest that SHA. The same exact -image runs the staging-only Live workbench. The no-port withdrawal operator remains independently -pinned at `0a475717d45d32cec38afdb8fc35fb772a994017`. That previous contract-compatible Listener image remains available as -an application-only rollback target; the operator and current database must remain running so legal +`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 diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index da8176a1..5ebf407a 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -9,8 +9,8 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and ## Exact deployed state - Public candidate: `https://listen.harmonicbeacon.com/` -- Listener image/SHA: `acc90ba35fea52f63ef18337e3a555ef637c552f` -- Previous contract-compatible Listener application image: `0a475717d45d32cec38afdb8fc35fb772a994017` +- 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` @@ -25,6 +25,7 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and - 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 +- 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 diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index f965e34e..451c9db8 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -8,7 +8,9 @@ > explicit release and audio gates in this document. > **Current launch memory (2026-08-15):** the public Listener candidate runs exact -> SHA `acc90ba35fea52f63ef18337e3a555ef637c552f`; the private withdrawal operator remains pinned at +> 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 diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 736963c3..185ffee1 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -206,9 +206,29 @@ test('preview lifecycle pins and preserves the private withdrawal operator', asy '-c', '. "$1"; require_withdrawal_operator_image "$2"', 'sh', path.join(repositoryRoot, 'scripts/early-birds-preview/lib.sh'), missingTagEnv, ], { encoding: 'utf8' }); - await fs.rm(temporary, { recursive: true, force: true }); 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 () => { diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index 0b003c59..d5786ac0 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -44,9 +44,10 @@ require_withdrawal_operator_image() { 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; then - test "$operator_tag" = synthetic || preview_fail 'synthetic withdrawal operator image tag must be synthetic' - test "$operator_expected_sha" = synthetic-preview || preview_fail 'synthetic withdrawal operator provenance must be synthetic-preview' + 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' From d88d508351f90df8fc0b913d2748c71f9525269e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 07:41:32 -0300 Subject: [PATCH 233/376] feat(listener): observe container continuity for external smoke --- .../workflows/early-birds-fast-forward.yml | 4 + .gitignore | 2 + docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md | 132 +++++-- ops/early-birds/runbook/README.md | 61 ++- ...beacon-listener-container-observer.service | 30 ++ ...c-beacon-listener-container-observer.timer | 12 + ops/early-birds/test/config.test.mjs | 26 ++ scripts/listener_container_observer.py | 361 ++++++++++++++++++ tools/early-birds-hls-load/README.md | 4 +- tools/early-birds-hls-load/package.json | 4 +- .../early-birds-hls-load/src/smoke-safety.mjs | 2 + .../early-birds-hls-load/src/target-probe.mjs | 77 +++- .../test/smoke-safety.test.mjs | 1 + .../test/target-monitor.test.mjs | 86 +++++ .../test/test_listener_container_observer.py | 159 ++++++++ 15 files changed, 881 insertions(+), 80 deletions(-) create mode 100644 ops/early-birds/systemd/harmonic-beacon-listener-container-observer.service create mode 100644 ops/early-birds/systemd/harmonic-beacon-listener-container-observer.timer create mode 100644 scripts/listener_container_observer.py create mode 100644 tools/early-birds-hls-load/test/test_listener_container_observer.py diff --git a/.github/workflows/early-birds-fast-forward.yml b/.github/workflows/early-birds-fast-forward.yml index f5f116da..2cf22286 100644 --- a/.github/workflows/early-birds-fast-forward.yml +++ b/.github/workflows/early-birds-fast-forward.yml @@ -96,9 +96,13 @@ jobs: - 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 diff --git a/.gitignore b/.gitignore index dee5ef8a..6460594b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ /e2e/.auth/ /artifacts/load-test/ /artifacts/early-birds-hls-load/ +__pycache__/ +*.py[cod] # next.js /.next/ diff --git a/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md index d6228080..85dab5bb 100644 --- a/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md +++ b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md @@ -1,10 +1,11 @@ # First external Listener HLS smoke -**Status: code-complete but runtime-blocked. This smoke is not ready to -execute.** The fail-closed harness, monitor, canary and policy are complete and -tested, but the restart/OOM preflight blocker below is unresolved on `mona`: -no supported per-container restart/OOM observer exists yet. No monitored smoke -may run until one is implemented and verified (see "Runtime blocker" below). +**Status: observer implementation prepared but not deployed; this smoke is not +ready to execute.** The fail-closed harness, monitor, canary, policy and a +reviewable fixed-target observer are complete and tested. The observer has not +been installed or verified on `mona`, so its metrics remain absent and every +network run still fails closed. Installation is a separate operational change +requiring explicit review because the root-owned process reads Docker state. 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 @@ -75,7 +76,8 @@ 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 -containers: +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 \ @@ -92,22 +94,23 @@ monitor never infers Alertmanager health from Prometheus. ### Restart/OOM preflight blocker -The restart/OOM baseline requires per-container `container_start_time_seconds` -and `container_oom_events_total` series (currently expected from cAdvisor) for -exactly -`earlybirds-preview-listener-1` and `earlybirds-preview-beacon-stream-1`, each -resolving to exactly one finite series. Before scheduling load, run the monitor +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`, -`containerRestartsObserved: 0` and `oomEventsDelta: 0`. If those container -metrics are absent or ambiguous, the monitor keeps reporting `FAIL` — that is -the exact preflight blocker, and it is currently unresolved on `mona` (see -"Runtime blocker" below). The monitor never silently claims zero restarts. +`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, and the wrapper rejects such a status. +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. -### Runtime blocker: no supported per-container restart/OOM observer +### Root-owned fixed-target observer Verified on `mona` (read-only inspection): Prometheus currently exposes **only the root cgroup** for `container_start_time_seconds` and @@ -132,34 +135,91 @@ 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. -Therefore the ten-client smoke **cannot start** and stays blocked: the -monitor's exact container queries return empty vectors, every probe reports -`FAIL`, and the wrapper refuses the network run. There is no fallback — no -Docker CLI/API read, no inferred zero, no weakened restart/OOM evidence. No -monitored smoke may run until a supported, read-only per-container -restart/OOM observer is implemented and verified on `mona`. Candidate options -are listed in `ops/early-birds/runbook/README.md` ("Per-container restart/OOM -observability blocker"); none may be implemented, restarted or deployed -without explicit operational approval. +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 fifteen 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 code being merged does **not** authorize installation. Before any load, a +host operator must review the exact release and explicitly install the script +and units: -Only after such an observer is deployed and verified, the operator confirms — -through the loopback SSH tunnel — that each of the four exact queries returns -exactly one finite series, not an empty vector: +```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 \ - 'container_start_time_seconds{name="earlybirds-preview-listener-1"}' \ - 'container_start_time_seconds{name="earlybirds-preview-beacon-stream-1"}' \ - 'container_oom_events_total{name="earlybirds-preview-listener-1"}' \ - 'container_oom_events_total{name="earlybirds-preview-beacon-stream-1"}' + '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 ``` -Only then run the monitor `--once` preflight above. An empty vector at any -step is a hard blocker: stop and resolve observability first; never treat -missing series as zero restarts or zero OOM events. +Require observer `up=1`, age between zero and fifteen seconds, then run the +monitor `--once` preflight above. 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 diff --git a/ops/early-birds/runbook/README.md b/ops/early-birds/runbook/README.md index a1713726..a2108136 100644 --- a/ops/early-birds/runbook/README.md +++ b/ops/early-birds/runbook/README.md @@ -97,12 +97,12 @@ projection is verified before the drill is considered complete. ## Per-container restart/OOM observability blocker -Per-container `container_start_time_seconds` and `container_oom_events_total` -series for the isolated Listener and origin containers are a hard prerequisite -for the Listener external smoke (see -`docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md`). On `mona`, Prometheus -currently exposes only the root cgroup for these series and the exact -per-container queries return empty vectors; cAdvisor logs that it cannot find +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 @@ -116,34 +116,27 @@ 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. No monitored smoke -may run until a supported, read-only per-container restart/OOM observer is -implemented and verified on `mona`. Until then the smoke stays -runtime-blocked (its harness is code-complete and fails closed on the missing -series). - -Candidate future options, for evaluation only — none may be implemented, -restarted or deployed without explicit operational approval: - -1. A minimal read-only Docker Engine observer that watches the Engine event - stream and container state (restart counts, OOM-killed status) for exactly - the isolated Listener and origin containers and exports the required - Prometheus series. This needs read-only access to the Docker socket, which - is root-equivalent on the host, so it is acceptable only with an explicit - threat model: dedicated least-privilege observer, read-only socket mount, - no write API calls. -2. A proven containerd-compatible per-container collector — for example a - cAdvisor release verified against the containerd image store, or a - containerd-native metrics source — validated read-only in a throwaway - container on `mona` before any change to the checked-in observability - stack. -3. Any other observer only if it keeps the same fail-closed contract: exactly - one finite series per exact container query, no inferred zeros, no - weakened restart/OOM evidence. - -Whatever is chosen, verification is unchanged: the four exact per-container -queries must each return exactly one finite series before any load, and empty -vectors remain a hard blocker, never a reason to proceed. +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 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/test/config.test.mjs b/ops/early-birds/test/config.test.mjs index a0d98ba1..a3ccb770 100644 --- a/ops/early-birds/test/config.test.mjs +++ b/ops/early-birds/test/config.test.mjs @@ -56,6 +56,32 @@ test('scrapes node-exporter by the internal Docker DNS name', async () => { 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/); 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/tools/early-birds-hls-load/README.md b/tools/early-birds-hls-load/README.md index bbec118d..fc389f6d 100644 --- a/tools/early-birds-hls-load/README.md +++ b/tools/early-birds-hls-load/README.md @@ -115,7 +115,9 @@ same external host as the wrapper. The target monitor queries Alertmanager (`--prometheus-url`, default `http://127.0.0.1:19090`) through separate uncredentialed loopback SSH tunnels, evaluates the immediate host thresholds directly and maintains an in-process restart/OOM baseline for the isolated -Listener and origin containers. The wrapper polls both status files every two +Listener and origin roles. A passing sample additionally requires the private +fixed-target observer to be up, fresh, in the same durable epoch and to expose +exactly one finite start/restart/OOM series per role. The wrapper polls both status files every two seconds and aborts the load child exactly once on the first failing or stale check. See [`LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md`](../../docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md). diff --git a/tools/early-birds-hls-load/package.json b/tools/early-birds-hls-load/package.json index a044bb63..088ab4fb 100644 --- a/tools/early-birds-hls-load/package.json +++ b/tools/early-birds-hls-load/package.json @@ -5,8 +5,8 @@ "type": "module", "description": "External, bounded, format-neutral HLS load and soak evidence harness", "scripts": { - "check": "node --check run.mjs && node --check run-staging-smoke.mjs && node --check external-decoded-canary.mjs && node --check external-target-monitor.mjs && node --check aggregate.mjs && node --check verify-planned.mjs && node --check src/contracts.mjs && node --check src/runner.mjs && node --check src/smoke-safety.mjs && node --check src/target-probe.mjs && node --check src/smoke-guard.mjs && node --check src/network-run-lock.mjs", - "test": "node --test test/*.test.mjs" + "check": "python3 -m py_compile ../../scripts/listener_container_observer.py && node --check run.mjs && node --check run-staging-smoke.mjs && node --check external-decoded-canary.mjs && node --check external-target-monitor.mjs && node --check aggregate.mjs && node --check verify-planned.mjs && node --check src/contracts.mjs && node --check src/runner.mjs && node --check src/smoke-safety.mjs && node --check src/target-probe.mjs && node --check src/smoke-guard.mjs && node --check src/network-run-lock.mjs", + "test": "node --test test/*.test.mjs && python3 -m unittest discover -s test -p 'test_*.py'" }, "engines": { "node": ">=22" diff --git a/tools/early-birds-hls-load/src/smoke-safety.mjs b/tools/early-birds-hls-load/src/smoke-safety.mjs index b3f03092..48bcf04e 100644 --- a/tools/early-birds-hls-load/src/smoke-safety.mjs +++ b/tools/early-birds-hls-load/src/smoke-safety.mjs @@ -174,6 +174,8 @@ export function validateTargetMonitorStatus(status, nowMs = Date.now()) { boundedCount(status.interfaceErrorsDrops, 'target monitor interface errors/drops', 1e9); assert(status.interfaceErrorsDrops === MONITOR_THRESHOLDS.maxInterfaceErrorsDrops, 'target monitor reports interface errors or drops'); + assert(status.containerObserverFresh === true, + 'target monitor container observer is missing, failed or stale'); // A status that has not established and verified a restart/OOM baseline can // never be accepted; nor can one that observed a restart or OOM kill. assert(status.restartBaselineEstablished === true, diff --git a/tools/early-birds-hls-load/src/target-probe.mjs b/tools/early-birds-hls-load/src/target-probe.mjs index 8df11671..1e12ad62 100644 --- a/tools/early-birds-hls-load/src/target-probe.mjs +++ b/tools/early-birds-hls-load/src/target-probe.mjs @@ -15,6 +15,7 @@ export const STAGING_ATTESTATION = 'early-birds-staging'; // earlybirds-preview Compose project. Never participant or event containers. export const LISTENER_CONTAINER = 'earlybirds-preview-listener-1'; export const ORIGIN_CONTAINER = 'earlybirds-preview-beacon-stream-1'; +export const CONTAINER_OBSERVER_MAX_AGE_SECONDS = 15; // Instant Prometheus queries. Every query must yield exactly one vector // element; an empty, duplicated or non-finite result fails the probe. Host @@ -36,10 +37,16 @@ export const MONITOR_QUERIES = Object.freeze({ + ' + sum(node_network_receive_drop_total{device!~"lo|docker.*|veth.*"})', originUp: 'up{job="beacon-stream"}', canaryOk: 'beacon_stream_canary_ok', - listenerStartSeconds: `container_start_time_seconds{name="${LISTENER_CONTAINER}"}`, - originStartSeconds: `container_start_time_seconds{name="${ORIGIN_CONTAINER}"}`, - listenerOomEvents: `container_oom_events_total{name="${LISTENER_CONTAINER}"}`, - originOomEvents: `container_oom_events_total{name="${ORIGIN_CONTAINER}"}`, + containerObserverUp: 'beacon_listener_container_observer_up', + containerObserverAgeSeconds: + 'time() - beacon_listener_container_observer_last_success_timestamp_seconds', + containerObserverEpochSeconds: 'beacon_listener_container_observer_epoch_start_time_seconds', + listenerStartSeconds: 'beacon_listener_container_start_time_seconds{role="listener"}', + originStartSeconds: 'beacon_listener_container_start_time_seconds{role="origin"}', + listenerRestartEvents: 'beacon_listener_container_restart_events_total{role="listener"}', + originRestartEvents: 'beacon_listener_container_restart_events_total{role="origin"}', + listenerOomEvents: 'beacon_listener_container_oom_events_total{role="listener"}', + originOomEvents: 'beacon_listener_container_oom_events_total{role="origin"}', }); export function loopbackTunnelOrigin(value, label) { @@ -147,7 +154,10 @@ export function createContainerBaseline() { established: false, verified: false, startSeconds: null, + restartEvents: null, oomEvents: null, + observerEpochSeconds: null, + continuityLost: false, restartsObserved: 0, oomEventsDelta: 0, }; @@ -155,19 +165,36 @@ export function createContainerBaseline() { export function observeContainerBaseline(baseline, sample) { const starts = [sample.listenerStartSeconds, sample.originStartSeconds]; + const restarts = [sample.listenerRestartEvents, sample.originRestartEvents]; const ooms = [sample.listenerOomEvents, sample.originOomEvents]; if (!baseline.established) { baseline.established = true; baseline.startSeconds = starts; + baseline.restartEvents = restarts; baseline.oomEvents = ooms; + baseline.observerEpochSeconds = sample.containerObserverEpochSeconds; return; } + if (sample.containerObserverEpochSeconds !== baseline.observerEpochSeconds) { + baseline.continuityLost = true; + baseline.verified = false; + } starts.forEach((value, index) => { - if (value !== baseline.startSeconds[index]) { + const restartDelta = restarts[index] - baseline.restartEvents[index]; + if (restartDelta < 0) { + baseline.continuityLost = true; + baseline.verified = false; + } else if (restartDelta > 0) { + baseline.restartsObserved += restartDelta; + baseline.verified = false; + } else if (value !== baseline.startSeconds[index]) { + // A changed start time with no matching monotonic counter movement is + // still a restart and must never become a clean sample. baseline.restartsObserved += 1; - baseline.startSeconds[index] = value; baseline.verified = false; } + baseline.restartEvents[index] = restarts[index]; + baseline.startSeconds[index] = value; }); ooms.forEach((value, index) => { const delta = value - baseline.oomEvents[index]; @@ -175,13 +202,41 @@ export function observeContainerBaseline(baseline, sample) { baseline.oomEventsDelta += delta; baseline.verified = false; } + if (delta < 0) { + baseline.continuityLost = true; + baseline.verified = false; + } if (delta !== 0) baseline.oomEvents[index] = value; }); - if (baseline.restartsObserved === 0 && baseline.oomEventsDelta === 0) { + if (!baseline.continuityLost + && baseline.restartsObserved === 0 && baseline.oomEventsDelta === 0) { baseline.verified = true; } } +function validateContainerObserverScalars(scalars) { + if (scalars.containerObserverUp !== 1 + || scalars.containerObserverAgeSeconds < 0 + || scalars.containerObserverAgeSeconds > CONTAINER_OBSERVER_MAX_AGE_SECONDS + || !Number.isSafeInteger(scalars.containerObserverEpochSeconds) + || scalars.containerObserverEpochSeconds <= 0) { + throw new Error('container observer is missing, failed or stale'); + } + for (const name of ['listenerStartSeconds', 'originStartSeconds']) { + if (!(scalars[name] > 0)) throw new Error('container start time is invalid'); + } + for (const name of [ + 'listenerRestartEvents', + 'originRestartEvents', + 'listenerOomEvents', + 'originOomEvents', + ]) { + if (!Number.isSafeInteger(scalars[name]) || scalars[name] < 0) { + throw new Error('container continuity counter is invalid'); + } + } +} + function withinImmediateThresholds(scalars) { return scalars.cpuUsedRatio < MONITOR_THRESHOLDS.maxCpuUsedRatio && scalars.memoryUsedRatio < MONITOR_THRESHOLDS.maxMemoryUsedRatio @@ -232,6 +287,7 @@ export async function probeMonitor({ egressBitsPerSecond: null, tcpRetransmitRatio: null, interfaceErrorsDrops: null, + containerObserverFresh: false, restartBaselineEstablished: baseline.verified, containerRestartsObserved: baseline.restartsObserved, oomEventsDelta: baseline.oomEventsDelta, @@ -254,15 +310,21 @@ export async function probeMonitor({ ]) ); const scalars = Object.fromEntries(scalarNames.map((name, index) => [name, scalarValues[index]])); + validateContainerObserverScalars(scalars); observeContainerBaseline(baseline, { + containerObserverEpochSeconds: scalars.containerObserverEpochSeconds, listenerStartSeconds: scalars.listenerStartSeconds, originStartSeconds: scalars.originStartSeconds, + listenerRestartEvents: scalars.listenerRestartEvents, + originRestartEvents: scalars.originRestartEvents, listenerOomEvents: scalars.listenerOomEvents, originOomEvents: scalars.originOomEvents, }); + const containerObserverFresh = true; const passed = listenerReady && streamHealthy && liveReady && alertmanager.ready && alertmanager.activeAlerts === 0 && firingAlerts === 0 && withinImmediateThresholds(scalars) + && containerObserverFresh && baseline.verified && baseline.restartsObserved === 0 && baseline.oomEventsDelta === 0; return { @@ -283,6 +345,7 @@ export async function probeMonitor({ egressBitsPerSecond: scalars.egressBitsPerSecond, tcpRetransmitRatio: scalars.tcpRetransmitRatio, interfaceErrorsDrops: scalars.interfaceErrorsDrops, + containerObserverFresh, restartBaselineEstablished: baseline.verified, containerRestartsObserved: baseline.restartsObserved, oomEventsDelta: baseline.oomEventsDelta, diff --git a/tools/early-birds-hls-load/test/smoke-safety.test.mjs b/tools/early-birds-hls-load/test/smoke-safety.test.mjs index 8caebbcc..3fcb3abf 100644 --- a/tools/early-birds-hls-load/test/smoke-safety.test.mjs +++ b/tools/early-birds-hls-load/test/smoke-safety.test.mjs @@ -82,6 +82,7 @@ function fixture(nowMs = Date.now()) { egressBitsPerSecond: 500_000_000, tcpRetransmitRatio: 0.001, interfaceErrorsDrops: 0, + containerObserverFresh: true, restartBaselineEstablished: true, containerRestartsObserved: 0, oomEventsDelta: 0, diff --git a/tools/early-birds-hls-load/test/target-monitor.test.mjs b/tools/early-birds-hls-load/test/target-monitor.test.mjs index c66d12c6..89148b3b 100644 --- a/tools/early-birds-hls-load/test/target-monitor.test.mjs +++ b/tools/early-birds-hls-load/test/target-monitor.test.mjs @@ -26,8 +26,13 @@ const DEFAULT_SCALARS = { interfaceErrorsDrops: 0, originUp: 1, canaryOk: 1, + containerObserverUp: 1, + containerObserverAgeSeconds: 2, + containerObserverEpochSeconds: 1_700_000_050, listenerStartSeconds: 1_700_000_000, originStartSeconds: 1_700_000_100, + listenerRestartEvents: 0, + originRestartEvents: 0, listenerOomEvents: 0, originOomEvents: 0, }; @@ -242,6 +247,87 @@ test('a changed container start timestamp is latched as a restart', async () => assert.equal(latched.containerRestartsObserved, 1); }); +test('a monotonic restart counter increase is counted once even when start time also changes', async () => { + const state = { scalars: {} }; + const baseline = createContainerBaseline(); + const options = { + fetchImpl: makeFetch(state), + prometheusOrigin: PROMETHEUS, + alertmanagerOrigin: ALERTMANAGER, + hostHash: TEST_HOST_HASH, + baseline, + }; + await probeMonitor(options); + assert.equal((await probeMonitor(options)).status, 'PASS'); + state.scalars.listenerRestartEvents = 1; + state.scalars.listenerStartSeconds = DEFAULT_SCALARS.listenerStartSeconds + 60; + const restarted = await probeMonitor(options); + assert.equal(restarted.status, 'FAIL'); + assert.equal(restarted.containerRestartsObserved, 1); +}); + +test('observer failure, staleness or epoch/counter reset fails closed and stays latched', async () => { + const down = await probeTwice({ scalars: { containerObserverUp: 0 } }); + assert.equal(down.second.status, 'FAIL'); + assert.equal(down.second.containerObserverFresh, false); + + const stale = await probeTwice({ scalars: { containerObserverAgeSeconds: 16 } }); + assert.equal(stale.second.status, 'FAIL'); + assert.equal(stale.second.containerObserverFresh, false); + + const state = { scalars: {} }; + const baseline = createContainerBaseline(); + const options = { + fetchImpl: makeFetch(state), + prometheusOrigin: PROMETHEUS, + alertmanagerOrigin: ALERTMANAGER, + hostHash: TEST_HOST_HASH, + baseline, + }; + await probeMonitor(options); + assert.equal((await probeMonitor(options)).status, 'PASS'); + state.scalars.containerObserverEpochSeconds = DEFAULT_SCALARS.containerObserverEpochSeconds + 1; + assert.equal((await probeMonitor(options)).status, 'FAIL'); + state.scalars.containerObserverEpochSeconds = DEFAULT_SCALARS.containerObserverEpochSeconds; + assert.equal((await probeMonitor(options)).status, 'FAIL'); + + const counterState = { scalars: { listenerRestartEvents: 3 } }; + const counterBaseline = createContainerBaseline(); + const counterOptions = { + ...options, + fetchImpl: makeFetch(counterState), + baseline: counterBaseline, + }; + await probeMonitor(counterOptions); + assert.equal((await probeMonitor(counterOptions)).status, 'PASS'); + counterState.scalars.listenerRestartEvents = 2; + assert.equal((await probeMonitor(counterOptions)).status, 'FAIL'); + counterState.scalars.listenerRestartEvents = 3; + assert.equal((await probeMonitor(counterOptions)).status, 'FAIL'); +}); + +test('invalid observer timestamps and counters fail before establishing a baseline', async () => { + for (const scalars of [ + { containerObserverAgeSeconds: -1 }, + { containerObserverEpochSeconds: 1.5 }, + { listenerStartSeconds: 0 }, + { listenerRestartEvents: -1 }, + { originOomEvents: 0.5 }, + ]) { + const baseline = createContainerBaseline(); + const result = await probeMonitor({ + fetchImpl: makeFetch({ scalars }), + prometheusOrigin: PROMETHEUS, + alertmanagerOrigin: ALERTMANAGER, + hostHash: TEST_HOST_HASH, + baseline, + }); + assert.equal(result.status, 'FAIL'); + assert.equal(result.containerObserverFresh, false); + assert.equal(baseline.established, false); + } +}); + test('an increased container OOM counter fails closed with a bounded delta', async () => { const state = { scalars: {} }; const baseline = createContainerBaseline(); diff --git a/tools/early-birds-hls-load/test/test_listener_container_observer.py b/tools/early-birds-hls-load/test/test_listener_container_observer.py new file mode 100644 index 00000000..06c93d93 --- /dev/null +++ b/tools/early-birds-hls-load/test/test_listener_container_observer.py @@ -0,0 +1,159 @@ +import copy +from datetime import datetime +import importlib.util +import json +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +SPEC = importlib.util.spec_from_file_location( + "listener_container_observer", ROOT / "scripts/listener_container_observer.py" +) +OBSERVER = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(OBSERVER) + +IDS = { + "listener": "a" * 64, + "origin": "b" * 64, + "listener_replacement": "c" * 64, +} + + +def inspect_rows(): + rows = [] + for index, target in enumerate(OBSERVER.TARGETS): + rows.append( + { + "Id": IDS[target["role"]], + "Name": f"/{target['name']}", + "Config": { + "Labels": { + "com.docker.compose.project": "earlybirds-preview", + "com.docker.compose.service": target["service"], + } + }, + "State": { + "Status": "running", + "StartedAt": f"2026-08-15T10:00:0{index}.000000000Z", + "OOMKilled": False, + }, + "RestartCount": 0, + } + ) + return rows + + +def observations(): + return OBSERVER.parse_docker_inspect(json.dumps(inspect_rows())) + + +class ListenerContainerObserverTest(unittest.TestCase): + def test_parses_only_exact_running_compose_targets(self): + result = observations() + self.assertEqual(list(result), ["listener", "origin"]) + self.assertEqual( + result["listener"]["startTimeSeconds"], + datetime.fromisoformat("2026-08-15T10:00:00+00:00").timestamp(), + ) + self.assertEqual( + result["origin"]["startTimeSeconds"], + datetime.fromisoformat("2026-08-15T10:00:01+00:00").timestamp(), + ) + self.assertRegex(result["listener"]["identity"], r"^[a-f0-9]{64}$") + self.assertNotEqual(result["listener"]["identity"], IDS["listener"]) + + def test_rejects_missing_duplicate_wrong_boundary_and_stopped_targets(self): + with self.assertRaisesRegex(ValueError, "incomplete or ambiguous"): + OBSERVER.parse_docker_inspect("[]") + + duplicate = inspect_rows() + duplicate[1]["Name"] = duplicate[0]["Name"] + with self.assertRaisesRegex(ValueError, "duplicated"): + OBSERVER.parse_docker_inspect(json.dumps(duplicate)) + + wrong_project = inspect_rows() + wrong_project[0]["Config"]["Labels"]["com.docker.compose.project"] = "pmp-myth" + with self.assertRaisesRegex(ValueError, "fixed boundary"): + OBSERVER.parse_docker_inspect(json.dumps(wrong_project)) + + wrong_service = inspect_rows() + wrong_service[1]["Config"]["Labels"]["com.docker.compose.service"] = "listener" + with self.assertRaisesRegex(ValueError, "fixed boundary"): + OBSERVER.parse_docker_inspect(json.dumps(wrong_service)) + + stopped = inspect_rows() + stopped[0]["State"]["Status"] = "exited" + with self.assertRaisesRegex(ValueError, "not running"): + OBSERVER.parse_docker_inspect(json.dumps(stopped)) + + def test_durable_epoch_and_monotonic_restart_oom_totals(self): + first = OBSERVER.advance_observer_state(None, observations(), 1_700_000_000) + self.assertEqual(first["schemaVersion"], OBSERVER.OBSERVER_SCHEMA_VERSION) + self.assertEqual(first["targets"]["listener"]["restartEventsTotal"], 0) + + changed = observations() + changed["listener"].update( + { + "startTimeSeconds": changed["listener"]["startTimeSeconds"] + 20, + "dockerRestartCount": 1, + "oomKilled": True, + } + ) + second = OBSERVER.advance_observer_state(first, changed, 1_700_000_005) + self.assertEqual(second["epochStartedAtSeconds"], first["epochStartedAtSeconds"]) + self.assertEqual(second["targets"]["listener"]["restartEventsTotal"], 1) + self.assertEqual(second["targets"]["listener"]["oomEventsTotal"], 1) + + still_oomed = OBSERVER.advance_observer_state(second, changed, 1_700_000_010) + self.assertEqual(still_oomed["targets"]["listener"]["oomEventsTotal"], 1) + recovered_observation = copy.deepcopy(changed) + recovered_observation["listener"]["oomKilled"] = False + recovered = OBSERVER.advance_observer_state(still_oomed, recovered_observation, 1_700_000_015) + oomed_again = OBSERVER.advance_observer_state(recovered, changed, 1_700_000_020) + self.assertEqual(oomed_again["targets"]["listener"]["oomEventsTotal"], 2) + + def test_replacement_preserves_cumulative_totals(self): + initial = observations() + initial["listener"]["dockerRestartCount"] = 2 + first = OBSERVER.advance_observer_state(None, initial, 1_700_000_000) + replacement = observations() + replacement["listener"].update( + { + "identity": IDS["listener_replacement"], + "startTimeSeconds": replacement["listener"]["startTimeSeconds"] + 60, + "dockerRestartCount": 1, + } + ) + next_state = OBSERVER.advance_observer_state(first, replacement, 1_700_000_005) + self.assertEqual(next_state["targets"]["listener"]["restartEventsTotal"], 4) + + def test_refuses_corrupt_state_and_backwards_docker_counter(self): + initial = observations() + initial["listener"]["dockerRestartCount"] = 2 + first = OBSERVER.advance_observer_state(None, initial, 1_700_000_000) + corrupt = {**first, "schemaVersion": 99} + with self.assertRaisesRegex(ValueError, "state is invalid"): + OBSERVER.advance_observer_state(corrupt, observations(), 1_700_000_005) + with self.assertRaisesRegex(ValueError, "moved backwards"): + OBSERVER.advance_observer_state(first, observations(), 1_700_000_005) + + def test_metrics_use_fixed_roles_without_identity_or_container_name(self): + state = OBSERVER.advance_observer_state(None, observations(), 1_700_000_000) + metrics = OBSERVER.render_observer_metrics(state) + self.assertIn("beacon_listener_container_observer_up 1", metrics) + self.assertEqual(metrics.count('beacon_listener_container_start_time_seconds{role='), 2) + self.assertEqual(metrics.count('beacon_listener_container_restart_events_total{role='), 2) + self.assertEqual(metrics.count('beacon_listener_container_oom_events_total{role='), 2) + self.assertNotIn("earlybirds-preview", metrics) + self.assertNotIn("beacon-stream", metrics) + self.assertNotRegex(metrics, r"[a-f0-9]{64}") + + failure = OBSERVER.render_observer_failure_metrics(1_700_000_005) + self.assertIn("beacon_listener_container_observer_up 0", failure) + self.assertNotIn("role=", failure) + + +if __name__ == "__main__": + unittest.main() From 222051df89ae6d354e8757dd6d716383b43af321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 07:51:04 -0300 Subject: [PATCH 234/376] fix(listener): align observer freshness with scraping --- docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md | 12 +++++++----- tools/early-birds-hls-load/src/target-probe.mjs | 5 ++++- .../test/target-monitor.test.mjs | 6 +++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md index 85dab5bb..1a1bedc7 100644 --- a/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md +++ b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md @@ -158,7 +158,7 @@ Threat boundary: - 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 fifteen seconds. Freshness and exact-cardinality queries + 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 @@ -216,10 +216,12 @@ do done ``` -Require observer `up=1`, age between zero and fifteen seconds, then run the -monitor `--once` preflight above. 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. +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 diff --git a/tools/early-birds-hls-load/src/target-probe.mjs b/tools/early-birds-hls-load/src/target-probe.mjs index 1e12ad62..7911cc12 100644 --- a/tools/early-birds-hls-load/src/target-probe.mjs +++ b/tools/early-birds-hls-load/src/target-probe.mjs @@ -15,7 +15,10 @@ export const STAGING_ATTESTATION = 'early-birds-staging'; // earlybirds-preview Compose project. Never participant or event containers. export const LISTENER_CONTAINER = 'earlybirds-preview-listener-1'; export const ORIGIN_CONTAINER = 'earlybirds-preview-beacon-stream-1'; -export const CONTAINER_OBSERVER_MAX_AGE_SECONDS = 15; +// The host timer runs every 5s and Prometheus scrapes node-exporter every 15s. +// Thirty seconds tolerates one normal scrape interval plus timer alignment, +// while a missed scrape still fails the continuously polled monitor. +export const CONTAINER_OBSERVER_MAX_AGE_SECONDS = 30; // Instant Prometheus queries. Every query must yield exactly one vector // element; an empty, duplicated or non-finite result fails the probe. Host diff --git a/tools/early-birds-hls-load/test/target-monitor.test.mjs b/tools/early-birds-hls-load/test/target-monitor.test.mjs index 89148b3b..f212ed63 100644 --- a/tools/early-birds-hls-load/test/target-monitor.test.mjs +++ b/tools/early-birds-hls-load/test/target-monitor.test.mjs @@ -271,7 +271,11 @@ test('observer failure, staleness or epoch/counter reset fails closed and stays assert.equal(down.second.status, 'FAIL'); assert.equal(down.second.containerObserverFresh, false); - const stale = await probeTwice({ scalars: { containerObserverAgeSeconds: 16 } }); + const boundary = await probeTwice({ scalars: { containerObserverAgeSeconds: 30 } }); + assert.equal(boundary.second.status, 'PASS'); + assert.equal(boundary.second.containerObserverFresh, true); + + const stale = await probeTwice({ scalars: { containerObserverAgeSeconds: 31 } }); assert.equal(stale.second.status, 'FAIL'); assert.equal(stale.second.containerObserverFresh, false); From 59c4814db6985b90ba9442de80f1677306cae19d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 07:51:54 -0300 Subject: [PATCH 235/376] ci(listener): gate external smoke harness changes --- .github/workflows/early-birds-fast-forward.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/early-birds-fast-forward.yml b/.github/workflows/early-birds-fast-forward.yml index 2cf22286..9e2b1d7d 100644 --- a/.github/workflows/early-birds-fast-forward.yml +++ b/.github/workflows/early-birds-fast-forward.yml @@ -19,6 +19,9 @@ on: - ops/early-birds/** - ops/early-birds-preview/** - scripts/early-birds-preview/** + - 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 - .github/workflows/early-birds-fast-forward.yml push: @@ -40,6 +43,9 @@ on: - ops/early-birds/** - ops/early-birds-preview/** - scripts/early-birds-preview/** + - 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 - .github/workflows/early-birds-fast-forward.yml From b8b67f4b20733db112448358efe17823575e5a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 08:21:02 -0300 Subject: [PATCH 236/376] docs(listener): record first external media smoke --- docs/operations/LISTENER_LAUNCH_NOW.md | 10 ++++- docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md | 45 +++++++++++++++---- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index 5ebf407a..305a78db 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -25,6 +25,8 @@ and rollback procedures live in `FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md` and - 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 @@ -48,6 +50,12 @@ PayPal Sandbox has passed activation, pending cancellation, reactivation and terminal refund. Mercado Pago TEST has passed checkout, activation, pause, reactivation and reconciliation. Browser redirects never grant membership. +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. @@ -93,7 +101,7 @@ without explicit approval. 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 - `0a475717` if contract-compatible; keep the `0a475717` withdrawal operator and + `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. diff --git a/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md index 1a1bedc7..d978c5ad 100644 --- a/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md +++ b/docs/ops/LISTENER_FIRST_EXTERNAL_HLS_SMOKE.md @@ -1,11 +1,38 @@ # First external Listener HLS smoke -**Status: observer implementation prepared but not deployed; this smoke is not -ready to execute.** The fail-closed harness, monitor, canary, policy and a -reviewable fixed-target observer are complete and tested. The observer has not -been installed or verified on `mona`, so its metrics remain absent and every -network run still fails closed. Installation is a separate operational change -requiring explicit review because the root-owned process reads Docker state. +**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 @@ -164,9 +191,9 @@ Threat boundary: 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 code being merged does **not** authorize installation. Before any load, a -host operator must review the exact release and explicitly install the script -and units: +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 From eacd49066fb92fa8b4d237165ae550d2a86385bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 08:31:04 -0300 Subject: [PATCH 237/376] docs(listener): clarify productive rehearsal boundary --- .github/workflows/early-birds-fast-forward.yml | 6 ++++++ .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 12 +++++++++--- docs/operations/LISTENER_LAUNCH_NOW.md | 8 ++++++++ docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md | 13 +++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/early-birds-fast-forward.yml b/.github/workflows/early-birds-fast-forward.yml index 9e2b1d7d..2eb78b2b 100644 --- a/.github/workflows/early-birds-fast-forward.yml +++ b/.github/workflows/early-birds-fast-forward.yml @@ -23,6 +23,9 @@ on: - 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-*"] @@ -47,6 +50,9 @@ on: - 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: diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 268d544f..6c4e7eef 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -28,6 +28,11 @@ truthful launch baseline, not a substitute for counsel review. Human owner: Nico 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. The productive lanes have passed only +read-only provider preflight so far; neither provider has completed a Live +activation/cancel/refund rehearsal and no real charge has been created. + 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 @@ -164,9 +169,10 @@ converted back into a reversible action. 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. `0a475717` remains the bounded contract-compatible application rollback for the current - authority. Preserve the `0a475717` withdrawal operator and database so already-received legal - requests remain processable; hide new legal submissions with their feature switch if necessary. + 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. diff --git a/docs/operations/LISTENER_LAUNCH_NOW.md b/docs/operations/LISTENER_LAUNCH_NOW.md index 305a78db..f162cd7c 100644 --- a/docs/operations/LISTENER_LAUNCH_NOW.md +++ b/docs/operations/LISTENER_LAUNCH_NOW.md @@ -50,6 +50,14 @@ PayPal Sandbox has passed activation, pending cancellation, reactivation and terminal refund. Mercado Pago TEST has passed checkout, activation, pause, reactivation and reconciliation. Browser redirects never grant membership. +| Provider | Non-production lifecycle | Productive state | +| --- | --- | --- | +| PayPal | Sandbox activation, pending cancellation, reactivation and terminal refund accepted | Live catalog/webhook read-only preflight verified; lifecycle and real charge not yet rehearsed | +| 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 diff --git a/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md index 147863ab..204055a0 100644 --- a/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md +++ b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md @@ -23,6 +23,19 @@ not touch event, LiveKit, playlist-bot, tapestry or audio services. - 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. From 4d39b2d5b715c212cc9ba2fa3c3b7205d64e990a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 08:41:51 -0300 Subject: [PATCH 238/376] feat(listener): verify dormant Live commerce boundary --- .../FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md | 6 +- .../LISTENER_PRIVATE_LIVE_WORKBENCH.md | 14 ++ ops/early-birds-preview/package.json | 2 +- .../test/listener-live-dormant-check.test.mjs | 86 +++++++++ .../listener-live-dormant-check.mjs | 179 ++++++++++++++++++ 5 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 ops/early-birds-preview/test/listener-live-dormant-check.test.mjs create mode 100644 scripts/early-birds-preview/listener-live-dormant-check.mjs diff --git a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md index 6c4e7eef..2b3e3ac9 100644 --- a/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md +++ b/docs/operations/FOUNDING_LISTENER_COMMERCIAL_LAUNCH.md @@ -139,8 +139,10 @@ converted back into a reversible action. `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. The - preflight does not replace webhook signature or lifecycle tests. +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 diff --git a/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md index 204055a0..35d1c6f2 100644 --- a/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md +++ b/docs/operations/LISTENER_PRIVATE_LIVE_WORKBENCH.md @@ -39,6 +39,20 @@ The dormant preflight was reconfirmed after the first external media smoke: 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. diff --git a/ops/early-birds-preview/package.json b/ops/early-birds-preview/package.json index 20627b2a..89f83472 100644 --- a/ops/early-birds-preview/package.json +++ b/ops/early-birds-preview/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "check": "node --check ../../scripts/early-birds-preview/validate.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", + "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/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/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(); +} From 7ad0385df2f1d450f932f36f7a9c029692666b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 16:29:42 -0300 Subject: [PATCH 239/376] fix(listener): prioritize playback continuity --- .../operations/EARLY_BIRDS_STAGING_PREVIEW.md | 6 +- docs/operations/LISTENER_AUDIO_CONTINUITY.md | 57 ++++- docs/plans/EARLY_BIRDS.md | 2 +- services/beacon-stream/src/manifest.mjs | 12 +- services/beacon-stream/test/manifest.test.mjs | 25 +- .../analysis/frame/__tests__/route.test.ts | 5 + src/app/api/listener/analysis/frame/route.ts | 5 +- src/components/early-birds/ListenerPlayer.tsx | 226 ++++++++++++++---- .../__tests__/ListenerPlayer.test.tsx | 124 +++++++++- .../__tests__/listener-presentation.test.ts | 6 + .../early-birds/listener-presentation.ts | 7 +- .../listener/__tests__/media-boundary.test.ts | 15 +- 12 files changed, 414 insertions(+), 76 deletions(-) diff --git a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md index 746e5feb..00c82511 100644 --- a/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md +++ b/docs/operations/EARLY_BIRDS_STAGING_PREVIEW.md @@ -288,8 +288,10 @@ 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. -- Desktop hls.js targets five six-second segments behind the edge, a 60-second - forward buffer and a 90-second hard maximum; low-latency mode remains off. +- The current stability policy supersedes that first tuning: clients target + twenty six-second segments behind the edge, a 120-second forward buffer and + a 180-second hard maximum; 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 diff --git a/docs/operations/LISTENER_AUDIO_CONTINUITY.md b/docs/operations/LISTENER_AUDIO_CONTINUITY.md index df56b263..4e5230b8 100644 --- a/docs/operations/LISTENER_AUDIO_CONTINUITY.md +++ b/docs/operations/LISTENER_AUDIO_CONTINUITY.md @@ -35,6 +35,34 @@ 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 twenty entries (two minutes) behind the live edge. hls.js may +hold up to three minutes of forward media; native HLS seeks to the same +two-minute target rather than sitting directly on the edge. This costs about +4.8 MB of initial media at 320 kbit/s and intentionally trades latency for +continuity. + +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` and a matching console warning only when recovery begins. The payload is fixed and contains transport/action, media state, range counts/endpoints, lease generation/sequence, bounded HLS @@ -58,6 +86,29 @@ background/foreground cycle and speakers/headphones when available. Perform a For deterministic recovery evidence in staging, interrupt only the Listener control plane for less than 30 seconds while the independent origin remains -healthy. The same client must reconnect at the live position with one audible -source and one quota presence interval. Do not perform this exercise while an -event is active and do not restart the origin to simulate it. +healthy. The same client must reconnect at the configured buffered live +position with one audible source and one quota presence interval. Do not +perform this exercise while an event is active and do not restart the origin +to simulate it. + +## 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. cache immutable media segments at a CDN/edge and keep manifests private and + short-lived; +2. remove the Listener application/database from the segment hot path after a + bounded authorization decision, so control-plane latency cannot interrupt + already-authorized audio; +3. publish the identical encoded timeline from at least two failure domains + and provide tested client/playlist failover without overlapping audio; +4. run synthetic audio canaries from North America, Europe and Latin America, + and retain low-cardinality, non-PII browser recovery causes; +5. 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/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 451c9db8..35c8762e 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -67,7 +67,7 @@ change has passed its own audio and operational acceptance. | 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 | Desktop HLS stays about five segments behind the edge with a 60-second target buffer; Stop and a later Listen rejoin the current configured edge. | +| Favor continuity over low latency in the Listener | Accepted | Listener retains a five-minute origin window and starts about two minutes behind the edge, with up to three minutes of client buffer; 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. | diff --git a/services/beacon-stream/src/manifest.mjs b/services/beacon-stream/src/manifest.mjs index 34f775db..e94049ff 100644 --- a/services/beacon-stream/src/manifest.mjs +++ b/services/beacon-stream/src/manifest.mjs @@ -1,6 +1,10 @@ import { signedUrl } from './auth.mjs'; -const WINDOW_SEGMENTS = 6; +// Listener values continuity over realtime latency. Fifty six-second entries +// retain a five-minute recovery window while the player deliberately starts +// two 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); @@ -34,9 +38,13 @@ export function renderManifest({ nowMs = Date.now(), tokenTtlSeconds = 120, authorizationExpiresAtSeconds = Number.POSITIVE_INFINITY, + windowSegments = WINDOW_SEGMENTS, }) { + 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 - (WINDOW_SEGMENTS - 1)); + const firstSequence = Math.max(0, edgeSequence - (windowSegments - 1)); const expiresAt = Math.min( Math.floor(nowMs / 1000) + tokenTtlSeconds, authorizationExpiresAtSeconds, diff --git a/services/beacon-stream/test/manifest.test.mjs b/services/beacon-stream/test/manifest.test.mjs index 23a59dab..36c71e20 100644 --- a/services/beacon-stream/test/manifest.test.mjs +++ b/services/beacon-stream/test/manifest.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { renderManifest } from '../src/manifest.mjs'; +import { renderManifest, WINDOW_SEGMENTS } from '../src/manifest.mjs'; import { verifySignedPath } from '../src/auth.mjs'; import { metadata, variableMetadata } from './helpers.mjs'; @@ -10,17 +10,30 @@ 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:2/); + 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, 6); + 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 a five-minute stability window 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://')); + assert.equal(WINDOW_SEGMENTS, 50); + assert.equal(urls.length, WINDOW_SEGMENTS); +}); + test('never signs a segment beyond the inbound manifest authorization horizon', () => { const item = metadata(); const nowMs = item.epochMs + 42_000; @@ -45,7 +58,7 @@ 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:4/); + 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,/); @@ -57,8 +70,8 @@ test('renders a signed fMP4 map and preserves a short final segment across loops 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 }); - const after = renderManifest({ metadata: item, origin: 'https://stream.example.test', secret, nowMs: epoch + 65_000 }); + 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/); diff --git a/src/app/api/listener/analysis/frame/__tests__/route.test.ts b/src/app/api/listener/analysis/frame/__tests__/route.test.ts index d11086bc..b536f15a 100644 --- a/src/app/api/listener/analysis/frame/__tests__/route.test.ts +++ b/src/app/api/listener/analysis/frame/__tests__/route.test.ts @@ -82,6 +82,11 @@ describe('GET /api/listener/analysis/frame', () => { }); 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, diff --git a/src/app/api/listener/analysis/frame/route.ts b/src/app/api/listener/analysis/frame/route.ts index 5927d262..a0c51277 100644 --- a/src/app/api/listener/analysis/frame/route.ts +++ b/src/app/api/listener/analysis/frame/route.ts @@ -21,7 +21,10 @@ const NO_STORE_HEADERS = { 'X-Content-Type-Options': 'nosniff', }; const LEASE_ID = /^[0-9a-f-]{36}$/i; -const MAX_AUDIBLE_LATENCY_MS = 90_000; +// 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) { diff --git a/src/components/early-birds/ListenerPlayer.tsx b/src/components/early-birds/ListenerPlayer.tsx index c6021cbb..732c91c9 100644 --- a/src/components/early-birds/ListenerPlayer.tsx +++ b/src/components/early-birds/ListenerPlayer.tsx @@ -32,6 +32,10 @@ export { getOrCreateEarlyBirdDeviceId } from './listener-tab-identity'; type DropLanguage = 'es' | 'en'; type PlaybackMode = 'intro' | 'beacon'; type LiveState = 'idle' | 'loading' | 'recovering' | 'playing' | 'paused' | 'error' | 'displaced'; +type BackgroundSuspension = { + source: 'beacon' | DropLanguage; + rebuildHls: boolean; +}; type LeasePayload = { leaseId: string; leaseGeneration: number; @@ -65,6 +69,7 @@ const STALL_RECOVERY_DELAY_MS = 1_000; const LIVE_FADE_IN_MS = 3_000; const TRANSPORT_FADE_OUT_MS = 650; const DEFAULT_LISTENER_VOLUME = 0.7; +const LISTENER_STABILITY_DELAY_SECONDS = 120; export const LISTENER_PLAYBACK_PRESENCE_EVENT = 'listener:playback-presence'; export const LISTENER_PLAYBACK_DIAGNOSTIC_EVENT = 'listener:playback-diagnostic'; @@ -125,24 +130,32 @@ export function nextPresenceSequence( return nextPresence === currentPresence ? currentSequence : currentSequence + 1; } -// The Listener does not need low latency. Holding roughly five six-second HLS -// segments behind the edge gives desktop browsers useful network headroom -// while every fresh play still seeks to the current configured live position. +// The Listener does not need low latency. Starting roughly twenty six-second +// HLS segments behind the edge gives browsers two minutes of network headroom +// while every fresh play still joins the current continuous program. export const LISTENER_HLS_BUFFER_CONFIG = { lowLatencyMode: false, liveDurationInfinity: true, - liveSyncDurationCount: 5, - liveMaxLatencyDurationCount: 10, - maxBufferLength: 60, - maxMaxBufferLength: 90, + initialLiveManifestSize: 21, + liveSyncDurationCount: 20, + liveMaxLatencyDurationCount: 45, + liveSyncMode: 'buffered', + startOnSegmentBoundary: true, + maxBufferLength: 120, + maxMaxBufferLength: 180, backBufferLength: 0, } as const; export function seekNativeAudioToLiveEdge(audio: HTMLAudioElement): boolean { if (audio.seekable.length < 1) return false; - const edge = audio.seekable.end(audio.seekable.length - 1); - if (!Number.isFinite(edge)) return false; - audio.currentTime = Math.max(0, edge - 0.25); + const rangeIndex = audio.seekable.length - 1; + const start = audio.seekable.start(rangeIndex); + const edge = audio.seekable.end(rangeIndex); + if (!Number.isFinite(start) || !Number.isFinite(edge) || edge <= start) return false; + // Listener has no low-latency requirement. Native HLS should use the same + // two-minute safety margin as hls.js instead of sitting 250 ms from the + // edge, where one delayed segment becomes an audible interruption. + audio.currentTime = Math.max(start, edge - LISTENER_STABILITY_DELAY_SECONDS); return true; } @@ -286,8 +299,10 @@ function ListenerPlayerController({ fatal: null, }); const listenerPresence = useRef<'idle' | 'listening'>('idle'); - const automaticRecovery = useRef<(initialDelayMs?: number) => void>(() => undefined); + const automaticRecovery = useRef<(initialDelayMs?: number, action?: string) => void>(() => undefined); const deferLiveFadeForRecovery = useRef<() => void>(() => undefined); + const backgroundSuspension = useRef(null); + const playbackLifecycleGeneration = useRef(0); const [liveState, setLiveState] = useState('idle'); const [playingDrop, setPlayingDrop] = useState(null); const [transportStopped, setTransportStopped] = useState(true); @@ -490,7 +505,7 @@ function ListenerPlayerController({ if (!data.fatal) return; deferLiveFadeForRecovery.current(); liveAudio.current?.pause(); - automaticRecovery.current(0); + automaticRecovery.current(0, 'hls-fatal'); }); instance.on(HlsConstructor.Events.FRAG_CHANGED, (_event, data) => { const programStartMs = data.frag.programDateTime; @@ -834,6 +849,7 @@ function ListenerPlayerController({ const attemptLivePlayback = useCallback(async ( forceRefresh = false, verifyExistingLease = false, + expectedLifecycleGeneration = playbackLifecycleGeneration.current, ): Promise => { const audio = liveAudio.current; if (!audio || playbackAttemptRunning.current) return false; @@ -916,6 +932,11 @@ function ListenerPlayerController({ } } + if ( + expectedLifecycleGeneration !== playbackLifecycleGeneration.current + || !wantsLivePlayback.current + || document.visibilityState !== 'visible' + ) return false; const liveSyncPosition = hls.current?.liveSyncPosition; if (typeof liveSyncPosition === 'number' && Number.isFinite(liveSyncPosition)) { audio.currentTime = liveSyncPosition; @@ -923,6 +944,14 @@ function ListenerPlayerController({ seekNativeAudioToLiveEdge(audio); } await audio.play(); + if ( + expectedLifecycleGeneration !== playbackLifecycleGeneration.current + || !wantsLivePlayback.current + || document.visibilityState !== 'visible' + ) { + audio.pause(); + return false; + } return true; } catch { audio.pause(); @@ -1079,10 +1108,13 @@ function ListenerPlayerController({ } }, [dropIns.en, dropIns.es]); - const scheduleAutomaticRecovery = useCallback((initialDelayMs = 0) => { + const scheduleAutomaticRecovery = useCallback(( + initialDelayMs = 0, + action = 'automatic-recovery', + ) => { if (!wantsLivePlayback.current || liveStateRef.current === 'displaced') return; - lastPlaybackAction.current = 'automatic-recovery'; + lastPlaybackAction.current = action; if (playbackAttemptRunning.current) { queuedRecoveryDelay.current = queuedRecoveryDelay.current === null @@ -1093,7 +1125,6 @@ function ListenerPlayerController({ } if (recoveryTimer.current !== null) return; - updateLiveState('recovering'); const runAttempt = (delayMs: number) => { if (!wantsLivePlayback.current) return; if (recoveryAttempts.current >= RECOVERY_DELAYS_MS.length) { @@ -1106,6 +1137,7 @@ function ListenerPlayerController({ recoveryTimer.current = window.setTimeout(async () => { recoveryTimer.current = null; if (!wantsLivePlayback.current) return; + updateLiveState('recovering'); recoveryAttempts.current += 1; const recovered = await attemptLivePlayback(true, true); if (!wantsLivePlayback.current) return; @@ -1161,7 +1193,7 @@ function ListenerPlayerController({ })); reportPresence('idle'); deferLiveFade(); - scheduleAutomaticRecovery(0); + scheduleAutomaticRecovery(0, 'watchdog-recovery'); }, LISTENER_PLAYBACK_WATCHDOG_INTERVAL_MS); return () => window.clearInterval(interval); }, [deferLiveFade, reportPresence, scheduleAutomaticRecovery]); @@ -1185,7 +1217,7 @@ function ListenerPlayerController({ if (queuedRecoveryDelay.current !== null) { const queuedDelay = queuedRecoveryDelay.current; queuedRecoveryDelay.current = null; - scheduleAutomaticRecovery(queuedDelay); + scheduleAutomaticRecovery(queuedDelay, lastPlaybackAction.current); return; } if (played) { @@ -1194,7 +1226,7 @@ function ListenerPlayerController({ updateLiveState('playing'); return; } - scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS); + scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS, 'play-failed'); }, [armLiveFadeIn, attemptLivePlayback, cancelRecovery, pauseDropIns, reportPresence, scheduleAutomaticRecovery, updateLiveState]); function playBeaconOnly() { @@ -1240,6 +1272,8 @@ function ListenerPlayerController({ function stopTransport() { lastPlaybackAction.current = 'stop'; + playbackLifecycleGeneration.current += 1; + backgroundSuspension.current = null; playbackWatchdog.current.reset(); dropGeneration.current += 1; setTransportStopped(true); @@ -1272,16 +1306,17 @@ function ListenerPlayerController({ if (!wantsLivePlayback.current || !['playing', 'recovering'].includes(liveStateRef.current)) { return; } - if (kind === 'suspend') { - // `suspend` commonly means that the browser intentionally stopped - // fetching enough buffered media. It is only supporting evidence; - // stalled/error or a non-progressing page resume drives recovery. + if (kind !== 'error') { + // `stalled` and `suspend` are advisory and can be emitted while a + // healthy forward buffer still exists. A single event must not + // flash “Reconnecting” or rebuild HLS. The media-clock watchdog is + // the authority after a sustained 15-second non-progress interval. nativeSuspendObserved.current = true; return; } reportPresence('idle'); deferLiveFade(); - scheduleAutomaticRecovery(kind === 'error' ? 0 : STALL_RECOVERY_DELAY_MS); + scheduleAutomaticRecovery(0, 'native-error'); }, [deferLiveFade, reportPresence, scheduleAutomaticRecovery]); function handleNativePlaying() { @@ -1319,38 +1354,139 @@ function ListenerPlayerController({ } useEffect(() => { - const recoverAfterResume = () => { - if (document.visibilityState !== 'visible') { - if (wantsLivePlayback.current) deferLiveFade(); + const suspendForBackground = () => { + if (document.visibilityState === 'visible' || backgroundSuspension.current) return; + const introduction = activeDrop.current; + if (!wantsLivePlayback.current && !introduction) return; + + playbackLifecycleGeneration.current += 1; + const rebuildHls = hls.current !== null; + backgroundSuspension.current = { + source: introduction ?? 'beacon', + rebuildHls, + }; + lastPlaybackAction.current = 'background-suspend'; + playbackWatchdog.current.reset(); + cancelRecovery(true); + wantsLivePlayback.current = false; + reportPresence('idle'); + analysisProvider.current?.pauseAnalysis(); + + const live = liveAudio.current; + live?.pause(); + if (introduction) { + dropAudio[introduction].current?.pause(); + cancelLiveFade(); + pendingLiveFade.current = false; + liveSuppressedForDrop.current = true; + if (live) { + live.muted = true; + if (volumeSupported) live.volume = 0; + } + } else { + deferLiveFade(); + } + if (rebuildHls) stopHls(); + updateLiveState('paused'); + }; + + const resumeAfterBackground = async () => { + if (document.visibilityState !== 'visible') return; + const suspension = backgroundSuspension.current; + if (!suspension) { + if (!wantsLivePlayback.current) revalidateIdlePreparedSource(); + return; + } + backgroundSuspension.current = null; + playbackLifecycleGeneration.current += 1; + const lifecycleGeneration = playbackLifecycleGeneration.current; + lastPlaybackAction.current = 'foreground-resume'; + wantsLivePlayback.current = true; + cancelRecovery(true); + updateLiveState('loading'); + + const introduction = suspension.source === 'beacon' + ? null + : dropAudio[suspension.source].current; + const introResume = introduction + ? introduction.play().then(() => true).catch(() => false) + : Promise.resolve(true); + const liveResume = attemptLivePlayback( + suspension.rebuildHls, + true, + lifecycleGeneration, + ); + const [introPlayed, livePlayed] = await Promise.all([introResume, liveResume]); + if ( + lifecycleGeneration !== playbackLifecycleGeneration.current + || document.visibilityState !== 'visible' + ) return; + + if (livePlayed && introPlayed) { + nativeSuspendObserved.current = false; + playbackWatchdog.current.reset(); + setTransportPaused(false); + reportPresence('listening'); + updateLiveState('playing'); + if (!introduction) beginLiveFade(); + else startReactiveAnalysis(`intro-${suspension.source}`); + return; + } + + if (introduction && !introPlayed) { + setTransportPaused(true); + reportPresence('idle'); + updateLiveState('paused'); + return; + } + scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS, 'foreground-resume-failed'); + }; + + const handleVisibility = () => { + if (document.visibilityState === 'visible') void resumeAfterBackground(); + else suspendForBackground(); + }; + const recoverVisibleTransport = () => { + if (document.visibilityState !== 'visible') return; + if (backgroundSuspension.current) { + void resumeAfterBackground(); return; } if (!wantsLivePlayback.current) { revalidateIdlePreparedSource(); return; } - const pausedDuringPendingFade = pendingLiveFade.current - && Boolean(liveAudio.current?.paused); - if (pendingLiveFade.current && liveAudio.current && !liveAudio.current.paused) beginLiveFade(); const leaseNearExpiry = manifestExpiresAt.current <= Date.now() + 30_000; const suspendedWithoutFutureData = nativeSuspendObserved.current && Boolean(liveAudio.current) && (liveAudio.current?.readyState ?? 0) < 3; - if (liveStateRef.current === 'recovering' - || leaseNearExpiry - || suspendedWithoutFutureData - || pausedDuringPendingFade) { - scheduleAutomaticRecovery(0); + if (liveStateRef.current === 'recovering' || leaseNearExpiry || suspendedWithoutFutureData) { + scheduleAutomaticRecovery(0, 'foreground-health-check'); } }; - document.addEventListener('visibilitychange', recoverAfterResume); - window.addEventListener('online', recoverAfterResume); - window.addEventListener('pageshow', recoverAfterResume); + document.addEventListener('visibilitychange', handleVisibility); + window.addEventListener('online', recoverVisibleTransport); + window.addEventListener('pageshow', recoverVisibleTransport); return () => { - document.removeEventListener('visibilitychange', recoverAfterResume); - window.removeEventListener('online', recoverAfterResume); - window.removeEventListener('pageshow', recoverAfterResume); + document.removeEventListener('visibilitychange', handleVisibility); + window.removeEventListener('online', recoverVisibleTransport); + window.removeEventListener('pageshow', recoverVisibleTransport); }; - }, [beginLiveFade, deferLiveFade, revalidateIdlePreparedSource, scheduleAutomaticRecovery]); + }, [ + attemptLivePlayback, + beginLiveFade, + cancelLiveFade, + cancelRecovery, + deferLiveFade, + dropAudio, + reportPresence, + revalidateIdlePreparedSource, + scheduleAutomaticRecovery, + startReactiveAnalysis, + stopHls, + updateLiveState, + volumeSupported, + ]); useEffect(() => { if (!reactiveVisualizationEnabled) return; @@ -1399,7 +1535,7 @@ function ListenerPlayerController({ livePreparedRef.current = false; setLivePrepared(false); setDevicePreparedByGesture(false); - if (wantsLivePlayback.current) scheduleAutomaticRecovery(0); + if (wantsLivePlayback.current) scheduleAutomaticRecovery(0, 'lease-expired'); else void prepareLiveSource(true); return; } @@ -1413,6 +1549,8 @@ function ListenerPlayerController({ }, [cancelRecovery, clearLeaseCursor, prepareLiveSource, probeExistingLease, reportPresence, scheduleAutomaticRecovery, stopHls, updateLiveState]); useEffect(() => () => { + playbackLifecycleGeneration.current += 1; + backgroundSuspension.current = null; wantsLivePlayback.current = false; reportPresence('idle'); cancelRecovery(true); @@ -1502,14 +1640,14 @@ function ListenerPlayerController({ void attemptLivePlayback().then((played) => { if (!wantsLivePlayback.current) return; if (played) updateLiveState('playing'); - else scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS); + else scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS, 'intro-live-start-failed'); }); } await introStarted; if (!isCurrent()) return; if (liveStarted) { void liveStarted.then(() => updateLiveState('playing')).catch(() => { - scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS); + scheduleAutomaticRecovery(STALL_RECOVERY_DELAY_MS, 'intro-native-live-start-failed'); }); } setHasStarted(true); diff --git a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx index b465678d..d9bf8e81 100644 --- a/src/components/early-birds/__tests__/ListenerPlayer.test.tsx +++ b/src/components/early-birds/__tests__/ListenerPlayer.test.tsx @@ -132,6 +132,7 @@ import ListenerPlayer, { beforeEach(() => { vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Google Inc.'); + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); }); afterEach(() => { @@ -586,23 +587,123 @@ describe('EarlyBird Listener player', () => { currentTime: 12, seekable: { length: 1, - end: () => 123.5, + start: () => 20, + end: () => 250, }, } as unknown as HTMLAudioElement; expect(seekNativeAudioToLiveEdge(audio)).toBe(true); - expect(audio.currentTime).toBe(123.25); + expect(audio.currentTime).toBe(130); }); it('keeps a stability-first desktop HLS buffer without enabling low latency', () => { expect(LISTENER_HLS_BUFFER_CONFIG).toMatchObject({ lowLatencyMode: false, - liveSyncDurationCount: 5, - liveMaxLatencyDurationCount: 10, - maxBufferLength: 60, - maxMaxBufferLength: 90, + initialLiveManifestSize: 21, + liveSyncDurationCount: 20, + liveMaxLatencyDurationCount: 45, + liveSyncMode: 'buffered', + startOnSegmentBoundary: true, + maxBufferLength: 120, + maxMaxBufferLength: 180, }); }); + it('does not advertise a reconnect for a transient native stalled event', async () => { + vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); + vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); + vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue(''); + const grant = { + leaseId: '00000000-0000-4000-8000-000000000003', + leaseGeneration: 1, + presenceSequence: 0, + leaseExpiresAt: '2099-08-06T12:03:00.000Z', + stream: { + manifestUrl: '/api/early-birds/stream/manifest?leaseId=stable&leaseGeneration=1', + expiresAt: '2099-08-06T12:03:00.000Z', + }, + }; + vi.stubGlobal('fetch', vi.fn().mockImplementation((_url, init) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { presenceSequence?: number }; + return Promise.resolve(new Response(JSON.stringify({ + ...grant, + presenceSequence: body.presenceSequence ?? 0, + }), { status: 200 })); + })); + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: 'Listen' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument()); + const live = screen.getByLabelText('Beacon'); + fireEvent.stalled(live); + + expect(screen.queryByText('Restoring connection…')).toBeNull(); + expect(hlsHarness.instances).toHaveLength(1); + expect(hlsHarness.instances[0].destroy).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument(); + }); + + it('suspends quota and HLS in background then rejoins with the same lease in foreground', async () => { + const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); + const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); + vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue(''); + const grant = { + leaseId: '00000000-0000-4000-8000-000000000003', + leaseGeneration: 1, + presenceSequence: 0, + leaseExpiresAt: '2099-08-06T12:03:00.000Z', + stream: { + manifestUrl: '/api/early-birds/stream/manifest?leaseId=background&leaseGeneration=1', + expiresAt: '2099-08-06T12:03:00.000Z', + }, + }; + const presenceBodies: Array<{ presence?: string; presenceSequence?: number }> = []; + const fetchMock = vi.fn().mockImplementation((url, init) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { + presence?: string; + presenceSequence?: number; + }; + if (String(url).includes('/heartbeat')) presenceBodies.push(body); + return Promise.resolve(new Response(JSON.stringify({ + ...grant, + presenceSequence: body.presenceSequence ?? 0, + }), { status: 200 })); + }); + vi.stubGlobal('fetch', fetchMock); + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: 'Listen' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument()); + expect(hlsHarness.instances).toHaveLength(1); + + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + fireEvent(document, new Event('visibilitychange')); + await waitFor(() => expect(presenceBodies.some(({ presence }) => presence === 'idle')).toBe(true)); + expect(hlsHarness.instances[0].destroy).toHaveBeenCalledOnce(); + expect(screen.queryByText('Restoring connection…')).toBeNull(); + + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + fireEvent(document, new Event('visibilitychange')); + await waitFor(() => expect(hlsHarness.instances).toHaveLength(2)); + await waitFor(() => expect(presenceBodies.at(-1)).toMatchObject({ presence: 'listening' })); + + expect(fetchMock.mock.calls.filter(([url]) => ( + url === '/api/early-birds/stream/lease' + ))).toHaveLength(1); + expect(hlsHarness.instances[1].loadedSources).toEqual([grant.stream.manifestUrl]); + expect(play).toHaveBeenCalledTimes(2); + expect(pause).toHaveBeenCalled(); + expect(screen.queryByText('Restoring connection…')).toBeNull(); + expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument(); + }); + it('does not trust Chromium native HLS claims while preserving Apple native playback', () => { const audio = { canPlayType: () => 'maybe' } as unknown as HTMLAudioElement; expect(prefersNativeHls(audio, { vendor: 'Google Inc.' })).toBe(false); @@ -654,7 +755,10 @@ describe('EarlyBird Listener player', () => { url === '/api/early-birds/stream/heartbeat' )).length).toBeGreaterThanOrEqual(2); expect(hlsHarness.instances[0].destroy).toHaveBeenCalledOnce(); - expect(hlsHarness.instances[1].loadedSources).toEqual([grants[1].stream.manifestUrl]); + expect(hlsHarness.instances[1].loadedSources).toEqual([grants[0].stream.manifestUrl]); + expect(fetchMock.mock.calls.filter(([url]) => ( + url === '/api/early-birds/stream/lease' + ))).toHaveLength(1); expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument(); }); @@ -719,7 +823,11 @@ describe('EarlyBird Listener player', () => { monotonicNow = 15_000; watchdog!.callback(); - await waitFor(() => expect(hlsHarness.instances).toHaveLength(2)); + // This case deliberately replaces window.setInterval so the watchdog + // can be driven without waiting 15 seconds. Vitest's poller uses the + // real timer; Testing Library's waitFor would otherwise be captured by + // that interval seam and never observe this non-DOM array mutation. + await vi.waitFor(() => expect(hlsHarness.instances).toHaveLength(2)); expect(hlsHarness.instances[0].destroy).toHaveBeenCalledOnce(); expect(hlsHarness.instances[1].loadedSources).toEqual([manifestUrl]); expect(diagnostics).toHaveLength(1); diff --git a/src/components/early-birds/__tests__/listener-presentation.test.ts b/src/components/early-birds/__tests__/listener-presentation.test.ts index 5db7735c..312d96f9 100644 --- a/src/components/early-birds/__tests__/listener-presentation.test.ts +++ b/src/components/early-birds/__tests__/listener-presentation.test.ts @@ -38,6 +38,12 @@ describe('Listener presentation phase', () => { transportStopped: false, liveState: 'recovering', })).toBe('reconnecting'); + expect(deriveListenerPresentationPhase({ + ...baseline, + transportStopped: false, + liveState: 'recovering', + playingDrop: 'en', + })).toBe('intro'); }); it('gives access and transport failures precedence', () => { diff --git a/src/components/early-birds/listener-presentation.ts b/src/components/early-birds/listener-presentation.ts index 68950a04..ea035739 100644 --- a/src/components/early-birds/listener-presentation.ts +++ b/src/components/early-birds/listener-presentation.ts @@ -26,10 +26,13 @@ export function deriveListenerPresentationPhase({ }): ListenerPresentationPhase { if (liveState === 'displaced') return 'displaced'; if (liveState === 'error') return 'unavailable'; - if (liveState === 'recovering') return 'reconnecting'; if (transportPaused) return 'paused'; - if (transportStopped) return hasStarted ? 'stopped' : 'ready'; + // The introduction remains the audible source while the hidden Beacon + // pipeline is repaired. Its state is more truthful than a reconnect label + // for media the listener cannot hear yet. if (playingDrop) return 'intro'; + if (liveState === 'recovering') return 'reconnecting'; + if (transportStopped) return hasStarted ? 'stopped' : 'ready'; if (liveState === 'playing') return 'beacon'; if (livePreparing || liveState === 'loading') return 'preparing'; return hasStarted ? 'stopped' : 'ready'; diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts index 52a21b71..c1174396 100644 --- a/src/lib/listener/__tests__/media-boundary.test.ts +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -5,13 +5,14 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const MEDIA_FILE_SHA256 = { - // Reviewed and re-pinned for the non-acoustic playback liveness recovery. - // A privacy-safe media-clock watchdog rebuilds the single hls.js instance - // after 15 seconds without progress and ordinary recovery now reattaches a - // verified lease even when its manifest URL is unchanged. HLS parameters, - // source URLs, media assets, element gain/fades, AudioContext and event - // audio remain unchanged. - 'src/components/early-birds/ListenerPlayer.tsx': '8aa13c93b886ccf51aebead3b960fe7f6478933fed101dd6c296a524f0001234', + // Reviewed and re-pinned for Nico's stability-first Listener policy. The + // Listener-only HLS window/target changes from 60/90 seconds to a two-minute + // start target and three-minute maximum, native HLS uses the same margin, + // advisory stalls no longer rebuild a healthy buffer, and hidden documents + // explicitly pause before same-lease foreground recovery. Source URLs, + // media assets, codec, element gain/fades, AudioContext and event audio are + // unchanged. + 'src/components/early-birds/ListenerPlayer.tsx': '70a0bf3b72acf37f69974f97feed2bbd53499ad9e62b4eab18b2c7e0a7a96bc5', 'src/lib/early-birds/stream.ts': '96a2d9fe798591833327631b59a73a5b2fc5ca06be7081945a0b07450970da84', 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', 'src/app/api/early-birds/stream/lease/route.ts': 'ec0e8780387bc1f493eb33d13a2d90e01cfdb6d899fc6232e04f51aaf2dfc508', From 3526f2598f6558eacd2200855143a0ed17921ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 16:44:51 -0300 Subject: [PATCH 240/376] docs(listener): distinguish latency from startup --- docs/operations/LISTENER_AUDIO_CONTINUITY.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/operations/LISTENER_AUDIO_CONTINUITY.md b/docs/operations/LISTENER_AUDIO_CONTINUITY.md index 4e5230b8..62e1b44e 100644 --- a/docs/operations/LISTENER_AUDIO_CONTINUITY.md +++ b/docs/operations/LISTENER_AUDIO_CONTINUITY.md @@ -41,9 +41,10 @@ Listener is not a low-latency product. The canonical origin retains fifty six-second entries (approximately five minutes) and a fresh browser starts approximately twenty entries (two minutes) behind the live edge. hls.js may hold up to three minutes of forward media; native HLS seeks to the same -two-minute target rather than sitting directly on the edge. This costs about -4.8 MB of initial media at 320 kbit/s and intentionally trades latency for -continuity. +two-minute target rather than sitting directly on the edge. A full two-minute +forward buffer is about 4.8 MB at 320 kbit/s, but playback does not wait for the +whole buffer to fill. The trade is approximately two minutes of program delay, +not two 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 From 03ba82fe842c7eef6c7afaf77261c9794671bcfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 17:34:18 -0300 Subject: [PATCH 241/376] feat(listener): decouple authorized audio delivery --- docs/architecture/EARLY_BIRDS_LISTENER.md | 21 +-- ...3-deterministic-hls-and-audio-guardrail.md | 19 ++- docs/operations/LISTENER_AUDIO_CONTINUITY.md | 33 +++-- docs/plans/EARLY_BIRDS.md | 12 +- ops/early-birds-preview/compose.yml | 7 + .../stream.harmonicbeacon.com.conf.template | 4 + .../preview.env.synthetic.example | 1 + .../test/preview-contract.test.mjs | 12 ++ scripts/early-birds-preview/lib.sh | 2 + scripts/early-birds-preview/validate.mjs | 10 +- services/beacon-stream/docker-compose.yml | 4 + services/beacon-stream/src/control-auth.mjs | 23 ++++ services/beacon-stream/src/manifest.mjs | 15 +- services/beacon-stream/src/media-grants.mjs | 71 ++++++++++ services/beacon-stream/src/server.mjs | 75 +++++++++- services/beacon-stream/test/manifest.test.mjs | 25 ++++ .../beacon-stream/test/media-grants.test.mjs | 24 ++++ services/beacon-stream/test/server.test.mjs | 73 ++++++++++ .../stream/heartbeat/__tests__/route.test.ts | 7 +- .../stream/lease/__tests__/route.test.ts | 7 +- .../api/health/__tests__/ready-route.test.ts | 20 +++ src/app/api/health/ready/route.ts | 11 ++ .../__tests__/stream-contract.test.ts | 77 +++++++++-- .../__tests__/stream-lease.test.ts | 12 +- src/lib/early-birds/stream.ts | 129 +++++++++++++++--- .../listener/__tests__/media-boundary.test.ts | 10 +- 26 files changed, 614 insertions(+), 90 deletions(-) create mode 100644 services/beacon-stream/src/control-auth.mjs create mode 100644 services/beacon-stream/src/media-grants.mjs create mode 100644 services/beacon-stream/test/media-grants.test.mjs diff --git a/docs/architecture/EARLY_BIRDS_LISTENER.md b/docs/architecture/EARLY_BIRDS_LISTENER.md index 94d9b96c..6a6dd5ea 100644 --- a/docs/architecture/EARLY_BIRDS_LISTENER.md +++ b/docs/architecture/EARLY_BIRDS_LISTENER.md @@ -196,15 +196,18 @@ 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. The -browser plays a stable same-origin URL under `/api/early-birds/stream/manifest`; the route rechecks -the authenticated account, current membership and non-evicted lease before proxying a short-lived -origin manifest with `private, no-store` behavior. - -The origin signature is HMAC-SHA-256 base64url over the exact bytes -`GET\n/v1/hls/{artifactId}/live.m3u8\n{unix_expiry}`. Expiry never exceeds the lease or ten minutes. -The origin manifest must contain individually signed, same-origin segment URLs. Signing material and -signed URLs are never returned in API JSON or logged. +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 diff --git a/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md b/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md index c7eb4206..a01df35a 100644 --- a/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md +++ b/docs/decisions/0003-deterministic-hls-and-audio-guardrail.md @@ -10,13 +10,18 @@ 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 a stable, same-origin lease-manifest route. That route rechecks the -session, current membership and device lease on every manifest refresh, then -proxies a very short-lived origin manifest whose segment URLs are individually -signed. Native players therefore never need an `audio.src` replacement merely -to refresh authorization. Signatures cover method, canonical path and expiry, -use constant-time comparison and are never logged. Public health is minimal; -metrics bind privately. +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 diff --git a/docs/operations/LISTENER_AUDIO_CONTINUITY.md b/docs/operations/LISTENER_AUDIO_CONTINUITY.md index 62e1b44e..3991da24 100644 --- a/docs/operations/LISTENER_AUDIO_CONTINUITY.md +++ b/docs/operations/LISTENER_AUDIO_CONTINUITY.md @@ -85,12 +85,22 @@ 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, interrupt only the Listener -control plane for less than 30 seconds while the independent origin remains -healthy. The same client must reconnect at the configured buffered live -position with one audible source and one quota presence interval. Do not -perform this exercise while an event is active and do not restart the origin -to simulate it. +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 two-minute playback +buffer, a failure immediately after renewal can preserve roughly five 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 @@ -98,16 +108,11 @@ 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. cache immutable media segments at a CDN/edge and keep manifests private and - short-lived; -2. remove the Listener application/database from the segment hot path after a - bounded authorization decision, so control-plane latency cannot interrupt - already-authorized audio; -3. publish the identical encoded timeline from at least two failure domains +1. publish the identical encoded timeline from at least two failure domains and provide tested client/playlist failover without overlapping audio; -4. run synthetic audio canaries from North America, Europe and Latin America, +2. run synthetic audio canaries from North America, Europe and Latin America, and retain low-cardinality, non-PII browser recovery causes; -5. define and gate on interruption-free session rate, rebuffer ratio, join +3. define and gate on interruption-free session rate, rebuffer ratio, join success and recovery time, including multi-hour network and origin-failure drills. diff --git a/docs/plans/EARLY_BIRDS.md b/docs/plans/EARLY_BIRDS.md index 35c8762e..c469557b 100644 --- a/docs/plans/EARLY_BIRDS.md +++ b/docs/plans/EARLY_BIRDS.md @@ -289,12 +289,12 @@ promotion, never an accidental restart side effect. - 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 same-origin lease-manifest URL. Each refresh - rechecks session, membership and device lease before proxying a fresh signed - origin manifest, so authorization refresh never replaces the media source. -- The manifest embeds individually signed segment URLs; signatures cover HTTP - method, canonical path and expiry, are compared in constant time and are - never logged. +- 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. diff --git a/ops/early-birds-preview/compose.yml b/ops/early-birds-preview/compose.yml index 9463934f..1de21ba1 100644 --- a/ops/early-birds-preview/compose.yml +++ b/ops/early-birds-preview/compose.yml @@ -82,6 +82,7 @@ services: 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} @@ -119,6 +120,7 @@ services: networks: - preview_db - listener_egress + - stream_control depends_on: postgres: { condition: service_healthy } migration: { condition: service_completed_successfully } @@ -175,6 +177,11 @@ networks: # 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: diff --git a/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template b/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template index a8bfc422..fbbff21a 100644 --- a/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template +++ b/ops/early-birds-preview/nginx/stream.harmonicbeacon.com.conf.template @@ -38,6 +38,10 @@ server { } 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; diff --git a/ops/early-birds-preview/preview.env.synthetic.example b/ops/early-birds-preview/preview.env.synthetic.example index 2ad50113..407ad799 100644 --- a/ops/early-birds-preview/preview.env.synthetic.example +++ b/ops/early-birds-preview/preview.env.synthetic.example @@ -71,6 +71,7 @@ EARLY_BIRDS_BEACON_SERVICE_KEY_CURRENT=synthetic-preview-inbound-token-at-least- # 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 diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 185ffee1..62de3a9e 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -287,6 +287,18 @@ test('stream publishes only through a dedicated edge network', async () => { 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 () => { diff --git a/scripts/early-birds-preview/lib.sh b/scripts/early-birds-preview/lib.sh index d5786ac0..c4127877 100755 --- a/scripts/early-birds-preview/lib.sh +++ b/scripts/early-birds-preview/lib.sh @@ -116,6 +116,7 @@ require_synthetic_env() { 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 @@ -197,6 +198,7 @@ require_synthetic_env() { 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) ;; diff --git a/scripts/early-birds-preview/validate.mjs b/scripts/early-birds-preview/validate.mjs index ac6f0ea2..df93898c 100644 --- a/scripts/early-birds-preview/validate.mjs +++ b/scripts/early-birds-preview/validate.mjs @@ -45,6 +45,7 @@ const syntheticEnv = [ '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', @@ -128,7 +129,8 @@ try { 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']); + 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); @@ -145,7 +147,9 @@ try { 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_edge', 'stream_observability']); + 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); @@ -175,7 +179,7 @@ try { 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', + 'authority_private', 'listener_egress', 'preview_db', 'stream_control', ]); assert.deepEqual( authorityResolved.services.listener.networks.authority_private.aliases, diff --git a/services/beacon-stream/docker-compose.yml b/services/beacon-stream/docker-compose.yml index 581be7af..36184cb3 100644 --- a/services/beacon-stream/docker-compose.yml +++ b/services/beacon-stream/docker-compose.yml @@ -26,6 +26,7 @@ services: # `internal` network. This separate edge bridge permits the explicit # loopback binding above without exposing private readiness/metrics. - stream_edge + - stream_control deploy: resources: limits: @@ -46,3 +47,6 @@ networks: internal: true stream_edge: name: earlybirds_stream_edge + stream_control: + name: earlybirds_stream_control_internal + internal: true 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/manifest.mjs b/services/beacon-stream/src/manifest.mjs index e94049ff..a692605a 100644 --- a/services/beacon-stream/src/manifest.mjs +++ b/services/beacon-stream/src/manifest.mjs @@ -39,6 +39,7 @@ export function renderManifest({ 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'); @@ -62,9 +63,19 @@ export function renderManifest({ '#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="${signedUrl({ origin, secret, pathname, expiresAt })}"`); + lines.push(`#EXT-X-MAP:URI="${mediaUrl(pathname)}"`); } for (let sequence = firstSequence; sequence <= edgeSequence; sequence += 1) { @@ -74,7 +85,7 @@ export function renderManifest({ 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(signedUrl({ origin, secret, pathname, expiresAt })); + 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/server.mjs b/services/beacon-stream/src/server.mjs index 4b39507b..e34e8f58 100644 --- a/services/beacon-stream/src/server.mjs +++ b/services/beacon-stream/src/server.mjs @@ -3,7 +3,9 @@ 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 = {}) { @@ -62,8 +64,33 @@ function authorized({ request, url, secret }) { }); } +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'; @@ -75,7 +102,7 @@ function mediaContentType(file) { return 'application/octet-stream'; } -export function createPublicHandler({ artifactRoot, metadata, publicOrigin, signingSecret, allowedOrigins = new Set(), metrics = new Metrics(), now = () => Date.now() }) { +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/`; @@ -90,6 +117,40 @@ export function createPublicHandler({ artifactRoot, metadata, publicOrigin, sign 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' }); @@ -101,11 +162,12 @@ export function createPublicHandler({ artifactRoot, metadata, publicOrigin, sign return; } if (url.pathname === manifestPath) { - if (!authorized({ request, url, secret: signingSecret })) { + 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, @@ -114,6 +176,7 @@ export function createPublicHandler({ artifactRoot, metadata, publicOrigin, sign // 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); @@ -124,7 +187,7 @@ export function createPublicHandler({ artifactRoot, metadata, publicOrigin, sign return; } if (url.pathname.startsWith(segmentPrefix)) { - if (!authorized({ request, url, secret: signingSecret })) { + if (!authorizedMedia({ request, url, secret: signingSecret, mediaGrants })) { status = 403; respond(status, 'forbidden\n', { 'Cache-Control': 'no-store' }); return; @@ -154,10 +217,10 @@ export function createPublicHandler({ artifactRoot, metadata, publicOrigin, sign } status = 404; respond(status, 'not found\n', { 'Cache-Control': 'no-store' }); - } catch { + } catch (error) { // Do not expose filesystem paths, credentials or signed URLs. - status = 500; - if (!response.headersSent) respond(status, 'internal server error\n', { 'Cache-Control': 'no-store' }); + 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) }); } diff --git a/services/beacon-stream/test/manifest.test.mjs b/services/beacon-stream/test/manifest.test.mjs index 36c71e20..142d0b7c 100644 --- a/services/beacon-stream/test/manifest.test.mjs +++ b/services/beacon-stream/test/manifest.test.mjs @@ -54,6 +54,31 @@ test('never signs a segment beyond the inbound manifest authorization horizon', } }); +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; 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 index 73630a21..b2545966 100644 --- a/services/beacon-stream/test/server.test.mjs +++ b/services/beacon-stream/test/server.test.mjs @@ -4,6 +4,7 @@ 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); @@ -111,3 +112,75 @@ test('publishes readiness and Prometheus metrics only on the internal listener', 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/api/early-birds/stream/heartbeat/__tests__/route.test.ts b/src/app/api/early-birds/stream/heartbeat/__tests__/route.test.ts index 8561ae7f..284d15fd 100644 --- a/src/app/api/early-birds/stream/heartbeat/__tests__/route.test.ts +++ b/src/app/api/early-birds/stream/heartbeat/__tests__/route.test.ts @@ -77,7 +77,8 @@ describe('EarlyBird stream heartbeat route', () => { }); }); - it('returns a renewed same-origin grant for an active lease', async () => { + 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', @@ -86,14 +87,14 @@ describe('EarlyBird stream heartbeat route', () => { presenceSequence: 3, leaseExpiresAt: new Date('2026-08-06T12:03:00.000Z'), stream: { - manifestUrl: `/api/early-birds/stream/manifest?leaseId=${LEASE_ID}`, + 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: `/api/early-birds/stream/manifest?leaseId=${LEASE_ID}` }, + stream: { manifestUrl: directGrant }, }); expect(mocks.heartbeatEarlyBirdStreamLease).toHaveBeenCalledWith( 'listener-1', LEASE_ID, 2, 3, undefined, undefined, true, 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 index d0965552..5624d8db 100644 --- a/src/app/api/early-birds/stream/lease/__tests__/route.test.ts +++ b/src/app/api/early-birds/stream/lease/__tests__/route.test.ts @@ -69,7 +69,7 @@ describe('EarlyBird stream lease route', () => { expect(acquireEarlyBirdStreamLease).not.toHaveBeenCalled(); }); - it('returns only the stable same-origin manifest grant', async () => { + 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'), @@ -81,7 +81,7 @@ describe('EarlyBird stream lease route', () => { 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', + 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'), }, }); @@ -89,7 +89,8 @@ describe('EarlyBird stream lease route', () => { expect(response.status).toBe(200); const body = await response.json(); expect(body.evictedAnotherDevice).toBe(true); - expect(body.stream.manifestUrl).toMatch(/^\/api\/early-birds\/stream\/manifest/); + 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='); }); diff --git a/src/app/api/health/__tests__/ready-route.test.ts b/src/app/api/health/__tests__/ready-route.test.ts index e8def086..3174b6a5 100644 --- a/src/app/api/health/__tests__/ready-route.test.ts +++ b/src/app/api/health/__tests__/ready-route.test.ts @@ -51,6 +51,26 @@ describe('GET /api/health/ready', () => { } }); + 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() }; diff --git a/src/app/api/health/ready/route.ts b/src/app/api/health/ready/route.ts index 01ca9966..8f002bdc 100644 --- a/src/app/api/health/ready/route.ts +++ b/src/app/api/health/ready/route.ts @@ -3,6 +3,7 @@ import { prisma } from '@/lib/db'; import { redactError } from '@/lib/redact'; import { ListenerRuntimeEnvironmentError, + listenerRuntimeFlag, validateListenerRuntimeEnvironment, } from '@/lib/listener/runtime-env'; import { @@ -14,6 +15,11 @@ import { ListenerWithdrawalConfigurationError, listenerWithdrawalConfiguration, } from '@/lib/listener/consumer-withdrawal'; +import { + earlyBirdOriginConfig, + earlyBirdStreamControlOrigin, + EarlyBirdStreamIssuerUnavailableError, +} from '@/lib/early-birds/stream'; export const dynamic = 'force-dynamic'; @@ -33,12 +39,17 @@ export async function GET() { let listenerWithdrawalConfigured = false; try { listenerRuntimeConfigured = validateListenerRuntimeEnvironment(); + if (listenerRuntimeFlag('ENABLED')) { + earlyBirdOriginConfig(); + earlyBirdStreamControlOrigin(); + } validateListenerLiveWorkbenchEnvironment(); listenerWithdrawalConfigured = listenerWithdrawalConfiguration().enabled; } 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); diff --git a/src/lib/early-birds/__tests__/stream-contract.test.ts b/src/lib/early-birds/__tests__/stream-contract.test.ts index 0a57ff22..1dc4269f 100644 --- a/src/lib/early-birds/__tests__/stream-contract.test.ts +++ b/src/lib/early-birds/__tests__/stream-contract.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { - EARLY_BIRD_LEASE_MANIFEST_PATH, + EnvironmentManifestIssuer, earlyBirdOriginConfig, earlyBirdOriginManifestPath, - earlyBirdStreamUrlIssuer, + earlyBirdStreamControlOrigin, + signEarlyBirdStreamControlRequest, EarlyBirdStreamIssuerUnavailableError, setEarlyBirdStreamUrlIssuerForTests, signEarlyBirdOriginPath, @@ -32,6 +33,17 @@ describe('Beacon origin signing contract', () => { expect(beaconSignature).toBe(signPath(input)); }); + it('matches the origin grant-control request signer byte for byte', async () => { + const { signControlRequest } = await import('../../../../services/beacon-stream/src/control-auth.mjs'); + const input = { + secret: SECRET, + pathname: `/internal/v1/listener/media-grants/${'a'.repeat(64)}`, + timestamp: 1_800_000_000, + body: '{"tokenSha256":"abc","expiresAtMs":1800000180000}', + }; + expect(signEarlyBirdStreamControlRequest(input)).toBe(signControlRequest(input)); + }); + it('caps origin authorization at the lease horizon and emits exp/sig only server-side', () => { const config = { origin: 'https://stream.example.test', @@ -56,6 +68,14 @@ describe('Beacon origin signing contract', () => { EARLY_BIRDS_STREAM_ARTIFACT_ID: '../escape', EARLY_BIRDS_STREAM_SIGNING_SECRET: SECRET, } as NodeJS.ProcessEnv)).toThrow(EarlyBirdStreamIssuerUnavailableError); + expect(() => earlyBirdStreamControlOrigin({ + NODE_ENV: 'production', + EARLY_BIRDS_STREAM_CONTROL_ORIGIN: 'https://external.example.test', + } as NodeJS.ProcessEnv)).toThrow(EarlyBirdStreamIssuerUnavailableError); + expect(earlyBirdStreamControlOrigin({ + NODE_ENV: 'production', + EARLY_BIRDS_STREAM_CONTROL_ORIGIN: 'http://beacon-stream:8080', + } as NodeJS.ProcessEnv)).toBe('http://beacon-stream:8080'); expect(() => earlyBirdOriginConfig({ NODE_ENV: 'production', EARLY_BIRDS_STREAM_ORIGIN: 'http://stream.example.test', @@ -84,19 +104,52 @@ describe('Beacon origin signing contract', () => { expect(validSignedOriginManifest(manifest.replace('&sig=map-signature', ''), config)).toBe(false); }); - it('gives browsers only the stable same-origin lease manifest URL', async () => { - 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', SECRET); - const grant = await earlyBirdStreamUrlIssuer().issue({ + it('registers a stable opaque grant privately and gives the browser a direct origin URL', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + const issuer = new EnvironmentManifestIssuer({ + NODE_ENV: 'production', + EARLY_BIRDS_STREAM_ORIGIN: 'https://stream.example.test', + EARLY_BIRDS_STREAM_CONTROL_ORIGIN: 'http://beacon-stream:8080', + EARLY_BIRDS_STREAM_ARTIFACT_ID: 'approved-v1', + EARLY_BIRDS_STREAM_SIGNING_SECRET: SECRET, + } as NodeJS.ProcessEnv, fetchMock); + const issuedAt = new Date('2027-01-15T08:00:00.000Z'); + const request = { accountId: 'listener-1', leaseId: '00000000-0000-4000-8000-000000000111', leaseGeneration: 7, - issuedAt: new Date(), - leaseExpiresAt: new Date(Date.now() + 60_000), - }); - expect(grant.manifestUrl).toBe(`${EARLY_BIRD_LEASE_MANIFEST_PATH}?leaseId=00000000-0000-4000-8000-000000000111&leaseGeneration=7`); + issuedAt, + leaseExpiresAt: new Date(issuedAt.getTime() + 180_000), + }; + const grant = await issuer.issue(request); + const renewed = await issuer.issue({ ...request, issuedAt: new Date(issuedAt.getTime() + 60_000) }); + expect(grant.manifestUrl).toBe(renewed.manifestUrl); + const browserUrl = new URL(grant.manifestUrl); + expect(browserUrl.origin).toBe('https://stream.example.test'); + expect(browserUrl.pathname).toBe('/v1/hls/approved-v1/live.m3u8'); + expect(browserUrl.searchParams.get('grantId')).toMatch(/^[a-f0-9]{64}$/); + expect(browserUrl.searchParams.get('grant')).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(grant.manifestUrl).not.toContain('sig='); - expect(grant.manifestUrl).not.toContain('stream.example.test'); + expect(grant.manifestUrl).not.toContain(request.leaseId); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [controlUrl, init] = fetchMock.mock.calls[0]; + expect(String(controlUrl)).toMatch(/^http:\/\/beacon-stream:8080\/internal\/v1\/listener\/media-grants\/[a-f0-9]{64}$/); + expect(init.body).not.toContain('listener-1'); + expect(init.body).not.toContain(request.leaseId); + expect(init.headers['x-beacon-control-signature']).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it('fails closed when the private grant registry is unavailable', async () => { + const issuer = new EnvironmentManifestIssuer({ + NODE_ENV: 'test', + EARLY_BIRDS_STREAM_ORIGIN: 'https://stream.example.test', + EARLY_BIRDS_STREAM_CONTROL_ORIGIN: 'http://control.example.test', + EARLY_BIRDS_STREAM_ARTIFACT_ID: 'approved-v1', + EARLY_BIRDS_STREAM_SIGNING_SECRET: SECRET, + } as NodeJS.ProcessEnv, vi.fn().mockResolvedValue(new Response(null, { status: 503 }))); + await expect(issuer.issue({ + accountId: 'listener-1', leaseId: crypto.randomUUID(), leaseGeneration: 1, + issuedAt: new Date(), leaseExpiresAt: new Date(Date.now() + 60_000), + })).rejects.toBeInstanceOf(EarlyBirdStreamIssuerUnavailableError); }); }); diff --git a/src/lib/early-birds/__tests__/stream-lease.test.ts b/src/lib/early-birds/__tests__/stream-lease.test.ts index b3adaabf..6fa0aea3 100644 --- a/src/lib/early-birds/__tests__/stream-lease.test.ts +++ b/src/lib/early-birds/__tests__/stream-lease.test.ts @@ -159,7 +159,7 @@ describe('quota-aware two-connection leases', () => { expect(tx.earlyBirdStreamLease.updateMany).not.toHaveBeenCalled(); }); - it('rolls back play+anchor when deterministic manifest issuance fails in-transaction', async () => { + it('revokes the exact committed lease generation if private grant issuance fails', async () => { quotaMocks.settleLockedEarlyBirdQuota .mockResolvedValueOnce(unstarted) .mockResolvedValueOnce(listening); @@ -167,7 +167,15 @@ describe('quota-aware two-connection leases', () => { await expect(acquireEarlyBirdStreamLease( 'listener-1', 'device_abcdefghijklmnopqrstuvwxyz', NOW, failingIssuer, )).rejects.toThrow('config'); - expect(prisma.earlyBirdStreamLease.updateMany).not.toHaveBeenCalled(); + expect(prisma.earlyBirdStreamLease.updateMany).toHaveBeenCalledWith({ + where: { id: LEASE_ID, accountId: 'listener-1', generation: 1 }, + data: { + evictedAt: NOW, + presence: 'IDLE', + presenceUpdatedAt: NOW, + expiresAt: NOW, + }, + }); }); it('commits exhausted settlement before denying manifest authorization', async () => { diff --git a/src/lib/early-birds/stream.ts b/src/lib/early-birds/stream.ts index 12a5de9c..661570e8 100644 --- a/src/lib/early-birds/stream.ts +++ b/src/lib/early-birds/stream.ts @@ -1,4 +1,4 @@ -import { createHmac } from 'node:crypto'; +import { createHash, createHmac } from 'node:crypto'; import type { Prisma } from '@prisma/client'; @@ -20,8 +20,8 @@ export const EARLY_BIRD_MAX_STREAM_DEVICES = 2; export const EARLY_BIRD_LEASE_TTL_MS = 3 * 60 * 1000; export const EARLY_BIRD_ORIGIN_MAX_SIGNATURE_TTL_SECONDS = 10 * 60; export const EARLY_BIRD_ORIGIN_MANIFEST_TTL_SECONDS = 60; -export const EARLY_BIRD_LEASE_MANIFEST_PATH = '/api/early-birds/stream/manifest'; export const EARLY_BIRD_FREE_FOR_ALL_ACCOUNT_ID = 'early-birds-free-for-all'; +export const EARLY_BIRD_STREAM_CONTROL_TIMEOUT_MS = 3_000; export type ListenerLeasePresence = { state: 'IDLE' | 'LISTENING'; @@ -60,18 +60,61 @@ export class EarlyBirdStreamIssuerUnavailableError extends Error { } } -class EnvironmentManifestIssuer implements EarlyBirdStreamUrlIssuer { +export class EnvironmentManifestIssuer implements EarlyBirdStreamUrlIssuer { + constructor( + private readonly environment: NodeJS.ProcessEnv = process.env, + private readonly fetchImpl: typeof fetch = fetch, + ) {} + async issue(request: StreamUrlIssueRequest): Promise { - // Validate the origin integration at lease issuance, but expose only a - // stable same-origin URL to the browser. The route signs and refreshes - // the upstream manifest on every HLS poll. - earlyBirdOriginConfig(); + const config = earlyBirdOriginConfig(this.environment); + const controlOrigin = earlyBirdStreamControlOrigin(this.environment); + const identity = `${request.leaseId}:${request.leaseGeneration}`; + const grantId = createHmac('sha256', config.signingSecret) + .update(`listener-media-grant-id:v1:${identity}`, 'utf8') + .digest('hex'); + const grantToken = createHmac('sha256', config.signingSecret) + .update(`listener-media-grant-token:v1:${identity}`, 'utf8') + .digest('base64url'); + const pathname = `/internal/v1/listener/media-grants/${grantId}`; + const body = JSON.stringify({ + tokenSha256: createHash('sha256').update(grantToken, 'utf8').digest('hex'), + expiresAtMs: request.leaseExpiresAt.getTime(), + }); + const timestamp = Math.floor(request.issuedAt.getTime() / 1000); + const signature = signEarlyBirdStreamControlRequest({ + secret: config.signingSecret, + pathname, + timestamp, + body, + }); + let response: Response; + try { + response = await this.fetchImpl(new URL(pathname, controlOrigin), { + method: 'PUT', + headers: { + 'content-type': 'application/json', + 'x-beacon-control-timestamp': String(timestamp), + 'x-beacon-control-signature': signature, + }, + body, + cache: 'no-store', + signal: AbortSignal.timeout(EARLY_BIRD_STREAM_CONTROL_TIMEOUT_MS), + }); + } catch { + throw new EarlyBirdStreamIssuerUnavailableError(); + } + if (response.status !== 204) throw new EarlyBirdStreamIssuerUnavailableError(); + + const manifestUrl = new URL(earlyBirdOriginManifestPath(config.artifactId), config.origin); + manifestUrl.searchParams.set('grantId', grantId); + manifestUrl.searchParams.set('grant', grantToken); const query = new URLSearchParams({ - leaseId: request.leaseId, - leaseGeneration: String(request.leaseGeneration), + grantId, + grant: grantToken, }); return { - manifestUrl: `${EARLY_BIRD_LEASE_MANIFEST_PATH}?${query}`, + manifestUrl: `${manifestUrl.origin}${manifestUrl.pathname}?${query}`, expiresAt: request.leaseExpiresAt, }; } @@ -176,6 +219,36 @@ export type EarlyBirdOriginConfig = { signingSecret: string; }; +export function earlyBirdStreamControlOrigin( + environment: NodeJS.ProcessEnv = process.env, +): string { + const value = environment.EARLY_BIRDS_STREAM_CONTROL_ORIGIN?.trim(); + if (!value) throw new EarlyBirdStreamIssuerUnavailableError(); + let parsed: URL; + try { parsed = new URL(value); } catch { throw new EarlyBirdStreamIssuerUnavailableError(); } + if (!['http:', 'https:'].includes(parsed.protocol) + || parsed.username || parsed.password || parsed.pathname !== '/' + || parsed.search || parsed.hash) { + throw new EarlyBirdStreamIssuerUnavailableError(); + } + if (environment.NODE_ENV === 'production' + && !(parsed.protocol === 'http:' && parsed.hostname === 'beacon-stream')) { + throw new EarlyBirdStreamIssuerUnavailableError(); + } + return parsed.origin; +} + +export function signEarlyBirdStreamControlRequest(input: { + secret: string; + pathname: string; + timestamp: number; + body: string; +}): string { + const bodyHash = createHash('sha256').update(input.body).digest('hex'); + const canonical = `PUT\n${input.pathname}\n${bodyHash}\n${input.timestamp}`; + return createHmac('sha256', input.secret).update(canonical).digest('base64url'); +} + export function earlyBirdOriginConfig( environment: NodeJS.ProcessEnv = process.env, ): EarlyBirdOriginConfig { @@ -398,13 +471,6 @@ async function acquireEarlyBirdStreamLeaseWithMode( data: { expiresAt: leaseExpiresAt }, }); } - const stream = await issuer.issue({ - accountId, - leaseId: current.id, - leaseGeneration: current.generation, - issuedAt: now, - leaseExpiresAt, - }); return { kind: 'ok' as const, current, @@ -412,20 +478,45 @@ async function acquireEarlyBirdStreamLeaseWithMode( leaseExpiresAt, serverNow: now, access: accessAfter, - stream, }; }); if (lease.kind === 'denied') throw new EarlyBirdAccessDeniedError(); if (lease.kind === 'capacity') throw new EarlyBirdDeviceCapacityError(); + let stream: StreamUrlGrant; + try { + stream = await issuer.issue({ + accountId, + leaseId: lease.current.id, + leaseGeneration: lease.current.generation, + issuedAt: lease.serverNow, + leaseExpiresAt: lease.leaseExpiresAt, + }); + } catch (error) { + await prisma.earlyBirdStreamLease.updateMany({ + where: { + id: lease.current.id, + accountId, + generation: lease.current.generation, + }, + data: { + evictedAt: lease.serverNow, + presence: 'IDLE', + presenceUpdatedAt: lease.serverNow, + expiresAt: lease.serverNow, + }, + }); + throw error; + } + return { leaseId: lease.current.id, leaseGeneration: lease.current.generation, presenceSequence: lease.current.presenceSequence, leaseExpiresAt: lease.leaseExpiresAt, evictedLeaseId: lease.evictedLeaseId, - stream: lease.stream, + stream, serverNow: lease.serverNow, accessKind: lease.access.kind, quota: lease.access.quota ? serializeEarlyBirdQuotaSnapshot(lease.access.quota) : null, diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts index c1174396..574ee824 100644 --- a/src/lib/listener/__tests__/media-boundary.test.ts +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -9,11 +9,13 @@ const MEDIA_FILE_SHA256 = { // Listener-only HLS window/target changes from 60/90 seconds to a two-minute // start target and three-minute maximum, native HLS uses the same margin, // advisory stalls no longer rebuild a healthy buffer, and hidden documents - // explicitly pause before same-lease foreground recovery. Source URLs, - // media assets, codec, element gain/fades, AudioContext and event audio are - // unchanged. + // explicitly pause before same-lease foreground recovery. The stream core + // was re-reviewed again for the bounded direct-origin grant: it moves only + // authorization/network issuance out of the DB transaction and leaves all + // player, signal, codec, buffer, gain/fade, AudioContext, asset and event + // audio behavior unchanged. 'src/components/early-birds/ListenerPlayer.tsx': '70a0bf3b72acf37f69974f97feed2bbd53499ad9e62b4eab18b2c7e0a7a96bc5', - 'src/lib/early-birds/stream.ts': '96a2d9fe798591833327631b59a73a5b2fc5ca06be7081945a0b07450970da84', + 'src/lib/early-birds/stream.ts': '40c5a17d27e8d6f820251d96dd33cd2bbbd83b164e50a212acf8fd0bb158c51c', 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', 'src/app/api/early-birds/stream/lease/route.ts': 'ec0e8780387bc1f493eb33d13a2d90e01cfdb6d899fc6232e04f51aaf2dfc508', 'src/app/api/early-birds/stream/manifest/route.ts': '56d8266fd8144c1e3ee13b168115f0a814a1da9f0a9f2db8b33e33facbba094e', From 7804c02951abf329d7691d2d269a923e4918fcc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 17:40:54 -0300 Subject: [PATCH 242/376] test(listener): enforce direct media grants --- .../test/preview-contract.test.mjs | 6 +- .../registered-free-smoke.sh | 37 ++++- .../stream/manifest/__tests__/route.test.ts | 127 ------------------ .../api/early-birds/stream/manifest/route.ts | 108 --------------- .../listener/__tests__/media-boundary.test.ts | 1 - 5 files changed, 39 insertions(+), 240 deletions(-) delete mode 100644 src/app/api/early-birds/stream/manifest/__tests__/route.test.ts delete mode 100644 src/app/api/early-birds/stream/manifest/route.ts diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 62de3a9e..2328c589 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -603,10 +603,14 @@ test('registered Free smoke covers weekly quota and device boundaries without ex assert.match(source, /for ordinal in 1 2 3/); assert.match(source, /evictedAnotherDevice/); assert.match(source, /\.reason == "displaced"/); - assert.match(source, /api\/early-birds\/stream\/manifest/); + 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 () => { diff --git a/scripts/early-birds-preview/registered-free-smoke.sh b/scripts/early-birds-preview/registered-free-smoke.sh index 9b8582f1..3a40198d 100755 --- a/scripts/early-birds-preview/registered-free-smoke.sh +++ b/scripts/early-birds-preview/registered-free-smoke.sh @@ -91,11 +91,42 @@ jq -e '.reason == "displaced"' "$temporary/heartbeat.response" >/dev/null || \ 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: https://earlybirds-staging.harmonicbeacon.com"\n' \ + "$manifest_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: https://earlybirds-staging.harmonicbeacon.com"\n' \ + "$segment_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 "$manifest_status" = 200 || preview_fail "active Free manifest returned HTTP $manifest_status" -grep -q '^#EXTM3U' "$temporary/manifest.m3u8" || preview_fail "active Free manifest is invalid" +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" \ @@ -110,4 +141,4 @@ jq -e ' (.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 anchor, three-hour allowance, removed legacy APIs, two-device eviction, and generation-bound manifest." +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/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts b/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts deleted file mode 100644 index 5b3b858d..00000000 --- a/src/app/api/early-birds/stream/manifest/__tests__/route.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { NextRequest } from 'next/server'; - -const mocks = vi.hoisted(() => ({ - currentEarlyBirdSession: vi.fn(), - authorizeEarlyBirdStreamLease: vi.fn(), - authorizeFreeForAllStreamLease: vi.fn(), - earlyBirdOriginConfig: vi.fn(), - signedEarlyBirdOriginManifestUrl: vi.fn(), - validSignedOriginManifest: vi.fn(), - LeaseInactive: class extends Error { - constructor(readonly reason: 'evicted' | 'expired' | 'missing' = 'missing') { - super('inactive'); - } - }, - AccessDenied: class extends Error {}, -})); - -const { - currentEarlyBirdSession, - authorizeEarlyBirdStreamLease, - earlyBirdOriginConfig, - signedEarlyBirdOriginManifestUrl, - validSignedOriginManifest, - LeaseInactive, -} = mocks; - -vi.mock('@/lib/early-birds/auth', () => ({ currentEarlyBirdSession: mocks.currentEarlyBirdSession })); -vi.mock('@/lib/early-birds/stream', () => ({ - authorizeEarlyBirdStreamLease: mocks.authorizeEarlyBirdStreamLease, - authorizeFreeForAllStreamLease: mocks.authorizeFreeForAllStreamLease, - earlyBirdOriginConfig: mocks.earlyBirdOriginConfig, - signedEarlyBirdOriginManifestUrl: mocks.signedEarlyBirdOriginManifestUrl, - validSignedOriginManifest: mocks.validSignedOriginManifest, - EarlyBirdLeaseInactiveError: mocks.LeaseInactive, - EarlyBirdAccessDeniedError: mocks.AccessDenied, -})); - -import { GET } from '../route'; - -const LEASE_ID = '00000000-0000-4000-8000-000000000003'; - -function request() { - return new NextRequest(`https://live.example.test/api/early-birds/stream/manifest?leaseId=${LEASE_ID}&leaseGeneration=2`); -} - -beforeEach(() => vi.stubEnv('EARLY_BIRDS_ENABLED', '1')); -afterEach(() => { - vi.clearAllMocks(); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); -}); - -describe('stable EarlyBird lease manifest', () => { - it('does not contact the origin before auth and lease authorization', async () => { - currentEarlyBirdSession.mockResolvedValue(null); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - const response = await GET(request()); - expect(response.status).toBe(401); - expect(authorizeEarlyBirdStreamLease).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('proxies only a validated signed-segment manifest with no-store', async () => { - currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); - authorizeEarlyBirdStreamLease.mockResolvedValue({ - lease: { id: LEASE_ID, expiresAt: new Date(Date.now() + 120_000) }, - serverNow: new Date('2026-08-06T12:00:00.000Z'), - }); - earlyBirdOriginConfig.mockReturnValue({ origin: 'https://stream.example.test' }); - signedEarlyBirdOriginManifestUrl.mockReturnValue('https://stream.example.test/live.m3u8?exp=1&sig=secret-url'); - validSignedOriginManifest.mockReturnValue(true); - const manifest = '#EXTM3U\nhttps://stream.example.test/segment?exp=2&sig=x\n'; - const fetchMock = vi.fn().mockResolvedValue(new Response(manifest, { status: 200 })); - vi.stubGlobal('fetch', fetchMock); - - const response = await GET(request()); - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toContain('no-store'); - expect(response.headers.get('content-type')).toContain('application/vnd.apple.mpegurl'); - await expect(response.text()).resolves.toBe(manifest); - expect(authorizeEarlyBirdStreamLease).toHaveBeenCalledWith('listener-1', LEASE_ID, 2); - expect(fetchMock).toHaveBeenCalledOnce(); - }); - - it('authorizes the bearer lease without session lookup in Free for All mode', async () => { - vi.stubEnv('EARLY_BIRDS_FREE_FOR_ALL', '1'); - currentEarlyBirdSession.mockResolvedValue(null); - mocks.authorizeFreeForAllStreamLease.mockResolvedValue({ - id: LEASE_ID, - expiresAt: new Date(Date.now() + 120_000), - }); - earlyBirdOriginConfig.mockReturnValue({ origin: 'https://stream.example.test' }); - signedEarlyBirdOriginManifestUrl.mockReturnValue('https://stream.example.test/live.m3u8?exp=1&sig=x'); - validSignedOriginManifest.mockReturnValue(true); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response( - '#EXTM3U\nhttps://stream.example.test/segment?exp=2&sig=x\n', - { status: 200 }, - ))); - - expect((await GET(request())).status).toBe(200); - expect(mocks.authorizeFreeForAllStreamLease).toHaveBeenCalledWith(LEASE_ID, 2); - expect(currentEarlyBirdSession).not.toHaveBeenCalled(); - expect(authorizeEarlyBirdStreamLease).not.toHaveBeenCalled(); - }); - - it('cuts off a displaced device on its next manifest refresh', async () => { - currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); - authorizeEarlyBirdStreamLease.mockRejectedValue(new LeaseInactive('evicted')); - const response = await GET(request()); - expect(response.status).toBe(410); - expect(response.headers.get('cache-control')).toContain('no-store'); - await expect(response.json()).resolves.toMatchObject({ reason: 'displaced' }); - }); - - it('reports ordinary lease expiry without claiming another device displaced it', async () => { - currentEarlyBirdSession.mockResolvedValue({ user: { id: 'listener-1' } }); - authorizeEarlyBirdStreamLease.mockRejectedValue(new LeaseInactive('expired')); - const response = await GET(request()); - expect(response.status).toBe(410); - await expect(response.json()).resolves.toEqual({ - error: 'Listening lease expired.', - reason: 'expired', - }); - }); -}); diff --git a/src/app/api/early-birds/stream/manifest/route.ts b/src/app/api/early-birds/stream/manifest/route.ts deleted file mode 100644 index 07b69cd9..00000000 --- a/src/app/api/early-birds/stream/manifest/route.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { NextResponse, type NextRequest } from 'next/server'; - -import { currentEarlyBirdSession } from '@/lib/early-birds/auth'; -import { - earlyBirdsEnabled, - earlyBirdsFreeForAll, - earlyBirdsUnavailableResponse, -} from '@/lib/early-birds/enabled'; -import { - authorizeEarlyBirdStreamLease, - authorizeFreeForAllStreamLease, - earlyBirdOriginConfig, - EarlyBirdAccessDeniedError, - EarlyBirdLeaseInactiveError, - signedEarlyBirdOriginManifestUrl, - validSignedOriginManifest, -} from '@/lib/early-birds/stream'; - -export const dynamic = 'force-dynamic'; - -const MANIFEST_HEADERS = { - 'Cache-Control': 'private, no-store, max-age=0', - 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', - 'X-Content-Type-Options': 'nosniff', -}; - -export async function GET(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, - headers: { 'Cache-Control': 'private, no-store' }, - }); - } - const leaseId = request.nextUrl.searchParams.get('leaseId') ?? ''; - const leaseGeneration = Number(request.nextUrl.searchParams.get('leaseGeneration')); - if (!/^[0-9a-f-]{36}$/i.test(leaseId)) { - return NextResponse.json({ error: 'Invalid lease.' }, { - status: 400, - headers: { 'Cache-Control': 'private, no-store' }, - }); - } - if (!Number.isSafeInteger(leaseGeneration) || leaseGeneration < 1) { - return NextResponse.json({ error: 'Invalid lease generation.' }, { - status: 400, - headers: { 'Cache-Control': 'private, no-store' }, - }); - } - - try { - const freeForAllLease = freeForAll - ? await authorizeFreeForAllStreamLease(leaseId, leaseGeneration) - : null; - const authorization = freeForAll - ? { lease: freeForAllLease!, serverNow: new Date() } - : await authorizeEarlyBirdStreamLease(session!.user.id, leaseId, leaseGeneration); - const config = earlyBirdOriginConfig(); - const upstreamUrl = signedEarlyBirdOriginManifestUrl({ - config, - leaseExpiresAt: authorization.lease.expiresAt, - now: authorization.serverNow, - }); - const upstream = await fetch(upstreamUrl, { - method: 'GET', - cache: 'no-store', - redirect: 'error', - signal: AbortSignal.timeout(5_000), - headers: { accept: 'application/vnd.apple.mpegurl' }, - }); - if (!upstream.ok) throw new Error('origin manifest unavailable'); - const manifest = await upstream.text(); - if (!validSignedOriginManifest(manifest, config)) { - throw new Error('origin manifest contract mismatch'); - } - return new NextResponse(manifest, { status: 200, headers: MANIFEST_HEADERS }); - } catch (error) { - 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, - headers: { 'Cache-Control': 'private, no-store' }, - }); - } - if (error instanceof EarlyBirdAccessDeniedError) { - return NextResponse.json({ error: 'Listening access inactive.' }, { - status: 403, - headers: { 'Cache-Control': 'private, no-store' }, - }); - } - // Never relay or log the signed upstream URL, secret, or response. - return NextResponse.json({ error: 'Stream temporarily unavailable.' }, { - status: 503, - headers: { 'Cache-Control': 'private, no-store' }, - }); - } -} diff --git a/src/lib/listener/__tests__/media-boundary.test.ts b/src/lib/listener/__tests__/media-boundary.test.ts index 574ee824..72683a38 100644 --- a/src/lib/listener/__tests__/media-boundary.test.ts +++ b/src/lib/listener/__tests__/media-boundary.test.ts @@ -18,7 +18,6 @@ const MEDIA_FILE_SHA256 = { 'src/lib/early-birds/stream.ts': '40c5a17d27e8d6f820251d96dd33cd2bbbd83b164e50a212acf8fd0bb158c51c', 'src/lib/early-birds/drop-ins.ts': '3b0d18c2c8548aa3ee917ece726cbca4b6d253ea3b4941a8424f8bcbfb8922e2', 'src/app/api/early-birds/stream/lease/route.ts': 'ec0e8780387bc1f493eb33d13a2d90e01cfdb6d899fc6232e04f51aaf2dfc508', - 'src/app/api/early-birds/stream/manifest/route.ts': '56d8266fd8144c1e3ee13b168115f0a814a1da9f0a9f2db8b33e33facbba094e', 'src/app/api/early-birds/stream/heartbeat/route.ts': '268ced6066e4a666f09286f2dc995a4085d9efd7a3f4ea4e983dc8055c9eab6b', 'src/app/api/early-birds/drop-ins/[language]/route.ts': '545c6c04dc1532b939ae99f4e99e1d862d8b16e79ae94db184aef162154d5fa1', } as const; From ec59c4bfe4e8d933be51aaf63d854ff3d23e02ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 17:45:29 -0300 Subject: [PATCH 243/376] test(listener): stabilize direct media release gates --- scripts/early-birds-preview/registered-free-smoke.sh | 12 ++++++------ .../test/run-staging-smoke.test.mjs | 10 ++++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/scripts/early-birds-preview/registered-free-smoke.sh b/scripts/early-birds-preview/registered-free-smoke.sh index 3a40198d..1a1ed41a 100755 --- a/scripts/early-birds-preview/registered-free-smoke.sh +++ b/scripts/early-birds-preview/registered-free-smoke.sh @@ -9,8 +9,8 @@ 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) ;; - *) preview_fail "BASE_URL must be the protected EarlyBirds staging host" ;; + 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) @@ -96,16 +96,16 @@ 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: https://earlybirds-staging.harmonicbeacon.com"\n' \ - "$manifest_url" >"$temporary/manifest.curl" +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: https://earlybirds-staging.harmonicbeacon.com"\n' \ - "$segment_url" >"$temporary/segment.curl" +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" diff --git a/tools/early-birds-hls-load/test/run-staging-smoke.test.mjs b/tools/early-birds-hls-load/test/run-staging-smoke.test.mjs index f06f45a9..16336b7f 100644 --- a/tools/early-birds-hls-load/test/run-staging-smoke.test.mjs +++ b/tools/early-birds-hls-load/test/run-staging-smoke.test.mjs @@ -74,7 +74,10 @@ test('a later run passes the lock after a clean release and still fails closed', assert.equal(result.code, 1); // The lock was acquired; the run now fails closed on the missing // precondition file instead of on the lock. - assert.match(result.stderr, /cannot read signed manifest file/); + assert.match( + result.stderr, + /cannot read (?:signed manifest file|external decoded canary status file|target monitor status file)/, + ); assert.doesNotMatch(result.stderr, /network run lock/); // The validation-error path released the lock deterministically. await assert.rejects(access(NETWORK_RUN_LOCK_PATH), /ENOENT/); @@ -86,7 +89,10 @@ test('a wrapper that acquires the lock itself refuses a second wrapper until it // The first wrapper failed closed on preconditions and released its lock, // so a following invocation is again refused only on preconditions. assert.equal(first.code, 1); - assert.match(first.stderr, /cannot read signed manifest file/); + assert.match( + first.stderr, + /cannot read (?:signed manifest file|external decoded canary status file|target monitor status file)/, + ); await assert.rejects(access(NETWORK_RUN_LOCK_PATH), /ENOENT/); }); From 9f7e97acee80160940bb954cc0c79ce0bbf2d3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Ech=C3=A1niz?= Date: Sat, 15 Aug 2026 18:33:54 -0300 Subject: [PATCH 244/376] feat(listener): add inner-anchor kelp field --- docs/design/LISTENER_REACTIVE_CAMPFIRE.md | 8 +- .../test/preview-contract.test.mjs | 2 + scripts/listener-ui-preview.sh | 6 +- .../reactive/ReactiveCampfireCanvas.tsx | 2 + .../reactive/ReactiveCampfireTuningPanel.tsx | 35 +- .../reactive/__tests__/components.test.tsx | 27 ++ .../listener/reactive/__tests__/draw.test.ts | 40 ++- .../reactive/__tests__/frame-state.test.ts | 23 +- .../listener/reactive/__tests__/scene.test.ts | 69 ++++ .../reactive/__tests__/settings.test.ts | 9 + src/components/listener/reactive/draw.ts | 331 ++++++++++++++---- .../listener/reactive/frame-state.ts | 18 + src/components/listener/reactive/scene.ts | 44 ++- src/components/listener/reactive/settings.ts | 25 ++ 14 files changed, 562 insertions(+), 77 deletions(-) diff --git a/docs/design/LISTENER_REACTIVE_CAMPFIRE.md b/docs/design/LISTENER_REACTIVE_CAMPFIRE.md index 68029aee..fe1a7928 100644 --- a/docs/design/LISTENER_REACTIVE_CAMPFIRE.md +++ b/docs/design/LISTENER_REACTIVE_CAMPFIRE.md @@ -63,13 +63,19 @@ 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 three full renderers over the same frame: +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. The field rotates very slowly counter-clockwise 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 diff --git a/ops/early-birds-preview/test/preview-contract.test.mjs b/ops/early-birds-preview/test/preview-contract.test.mjs index 2328c589..0ab5e828 100644 --- a/ops/early-birds-preview/test/preview-contract.test.mjs +++ b/ops/early-birds-preview/test/preview-contract.test.mjs @@ -116,6 +116,8 @@ test('payment workbench keeps OAuth state and callback on the staging origin', a 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, /api\/health\/ready/); assert.doesNotMatch(source, /PREVIEW_ORIGIN="https:\/\/listen\.harmonicbeacon\.com"/); }); diff --git a/scripts/listener-ui-preview.sh b/scripts/listener-ui-preview.sh index b37ddbab..ec1ccb72 100755 --- a/scripts/listener-ui-preview.sh +++ b/scripts/listener-ui-preview.sh @@ -13,6 +13,7 @@ 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_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}" @@ -20,7 +21,7 @@ PREVIEW_EXPECTED_SHA="${LISTENER_UI_PREVIEW_EXPECTED_SHA:-}" LIVE_WORKBENCH_ENV_FILE="/etc/harmonic-beacon/listener-live-workbench.env" PREVIEW_ORIGIN="https://earlybirds-staging.harmonicbeacon.com" -for switch in "$PREVIEW_FREE_FOR_ALL" "$PREVIEW_PAYPAL_CHECKOUT" \ +for switch in "$PREVIEW_FREE_FOR_ALL" "$PREVIEW_REACTIVE_FIELD_LAB" "$PREVIEW_PAYPAL_CHECKOUT" \ "$PREVIEW_MERCADO_PAGO_CHECKOUT" "$PREVIEW_LIVE_WORKBENCH"; do case "$switch" in 0|1) ;; *) echo "Preview switches must be 0 or 1." >&2; exit 2 ;; esac done @@ -63,7 +64,7 @@ sync_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_PAYPAL_CHECKOUT='$PREVIEW_PAYPAL_CHECKOUT' PREVIEW_MERCADO_PAGO_CHECKOUT='$PREVIEW_MERCADO_PAGO_CHECKOUT' PREVIEW_LIVE_WORKBENCH='$PREVIEW_LIVE_WORKBENCH' PREVIEW_EXPECTED_SHA='$PREVIEW_EXPECTED_SHA' LIVE_WORKBENCH_ENV_FILE='$LIVE_WORKBENCH_ENV_FILE' PREVIEW_ORIGIN='$PREVIEW_ORIGIN' bash -s" <<'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_PAYPAL_CHECKOUT='$PREVIEW_PAYPAL_CHECKOUT' PREVIEW_MERCADO_PAGO_CHECKOUT='$PREVIEW_MERCADO_PAGO_CHECKOUT' PREVIEW_LIVE_WORKBENCH='$PREVIEW_LIVE_WORKBENCH' PREVIEW_EXPECTED_SHA='$PREVIEW_EXPECTED_SHA' LIVE_WORKBENCH_ENV_FILE='$LIVE_WORKBENCH_ENV_FILE' PREVIEW_ORIGIN='$PREVIEW_ORIGIN' bash -s" <<'REMOTE' set -euo pipefail if [ "$PREVIEW_LIVE_WORKBENCH" = 1 ]; then @@ -176,6 +177,7 @@ docker run -d \ -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 \ diff --git a/src/components/listener/reactive/ReactiveCampfireCanvas.tsx b/src/components/listener/reactive/ReactiveCampfireCanvas.tsx index 0bec6fea..03605a69 100644 --- a/src/components/listener/reactive/ReactiveCampfireCanvas.tsx +++ b/src/components/listener/reactive/ReactiveCampfireCanvas.tsx @@ -155,6 +155,8 @@ export function ReactiveCampfireCanvas({ frameStateRef.current.history as ReactiveTrailHistory, decay, frameStateRef.current.lastActivatedAtMs, + frameStateRef.current.activationStartedAtMs, + performance.timeOrigin + now, ); drawReactiveCampfire(context, width, height, scene, settingsRef.current); }; diff --git a/src/components/listener/reactive/ReactiveCampfireTuningPanel.tsx b/src/components/listener/reactive/ReactiveCampfireTuningPanel.tsx index cfb7b29d..10621fe7 100644 --- a/src/components/listener/reactive/ReactiveCampfireTuningPanel.tsx +++ b/src/components/listener/reactive/ReactiveCampfireTuningPanel.tsx @@ -31,6 +31,7 @@ const NUMBER_FIELDS: Array<{ max: number; step: number; suffix?: string; + modes?: ReactiveCampfireSettings['visualizationMode'][]; }> = [ { key: 'sensitivity', label: 'Variation sensitivity', min: 0.2, max: 3, step: 0.05 }, { key: 'absoluteFloorDb', label: 'Visible floor', min: -120, max: -36, step: 1, suffix: ' dB' }, @@ -45,12 +46,37 @@ const NUMBER_FIELDS: Array<{ { key: 'zoomPercent', label: 'Zoom', min: 50, max: 220, step: 1, suffix: '%' }, { key: 'activationTtlSeconds', label: 'Activation TTL', min: 0, max: 30, step: 0.5, suffix: ' s' }, { key: 'ribbonWidth', label: 'Ribbon width', min: 0.6, max: 3, step: 0.05 }, + { + key: 'kelpPropagationSpeed', + label: 'Kelp propagation speed', + min: 0.2, + max: 2, + step: 0.02, + modes: ['inner-anchor-kelp'], + }, + { + key: 'kelpDamping', + label: 'Kelp damping', + min: 0.2, + max: 3, + step: 0.05, + modes: ['inner-anchor-kelp'], + }, + { + key: 'kelpInnerImpulse', + label: 'Inner impulse', + min: 0, + max: 3, + step: 0.05, + modes: ['inner-anchor-kelp'], + }, ]; const VISUALIZATION_LABELS: Record = { 'minimal-pulse': 'Minimal pulse · 2 fps', 'harmonic-radial-series': 'Harmonic radial series', 'radial-ribbons': 'Radial ribbons', + 'inner-anchor-kelp': 'Inner-anchor kelp', 'horizon-flow': 'Horizon flow', }; @@ -96,7 +122,9 @@ export function ReactiveCampfireTuningPanel({ Audio stays native. Visual frames come from the server; Minimal pulse is the lowest rendering workload before comparing a full field.

- {NUMBER_FIELDS.map((field) => ( + {NUMBER_FIELDS.filter((field) => ( + !field.modes || field.modes.includes(settings.visualizationMode) + )).map((field) => (