Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/payments/api/.env
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,4 @@ METERING_CONFIG__CLOUD_TASKS__THRESHOLD__TASK_URL=http://host.docker.internal:30
METERING_CONFIG__CLOUD_TASKS__THRESHOLD__QUEUE_NAME=metering-threshold-checks
METERING_CONFIG__CLOUD_TASKS__THRESHOLD__BUCKET_SIZE_MS=5000
METERING_CONFIG__CLOUD_TASKS__THRESHOLD__SCHEDULE_DELAY_MS=10000
FREE_ACCESS_PROGRAM_CLIENT_CONFIG__COLLECTION_NAME=subplat-free-access-program
2 changes: 1 addition & 1 deletion apps/payments/api/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { StrapiClientConfig } from '@fxa/shared/cms';
import { MySQLConfig } from '@fxa/shared/db/mysql/core';
import { FxaWebhookConfig, StripeEventConfig } from '@fxa/payments/webhooks';
import { StatsDConfig } from '@fxa/shared/metrics/statsd';
import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config';
import { FirestoreConfig } from '@fxa/shared/db/firestore';
import { FxaOAuthConfig } from '@fxa/payments/auth';

export class RootConfig {
Expand Down
14 changes: 14 additions & 0 deletions libs/free-access-program/.swcrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"jsc": {
"target": "es2017",
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"decoratorMetadata": true,
"legacyDecorator": true
}
}
}
65 changes: 65 additions & 0 deletions libs/free-access-program/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# free-access-program

Front-door library for the Strapi-managed **B2B free-access allowlist** —
resolves per-email capability grants at read time, and reconciles Strapi
state into `profileDataChange` SNS events for downstream RPs at
write/webhook/cron time.

The projection itself (the by-email map of `email → { capabilities,
offeringApiIdentifiers }`) is owned by `FreeAccessProgramConfigurationManager`
in [`@fxa/shared/cms`](../shared/cms) — this lib layers the front-door
service and the durable reconciler journal on top of that projection.

## Runtime layout

| Piece | Lives in | Purpose |
|---|---|---|
| `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`. |
| `FreeAccessProgramService` | this lib | Front door. `findCapabilitiesForEmail` / `findOfferingIdsForEmail` (O(1) map lookup); `reconcile()` (diff journal ↔ fresh projection, fan out per-email change signals). |
| `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. |
| `FreeAccessNotifier` interface + `FREE_ACCESS_NOTIFIER` token | this lib | Contract: `notifyEmailChanged(email)`. |
| `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`. |
| 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()`. |
| Reconcile cron | `packages/fxa-auth-server/scripts/free-access-program-reconcile.ts` | Periodic safety-net sweep. Same `reconcile()` call as the webhook. |

## Reconcile flow

1. `journalManager.get()` returns the last-fanned-out by-email projection, or `null` on cold start.
2. `configurationManager.getFreshProjection()` re-projects Strapi (skipping the read cache).
3. **Cold start** (`before === null`): `journalManager.set(after)` seeds the baseline; fire zero notifications.
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.
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.
6. Fire `notifier.notifyEmailChanged(email)` for every affected email. Per-call failures are isolated so one bad email can't block the rest.

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.

## Building

```
nx build free-access-program
```

## Running unit tests

```
nx test-unit free-access-program
```

## Reconcile cron

Runs in-process as an auth-server script:

```
NODE_ENV=<env> node -r ts-node/register packages/fxa-auth-server/scripts/free-access-program-reconcile.ts
```

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.

## Config

Wired via auth-server convict (`packages/fxa-auth-server/config/index.ts`).

- `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.
- `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.

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.
38 changes: 38 additions & 0 deletions libs/free-access-program/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/* eslint-disable */
import { readFileSync } from 'fs';
import { Config } from 'jest';

// Reading the SWC compilation config and remove the "exclude"
// for the test files to be compiled by SWC
const { exclude: _, ...swcJestConfig } = JSON.parse(
readFileSync(`${__dirname}/.swcrc`, 'utf-8')
);

// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
if (swcJestConfig.swcrc === undefined) {
swcJestConfig.swcrc = false;
}

const config: Config = {
displayName: 'free-access-program',
preset: '../../jest.preset.js',
transform: {
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
},
moduleFileExtensions: ['ts', 'js', 'html'],
testEnvironment: 'node',
coverageDirectory: '../../coverage/libs/free-access-program',
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: 'artifacts/tests/free-access-program',
outputName: 'free-access-program-jest-unit-results.xml',
},
],
],
};

