Skip to content

Commit dcde508

Browse files
committed
feat(subplat): add support for free access program
Because - Transfer ownership of the Mozilla VPN Free Access program to the SubPlat and EntPlat teams, which aligns with the goal of evolving the subscirption platform beyond subscriptions. - Allow other Mozilla services to also utilize the Free Access Program. This commit - Adds support for the Free Access Program, by broadcasting capabilities for enabled customers, even if they don't have a subscription. - Read list of emails from Strapi and what capabilities should be provided to these users. - On auth-server /profile query, return the relevant capability if the user email is in the list configured in Strapi. - Add webhook listener to `auth-server` that listens for changes in Strapi and then broadcasts profile changed for entries that were added, expired or removed. - Adds a script to be added as a cron job, to broadcast a profile change to RPs when access has expired. Closes PAY-3780
1 parent e6d7f7e commit dcde508

71 files changed

Lines changed: 4071 additions & 25 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/payments/api/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,4 @@ METERING_CONFIG__CLOUD_TASKS__THRESHOLD__TASK_URL=http://host.docker.internal:30
101101
METERING_CONFIG__CLOUD_TASKS__THRESHOLD__QUEUE_NAME=metering-threshold-checks
102102
METERING_CONFIG__CLOUD_TASKS__THRESHOLD__BUCKET_SIZE_MS=5000
103103
METERING_CONFIG__CLOUD_TASKS__THRESHOLD__SCHEDULE_DELAY_MS=10000
104+
FREE_ACCESS_PROGRAM_CLIENT_CONFIG__COLLECTION_NAME=subplat-free-access-program

apps/payments/api/src/config/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { StrapiClientConfig } from '@fxa/shared/cms';
1111
import { MySQLConfig } from '@fxa/shared/db/mysql/core';
1212
import { FxaWebhookConfig, StripeEventConfig } from '@fxa/payments/webhooks';
1313
import { StatsDConfig } from '@fxa/shared/metrics/statsd';
14-
import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config';
14+
import { FirestoreConfig } from '@fxa/shared/db/firestore';
1515
import { FxaOAuthConfig } from '@fxa/payments/auth';
1616

1717
export class RootConfig {

libs/free-access-program/.swcrc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"jsc": {
3+
"target": "es2017",
4+
"parser": {
5+
"syntax": "typescript",
6+
"decorators": true,
7+
"dynamicImport": true
8+
},
9+
"transform": {
10+
"decoratorMetadata": true,
11+
"legacyDecorator": true
12+
}
13+
}
14+
}

libs/free-access-program/README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/* eslint-disable */
2+
import { readFileSync } from 'fs';
3+
import { Config } from 'jest';
4+
5+
// Reading the SWC compilation config and remove the "exclude"
6+
// for the test files to be compiled by SWC
7+
const { exclude: _, ...swcJestConfig } = JSON.parse(
8+
readFileSync(`${__dirname}/.swcrc`, 'utf-8')
9+
);
10+
11+
// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
12+
// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
13+
if (swcJestConfig.swcrc === undefined) {
14+
swcJestConfig.swcrc = false;
15+
}
16+
17+
const config: Config = {
18+
displayName: 'free-access-program',
19+
preset: '../../jest.preset.js',
20+
transform: {
21+
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
22+
},
23+
moduleFileExtensions: ['ts', 'js', 'html'],
24+
testEnvironment: 'node',
25+
coverageDirectory: '../../coverage/libs/free-access-program',
26+
reporters: [
27+
'default',
28+
[
29+
'jest-junit',
30+
{
31+
outputDirectory: 'artifacts/tests/free-access-program',
32+
outputName: 'free-access-program-jest-unit-results.xml',
33+
},
34+
],
35+
],
36+
};
37+
38+
export default config;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "@fxa/free-access-program",
3+
"version": "0.0.1"
4+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "free-access-program",
3+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "libs/free-access-program/src",
5+
"projectType": "library",
6+
"tags": [],
7+
"targets": {
8+
"build": {
9+
"dependsOn": ["build-ts"],
10+
"executor": "nx:run-commands",
11+
"options": {
12+
"command": "echo Build complete"
13+
}
14+
},
15+
"build-ts": {
16+
"executor": "@nx/js:tsc",
17+
"outputs": ["{options.outputPath}"],
18+
"options": {
19+
"main": "libs/free-access-program/src/index.ts",
20+
"outputPath": "dist/libs/free-access-program",
21+
"tsConfig": "libs/free-access-program/tsconfig.lib.json",
22+
"assets": [
23+
{
24+
"glob": "libs/free-access-program/README.md",
25+
"input": ".",
26+
"output": "."
27+
}
28+
]
29+
}
30+
},
31+
"lint": {
32+
"executor": "@nx/eslint:lint",
33+
"outputs": ["{options.outputFile}"],
34+
"options": {
35+
"lintFilePatterns": [
36+
"libs/free-access-program/**/*.ts",
37+
"libs/free-access-program/package.json"
38+
]
39+
}
40+
},
41+
"test-unit": {
42+
"executor": "@nx/jest:jest",
43+
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
44+
"options": {
45+
"jestConfig": "libs/free-access-program/jest.config.ts"
46+
}
47+
}
48+
}
49+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
export * from './lib/free-access-program.factories';
6+
export * from './lib/free-access-program.journal.manager';
7+
export * from './lib/free-access-program.journal.manager.config';
8+
export * from './lib/free-access-program.repository';
9+
export * from './lib/free-access-program.service';
10+
export * from './lib/free-access-program.types';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import type { ReconcileResult } from './free-access-program.types';
6+
7+
export const ReconcileResultFactory = (
8+
override?: Partial<ReconcileResult>
9+
): ReconcileResult => ({
10+
changed: 0,
11+
...override,
12+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { faker } from '@faker-js/faker';
6+
import { Provider } from '@nestjs/common';
7+
import { IsString } from 'class-validator';
8+
9+
export class FreeAccessProgramJournalManagerConfig {
10+
@IsString()
11+
public readonly collectionName!: string;
12+
}
13+
14+
export const MockFreeAccessProgramJournalManagerConfig = {
15+
collectionName: faker.string.uuid(),
16+
} satisfies FreeAccessProgramJournalManagerConfig;
17+
18+
export const MockFreeAccessProgramJournalManagerConfigProvider = {
19+
provide: FreeAccessProgramJournalManagerConfig,
20+
useValue: MockFreeAccessProgramJournalManagerConfig,
21+
} satisfies Provider<FreeAccessProgramJournalManagerConfig>;

0 commit comments

Comments
 (0)