diff --git a/apps/payments/api/.env b/apps/payments/api/.env index 7f16566daa5..2688f92e6dc 100644 --- a/apps/payments/api/.env +++ b/apps/payments/api/.env @@ -110,3 +110,6 @@ SENTRY_CONFIG__SAMPLE_RATE=1 SENTRY_CONFIG__TRACES_SAMPLE_RATE=0 SWAGGER_UI=false + +# Free Access Program +FREE_ACCESS_PROGRAM_CONFIG__ENABLED=false diff --git a/apps/payments/api/src/app/app.module.ts b/apps/payments/api/src/app/app.module.ts index aa2fbd9ff13..d02227021c3 100644 --- a/apps/payments/api/src/app/app.module.ts +++ b/apps/payments/api/src/app/app.module.ts @@ -44,6 +44,11 @@ import { StripeWebhookService, SubscriptionEventsService, } from '@fxa/payments/webhooks'; +import { + FreeAccessProgramService, + FreeAccessProgramWebhooksController, + FreeAccessProgramWebhooksService, +} from '@fxa/free-access-program'; import { FirestoreProvider } from '@fxa/shared/db/firestore'; import { AccountCustomerManager, StripeClient } from '@fxa/payments/stripe'; import { StatsDProvider } from '@fxa/shared/metrics/statsd'; @@ -75,6 +80,7 @@ import { AccountManager } from '@fxa/shared/account/account'; import { CartManager } from '@fxa/payments/cart'; import { CmsContentValidationManager, + FreeAccessProgramConfigurationManager, MeteringConfigurationManager, ProductConfigurationManager, StrapiClient, @@ -106,6 +112,7 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat AppController, BillingAndSubscriptionsController, CmsWebhooksController, + FreeAccessProgramWebhooksController, FxaWebhooksController, StripeWebhooksController, UsageController, @@ -128,6 +135,7 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat BillingAndSubscriptionsService, CapabilityManager, ProductConfigurationManager, + FreeAccessProgramConfigurationManager, CartManager, SubscriptionEventsService, PaymentsGleanFactory, @@ -156,6 +164,8 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat CmsContentValidationManager, MeteringConfigurationManager, CmsWebhookService, + FreeAccessProgramService, + FreeAccessProgramWebhooksService, FxaWebhookService, NimbusManager, NimbusManagerConfig, diff --git a/apps/payments/api/src/config/index.ts b/apps/payments/api/src/config/index.ts index d5aa0a8cff6..bf644f32bdf 100644 --- a/apps/payments/api/src/config/index.ts +++ b/apps/payments/api/src/config/index.ts @@ -7,6 +7,7 @@ import { AppleIapClientConfig, GoogleIapClientConfig } from '@fxa/payments/iap'; import { PaymentsGleanConfig } from '@fxa/payments/metrics'; import { PaypalClientConfig } from '@fxa/payments/paypal'; import { StripeConfig } from '@fxa/payments/stripe'; +import { FreeAccessProgramConfig } from '@fxa/payments/api-server'; import { StrapiClientConfig } from '@fxa/shared/cms'; import { MySQLConfig } from '@fxa/shared/db/mysql/core'; import { FxaWebhookConfig, StripeEventConfig } from '@fxa/payments/webhooks'; @@ -99,4 +100,9 @@ export class RootConfig { @IsBoolean() @IsDefined() public readonly swaggerUi!: boolean; + + @Type(() => FreeAccessProgramConfig) + @ValidateNested() + @IsDefined() + public readonly freeAccessProgramConfig!: Partial; } diff --git a/apps/payments/api/src/swagger.utils.ts b/apps/payments/api/src/swagger.utils.ts index 8847aacb879..56c270120d1 100644 --- a/apps/payments/api/src/swagger.utils.ts +++ b/apps/payments/api/src/swagger.utils.ts @@ -76,6 +76,16 @@ export function annotateWebhookRoutes(document: OpenAPIObject): void { }, }, }, + '/webhooks/strapi/free-access-program/access': { + summary: + 'Refresh the Free Access Program projection cache on a Strapi access change', + headers: { + authorization: { + description: 'Webhook authorization token', + required: true, + }, + }, + }, '/webhooks/fxa': { summary: 'Handle FXA account event webhook', headers: { diff --git a/libs/free-access-program/README.md b/libs/free-access-program/README.md index f6476adf1cf..9537a71405b 100644 --- a/libs/free-access-program/README.md +++ b/libs/free-access-program/README.md @@ -15,11 +15,13 @@ service and the durable reconciler journal on top of that projection. | 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). | +| `FreeAccessProgramService` | this lib | Front door. `findCapabilitiesForEmail` / `findOfferingIdsForEmail` (O(1) map lookup); `findFreeAccessForUid` (membership gate via the projection, then per-client grants from the full access); `reconcile()` (diff journal ↔ fresh projection, fan out per-email change signals). | | `FreeAccessProgramJournalManager` + `free-access-program.repository.ts` | this lib | Durable Firestore document (`/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()`. | +| Webhook helpers (`classifyAccessWebhook`, `isDuplicateAccessWebhook`) | this lib (`util/`) | Framework-agnostic Strapi-webhook building blocks. `classifyAccessWebhook` filters to `model=access` + publish/update/unpublish/delete and returns a dedupe key; `isDuplicateAccessWebhook` dedupes `(event, documentId, createdAt)` for 60s over a caller-owned map. Each app composes these with its own bearer check and side effect. | +| Webhook route (`POST /webhooks/strapi/free-access-program/access`) — auth-server | thin Hapi wrapper in `packages/fxa-auth-server/lib/routes/subscriptions/` | Verifies the shared bearer (→ Boom 401), then classify → dedupe → `service.reconcile()` (journal diff → RP notifications → cache invalidation). | +| Webhook route (`POST /webhooks/strapi/free-access-program/access`) — payments-api | thin Nest wrapper in this lib (`free-access-program-webhooks.{controller,service}.ts`) | Additive endpoint. Verifies the shared bearer (→ `UnauthorizedException`), then classify → dedupe → `FreeAccessProgramConfigurationManager.invalidateProjectionCache()` so the payments-api process's projection cache (in-memory L1 + shared Firestore L2) is refreshed. | | Reconcile cron | `packages/fxa-auth-server/scripts/free-access-program-reconcile.ts` | Periodic safety-net sweep. Same `reconcile()` call as the webhook. | ## Reconcile flow diff --git a/libs/free-access-program/src/index.ts b/libs/free-access-program/src/index.ts index ecf89166418..56d5001605c 100644 --- a/libs/free-access-program/src/index.ts +++ b/libs/free-access-program/src/index.ts @@ -6,5 +6,9 @@ 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-webhooks.controller'; +export * from './lib/free-access-program-webhooks.service'; export * from './lib/free-access-program.service'; export * from './lib/free-access-program.types'; +export * from './lib/util/classifyAccessWebhook'; +export * from './lib/util/isDuplicateAccessWebhook'; diff --git a/libs/free-access-program/src/lib/free-access-program-webhooks.controller.ts b/libs/free-access-program/src/lib/free-access-program-webhooks.controller.ts new file mode 100644 index 00000000000..12a9f345d32 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program-webhooks.controller.ts @@ -0,0 +1,22 @@ +/* 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 { Body, Controller, Headers, HttpCode, Post } from '@nestjs/common'; + +import { FreeAccessProgramWebhooksService } from './free-access-program-webhooks.service'; +import type { StrapiAccessWebhookPayload } from './util/classifyAccessWebhook'; + +@Controller('webhooks') +export class FreeAccessProgramWebhooksController { + constructor(private service: FreeAccessProgramWebhooksService) {} + + @Post('strapi/free-access-program/access') + @HttpCode(200) + async postAccess( + @Headers('authorization') authorization: string, + @Body() body: StrapiAccessWebhookPayload + ) { + return this.service.handleAccessWebhook(authorization, body); + } +} diff --git a/libs/free-access-program/src/lib/free-access-program-webhooks.service.spec.ts b/libs/free-access-program/src/lib/free-access-program-webhooks.service.spec.ts new file mode 100644 index 00000000000..a8164c05c3f --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program-webhooks.service.spec.ts @@ -0,0 +1,125 @@ +/* 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 { Logger, UnauthorizedException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import type { StatsD } from 'hot-shots'; + +import { FreeAccessProgramConfigurationManager, StrapiClient } from '@fxa/shared/cms'; +import { StatsDService } from '@fxa/shared/metrics/statsd'; + +import { FreeAccessProgramWebhooksService } from './free-access-program-webhooks.service'; +import type { StrapiAccessWebhookPayload } from './util/classifyAccessWebhook'; + +// Event-filtering/dedupe internals are covered by the shared-function spec; this +// suite covers the payments-api wiring (auth → cache invalidation, error translation). +describe('FreeAccessProgramWebhooksService', () => { + let service: FreeAccessProgramWebhooksService; + let manager: { invalidateProjectionCache: jest.Mock }; + let strapiClient: { verifyWebhookSignature: jest.Mock }; + let statsd: { increment: jest.Mock }; + let logger: { error: jest.Mock }; + + const payload: StrapiAccessWebhookPayload = { + event: 'entry.publish', + model: 'access', + createdAt: '2026-06-23T12:00:00.000Z', + entry: { documentId: 'ent-1' }, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + logger = { error: jest.fn() }; + statsd = { increment: jest.fn() }; + strapiClient = { verifyWebhookSignature: jest.fn().mockReturnValue(true) }; + manager = { invalidateProjectionCache: jest.fn().mockResolvedValue(undefined) }; + + const module = await Test.createTestingModule({ + providers: [ + FreeAccessProgramWebhooksService, + { provide: Logger, useValue: logger }, + { provide: StrapiClient, useValue: strapiClient }, + { + provide: FreeAccessProgramConfigurationManager, + useValue: manager, + }, + { provide: StatsDService, useValue: statsd as unknown as StatsD }, + ], + }).compile(); + + service = module.get(FreeAccessProgramWebhooksService); + }); + + it('throws UnauthorizedException and increments statsd on an invalid signature', async () => { + strapiClient.verifyWebhookSignature.mockReturnValue(false); + + await expect( + service.handleAccessWebhook('Bearer wrong', payload) + ).rejects.toThrow(UnauthorizedException); + + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.webhook.auth.error' + ); + expect(manager.invalidateProjectionCache).not.toHaveBeenCalled(); + }); + + it.each([ + ['entry.publish'], + ['entry.update'], + ['entry.unpublish'], + ['entry.delete'], + ])('invalidates the projection cache on a %s access event', async (event) => { + const result = await service.handleAccessWebhook('Bearer valid', { + ...payload, + event, + }); + + expect(manager.invalidateProjectionCache).toHaveBeenCalledTimes(1); + expect(result).toEqual({ handled: true }); + }); + + it('does not invalidate for a non-access model', async () => { + const result = await service.handleAccessWebhook('Bearer valid', { + ...payload, + model: 'offering', + }); + + expect(result).toEqual({ handled: false, reason: 'model' }); + expect(manager.invalidateProjectionCache).not.toHaveBeenCalled(); + }); + + it('does not invalidate for an unknown event type', async () => { + const result = await service.handleAccessWebhook('Bearer valid', { + ...payload, + event: 'media.upload', + }); + + expect(result).toEqual({ handled: false, reason: 'event' }); + expect(manager.invalidateProjectionCache).not.toHaveBeenCalled(); + }); + + it('dedupes a replayed webhook without invalidating twice', async () => { + const first = await service.handleAccessWebhook('Bearer valid', payload); + const second = await service.handleAccessWebhook('Bearer valid', payload); + + expect(manager.invalidateProjectionCache).toHaveBeenCalledTimes(1); + expect(first).toEqual({ handled: true }); + expect(second).toEqual({ handled: true, dedupe: true }); + }); + + it('swallows an invalidateProjectionCache failure and still reports handled', async () => { + manager.invalidateProjectionCache.mockRejectedValue( + new Error('firestore down') + ); + + const result = await service.handleAccessWebhook('Bearer valid', payload); + + // Strapi must not retry; the cron sweep is the backstop. + expect(result).toEqual({ handled: true }); + expect(logger.error).toHaveBeenCalledWith( + 'freeAccessProgramWebhook.invalidate.error', + { err: expect.any(Error) } + ); + }); +}); diff --git a/libs/free-access-program/src/lib/free-access-program-webhooks.service.ts b/libs/free-access-program/src/lib/free-access-program-webhooks.service.ts new file mode 100644 index 00000000000..536c0b7e484 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program-webhooks.service.ts @@ -0,0 +1,66 @@ +/* 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, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import type { StatsD } from 'hot-shots'; + +import { FreeAccessProgramConfigurationManager, StrapiClient } from '@fxa/shared/cms'; +import { StatsDService } from '@fxa/shared/metrics/statsd'; + +import { + classifyAccessWebhook, + type FreeAccessProgramWebhookResult, + type StrapiAccessWebhookPayload, +} from './util/classifyAccessWebhook'; +import { isDuplicateAccessWebhook } from './util/isDuplicateAccessWebhook'; + +/** + * Handles the Strapi Free Access Program `access` webhook for payments-api by + * invalidating this process's projection cache. Journal diffing and RP + * notifications remain auth-server's responsibility; this endpoint is additive. + */ +@Injectable() +export class FreeAccessProgramWebhooksService { + private readonly seenEvents = new Map(); + + constructor( + private strapiClient: StrapiClient, + private freeAccessManager: FreeAccessProgramConfigurationManager, + @Inject(StatsDService) private statsd: StatsD, + private logger: Logger + ) {} + + async handleAccessWebhook( + authorization: string, + body: StrapiAccessWebhookPayload + ): Promise { + if (!this.strapiClient.verifyWebhookSignature(authorization)) { + this.statsd.increment('free_access_program.webhook.auth.error'); + throw new UnauthorizedException(); + } + + const classification = classifyAccessWebhook(body); + if ('skip' in classification) { + return { handled: false, reason: classification.skip }; + } + + if (isDuplicateAccessWebhook(this.seenEvents, classification.dedupeKey, Date.now())) { + return { handled: true, dedupe: true }; + } + + try { + await this.freeAccessManager.invalidateProjectionCache(); + } catch (err) { + // Swallow: the periodic cron sweep is the backstop. + this.logger.error('freeAccessProgramWebhook.invalidate.error', { err }); + } + + return { handled: true }; + } +} diff --git a/libs/free-access-program/src/lib/free-access-program.service.spec.ts b/libs/free-access-program/src/lib/free-access-program.service.spec.ts index e4a17ff8b4f..1c54d7f4abe 100644 --- a/libs/free-access-program/src/lib/free-access-program.service.spec.ts +++ b/libs/free-access-program/src/lib/free-access-program.service.spec.ts @@ -6,7 +6,9 @@ import { Logger } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import type { StatsD } from 'hot-shots'; +import { AccountManager } from '@fxa/shared/account/account'; import { + type FreeAccessByClientProjection, FreeAccessProgramConfigurationManager, type FreeAccessProjection, type FreeAccessProjectionEntry, @@ -39,9 +41,11 @@ describe('FreeAccessProgramService', () => { FreeAccessProgramConfigurationManager, | 'getCachedProjection' | 'getFreshProjection' + | 'getCachedAccessGrantsByClient' | 'invalidateProjectionCache' > >; + let accountManager: jest.Mocked>; let journalManager: jest.Mocked< Pick >; @@ -54,8 +58,12 @@ describe('FreeAccessProgramService', () => { configurationManager = { getCachedProjection: jest.fn().mockResolvedValue({}), getFreshProjection: jest.fn().mockResolvedValue({}), + getCachedAccessGrantsByClient: jest.fn().mockResolvedValue({}), invalidateProjectionCache: jest.fn().mockResolvedValue(undefined), }; + accountManager = { + getPrimaryEmailByUid: jest.fn().mockResolvedValue(undefined), + }; journalManager = { // Default: warm-but-empty journal; tests opt into the cold-start // (null) path explicitly. @@ -80,6 +88,7 @@ describe('FreeAccessProgramService', () => { provide: FreeAccessProgramConfigurationManager, useValue: configurationManager, }, + { provide: AccountManager, useValue: accountManager }, { provide: FreeAccessProgramJournalManager, useValue: journalManager, @@ -152,6 +161,93 @@ describe('FreeAccessProgramService', () => { }); }); + describe('findFreeAccessForUid', () => { + const UID = 'uid-1'; + + const grantsByClient = ( + map: Record>> + ): FreeAccessByClientProjection => + Object.fromEntries( + Object.entries(map).map(([email, byClient]) => [ + email, + Object.fromEntries( + Object.entries(byClient).map(([clientId, grants]) => [ + clientId, + grants.map(([offeringApiIdentifier, expiresAt]) => ({ + offeringApiIdentifier, + expiresAt, + })), + ]) + ), + ]) + ); + + it('returns not-a-member without touching access grants when the uid has no email', async () => { + accountManager.getPrimaryEmailByUid.mockResolvedValue(undefined); + + expect(await service.findFreeAccessForUid(UID)).toEqual({ + isMember: false, + grantsByClient: {}, + }); + expect( + configurationManager.getCachedAccessGrantsByClient + ).not.toHaveBeenCalled(); + }); + + it('returns not-a-member when the email is absent from the projection gate', async () => { + accountManager.getPrimaryEmailByUid.mockResolvedValue('user@example.com'); + configurationManager.getCachedProjection.mockResolvedValue({}); + + expect(await service.findFreeAccessForUid(UID)).toEqual({ + isMember: false, + grantsByClient: {}, + }); + expect( + configurationManager.getCachedAccessGrantsByClient + ).not.toHaveBeenCalled(); + }); + + it('returns all grants for the email keyed by client, without filtering', async () => { + accountManager.getPrimaryEmailByUid.mockResolvedValue('User@Example.com'); + configurationManager.getCachedProjection.mockResolvedValue( + projection([['user@example.com', entry({ 'client-a': ['vpn'] })]]) + ); + configurationManager.getCachedAccessGrantsByClient.mockResolvedValue( + grantsByClient({ + 'user@example.com': { + 'client-a': [['vpn', 4_070_995_200_000]], + 'client-b': [['relay', 4_070_995_200_000]], + }, + }) + ); + + expect(await service.findFreeAccessForUid(UID)).toEqual({ + isMember: true, + grantsByClient: { + 'client-a': [ + { offeringApiIdentifier: 'vpn', expiresAt: 4_070_995_200_000 }, + ], + 'client-b': [ + { offeringApiIdentifier: 'relay', expiresAt: 4_070_995_200_000 }, + ], + }, + }); + }); + + it('is a member with an empty grant map when the email has no resolved grants', async () => { + accountManager.getPrimaryEmailByUid.mockResolvedValue('user@example.com'); + configurationManager.getCachedProjection.mockResolvedValue( + projection([['user@example.com', entry({ 'client-a': ['vpn'] })]]) + ); + configurationManager.getCachedAccessGrantsByClient.mockResolvedValue({}); + + expect(await service.findFreeAccessForUid(UID)).toEqual({ + isMember: true, + grantsByClient: {}, + }); + }); + }); + describe('reconcile', () => { it('notifies for an email that appears for the first time', async () => { journalManager.get.mockResolvedValue({}); @@ -324,4 +420,43 @@ describe('FreeAccessProgramService', () => { ); }); }); + + // payments-api constructs the service read-only (no journal manager / + // notifier). The read methods must work; reconcile() must fail loudly. + describe('read-only construction (optional write-path deps)', () => { + let readOnlyService: FreeAccessProgramService; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + { + provide: FreeAccessProgramConfigurationManager, + useValue: configurationManager, + }, + { provide: AccountManager, useValue: accountManager }, + { provide: StatsDService, useValue: statsd as unknown as StatsD }, + { provide: Logger, useValue: logger }, + FreeAccessProgramService, + ], + }).compile(); + readOnlyService = moduleRef.get(FreeAccessProgramService); + }); + + it('resolves free access for a uid without the write-path deps', async () => { + accountManager.getPrimaryEmailByUid.mockResolvedValue('user@example.com'); + configurationManager.getCachedProjection.mockResolvedValue( + projection([['user@example.com', entry({ 'client-a': ['vpn'] })]]) + ); + + await expect( + readOnlyService.findFreeAccessForUid('uid-1') + ).resolves.toEqual({ isMember: true, grantsByClient: {} }); + }); + + it('throws from reconcile() when the journal manager and notifier are absent', async () => { + await expect(readOnlyService.reconcile()).rejects.toThrow( + 'requires a journal manager and notifier' + ); + }); + }); }); diff --git a/libs/free-access-program/src/lib/free-access-program.service.ts b/libs/free-access-program/src/lib/free-access-program.service.ts index e5e46d6e493..70ee620c7d4 100644 --- a/libs/free-access-program/src/lib/free-access-program.service.ts +++ b/libs/free-access-program/src/lib/free-access-program.service.ts @@ -2,12 +2,13 @@ * 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, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; import * as Sentry from '@sentry/node'; import type { StatsD } from 'hot-shots'; +import { AccountManager } from '@fxa/shared/account/account'; import { - FreeAccessCapabilityMap, + type FreeAccessCapabilityMap, FreeAccessProgramConfigurationManager, } from '@fxa/shared/cms'; import { StatsDService } from '@fxa/shared/metrics/statsd'; @@ -15,6 +16,7 @@ import { StatsDService } from '@fxa/shared/metrics/statsd'; import { FreeAccessProgramJournalManager } from './free-access-program.journal.manager'; import { FREE_ACCESS_NOTIFIER, + type FreeAccessForUid, type FreeAccessNotifier, type ReconcileResult, } from './free-access-program.types'; @@ -24,10 +26,16 @@ import { diffByEmail } from './util/diffByEmail'; export class FreeAccessProgramService { constructor( private configurationManager: FreeAccessProgramConfigurationManager, - private journalManager: FreeAccessProgramJournalManager, - @Inject(FREE_ACCESS_NOTIFIER) private notifier: FreeAccessNotifier, + private accountManager: AccountManager, @Inject(StatsDService) private statsd: StatsD, - private logger: Logger + private logger: Logger, + // Write-path dependencies: required by reconcile(), unused by the read + // methods. Optional so read-only consumers (e.g. payments-api) can + // construct the service without wiring the journal/notifier. + @Optional() private journalManager?: FreeAccessProgramJournalManager, + @Optional() + @Inject(FREE_ACCESS_NOTIFIER) + private notifier?: FreeAccessNotifier ) {} async findCapabilitiesForEmail( @@ -41,22 +49,44 @@ export class FreeAccessProgramService { async findOfferingIdsForEmail(email?: string | null): Promise { if (!email) return []; const projection = await this.configurationManager.getCachedProjection(); - return [ - ...(projection[email.toLowerCase()]?.offeringApiIdentifiers ?? []), - ]; + return [...(projection[email.toLowerCase()]?.offeringApiIdentifiers ?? [])]; + } + + async findFreeAccessForUid(uid: string): Promise { + const empty: FreeAccessForUid = { isMember: false, grantsByClient: {} }; + + const email = ( + await this.accountManager.getPrimaryEmailByUid(uid) + )?.toLowerCase(); + if (!email) return empty; + + const projection = await this.configurationManager.getCachedProjection(); + if (!(email in projection)) return empty; + + const grantsByClient = + await this.configurationManager.getCachedAccessGrantsByClient(); + return { isMember: true, grantsByClient: grantsByClient[email] ?? {} }; } async reconcile(): Promise { + const journalManager = this.journalManager; + const notifier = this.notifier; + if (!journalManager || !notifier) { + throw new Error( + 'FreeAccessProgramService.reconcile requires a journal manager and notifier' + ); + } + const startedAt = Date.now(); try { const [before, after] = await Promise.all([ - this.journalManager.get(), + journalManager.get(), this.configurationManager.getFreshProjection(), ]); if (before === null) { // Cold start: seed the journal without firing N events. - await this.journalManager.set(after); + await journalManager.set(after); this.statsd.increment( 'free_access_program.reconcile.cold_start_seeded' ); @@ -73,10 +103,10 @@ export class FreeAccessProgramService { // Invalidate before fan-out so in-process readers don't serve stale // state while RPs are already learning the new state. try { - await this.journalManager.set(after); + await journalManager.set(after); await this.invalidateAfterFanout(); } finally { - await this.fireNotifications(changedEmails); + await this.fireNotifications(changedEmails, notifier); } this.recordDuration(startedAt); @@ -96,10 +126,13 @@ export class FreeAccessProgramService { ); } - private async fireNotifications(emails: string[]): Promise { + private async fireNotifications( + emails: string[], + notifier: FreeAccessNotifier + ): Promise { for (const email of emails) { try { - await this.notifier.notifyEmailChanged(email); + await notifier.notifyEmailChanged(email); } catch (err) { this.statsd.increment('free_access_program.reconcile.notify.error'); this.logger.error(err); @@ -113,9 +146,7 @@ export class FreeAccessProgramService { try { await this.configurationManager.invalidateProjectionCache(); } catch (err) { - this.statsd.increment( - 'free_access_program.reconcile.invalidate.error' - ); + this.statsd.increment('free_access_program.reconcile.invalidate.error'); this.logger.error(err); Sentry.captureException(err); } diff --git a/libs/free-access-program/src/lib/free-access-program.types.ts b/libs/free-access-program/src/lib/free-access-program.types.ts index 1e69d5672f7..7499751521c 100644 --- a/libs/free-access-program/src/lib/free-access-program.types.ts +++ b/libs/free-access-program/src/lib/free-access-program.types.ts @@ -2,10 +2,19 @@ * 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 { FreeAccessGrant } from '@fxa/shared/cms'; + export interface FreeAccessNotifier { notifyEmailChanged(email: string): Promise; } +export interface FreeAccessForUid { + /** True when the email is in the Free Access Program at all (any client). */ + isMember: boolean; + /** Granted offerings keyed by lowercased clientId. Callers select their own. */ + grantsByClient: Record; +} + // String token so NestJS and TypeDI DI can share it. export const FREE_ACCESS_NOTIFIER = 'FreeAccessNotifier'; diff --git a/libs/free-access-program/src/lib/util/classifyAccessWebhook.spec.ts b/libs/free-access-program/src/lib/util/classifyAccessWebhook.spec.ts new file mode 100644 index 00000000000..d8b78da5ca1 --- /dev/null +++ b/libs/free-access-program/src/lib/util/classifyAccessWebhook.spec.ts @@ -0,0 +1,56 @@ +/* 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 { + classifyAccessWebhook, + type StrapiAccessWebhookPayload, +} from './classifyAccessWebhook'; + +describe('classifyAccessWebhook', () => { + const payload = ( + overrides: Partial = {} + ): StrapiAccessWebhookPayload => ({ + event: 'entry.publish', + model: 'access', + entry: { documentId: 'ent-1' }, + ...overrides, + }); + + it('skips with reason "model" when the payload is not for access', () => { + expect(classifyAccessWebhook(payload({ model: 'offering' }))).toEqual({ + skip: 'model', + }); + }); + + it('skips with reason "no_document_id" when entry.documentId is missing', () => { + expect(classifyAccessWebhook(payload({ entry: {} }))).toEqual({ + skip: 'no_document_id', + }); + }); + + it('skips with reason "event" for unknown event types', () => { + expect(classifyAccessWebhook(payload({ event: 'media.upload' }))).toEqual({ + skip: 'event', + }); + }); + + it.each([ + ['entry.publish'], + ['entry.update'], + ['entry.unpublish'], + ['entry.delete'], + ])('returns a dedupe key for a relevant %s event', (event) => { + expect( + classifyAccessWebhook( + payload({ event, createdAt: '2026-06-23T12:00:00.000Z' }) + ) + ).toEqual({ dedupeKey: `${event}|ent-1|2026-06-23T12:00:00.000Z` }); + }); + + it('uses an empty createdAt segment when absent', () => { + expect(classifyAccessWebhook(payload())).toEqual({ + dedupeKey: 'entry.publish|ent-1|', + }); + }); +}); diff --git a/libs/free-access-program/src/lib/util/classifyAccessWebhook.ts b/libs/free-access-program/src/lib/util/classifyAccessWebhook.ts new file mode 100644 index 00000000000..d05e6289fcb --- /dev/null +++ b/libs/free-access-program/src/lib/util/classifyAccessWebhook.ts @@ -0,0 +1,49 @@ +/* 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/. */ + +const TARGET_MODEL = 'access'; +const UPSERT_EVENTS = new Set(['entry.publish', 'entry.update']); +const DELETE_EVENTS = new Set(['entry.unpublish', 'entry.delete']); + +export type StrapiAccessWebhookPayload = { + event: string; + model?: string; + createdAt?: string; + entry?: { + documentId?: string; + [k: string]: unknown; + }; + [k: string]: unknown; +}; + +export type FreeAccessProgramWebhookResult = + | { handled: false; reason: 'model' | 'no_document_id' | 'event' } + | { handled: true; dedupe?: true }; + +export type AccessWebhookClassification = + | { skip: 'model' | 'no_document_id' | 'event' } + | { dedupeKey: string }; + +/** + * Filters a Strapi webhook payload to relevant `access` upsert/delete events. + * Returns a `dedupeKey` for a relevant change, or a `skip` reason otherwise. + */ +export function classifyAccessWebhook( + payload: StrapiAccessWebhookPayload +): AccessWebhookClassification { + if (payload.model !== TARGET_MODEL) { + return { skip: 'model' }; + } + const documentId = payload.entry?.documentId; + if (!documentId) { + return { skip: 'no_document_id' }; + } + if ( + !UPSERT_EVENTS.has(payload.event) && + !DELETE_EVENTS.has(payload.event) + ) { + return { skip: 'event' }; + } + return { dedupeKey: `${payload.event}|${documentId}|${payload.createdAt ?? ''}` }; +} diff --git a/libs/free-access-program/src/lib/util/isDuplicateAccessWebhook.spec.ts b/libs/free-access-program/src/lib/util/isDuplicateAccessWebhook.spec.ts new file mode 100644 index 00000000000..51a9dfc5463 --- /dev/null +++ b/libs/free-access-program/src/lib/util/isDuplicateAccessWebhook.spec.ts @@ -0,0 +1,34 @@ +/* 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 { isDuplicateAccessWebhook } from './isDuplicateAccessWebhook'; + +describe('isDuplicateAccessWebhook', () => { + const NOW = new Date('2026-06-23T12:00:00.000Z').getTime(); + let seen: Map; + + beforeEach(() => { + seen = new Map(); + }); + + it('records a new key and returns false', () => { + expect(isDuplicateAccessWebhook(seen, 'k', NOW)).toBe(false); + expect(seen.has('k')).toBe(true); + }); + + it('returns true for a key seen within the TTL', () => { + isDuplicateAccessWebhook(seen, 'k', NOW); + expect(isDuplicateAccessWebhook(seen, 'k', NOW + 59_000)).toBe(true); + }); + + it('re-accepts a key after the TTL elapses', () => { + isDuplicateAccessWebhook(seen, 'k', NOW); + expect(isDuplicateAccessWebhook(seen, 'k', NOW + 61_000)).toBe(false); + }); + + it('treats distinct keys independently', () => { + isDuplicateAccessWebhook(seen, 'a', NOW); + expect(isDuplicateAccessWebhook(seen, 'b', NOW)).toBe(false); + }); +}); diff --git a/libs/free-access-program/src/lib/util/isDuplicateAccessWebhook.ts b/libs/free-access-program/src/lib/util/isDuplicateAccessWebhook.ts new file mode 100644 index 00000000000..191c8151990 --- /dev/null +++ b/libs/free-access-program/src/lib/util/isDuplicateAccessWebhook.ts @@ -0,0 +1,30 @@ +/* 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/. */ + +// Dedupe replays of Strapi's static bearer token; longer than realistic retry intervals. +const DEDUPE_TTL_MS = 60_000; +const DEDUPE_MAX_ENTRIES = 1000; + +/** + * Returns true if `key` was already seen within the TTL (a replay to ignore). + * Otherwise records it against `now` and returns false. Mutates `seen`, which + * the caller owns so state stays out of this helper. + */ +export function isDuplicateAccessWebhook( + seen: Map, + key: string, + now: number +): boolean { + const expiresAt = seen.get(key); + if (expiresAt !== undefined && expiresAt > now) { + return true; + } + if (seen.size >= DEDUPE_MAX_ENTRIES) { + for (const [k, exp] of seen) { + if (exp <= now) seen.delete(k); + } + } + seen.set(key, now + DEDUPE_TTL_MS); + return false; +} diff --git a/libs/payments/api-server/src/index.ts b/libs/payments/api-server/src/index.ts index 4824a9c329c..4484fa7e89b 100644 --- a/libs/payments/api-server/src/index.ts +++ b/libs/payments/api-server/src/index.ts @@ -8,3 +8,4 @@ export * from './lib/billing-and-subscriptions.schema'; export * from './lib/billing-and-subscriptions.factories'; export * from './lib/util/openapi'; export * from './lib/util/response-validation.interceptor'; +export * from './lib/free-access-program.config'; diff --git a/libs/payments/api-server/src/lib/billing-and-subscriptions.controller.spec.ts b/libs/payments/api-server/src/lib/billing-and-subscriptions.controller.spec.ts index eeac3b6bff0..2a10b27e6f3 100644 --- a/libs/payments/api-server/src/lib/billing-and-subscriptions.controller.spec.ts +++ b/libs/payments/api-server/src/lib/billing-and-subscriptions.controller.spec.ts @@ -41,16 +41,20 @@ import { StripeClient, } from '@fxa/payments/stripe'; import { + FreeAccessProgramConfigurationManager, MockStrapiClientConfigProvider, ProductConfigurationManager, StrapiClient, } from '@fxa/shared/cms'; +import { FreeAccessProgramService } from '@fxa/free-access-program'; +import { AccountManager } from '@fxa/shared/account/account'; import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; import { BillingAndSubscriptionsController } from './billing-and-subscriptions.controller'; import { BillingAndSubscriptionsService } from './billing-and-subscriptions.service'; +import { MockFreeAccessProgramConfigProvider } from './free-access-program.config'; const FxaOAuthUserFactory = ( override?: Partial @@ -79,6 +83,10 @@ describe('BillingAndSubscriptionsController', () => { ProductManager, CapabilityManager, ProductConfigurationManager, + FreeAccessProgramConfigurationManager, + FreeAccessProgramService, + MockFreeAccessProgramConfigProvider, + AccountManager, AppleIapPurchaseManager, AppleIapClient, MockAppleIapClientConfigProvider, diff --git a/libs/payments/api-server/src/lib/billing-and-subscriptions.schema.ts b/libs/payments/api-server/src/lib/billing-and-subscriptions.schema.ts index 5483d89afd7..42fa5f2570f 100644 --- a/libs/payments/api-server/src/lib/billing-and-subscriptions.schema.ts +++ b/libs/payments/api-server/src/lib/billing-and-subscriptions.schema.ts @@ -131,6 +131,29 @@ export const appleIapSubscriptionSchema = z.object({ */ export type AppleIapSubscription = z.infer; +/** + * Zod schema for a Free Access Program grant. + * + * A Free Access Program customer is not subscribed to a Stripe Price, so the + * product identity is sourced from the related offering. There is no price, + * subscription id, or billing interval behind a free grant, so none of those + * fields are reported — only the product the grant provides access to. + */ +export const freeAccessSubscriptionSchema = z.object({ + _subscription_type: z.literal('free_access'), + current_period_end: z + .number() + .describe('Unix timestamp when the current billing period ends'), + product_id: z.string(), +}); + +/** + * A Free Access Program grant. + */ +export type FreeAccessSubscription = z.infer< + typeof freeAccessSubscriptionSchema +>; + /** * Zod schema for a subscription returned by the billing-and-subscriptions * endpoint, discriminated on `_subscription_type`. @@ -139,6 +162,7 @@ export const subscriptionSchema = z.discriminatedUnion('_subscription_type', [ webSubscriptionSchema, googleIapSubscriptionSchema, appleIapSubscriptionSchema, + freeAccessSubscriptionSchema, ]); /** diff --git a/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts b/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts index fa76d1a1bce..1e08c81e462 100644 --- a/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts +++ b/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts @@ -51,10 +51,13 @@ import { StripeSubscriptionItemFactory, } from '@fxa/payments/stripe'; import { + FreeAccessProgramConfigurationManager, MockStrapiClientConfigProvider, ProductConfigurationManager, StrapiClient, } from '@fxa/shared/cms'; +import { FreeAccessProgramService } from '@fxa/free-access-program'; +import { AccountManager } from '@fxa/shared/account/account'; import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; @@ -65,6 +68,7 @@ import { IapOfferingFactory, } from './billing-and-subscriptions.factories'; import { BillingAndSubscriptionsService } from './billing-and-subscriptions.service'; +import { FreeAccessProgramConfig } from './free-access-program.config'; const UID = 'abc123'; const CLIENT_ID = 'client_xyz'; @@ -81,12 +85,17 @@ describe('BillingAndSubscriptionsService', () => { let productManager: ProductManager; let capabilityManager: CapabilityManager; let productConfigurationManager: ProductConfigurationManager; + let freeAccessProgramService: FreeAccessProgramService; let appleIapPurchaseManager: AppleIapPurchaseManager; let googleIapPurchaseManager: GoogleIapPurchaseManager; let logger: { log: jest.Mock; error: jest.Mock }; + // Mutable so individual tests can toggle the feature flag; reset in beforeEach. + const mockFreeAccessProgramConfig = { enabled: true }; + beforeEach(async () => { logger = { log: jest.fn(), error: jest.fn() }; + mockFreeAccessProgramConfig.enabled = true; const moduleRef = await Test.createTestingModule({ providers: [ @@ -100,6 +109,13 @@ describe('BillingAndSubscriptionsService', () => { ProductManager, CapabilityManager, ProductConfigurationManager, + FreeAccessProgramConfigurationManager, + FreeAccessProgramService, + { + provide: FreeAccessProgramConfig, + useValue: mockFreeAccessProgramConfig, + }, + AccountManager, AppleIapPurchaseManager, AppleIapClient, MockAppleIapClientConfigProvider, @@ -133,12 +149,17 @@ describe('BillingAndSubscriptionsService', () => { productManager = moduleRef.get(ProductManager); capabilityManager = moduleRef.get(CapabilityManager); productConfigurationManager = moduleRef.get(ProductConfigurationManager); + freeAccessProgramService = moduleRef.get(FreeAccessProgramService); appleIapPurchaseManager = moduleRef.get(AppleIapPurchaseManager); googleIapPurchaseManager = moduleRef.get(GoogleIapPurchaseManager); // Default: no IAP unless overridden. jest.spyOn(appleIapPurchaseManager, 'getForUser').mockResolvedValue([]); jest.spyOn(googleIapPurchaseManager, 'getForUser').mockResolvedValue([]); + // Default: no Free Access Program membership unless overridden. + jest + .spyOn(freeAccessProgramService, 'findFreeAccessForUid') + .mockResolvedValue({ isMember: false, grantsByClient: {} }); }); describe('get', () => { @@ -744,5 +765,246 @@ describe('BillingAndSubscriptionsService', () => { expect(loggedError.message).toContain('interval=fortnightly'); expect(loggedError.message).toContain('storeId=google_sku'); }); + + describe('free access program', () => { + // Per-offering filtering by clientId lives in FreeAccessProgramService and + // is covered by its spec; these tests cover how the billing service maps + // the grants it returns into subscriptions and gates NotFound on membership. + + // Grant expiries are epoch ms; the response reports current_period_end as + // a unix timestamp in seconds. + const FAP_EXPIRY_VPN_MS = Date.UTC(2099, 0, 2); + const FAP_EXPIRY_RELAY_MS = Date.UTC(2100, 0, 2); + const FAP_EXPIRY_VPN_SECONDS = Math.floor(FAP_EXPIRY_VPN_MS / 1000); + const FAP_EXPIRY_RELAY_SECONDS = Math.floor(FAP_EXPIRY_RELAY_MS / 1000); + + const customerNotFound = () => + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockRejectedValue( + new AccountCustomerNotFoundError(UID, new Error('not found')) + ); + + // Minimal stand-in for EligibilityContentByOfferingResultUtil: the service + // only reads stripeProductId from the offering. + const mockOffering = (stripeProductId: string) => + ({ + getOffering: () => ({ stripeProductId }), + } as never); + + it('emits a free_access subscription for each grant for the client', async () => { + customerNotFound(); + jest + .spyOn(freeAccessProgramService, 'findFreeAccessForUid') + .mockResolvedValue({ + isMember: true, + grantsByClient: { + [CLIENT_ID]: [ + { offeringApiIdentifier: 'vpn', expiresAt: FAP_EXPIRY_VPN_MS }, + ], + }, + }); + jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockResolvedValue(mockOffering('prod_vpn')); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + + expect(result.subscriptions).toHaveLength(1); + expect(result.subscriptions[0]).toEqual({ + _subscription_type: 'free_access', + current_period_end: FAP_EXPIRY_VPN_SECONDS, + product_id: 'prod_vpn', + }); + }); + + it('resolves free access for the requesting uid', async () => { + customerNotFound(); + const findSpy = jest + .spyOn(freeAccessProgramService, 'findFreeAccessForUid') + .mockResolvedValue({ + isMember: true, + grantsByClient: { + [CLIENT_ID]: [ + { offeringApiIdentifier: 'vpn', expiresAt: FAP_EXPIRY_VPN_MS }, + ], + }, + }); + const getOfferingSpy = jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockResolvedValue(mockOffering('prod_vpn')); + + await service.get({ uid: UID, clientId: CLIENT_ID }); + + expect(findSpy).toHaveBeenCalledWith(UID); + expect(getOfferingSpy).toHaveBeenCalledWith('vpn'); + }); + + it('emits nothing and resolves no offering for a member whose grants belong to another client', async () => { + customerNotFound(); + // Member has grants, but only for a different client than the requester. + jest + .spyOn(freeAccessProgramService, 'findFreeAccessForUid') + .mockResolvedValue({ + isMember: true, + grantsByClient: { + other_client: [ + { offeringApiIdentifier: 'vpn', expiresAt: FAP_EXPIRY_VPN_MS }, + ], + }, + }); + const getOfferingSpy = jest.spyOn( + productConfigurationManager, + 'getEligibilityContentByOffering' + ); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + + expect(result.subscriptions).toHaveLength(0); + // No offering is resolved when this client has no grants. + expect(getOfferingSpy).not.toHaveBeenCalled(); + }); + + it('does not throw NotFound for a member whose grants belong to another client', async () => { + customerNotFound(); + jest + .spyOn(freeAccessProgramService, 'findFreeAccessForUid') + .mockResolvedValue({ + isMember: true, + grantsByClient: { + other_client: [ + { offeringApiIdentifier: 'vpn', expiresAt: FAP_EXPIRY_VPN_MS }, + ], + }, + }); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + + expect(result.subscriptions).toEqual([]); + }); + + it('returns both a web subscription and a free_access subscription', async () => { + const customer = StripeResponseFactory( + StripeCustomerFactory({ + id: STRIPE_CUSTOMER_ID, + currency: 'usd', + }) + ); + const price = StripePriceFactory({ + id: 'price_web', + currency: 'usd', + recurring: StripePriceRecurringFactory({ interval: 'month' }), + }); + const sub = StripeSubscriptionFactory({ + id: 'sub_web', + status: 'active', + items: { + object: 'list', + data: [ + StripeSubscriptionItemFactory({ + price: { ...price, product: 'prod_web' }, + }), + ], + has_more: false, + url: '', + }, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue({ + uid: UID, + stripeCustomerId: STRIPE_CUSTOMER_ID, + } as never); + jest.spyOn(customerManager, 'retrieve').mockResolvedValue(customer); + jest + .spyOn(subscriptionManager, 'listActiveForCustomer') + .mockResolvedValue([sub]); + jest + .spyOn(paymentMethodManager, 'retrieve') + .mockResolvedValue( + StripeResponseFactory(StripeCardPaymentMethodFactory()) + ); + jest + .spyOn(freeAccessProgramService, 'findFreeAccessForUid') + .mockResolvedValue({ + isMember: true, + grantsByClient: { + [CLIENT_ID]: [ + { offeringApiIdentifier: 'vpn', expiresAt: FAP_EXPIRY_VPN_MS }, + ], + }, + }); + jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockResolvedValue(mockOffering('prod_vpn')); + jest + .spyOn(capabilityManager, 'priceIdsToClientCapabilities') + .mockResolvedValue({ [CLIENT_ID]: ['cap'] }); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + + const types = result.subscriptions.map((s) => s._subscription_type); + expect(types).toEqual(['web', 'free_access']); + }); + + it('returns a free_access subscription per grant with its own expiry', async () => { + customerNotFound(); + jest + .spyOn(freeAccessProgramService, 'findFreeAccessForUid') + .mockResolvedValue({ + isMember: true, + grantsByClient: { + [CLIENT_ID]: [ + { offeringApiIdentifier: 'vpn', expiresAt: FAP_EXPIRY_VPN_MS }, + { + offeringApiIdentifier: 'relay', + expiresAt: FAP_EXPIRY_RELAY_MS, + }, + ], + }, + }); + jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockImplementation(async (apiIdentifier: string) => + apiIdentifier === 'vpn' + ? mockOffering('prod_vpn') + : mockOffering('prod_relay') + ); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + + expect(result.subscriptions).toHaveLength(2); + // One subscription per grant, each with its own CMS-sourced expiry. + expect(result.subscriptions).toEqual([ + { + _subscription_type: 'free_access', + current_period_end: FAP_EXPIRY_VPN_SECONDS, + product_id: 'prod_vpn', + }, + { + _subscription_type: 'free_access', + current_period_end: FAP_EXPIRY_RELAY_SECONDS, + product_id: 'prod_relay', + }, + ]); + }); + + it('does not resolve free access when the feature is disabled', async () => { + mockFreeAccessProgramConfig.enabled = false; + // FAP-only customer: no Stripe customer and no IAP purchases. + customerNotFound(); + const findSpy = jest.spyOn( + freeAccessProgramService, + 'findFreeAccessForUid' + ); + + // With the feature off, a FAP-only customer is treated as unknown. + await expect( + service.get({ uid: UID, clientId: CLIENT_ID }) + ).rejects.toMatchObject({ response: { errno: 176 } }); + expect(findSpy).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/libs/payments/api-server/src/lib/billing-and-subscriptions.service.ts b/libs/payments/api-server/src/lib/billing-and-subscriptions.service.ts index 243b51104d4..100e84d416a 100644 --- a/libs/payments/api-server/src/lib/billing-and-subscriptions.service.ts +++ b/libs/payments/api-server/src/lib/billing-and-subscriptions.service.ts @@ -30,6 +30,10 @@ import { type StripeProduct, type StripeSubscription, } from '@fxa/payments/stripe'; +import { + type FreeAccessForUid, + FreeAccessProgramService, +} from '@fxa/free-access-program'; import { SanitizeExceptions } from '@fxa/shared/error'; import { ProductConfigurationManager } from '@fxa/shared/cms'; @@ -37,11 +41,13 @@ import type { BillingAndSubscriptionsResponse, Subscription, } from './billing-and-subscriptions.schema'; +import { FreeAccessProgramConfig } from './free-access-program.config'; import { buildBillingDetails } from './util/buildBillingDetails'; import { buildIapPriceInfoMap } from './util/buildIapPriceInfoMap'; import { hasSubscriptionRequiringPaymentMethod } from './util/hasSubscriptionRequiringPaymentMethod'; import { mapPriceInfo } from './util/mapPriceInfo'; import { transformToAppleIapSubscription } from './util/transformToAppleIapSubscription'; +import { transformToFreeAccessSubscription } from './util/transformToFreeAccessSubscription'; import { transformToGoogleIapSubscription } from './util/transformToGoogleIapSubscription'; import { transformToWebSubscription } from './util/transformToWebSubscription'; @@ -53,6 +59,8 @@ const UNKNOWN_SUBSCRIPTION_CUSTOMER_ERRNO = 176; @Injectable() export class BillingAndSubscriptionsService { constructor( + private readonly freeAccessProgramConfig: FreeAccessProgramConfig, + private readonly freeAccessProgramService: FreeAccessProgramService, private readonly accountCustomerManager: AccountCustomerManager, private readonly customerManager: CustomerManager, private readonly subscriptionManager: SubscriptionManager, @@ -94,19 +102,31 @@ export class BillingAndSubscriptionsService { } } - const [activeStripeSubscriptions, googleIapPurchases, appleIapPurchases] = - await Promise.all([ - stripeCustomer - ? this.subscriptionManager.listActiveForCustomer(stripeCustomer.id) - : Promise.resolve([]), - this.googleIapPurchaseManager.getForUser(uid), - this.appleIapPurchaseManager.getForUser(uid), - ]); + const [ + activeStripeSubscriptions, + googleIapPurchases, + appleIapPurchases, + freeAccess, + ] = await Promise.all([ + stripeCustomer + ? this.subscriptionManager.listActiveForCustomer(stripeCustomer.id) + : Promise.resolve([]), + this.googleIapPurchaseManager.getForUser(uid), + this.appleIapPurchaseManager.getForUser(uid), + // Only resolve Free Access Program data when the feature is enabled. + this.freeAccessProgramConfig.enabled + ? this.freeAccessProgramService.findFreeAccessForUid(uid) + : Promise.resolve({ + isMember: false, + grantsByClient: {}, + }), + ]); if ( !stripeCustomer && googleIapPurchases.length === 0 && - appleIapPurchases.length === 0 + appleIapPurchases.length === 0 && + !freeAccess.isMember ) { throw new NotFoundException({ errno: UNKNOWN_SUBSCRIPTION_CUSTOMER_ERRNO, @@ -120,11 +140,11 @@ export class BillingAndSubscriptionsService { activeStripeSubscriptions.length === 0 ? undefined : activeStripeSubscriptions.some( - (sub) => - this.subscriptionManager.getPaymentProvider(sub) === 'paypal' - ) - ? 'paypal' - : 'stripe'; + (sub) => + this.subscriptionManager.getPaymentProvider(sub) === 'paypal' + ) + ? 'paypal' + : 'stripe'; const defaultPaymentMethodId = stripeCustomer.invoice_settings.default_payment_method; @@ -162,8 +182,9 @@ export class BillingAndSubscriptionsService { for (const sub of activeStripeSubscriptions) { const price = getPriceFromSubscription(sub); - const clients = - await this.capabilityManager.priceIdsToClientCapabilities([price.id]); + const clients = await this.capabilityManager.priceIdsToClientCapabilities( + [price.id] + ); if (!(clientId in clients)) { continue; } @@ -230,8 +251,9 @@ export class BillingAndSubscriptionsService { if (!iap) { continue; } - const clients = - await this.capabilityManager.priceIdsToClientCapabilities([iap.priceId]); + const clients = await this.capabilityManager.priceIdsToClientCapabilities( + [iap.priceId] + ); if (!(clientId in clients)) { continue; } @@ -243,14 +265,31 @@ export class BillingAndSubscriptionsService { if (!iap) { continue; } - const clients = - await this.capabilityManager.priceIdsToClientCapabilities([iap.priceId]); + const clients = await this.capabilityManager.priceIdsToClientCapabilities( + [iap.priceId] + ); if (!(clientId in clients)) { continue; } subscriptions.push(transformToAppleIapSubscription(purchase, iap)); } + const freeAccessGrants = + freeAccess.grantsByClient[clientId.toLowerCase()] ?? []; + for (const grant of freeAccessGrants) { + const offering = ( + await this.productConfigurationManager.getEligibilityContentByOffering( + grant.offeringApiIdentifier + ) + ).getOffering(); + subscriptions.push( + transformToFreeAccessSubscription({ + currentPeriodEnd: Math.floor(grant.expiresAt / 1000), + productId: offering.stripeProductId, + }) + ); + } + return { ...billingDetails, subscriptions, diff --git a/libs/payments/api-server/src/lib/free-access-program.config.ts b/libs/payments/api-server/src/lib/free-access-program.config.ts new file mode 100644 index 00000000000..97fffbda30f --- /dev/null +++ b/libs/payments/api-server/src/lib/free-access-program.config.ts @@ -0,0 +1,26 @@ +/* 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 { Provider } from '@nestjs/common'; +import { Transform } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; + +export class FreeAccessProgramConfig { + @Transform(({ value }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; + }) + @IsBoolean() + public readonly enabled!: boolean; +} + +export const MockFreeAccessProgramConfig = { + enabled: true, +} satisfies FreeAccessProgramConfig; + +export const MockFreeAccessProgramConfigProvider = { + provide: FreeAccessProgramConfig, + useValue: MockFreeAccessProgramConfig, +} satisfies Provider; diff --git a/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.spec.ts b/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.spec.ts new file mode 100644 index 00000000000..daa6af7afe0 --- /dev/null +++ b/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.spec.ts @@ -0,0 +1,44 @@ +/* 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 { transformToFreeAccessSubscription } from './transformToFreeAccessSubscription'; + +// A unix timestamp in seconds — the unit the free_access schema expects. +const CURRENT_PERIOD_END = 4_070_995_200; // 2099-01-02T00:00:00Z + +describe('transformToFreeAccessSubscription', () => { + it('maps offering product identity into a free_access subscription', () => { + const result = transformToFreeAccessSubscription({ + currentPeriodEnd: CURRENT_PERIOD_END, + productId: 'prod_vpn', + }); + + expect(result).toEqual({ + _subscription_type: 'free_access', + current_period_end: CURRENT_PERIOD_END, + product_id: 'prod_vpn', + }); + }); + + it('passes the current_period_end through unchanged', () => { + const result = transformToFreeAccessSubscription({ + currentPeriodEnd: CURRENT_PERIOD_END, + productId: 'prod_vpn', + }); + + expect(result.current_period_end).toBe(CURRENT_PERIOD_END); + }); + + it('does not report any billing data for a free grant', () => { + const result = transformToFreeAccessSubscription({ + currentPeriodEnd: CURRENT_PERIOD_END, + productId: 'prod_vpn', + }); + + expect(result).not.toHaveProperty('price_id'); + expect(result).not.toHaveProperty('plan_id'); + expect(result).not.toHaveProperty('priceInfo'); + expect(result).not.toHaveProperty('subscription_id'); + }); +}); diff --git a/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.ts b/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.ts new file mode 100644 index 00000000000..d1bedc00ae7 --- /dev/null +++ b/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.ts @@ -0,0 +1,22 @@ +/* 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 { FreeAccessSubscription } from '../billing-and-subscriptions.schema'; + +/** + * Build a `free_access` subscription entry from the related offering's product + * identity. A free grant has no Stripe subscription or Price, so no billing + * data (price, interval, subscription id) is reported. `currentPeriodEnd` is the + * CMS-sourced grant expiry, as a unix timestamp in seconds. + */ +export const transformToFreeAccessSubscription = (args: { + currentPeriodEnd: number; + productId: string; +}): FreeAccessSubscription => { + return { + _subscription_type: 'free_access', + current_period_end: args.currentPeriodEnd, + product_id: args.productId, + }; +}; diff --git a/libs/shared/account/account/src/lib/account.manager.in.spec.ts b/libs/shared/account/account/src/lib/account.manager.in.spec.ts index 5b672653bac..b85fd8fbe06 100644 --- a/libs/shared/account/account/src/lib/account.manager.in.spec.ts +++ b/libs/shared/account/account/src/lib/account.manager.in.spec.ts @@ -66,4 +66,24 @@ describe('accountManager', () => { // // Validate that session is verified, and unverified session no longer exists. // }); }); + + describe('getPrimaryEmailByUid', () => { + it('returns the primary email for an existing account', async () => { + const email = faker.internet.email(); + const uid = await accountManager.createAccountStub(email, 1, 'en-US'); + + const result = await accountManager.getPrimaryEmailByUid(uid); + expect(result).toBe(email); + }); + + it('returns undefined for an unknown uid', async () => { + const unknownUid = faker.string.hexadecimal({ + length: 32, + prefix: '', + }); + + const result = await accountManager.getPrimaryEmailByUid(unknownUid); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/libs/shared/account/account/src/lib/account.manager.ts b/libs/shared/account/account/src/lib/account.manager.ts index 8a55d3d0343..343e69d6ef4 100644 --- a/libs/shared/account/account/src/lib/account.manager.ts +++ b/libs/shared/account/account/src/lib/account.manager.ts @@ -10,6 +10,7 @@ import { AccountDbProvider } from '@fxa/shared/db/mysql/account'; import { createAccount, getAccounts, + getPrimaryEmailByUid, verifyAccountSession, VerificationMethods, } from './account.repository'; @@ -54,6 +55,15 @@ export class AccountManager { return getAccounts(this.db, bufferUids); } + /** + * Return the current primary email address for an account, or undefined if + * the account has no primary email row (e.g. unknown uid). + */ + async getPrimaryEmailByUid(uid: string): Promise { + const row = await getPrimaryEmailByUid(this.db, uuidTransformer.to(uid)); + return row?.email; + } + async verifySession( uid: string, sessionTokenId: string, diff --git a/libs/shared/account/account/src/lib/account.repository.ts b/libs/shared/account/account/src/lib/account.repository.ts index 76880d0fe80..9752a8322c4 100644 --- a/libs/shared/account/account/src/lib/account.repository.ts +++ b/libs/shared/account/account/src/lib/account.repository.ts @@ -71,6 +71,22 @@ export function getAccounts(db: AccountDatabase, uids: Buffer[]) { .execute(); } +/** + * Get the current primary email address for an account by uid. + * + * Returns the row from the `emails` table flagged as primary — this is the + * user's current primary email, which may differ from the immutable signup + * email stored on the `accounts` table. + */ +export function getPrimaryEmailByUid(db: AccountDatabase, uid: Buffer) { + return db + .selectFrom('emails') + .select('email') + .where('uid', '=', uid) + .where('isPrimary', '=', true) + .executeTakeFirst(); +} + /** See session_token.js in auth server for master list. */ export enum VerificationMethods { email = 0, diff --git a/libs/shared/cms/src/lib/free-access-program-configuration.manager.spec.ts b/libs/shared/cms/src/lib/free-access-program-configuration.manager.spec.ts index 00d099f972c..6d8429083f1 100644 --- a/libs/shared/cms/src/lib/free-access-program-configuration.manager.spec.ts +++ b/libs/shared/cms/src/lib/free-access-program-configuration.manager.spec.ts @@ -153,6 +153,61 @@ describe('FreeAccessProgramConfigurationManager', () => { }); }); + describe('getCachedAccessGrantsByClient', () => { + it('returns the per-client access grants of the Strapi accesses query', async () => { + strapiClient.queryUncached.mockResolvedValue({ + accesses: [ + { + documentId: 'ent-1', + internalName: 'VPN + Relay', + offerings: [ + { + apiIdentifier: 'vpn', + capabilities: [ + { slug: 'vpn', services: [{ oauthClientId: 'client-a' }] }, + ], + }, + { + apiIdentifier: 'relay', + capabilities: [ + { slug: 'relay', services: [{ oauthClientId: 'client-b' }] }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'user@example.com': ['2099-01-01', ''] }, + }, + ], + }, + ], + }); + + const result = await manager.getCachedAccessGrantsByClient(); + + expect(strapiClient.queryUncached).toHaveBeenCalledTimes(1); + // Each client sees only its own offering — never the other's. + expect(result).toEqual({ + 'user@example.com': { + 'client-a': [ + { offeringApiIdentifier: 'vpn', expiresAt: Date.UTC(2099, 0, 2) }, + ], + 'client-b': [ + { offeringApiIdentifier: 'relay', expiresAt: Date.UTC(2099, 0, 2) }, + ], + }, + }); + }); + + it('propagates errors from the Strapi client', async () => { + strapiClient.queryUncached.mockRejectedValue(new Error('strapi-down')); + await expect(manager.getCachedAccessGrantsByClient()).rejects.toThrow( + 'strapi-down' + ); + }); + }); + describe('invalidateProjectionCache', () => { it('resolves without throwing (decorators stripped in unit context)', async () => { await expect( diff --git a/libs/shared/cms/src/lib/free-access-program-configuration.manager.ts b/libs/shared/cms/src/lib/free-access-program-configuration.manager.ts index c22fe43a0fb..0293b86bb3f 100644 --- a/libs/shared/cms/src/lib/free-access-program-configuration.manager.ts +++ b/libs/shared/cms/src/lib/free-access-program-configuration.manager.ts @@ -16,13 +16,17 @@ import { StaleWhileRevalidateWithFallbackStrategy, } from '@fxa/shared/db/type-cacheable'; -import type { FreeAccessProjection } from './queries/accesses/types'; +import type { + FreeAccessByClientProjection, + FreeAccessProjection, +} from './queries/accesses/types'; import { StrapiClient } from './strapi.client'; import { AccessUtil } from './queries/accesses/util'; import { accessesQuery } from './queries/accesses'; import { StrapiClientConfig } from './strapi.client.config'; const PROJECTION_CACHE_KEY = 'freeAccessProgramProjection'; +const ACCESS_GRANTS_CACHE_KEY = 'freeAccessProgramAccessGrantsByClient'; // Match StrapiClientConfig so a Strapi-degraded window survives end-to-end. const DEFAULT_FIRESTORE_OFFLINE_CACHE_TTL_SECONDS = 604800; // 7 days @@ -95,6 +99,50 @@ export class FreeAccessProgramConfigurationManager { return util.project(); } + /** + * The full per-offering access keyed by email then clientId. Cached with the + * same two-tier strategy as the projection; callers should gate on + * getCachedProjection() first so this heavier shape is only resolved for + * actual Free Access Program members. + */ + @Cacheable({ + cacheKey: () => ACCESS_GRANTS_CACHE_KEY, + strategy: (_args: any, context: FreeAccessProgramConfigurationManager) => + new CacheFirstStrategy( + (err) => Sentry.captureException(err), + () => {}, + context.log + ), + ttlSeconds: (_args: any, context: FreeAccessProgramConfigurationManager) => + context.config.memCacheTTL ?? DEFAULT_MEM_CACHE_TTL_SECONDS, + client: (_args: any, context: FreeAccessProgramConfigurationManager) => + context.memoryCacheAdapter, + }) + @Cacheable({ + cacheKey: () => ACCESS_GRANTS_CACHE_KEY, + strategy: (_args: any, context: FreeAccessProgramConfigurationManager) => + new StaleWhileRevalidateWithFallbackStrategy( + context.config.firestoreCacheTTL ?? DEFAULT_FIRESTORE_CACHE_TTL_SECONDS, + (err) => Sentry.captureException(err), + () => {}, + context.log + ), + ttlSeconds: (_args: any, context: FreeAccessProgramConfigurationManager) => + context.config.firestoreOfflineCacheTTL ?? + DEFAULT_FIRESTORE_OFFLINE_CACHE_TTL_SECONDS, + client: (_args: any, context: FreeAccessProgramConfigurationManager) => + context.firestoreCacheAdapter, + }) + async getCachedAccessGrantsByClient(): Promise { + const queryResult = await this.strapiClient.queryUncached( + accessesQuery, + {} + ); + const util = new AccessUtil(queryResult); + + return util.projectByClient(); + } + @CacheClear({ cacheKey: () => PROJECTION_CACHE_KEY, client: (_args: any, context: FreeAccessProgramConfigurationManager) => @@ -105,5 +153,15 @@ export class FreeAccessProgramConfigurationManager { client: (_args: any, context: FreeAccessProgramConfigurationManager) => context.firestoreCacheAdapter, }) + @CacheClear({ + cacheKey: () => ACCESS_GRANTS_CACHE_KEY, + client: (_args: any, context: FreeAccessProgramConfigurationManager) => + context.memoryCacheAdapter, + }) + @CacheClear({ + cacheKey: () => ACCESS_GRANTS_CACHE_KEY, + client: (_args: any, context: FreeAccessProgramConfigurationManager) => + context.firestoreCacheAdapter, + }) async invalidateProjectionCache(): Promise {} } diff --git a/libs/shared/cms/src/lib/queries/accesses/types.ts b/libs/shared/cms/src/lib/queries/accesses/types.ts index 87eb032f4e6..96fc512384d 100644 --- a/libs/shared/cms/src/lib/queries/accesses/types.ts +++ b/libs/shared/cms/src/lib/queries/accesses/types.ts @@ -2,16 +2,43 @@ * 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/. */ +// Documentation-only aliases (not branded — each is still `string`, so they are +// mutually assignable) to make the Record key positions below self-describing. +export type FreeAccessEmail = string; +export type FreeAccessClientId = string; + // Structurally identical to fxa-shared ClientIdCapabilityMap; duplicated to // avoid a shared/cms → fxa-shared dependency. -export type FreeAccessCapabilityMap = Record; +export type FreeAccessCapabilityMap = Record< + FreeAccessClientId, + readonly string[] +>; export interface FreeAccessProjectionEntry { capabilities: FreeAccessCapabilityMap; offeringApiIdentifiers: readonly string[]; } -export type FreeAccessProjection = Record; +export type FreeAccessProjection = Record< + FreeAccessEmail, + FreeAccessProjectionEntry +>; + +export interface FreeAccessGrant { + offeringApiIdentifier: string; + /** epoch ms, start-of-next-day UTC */ + expiresAt: number; +} + +/** + * The full per-offering access, keyed by lowercased email then lowercased + * clientId. Unlike FreeAccessProjection, offerings are NOT flattened across + * clients — each client only sees the offerings whose capabilities it holds. + */ +export type FreeAccessByClientProjection = Record< + FreeAccessEmail, + Record +>; export interface FreeAccessRecord { entitlementId: string; diff --git a/libs/shared/cms/src/lib/queries/accesses/util.spec.ts b/libs/shared/cms/src/lib/queries/accesses/util.spec.ts index 0f996ce17ec..3248d282463 100644 --- a/libs/shared/cms/src/lib/queries/accesses/util.spec.ts +++ b/libs/shared/cms/src/lib/queries/accesses/util.spec.ts @@ -7,6 +7,11 @@ import { AccessUtil } from './util'; const NOW = new Date('2026-06-01T12:00:00.000Z').getTime(); +// parseStrictDate resolves a YYYY-MM-DD grant date to start-of-next-day UTC, so a +// grant dated 2099-01-01 expires at the very start of 2099-01-02. +const EXPIRY_2099 = Date.UTC(2099, 0, 2); +const EXPIRY_2100 = Date.UTC(2100, 0, 2); + /** * Build a minimal `AccessesQuery` with a single email→matcher wiring — * keeps each test focused on the specific projection behaviour. @@ -286,3 +291,129 @@ describe('AccessUtil.project', () => { expect(entry.offeringApiIdentifiers).toEqual([]); }); }); + +describe('AccessUtil.projectByClient', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('attributes each offering only to the client whose capability it carries', () => { + // One email, one access, two offerings tied to two different clients. + const query = buildQuery([ + { + documentId: 'ent-1', + offerings: [ + { + apiIdentifier: 'vpn', + capabilities: [{ slug: 'vpn', oauthClientId: 'client-a' }], + }, + { + apiIdentifier: 'relay', + capabilities: [{ slug: 'relay', oauthClientId: 'client-b' }], + }, + ], + emails: { 'user@example.com': ['2099-01-01', ''] }, + }, + ]); + + expect(new AccessUtil(query).projectByClient()).toEqual({ + 'user@example.com': { + 'client-a': [{ offeringApiIdentifier: 'vpn', expiresAt: EXPIRY_2099 }], + 'client-b': [ + { offeringApiIdentifier: 'relay', expiresAt: EXPIRY_2099 }, + ], + }, + }); + }); + + it('lists an offering under every client it serves', () => { + const query = buildQuery([ + { + documentId: 'ent-1', + offerings: [ + { + apiIdentifier: 'vpn', + capabilities: [ + { slug: 'vpn', oauthClientId: 'Client-A' }, + { slug: 'vpn', oauthClientId: 'client-b' }, + ], + }, + ], + emails: { 'user@example.com': ['2099-01-01', ''] }, + }, + ]); + + const byClient = new AccessUtil(query).projectByClient()[ + 'user@example.com' + ]; + // oauthClientId is lowercased. + expect(byClient['client-a']).toEqual([ + { offeringApiIdentifier: 'vpn', expiresAt: EXPIRY_2099 }, + ]); + expect(byClient['client-b']).toEqual([ + { offeringApiIdentifier: 'vpn', expiresAt: EXPIRY_2099 }, + ]); + }); + + it('keeps the latest expiry when the same email/client/offering spans accesses', () => { + const query = buildQuery([ + { + documentId: 'ent-a', + offerings: [ + { + apiIdentifier: 'vpn', + capabilities: [{ slug: 'vpn', oauthClientId: 'client-a' }], + }, + ], + emails: { 'user@example.com': ['2099-01-01', ''] }, + }, + { + documentId: 'ent-b', + offerings: [ + { + apiIdentifier: 'vpn', + capabilities: [{ slug: 'vpn', oauthClientId: 'client-a' }], + }, + ], + emails: { 'user@example.com': ['2100-01-01', ''] }, + }, + ]); + + expect( + new AccessUtil(query).projectByClient()['user@example.com']['client-a'] + ).toEqual([{ offeringApiIdentifier: 'vpn', expiresAt: EXPIRY_2100 }]); + }); + + it('excludes past-expiry grants', () => { + const query = buildQuery([ + { + documentId: 'ent-stale', + offerings: [ + { + apiIdentifier: 'vpn', + capabilities: [{ slug: 'vpn', oauthClientId: 'client-a' }], + }, + ], + emails: { 'user@example.com': ['2024-01-01', ''] }, + }, + ]); + + expect(new AccessUtil(query).projectByClient()).toEqual({}); + }); + + it('excludes offerings that carry no oauth client', () => { + const query = buildQuery([ + { + documentId: 'ent-1', + offerings: [{ apiIdentifier: 'vpn', capabilities: [] }], + emails: { 'user@example.com': ['2099-01-01', ''] }, + }, + ]); + + expect(new AccessUtil(query).projectByClient()).toEqual({}); + }); +}); diff --git a/libs/shared/cms/src/lib/queries/accesses/util.ts b/libs/shared/cms/src/lib/queries/accesses/util.ts index 41104109614..b407896ea7f 100644 --- a/libs/shared/cms/src/lib/queries/accesses/util.ts +++ b/libs/shared/cms/src/lib/queries/accesses/util.ts @@ -4,8 +4,15 @@ import { AccessesQuery } from '../../../__generated__/graphql'; import { normalizeGraphQLAccess } from './utils/normalizeGraphQLAccess'; +import { parseMatcherValue } from './utils/parseMatcherValue'; +import { parseStrictDate } from './utils/parseStrictDate'; import { projectAccess } from './utils/projectAccess'; -import { FreeAccessProjection, FreeAccessProjectionEntry } from './types'; +import { + FreeAccessByClientProjection, + FreeAccessGrant, + FreeAccessProjection, + FreeAccessProjectionEntry, +} from './types'; export class AccessUtil { constructor(private rawResult: AccessesQuery) {} @@ -14,7 +21,10 @@ export class AccessUtil { const result = this.rawResult; const builder = new Map< string, - { caps: Map>; offerings: Set } + { + caps: Map>; + offerings: Set; + } >(); const now = new Date(); for (const access of result.accesses ?? []) { @@ -23,7 +33,10 @@ export class AccessUtil { for (const record of records) { let entry = builder.get(record.email); if (!entry) { - entry = { caps: new Map(), offerings: new Set() }; + entry = { + caps: new Map(), + offerings: new Set(), + }; builder.set(record.email, entry); } for (const [clientId, slugs] of Object.entries(record.capabilities)) { @@ -54,4 +67,98 @@ export class AccessUtil { } return projection; } + + /** + * Resolves the raw access into a per-email, per-client map of granted + * offerings. Each offering is attributed only to the clients whose + * capabilities it actually carries (offering → capability → service → + * oauthClientId), so a client never sees offerings granted to a different + * client on the same email — unlike project(), which flattens offerings + * across clients. + */ + projectByClient(): FreeAccessByClientProjection { + const result = this.rawResult; + const now = Date.now(); + // email -> clientId -> offeringApiIdentifier -> expiresAt (ms) + const builder = new Map>>(); + + for (const access of result.accesses ?? []) { + if (!access) continue; + + for (const matcher of access.matchers ?? []) { + if (matcher?.__typename !== 'ComponentMatchersEmailList') continue; + const emails = matcher.emails; + if (!emails || typeof emails !== 'object' || Array.isArray(emails)) { + continue; + } + + for (const [rawEmail, rawValue] of Object.entries( + emails as Record + )) { + const email = rawEmail.trim().toLowerCase(); + if (!email) continue; + const parsed = parseMatcherValue(rawValue); + if (!parsed) continue; + const expiresAtDate = parseStrictDate(parsed.dateStr); + if (!expiresAtDate) continue; + const expiresAt = expiresAtDate.getTime(); + if (expiresAt <= now) continue; + + for (const offering of access.offerings ?? []) { + const offeringApiIdentifier = offering?.apiIdentifier; + if ( + typeof offeringApiIdentifier !== 'string' || + offeringApiIdentifier.length === 0 + ) { + continue; + } + + const clientIds = new Set(); + for (const capability of offering?.capabilities ?? []) { + for (const service of capability?.services ?? []) { + const clientId = service?.oauthClientId; + if (typeof clientId === 'string' && clientId.length > 0) { + clientIds.add(clientId.toLowerCase()); + } + } + } + if (clientIds.size === 0) continue; + + let byClient = builder.get(email); + if (!byClient) { + byClient = new Map(); + builder.set(email, byClient); + } + for (const clientId of clientIds) { + let byOffering = byClient.get(clientId); + if (!byOffering) { + byOffering = new Map(); + byClient.set(clientId, byOffering); + } + // Most-generous grant wins across accesses for the same pairing. + byOffering.set( + offeringApiIdentifier, + Math.max(byOffering.get(offeringApiIdentifier) ?? 0, expiresAt) + ); + } + } + } + } + } + + const projection: FreeAccessByClientProjection = {}; + for (const [email, byClient] of builder) { + const clients: Record = {}; + for (const [clientId, byOffering] of byClient) { + clients[clientId] = [...byOffering].map( + ([offeringApiIdentifier, expiresAt]) => ({ + offeringApiIdentifier, + expiresAt, + }) + ); + } + projection[email] = clients; + } + return projection; + } } diff --git a/packages/fxa-auth-server/bin/key_server.js b/packages/fxa-auth-server/bin/key_server.js index 97d157a1831..ce3701fb663 100755 --- a/packages/fxa-auth-server/bin/key_server.js +++ b/packages/fxa-auth-server/bin/key_server.js @@ -411,10 +411,11 @@ async function run(config) { ); const freeAccessService = new FreeAccessProgramService( freeAccessConfigurationManager, - freeAccessJournalManager, - freeAccessNotifier, + accountManager, statsd, - log + log, + freeAccessJournalManager, + freeAccessNotifier ); Container.set(FreeAccessProgramService, freeAccessService); } diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/free-access-program-webhook.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/free-access-program-webhook.spec.ts index 56d7b2a1326..3c022858934 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/free-access-program-webhook.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/free-access-program-webhook.spec.ts @@ -5,34 +5,27 @@ import { createMock } from '@golevelup/ts-jest'; import Boom from '@hapi/boom'; -import { FreeAccessProgramWebhookHandler } from './free-access-program-webhook'; +import { freeAccessProgramWebhookRoutes } from './free-access-program-webhook'; import { AuthLogger } from '../../types'; -describe('FreeAccessProgramWebhookHandler', () => { - let handler: FreeAccessProgramWebhookHandler; +// Filtering/dedupe internals are covered by the shared-function spec; this suite +// verifies the Hapi route: auth-error → Boom 401, reconcile wiring, result passthrough. +describe('freeAccessProgramWebhookRoutes', () => { let log: any; let strapiClient: { verifyWebhookSignature: jest.Mock }; let reconciler: { reconcile: jest.Mock }; - beforeEach(() => { - log = createMock(); - strapiClient = { verifyWebhookSignature: jest.fn().mockReturnValue(true) }; - reconciler = { - reconcile: jest.fn().mockResolvedValue({ changed: 0 }), - }; - handler = new FreeAccessProgramWebhookHandler( + const route = () => { + const routes = freeAccessProgramWebhookRoutes( log, strapiClient as any, reconciler as any ); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); + return routes[0]; + }; - function buildRequest(overrides: Record = {}): any { - return { + const invoke = (overrides: Record = {}) => { + const request: any = { headers: { authorization: 'Bearer secret' }, payload: { event: 'entry.publish', @@ -41,125 +34,50 @@ describe('FreeAccessProgramWebhookHandler', () => { ...overrides, }, }; - } + return (route().handler as any)(request); + }; + + beforeEach(() => { + // fxa-auth-server's jest config sets clearMocks: true, and each mock is + // reassigned below, so no explicit clear is needed here. + log = createMock(); + strapiClient = { verifyWebhookSignature: jest.fn().mockReturnValue(true) }; + reconciler = { reconcile: jest.fn().mockResolvedValue({ changed: 0 }) }; + }); - it('throws unauthorized when the Strapi signature is invalid', async () => { + it('registers the access webhook route', () => { + const r = route(); + expect(r.method).toBe('POST'); + expect(r.path).toBe('/webhooks/strapi/free-access-program/access'); + }); + + it('maps an invalid signature to Boom 401', async () => { strapiClient.verifyWebhookSignature.mockReturnValue(false); - await expect(handler.postAccess(buildRequest())).rejects.toMatchObject({ + await expect(invoke()).rejects.toMatchObject({ isBoom: true, output: { statusCode: 401 }, }); expect(reconciler.reconcile).not.toHaveBeenCalled(); }); - it('throws unauthorized for an empty authorization header', async () => { + it('throws Boom.unauthorized for an empty authorization header', async () => { strapiClient.verifyWebhookSignature.mockReturnValue(false); - const req = buildRequest(); - req.headers.authorization = ''; - await expect(handler.postAccess(req)).rejects.toThrow( + await expect(invoke()).rejects.toThrow( Boom.unauthorized('Invalid Strapi webhook signature') ); }); - it('skips with reason "model" when the payload is not for access', async () => { - const result = await handler.postAccess( - buildRequest({ model: 'something-else' }) - ); - expect(result).toEqual({ handled: false, reason: 'model' }); - expect(reconciler.reconcile).not.toHaveBeenCalled(); - }); - - it('skips with reason "no_document_id" when entry.documentId is missing', async () => { - const result = await handler.postAccess(buildRequest({ entry: {} })); - expect(result).toEqual({ handled: false, reason: 'no_document_id' }); - expect(reconciler.reconcile).not.toHaveBeenCalled(); - }); - - it.each([ - ['entry.publish'], - ['entry.update'], - ['entry.unpublish'], - ['entry.delete'], - ])('dispatches a %s event to reconciler.reconcile()', async (event) => { - const result = await handler.postAccess(buildRequest({ event })); + it('dispatches a valid access event to reconciler.reconcile()', async () => { + const result = await invoke(); expect(reconciler.reconcile).toHaveBeenCalledTimes(1); expect(result).toEqual({ handled: true }); }); - it('skips with reason "event" for unknown event types', async () => { - const result = await handler.postAccess( - buildRequest({ event: 'entry.something-else' }) - ); - expect(result).toEqual({ handled: false, reason: 'event' }); + it('returns the core skip result verbatim', async () => { + const result = await invoke({ model: 'something-else' }); + expect(result).toEqual({ handled: false, reason: 'model' }); expect(reconciler.reconcile).not.toHaveBeenCalled(); }); - - it('returns 200 even when the reconciler throws (Strapi must not retry)', async () => { - reconciler.reconcile.mockRejectedValue(new Error('boom')); - const result = await handler.postAccess(buildRequest()); - expect(result).toEqual({ handled: true }); - expect(log.error).toHaveBeenCalled(); - }); - - describe('replay dedupe', () => { - it('dedupes a second identical webhook on (event, documentId, createdAt)', async () => { - const payload = { - event: 'entry.publish', - model: 'access', - createdAt: '2026-06-23T12:00:00.000Z', - entry: { documentId: 'ent-1' }, - }; - - const first = await handler.postAccess(buildRequest(payload)); - const second = await handler.postAccess(buildRequest(payload)); - - expect(reconciler.reconcile).toHaveBeenCalledTimes(1); - expect(first).toEqual({ handled: true }); - expect(second).toEqual({ handled: true, dedupe: true }); - }); - - it('does not dedupe webhooks with different createdAt timestamps', async () => { - await handler.postAccess( - buildRequest({ - createdAt: '2026-06-23T12:00:00.000Z', - entry: { documentId: 'ent-1' }, - }) - ); - await handler.postAccess( - buildRequest({ - createdAt: '2026-06-23T12:00:01.000Z', - entry: { documentId: 'ent-1' }, - }) - ); - - expect(reconciler.reconcile).toHaveBeenCalledTimes(2); - }); - - it('does not dedupe events that short-circuited before the dedupe check', async () => { - await handler.postAccess(buildRequest({ model: 'other' })); - const second = await handler.postAccess(buildRequest({ model: 'other' })); - expect(second).toEqual({ handled: false, reason: 'model' }); - }); - - it('re-accepts a payload after the dedupe TTL elapses', async () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-06-23T12:00:00.000Z')); - const payload = { - event: 'entry.publish', - model: 'access', - createdAt: '2026-06-23T12:00:00.000Z', - entry: { documentId: 'ent-1' }, - }; - - await handler.postAccess(buildRequest(payload)); - jest.setSystemTime(new Date('2026-06-23T12:01:01.000Z')); // +61s - const second = await handler.postAccess(buildRequest(payload)); - - expect(reconciler.reconcile).toHaveBeenCalledTimes(2); - expect(second).toEqual({ handled: true }); - jest.useRealTimers(); - }); - }); }); diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/free-access-program-webhook.ts b/packages/fxa-auth-server/lib/routes/subscriptions/free-access-program-webhook.ts index 85ad0f1cd09..7bf68709422 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/free-access-program-webhook.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/free-access-program-webhook.ts @@ -6,105 +6,17 @@ import Boom from '@hapi/boom'; import { ServerRoute } from '@hapi/hapi'; import isA from 'joi'; -import { FreeAccessProgramService } from '@fxa/free-access-program'; +import { + classifyAccessWebhook, + FreeAccessProgramService, + isDuplicateAccessWebhook, + type FreeAccessProgramWebhookResult, + type StrapiAccessWebhookPayload, +} from '@fxa/free-access-program'; import { StrapiClient } from '@fxa/shared/cms'; import type { AuthLogger, AuthRequest } from '../../types'; -const TARGET_MODEL = 'access'; -const UPSERT_EVENTS = new Set(['entry.publish', 'entry.update']); -const DELETE_EVENTS = new Set(['entry.unpublish', 'entry.delete']); - -// Dedupe replays of Strapi's static bearer token; longer than realistic retry intervals. -const DEDUPE_TTL_MS = 60_000; -const DEDUPE_MAX_ENTRIES = 1000; - -type StrapiAccessWebhookPayload = { - event: string; - model?: string; - createdAt?: string; - entry?: { - documentId?: string; - [k: string]: unknown; - }; - [k: string]: unknown; -}; - -export class FreeAccessProgramWebhookHandler { - private readonly seenEvents = new Map(); - - constructor( - private log: AuthLogger, - private strapiClient: StrapiClient, - private reconciler: FreeAccessProgramService - ) {} - - async postAccess(request: AuthRequest) { - this.log.begin('subscriptions.freeAccessProgramWebhook', request); - - const authorization = - (request.headers.authorization as string | undefined) ?? ''; - if (!this.strapiClient.verifyWebhookSignature(authorization)) { - throw Boom.unauthorized('Invalid Strapi webhook signature'); - } - - const payload = request.payload as StrapiAccessWebhookPayload; - if (payload.model !== TARGET_MODEL) { - return { handled: false, reason: 'model' as const }; - } - - const documentId = payload.entry?.documentId; - if (!documentId) { - return { handled: false, reason: 'no_document_id' as const }; - } - - const dedupeKey = `${payload.event}|${documentId}|${payload.createdAt ?? ''}`; - if (this.isReplay(dedupeKey)) { - return { handled: true, dedupe: true as const }; - } - this.markSeen(dedupeKey); - - if ( - !UPSERT_EVENTS.has(payload.event) && - !DELETE_EVENTS.has(payload.event) - ) { - return { handled: false, reason: 'event' as const }; - } - - try { - await this.reconciler.reconcile(); - } catch (err) { - // Swallow: the periodic cron sweep is the backstop. - this.log.error( - 'subscriptions.freeAccessProgramWebhook.reconcile.error', - { err } - ); - } - - return { handled: true }; - } - - private isReplay(key: string): boolean { - const expiresAt = this.seenEvents.get(key); - if (expiresAt === undefined) return false; - if (expiresAt <= Date.now()) { - this.seenEvents.delete(key); - return false; - } - return true; - } - - private markSeen(key: string): void { - const now = Date.now(); - if (this.seenEvents.size >= DEDUPE_MAX_ENTRIES) { - for (const [k, exp] of this.seenEvents) { - if (exp <= now) this.seenEvents.delete(k); - } - } - this.seenEvents.set(key, now + DEDUPE_TTL_MS); - } -} - const payloadSchema = isA .object({ event: isA.string().required(), @@ -125,11 +37,7 @@ export const freeAccessProgramWebhookRoutes = ( strapiClient: StrapiClient, reconciler: FreeAccessProgramService ): ServerRoute[] => { - const handler = new FreeAccessProgramWebhookHandler( - log, - strapiClient, - reconciler - ); + const seenEvents = new Map(); return [ { @@ -153,7 +61,41 @@ export const freeAccessProgramWebhookRoutes = ( .unknown(false), }, }, - handler: (request: AuthRequest) => handler.postAccess(request), + handler: async ( + request: AuthRequest + ): Promise => { + log.begin('subscriptions.freeAccessProgramWebhook', request); + + const authorization = + (request.headers.authorization as string | undefined) ?? ''; + if (!strapiClient.verifyWebhookSignature(authorization)) { + throw Boom.unauthorized('Invalid Strapi webhook signature'); + } + + const classification = classifyAccessWebhook( + request.payload as StrapiAccessWebhookPayload + ); + if ('skip' in classification) { + return { handled: false, reason: classification.skip }; + } + + if ( + isDuplicateAccessWebhook(seenEvents, classification.dedupeKey, Date.now()) + ) { + return { handled: true, dedupe: true }; + } + + try { + await reconciler.reconcile(); + } catch (err) { + // Swallow: the periodic cron sweep is the backstop. + log.error('subscriptions.freeAccessProgramWebhook.reconcile.error', { + err, + }); + } + + return { handled: true }; + }, }, ]; }; diff --git a/packages/fxa-auth-server/scripts/free-access-program-reconcile.ts b/packages/fxa-auth-server/scripts/free-access-program-reconcile.ts index 47f4da4a7fc..a99237b8c9d 100644 --- a/packages/fxa-auth-server/scripts/free-access-program-reconcile.ts +++ b/packages/fxa-auth-server/scripts/free-access-program-reconcile.ts @@ -6,7 +6,9 @@ import Container from 'typedi'; import { Logger } from '@nestjs/common'; import { StatsD } from 'hot-shots'; +import { AccountManager } from '@fxa/shared/account/account'; import { FirestoreService } from '@fxa/shared/db/firestore'; +import { setupAccountDatabase } from '@fxa/shared/db/mysql/account'; import { StatsDService } from '@fxa/shared/metrics/statsd'; import { FreeAccessProgramConfigurationManager, @@ -100,12 +102,17 @@ async function main() { firestore ); Container.set(FreeAccessProgramJournalManager, journalManager); + const accountDatabase = await setupAccountDatabase(config.database.mysql.auth); + const accountManager = new AccountManager(accountDatabase); + Container.set(AccountManager, accountManager); + const service = new FreeAccessProgramService( configurationManager, - journalManager, - notifier, + accountManager, statsd, - log as any + log as any, + journalManager, + notifier ); Container.set(FreeAccessProgramService, service);