export default config;
4 changes: 4 additions & 0 deletions libs/free-access-program/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "@fxa/free-access-program",
"version": "0.0.1"
}
49 changes: 49 additions & 0 deletions libs/free-access-program/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "free-access-program",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/free-access-program/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"dependsOn": ["build-ts"],
"executor": "nx:run-commands",
"options": {
"command": "echo Build complete"
}
},
"build-ts": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"main": "libs/free-access-program/src/index.ts",
"outputPath": "dist/libs/free-access-program",
"tsConfig": "libs/free-access-program/tsconfig.lib.json",
"assets": [
{
"glob": "libs/free-access-program/README.md",
"input": ".",
"output": "."
}
]
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"libs/free-access-program/**/*.ts",
"libs/free-access-program/package.json"
]
}
},
"test-unit": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/free-access-program/jest.config.ts"
}
}
}
}
10 changes: 10 additions & 0 deletions libs/free-access-program/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

export * from './lib/free-access-program.factories';
export * from './lib/free-access-program.journal.manager';
export * from './lib/free-access-program.journal.manager.config';
export * from './lib/free-access-program.repository';
export * from './lib/free-access-program.service';
export * from './lib/free-access-program.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import type { ReconcileResult } from './free-access-program.types';

export const ReconcileResultFactory = (
override?: Partial<ReconcileResult>
): ReconcileResult => ({
changed: 0,
...override,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { faker } from '@faker-js/faker';
import { Provider } from '@nestjs/common';
import { IsString } from 'class-validator';

export class FreeAccessProgramJournalManagerConfig {
@IsString()
public readonly collectionName!: string;
}

export const MockFreeAccessProgramJournalManagerConfig = {
collectionName: faker.string.uuid(),
} satisfies FreeAccessProgramJournalManagerConfig;

export const MockFreeAccessProgramJournalManagerConfigProvider = {
provide: FreeAccessProgramJournalManagerConfig,
useValue: MockFreeAccessProgramJournalManagerConfig,
} satisfies Provider<FreeAccessProgramJournalManagerConfig>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { Test } from '@nestjs/testing';

import { FirestoreService } from '@fxa/shared/db/firestore';
import type { FreeAccessProjection } from '@fxa/shared/cms';

import { FreeAccessProgramJournalManager } from './free-access-program.journal.manager';
import { MockFreeAccessProgramJournalManagerConfigProvider } from './free-access-program.journal.manager.config';
import * as repository from './free-access-program.repository';

jest.mock('./free-access-program.repository', () => ({
getFreeAccessProgramJournal: jest.fn(),
setFreeAccessProgramJournal: jest.fn(),
}));

describe('FreeAccessProgramJournalManager', () => {
let manager: FreeAccessProgramJournalManager;

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
MockFreeAccessProgramJournalManagerConfigProvider,
FreeAccessProgramJournalManager,
{
provide: FirestoreService,
useValue: {
collection: jest.fn().mockReturnValue({}),
},
},
],
}).compile();

manager = module.get(FreeAccessProgramJournalManager);
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('get', () => {
it('forwards to getFreeAccessProgramJournal with the collection ref', async () => {
const projection: FreeAccessProjection = {
'alice@example.com': {
capabilities: { 'client-a': ['vpn'] },
offeringApiIdentifiers: ['vpn'],
},
};
jest
.spyOn(repository, 'getFreeAccessProgramJournal')
.mockResolvedValue(projection);

const result = await manager.get();

expect(repository.getFreeAccessProgramJournal).toHaveBeenCalledWith(
expect.anything()
);
expect(result).toBe(projection);
});

it('returns null when the underlying repository returns null', async () => {
jest
.spyOn(repository, 'getFreeAccessProgramJournal')
.mockResolvedValue(null);
await expect(manager.get()).resolves.toBeNull();
});
});

describe('set', () => {
it('forwards the projection to setFreeAccessProgramJournal', async () => {
const projection: FreeAccessProjection = {
'alice@example.com': {
capabilities: { 'client-a': ['vpn'] },
offeringApiIdentifiers: ['vpn'],
},
};
jest
.spyOn(repository, 'setFreeAccessProgramJournal')
.mockResolvedValue(undefined);

await manager.set(projection);

expect(repository.setFreeAccessProgramJournal).toHaveBeenCalledWith(
expect.anything(),
projection
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { Inject, Injectable } from '@nestjs/common';
import type { CollectionReference, Firestore } from '@google-cloud/firestore';

import { FirestoreService } from '@fxa/shared/db/firestore';
import type { FreeAccessProjection } from '@fxa/shared/cms';

import { FreeAccessProgramJournalManagerConfig } from './free-access-program.journal.manager.config';
import {
getFreeAccessProgramJournal,
setFreeAccessProgramJournal,
} from './free-access-program.repository';

@Injectable()
export class FreeAccessProgramJournalManager {
constructor(
private config: FreeAccessProgramJournalManagerConfig,
@Inject(FirestoreService) private firestore: Firestore
) {}

get collectionRef(): CollectionReference {
return this.firestore.collection(this.config.collectionName);
}

async get(): Promise<FreeAccessProjection | null> {
return getFreeAccessProgramJournal(this.collectionRef);
}

async set(projection: FreeAccessProjection): Promise<void> {
return setFreeAccessProgramJournal(this.collectionRef, projection);
}
}
Loading
Loading