|
| 1 | +# free-access-program |
| 2 | + |
| 3 | +Front-door library for the Strapi-managed **B2B free-access allowlist** — |
| 4 | +resolves per-email capability grants at read time, and reconciles Strapi |
| 5 | +state into `profileDataChange` SNS events for downstream RPs at |
| 6 | +write/webhook/cron time. |
| 7 | + |
| 8 | +The projection itself (the by-email map of `email → { capabilities, |
| 9 | +offeringApiIdentifiers }`) is owned by `FreeAccessProgramConfigurationManager` |
| 10 | +in [`@fxa/shared/cms`](../shared/cms) — this lib layers the front-door |
| 11 | +service and the durable reconciler journal on top of that projection. |
| 12 | + |
| 13 | +## Runtime layout |
| 14 | + |
| 15 | +| Piece | Lives in | Purpose | |
| 16 | +|---|---|---| |
| 17 | +| `FreeAccessProgramConfigurationManager` | `@fxa/shared/cms` | Two-layer (memory + Firestore) cache in front of the Strapi `accesses` query, projected into a flat by-email map via `AccessUtil.project`. | |
| 18 | +| `FreeAccessProgramService` | this lib | Front door. `findCapabilitiesForEmail` / `findOfferingIdsForEmail` (O(1) map lookup); `reconcile()` (diff journal ↔ fresh projection, fan out per-email change signals). | |
| 19 | +| `FreeAccessProgramJournalManager` + `free-access-program.repository.ts` | this lib | Durable Firestore document (`<collection>/state`) holding the last-fanned-out by-email projection. No TTL — this is the diff source-of-truth so cache evictions can't silently drop notifications. | |
| 20 | +| `FreeAccessNotifier` interface + `FREE_ACCESS_NOTIFIER` token | this lib | Contract: `notifyEmailChanged(email)`. | |
| 21 | +| `FreeAccessInProcessNotifier` | `packages/fxa-auth-server/lib/payments/` | Auth-server implementation. Resolves email → uid via `db.accountRecord`, invalidates the profile-server cache via `ProfileClient.deleteCache`, and emits a coarse `profileDataChange` event via `log.notifyAttachedServices`. | |
| 22 | +| Webhook route (`POST /webhooks/strapi/free-access-program/access`) | `packages/fxa-auth-server/lib/routes/subscriptions/` | Strapi-facing entry point. Verifies the shared bearer, dedupes on `(event, documentId, createdAt)` for 60s, dispatches to `service.reconcile()`. | |
| 23 | +| Reconcile cron | `packages/fxa-auth-server/scripts/free-access-program-reconcile.ts` | Periodic safety-net sweep. Same `reconcile()` call as the webhook. | |
| 24 | + |
| 25 | +## Reconcile flow |
| 26 | + |
| 27 | +1. `journalManager.get()` returns the last-fanned-out by-email projection, or `null` on cold start. |
| 28 | +2. `configurationManager.getFreshProjection()` re-projects Strapi (skipping the read cache). |
| 29 | +3. **Cold start** (`before === null`): `journalManager.set(after)` seeds the baseline; fire zero notifications. |
| 30 | +4. **Warm path**: `diffByEmail(before, after)` walks both projections and returns emails whose capability map differs (order-insensitive on both `clientId` keys and capability slugs). New emails, removed emails, and changed capability sets all surface. |
| 31 | +5. Persist the new baseline (`journalManager.set(after)`) and invalidate the read cache **before** firing notifications, so concurrent in-process readers don't serve stale state while downstream RPs are already learning the new state. |
| 32 | +6. Fire `notifier.notifyEmailChanged(email)` for every affected email. Per-call failures are isolated so one bad email can't block the rest. |
| 33 | + |
| 34 | +Notifications are coarse: RPs receive a `profileDataChange` event and re-fetch their profile/capabilities view. We deliberately don't enumerate added vs removed capabilities — that stays inside the diff. |
| 35 | + |
| 36 | +## Building |
| 37 | + |
| 38 | +``` |
| 39 | +nx build free-access-program |
| 40 | +``` |
| 41 | + |
| 42 | +## Running unit tests |
| 43 | + |
| 44 | +``` |
| 45 | +nx test-unit free-access-program |
| 46 | +``` |
| 47 | + |
| 48 | +## Reconcile cron |
| 49 | + |
| 50 | +Runs in-process as an auth-server script: |
| 51 | + |
| 52 | +``` |
| 53 | +NODE_ENV=<env> node -r ts-node/register packages/fxa-auth-server/scripts/free-access-program-reconcile.ts |
| 54 | +``` |
| 55 | + |
| 56 | +Schedule via the same cron mechanism that drives the other auth-server periodic scripts. The script no-ops (with a `free-access-program-reconcile.skipped` log) when `subscriptions.enabled` is false or when the CMS Strapi client isn't configured. |
| 57 | + |
| 58 | +## Config |
| 59 | + |
| 60 | +Wired via auth-server convict (`packages/fxa-auth-server/config/index.ts`). |
| 61 | + |
| 62 | +- `cms.strapiClient.*` (env `STRAPI_CLIENT_*`) — shared with the `StrapiClient`. `FreeAccessProgramConfigurationManager` reuses this config for its projection cache; disjoint `type-cacheable` keys (`freeAccessProgramProjection` vs per-query) mean the two managers share the Firestore collection without colliding. |
| 63 | +- `subscriptions.freeAccessProgramJournal.collectionName` (env `FREE_ACCESS_PROGRAM_JOURNAL_COLLECTION_NAME`, default `subplat-free-access-program-journal`) — the durable journal collection, distinct from the read cache. |
| 64 | + |
| 65 | +The journal collection holds a single `state` document with `{ projection, updatedAt }`. No TTL: this doc is the reconciler's source of truth for the diff. |
0 commit comments