diff --git a/apps/payments/api/src/config/index.ts b/apps/payments/api/src/config/index.ts index 3bdf0edba9a..c5b0eabd71f 100644 --- a/apps/payments/api/src/config/index.ts +++ b/apps/payments/api/src/config/index.ts @@ -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 { diff --git a/apps/payments/next/.env b/apps/payments/next/.env index b8f390f63f2..b98a98fecb7 100644 --- a/apps/payments/next/.env +++ b/apps/payments/next/.env @@ -99,6 +99,13 @@ CHURN_INTERVENTION_CONFIG__ENABLED= # Free Trial Config FREE_TRIAL_CONFIG__FIRESTORE_COLLECTION_NAME=freeTrials +# Free Access Program Client Config +# NB: Ideally this matches the config in payments-api +FREE_ACCESS_PROGRAM_CLIENT_CONFIG__FIRESTORE_CACHE_COLLECTION_NAME=subplat-free-access-program-cache +FREE_ACCESS_PROGRAM_CLIENT_CONFIG__MEM_CACHE_T_T_L=300 # 5 minutes +FREE_ACCESS_PROGRAM_CLIENT_CONFIG__FIRESTORE_CACHE_TTL=1800 # 30 minutes +FREE_ACCESS_PROGRAM_CLIENT_CONFIG__FIRESTORE_OFFLINE_CACHE_TTL=604800 # 7 days + # StatsD Config STATS_D_CONFIG__SAMPLE_RATE= STATS_D_CONFIG__MAX_BUFFER_SIZE= diff --git a/apps/payments/next/app/[locale]/subscriptions/manage/en.ftl b/apps/payments/next/app/[locale]/subscriptions/manage/en.ftl index b80ce35e9c2..e7c57afd997 100644 --- a/apps/payments/next/app/[locale]/subscriptions/manage/en.ftl +++ b/apps/payments/next/app/[locale]/subscriptions/manage/en.ftl @@ -5,6 +5,8 @@ subscription-management-page-banner-warning-link-no-payment-method = Add a payme subscription-management-subscriptions-heading = Subscriptions subscription-management-free-trial-heading = Free trials subscription-management-your-free-trials-aria = Your free trials +subscription-management-free-access-heading = Services included with your account +subscription-management-your-free-access-aria = Services included with your account # Heading for mobile only quick links menu subscription-management-jump-to-heading = Jump to diff --git a/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx b/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx index 20f3617eeb3..9d27584f714 100644 --- a/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx +++ b/apps/payments/next/app/[locale]/subscriptions/manage/page.tsx @@ -14,6 +14,7 @@ import { SubPlatPaymentMethodType } from '@fxa/payments/customer'; import { Banner, BannerVariant, + FreeAccessContent, formatPlanInterval, FreeTrialContent, getCardIcon, @@ -76,6 +77,7 @@ export default async function Manage({ appleIapSubscriptions, googleIapSubscriptions, trialSubscriptions, + freeAccess, } = await getSubManPageContentAction( { ...resolvedParams }, { ...resolvedSearchParams }, @@ -268,6 +270,44 @@ export default async function Manage({ )} + {freeAccess && freeAccess.length > 0 && ( +
+

+ {l10n.getString( + 'subscription-management-free-access-heading', + 'Services included with your account' + )} +

+ +
+ )} + {trialSubscriptions.length > 0 && (
Number) + @IsNumber() + public readonly memCacheTTL?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + public readonly firestoreCacheTTL?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + public readonly firestoreOfflineCacheTTL?: number; +} + +export const MockFreeAccessProgramClientConfig = { + firestoreCacheCollectionName: faker.string.uuid(), +} satisfies FreeAccessProgramClientConfig; + +export const MockFreeAccessProgramClientConfigProvider = { + provide: FreeAccessProgramClientConfig, + useValue: MockFreeAccessProgramClientConfig, +} satisfies Provider; diff --git a/libs/free-access-program/src/lib/free-access-program.factories.ts b/libs/free-access-program/src/lib/free-access-program.factories.ts new file mode 100644 index 00000000000..7667f91c674 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.factories.ts @@ -0,0 +1,135 @@ +/* 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 type { + CapabilityChange, + ClientIdCapabilityMap, + FreeAccessRecord, + FreeAccessSnapshot, + NormalizedAccess, + ProjectionResult, + ProjectionSkip, + ProjectionSkipReason, + ReconcileResult, +} from './free-access-program.types'; +import { buildSnapshotKey } from './util/buildSnapshotKey'; + +/* ---------------- Domain factories ---------------- */ + +export const ClientIdCapabilityMapFactory = ( + override?: ClientIdCapabilityMap +): ClientIdCapabilityMap => + override ?? { + [`client-${faker.string.alphanumeric({ length: 8 }).toLowerCase()}`]: [ + `cap-${faker.string.alphanumeric({ length: 8 })}`, + ], + }; + +export const FreeAccessRecordFactory = ( + override?: Partial +): FreeAccessRecord => ({ + entitlementId: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + offeringApiIdentifiers: [ + `offering-${faker.string.alphanumeric({ length: 8 })}`, + ], + capabilities: ClientIdCapabilityMapFactory(), + expiresAt: faker.date.future().getTime(), + description: faker.lorem.sentence(), + internalName: faker.company.name(), + createdAt: faker.date.recent().getTime(), + ...override, +}); + +/** + * Build a snapshot from a list of records, keyed by the same composite id + * the manager would use at runtime. + */ +export const FreeAccessSnapshotFactory = ( + records?: ReadonlyArray +): FreeAccessSnapshot => { + const source = records ?? [FreeAccessRecordFactory()]; + const out: FreeAccessSnapshot = {}; + for (const record of source) { + out[buildSnapshotKey(record.entitlementId, record.email)] = record; + } + return out; +}; + +/* ---------------- Reconciler factories ---------------- */ + +export const CapabilityChangeFactory = ( + override?: Partial +): CapabilityChange => ({ + email: faker.internet.email().toLowerCase(), + entitlementId: faker.string.uuid(), + added: [`cap-${faker.string.alphanumeric({ length: 6 })}`], + ...override, +}); + +export const ReconcileResultFactory = ( + override?: Partial +): ReconcileResult => ({ + upserted: 0, + deleted: 0, + ...override, +}); + +/* ---------------- Projector factories ---------------- */ +// +// GraphQL-shaped factories (`AccessResultFactory`, `AccessOfferingFactory`, +// etc.) live in `@fxa/shared/cms` — re-use them rather than duplicating. + +export const NormalizedAccessFactory = ( + override?: Partial +): NormalizedAccess => ({ + documentId: faker.string.uuid(), + internalName: faker.company.name(), + offeringApiIdentifiers: [ + `offering-${faker.string.alphanumeric({ length: 8 })}`, + ], + capabilities: [ + { + slug: `cap-${faker.string.alphanumeric({ length: 8 })}`, + services: [ + { + oauthClientId: `client-${faker.string + .alphanumeric({ length: 8 }) + .toLowerCase()}`, + }, + ], + }, + ], + emailLists: [ + { [faker.internet.email().toLowerCase()]: ['2099-12-31', ''] }, + ], + ...override, +}); + +const PROJECTION_SKIP_REASONS: readonly ProjectionSkipReason[] = [ + 'missing-document-id', + 'no-capabilities', + 'malformed-emails', + 'array-email-form', + 'empty-email', + 'malformed-tuple', + 'invalid-date', + 'past-expiry', +]; + +export const ProjectionSkipFactory = ( + override?: Partial +): ProjectionSkip => ({ + reason: faker.helpers.arrayElement(PROJECTION_SKIP_REASONS), + ...override, +}); + +export const ProjectionResultFactory = ( + override?: Partial +): ProjectionResult => ({ + records: [FreeAccessRecordFactory()], + skipped: [], + ...override, +}); diff --git a/libs/free-access-program/src/lib/free-access-program.manager.spec.ts b/libs/free-access-program/src/lib/free-access-program.manager.spec.ts new file mode 100644 index 00000000000..d0ec09c7e48 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.manager.spec.ts @@ -0,0 +1,421 @@ +/* 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 } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; + +import { StrapiClient } from '@fxa/shared/cms'; +import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; + +import { FreeAccessProgramManager } from './free-access-program.manager'; +import { MockFreeAccessProgramClientConfigProvider } from './free-access-program.client.config'; +import type { FreeAccessSnapshot } from './free-access-program.types'; +import { buildSnapshotKey } from './util/buildSnapshotKey'; + +// Decorators are pass-through in unit tests: the cache layer is exercised +// in integration, not here. Mirrors strapi.client.spec.ts. +jest.mock('@type-cacheable/core', () => { + const noopDecorator = + () => + (_target: any, _key: string | symbol, descriptor: PropertyDescriptor) => + descriptor; + return { + __esModule: true, + default: { setOptions: jest.fn() }, + Cacheable: jest.fn(() => noopDecorator), + CacheClear: jest.fn(() => noopDecorator), + }; +}); + +jest.mock('@fxa/shared/db/type-cacheable', () => ({ + MemoryAdapter: jest.fn().mockImplementation(() => ({})), + FirestoreAdapter: jest.fn().mockImplementation(() => ({})), + CacheFirstStrategy: jest.fn().mockImplementation(() => ({})), + StaleWhileRevalidateWithFallbackStrategy: jest + .fn() + .mockImplementation(() => ({})), +})); + +describe('FreeAccessProgramManager', () => { + let manager: FreeAccessProgramManager; + let strapiClient: { queryUncached: jest.Mock }; + const NOW = new Date('2026-06-01T12:00:00.000Z').getTime(); + + beforeEach(async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + strapiClient = { + queryUncached: jest.fn(), + }; + const moduleRef = await Test.createTestingModule({ + providers: [ + MockFreeAccessProgramClientConfigProvider, + MockFirestoreProvider, + { provide: StrapiClient, useValue: strapiClient }, + { + provide: Logger, + useValue: { error: jest.fn(), log: jest.fn(), warn: jest.fn() }, + }, + FreeAccessProgramManager, + ], + }).compile(); + manager = moduleRef.get(FreeAccessProgramManager); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('findCapabilitiesForEmail', () => { + it('returns an empty map for null email without querying Strapi', async () => { + expect(await manager.findCapabilitiesForEmail(null)).toEqual({}); + expect(strapiClient.queryUncached).not.toHaveBeenCalled(); + }); + + it('returns an empty map for undefined email without querying Strapi', async () => { + expect(await manager.findCapabilitiesForEmail(undefined)).toEqual({}); + expect(strapiClient.queryUncached).not.toHaveBeenCalled(); + }); + + it('returns an empty map when the snapshot has no records for the email', async () => { + withStrapiAccesses([ + { + documentId: 'ent-1', + internalName: 'VPN', + offerings: [ + { + capabilities: [ + { slug: 'vpn', services: [{ oauthClientId: 'client-a' }] }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'someone@example.com': ['2027-01-01', ''] }, + }, + ], + }, + ]); + + expect( + await manager.findCapabilitiesForEmail('nobody@example.com') + ).toEqual({}); + }); + + it('returns the capability map for a matching email (lowercased)', async () => { + withStrapiAccesses([ + { + documentId: 'ent-1', + internalName: 'VPN', + offerings: [ + { + capabilities: [ + { slug: 'vpn', services: [{ oauthClientId: 'client-a' }] }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'User@Example.com': ['2027-01-01', ''] }, + }, + ], + }, + ]); + + const result = await manager.findCapabilitiesForEmail( + 'USER@example.com' + ); + expect(result).toEqual({ 'client-a': ['vpn'] }); + }); + + it('merges and dedupes capabilities across multiple entitlements for the same email', async () => { + withStrapiAccesses([ + { + documentId: 'ent-a', + internalName: 'A', + offerings: [ + { + capabilities: [ + { + slug: 'cap-foo', + services: [{ oauthClientId: 'client-1' }], + }, + { + slug: 'cap-bar', + services: [{ oauthClientId: 'client-1' }], + }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'user@example.com': ['2027-01-01', ''] }, + }, + ], + }, + { + documentId: 'ent-b', + internalName: 'B', + offerings: [ + { + capabilities: [ + { + slug: 'cap-bar', + services: [{ oauthClientId: 'client-1' }], + }, + { + slug: 'cap-baz', + services: [{ oauthClientId: 'client-1' }], + }, + { + slug: 'cap-qux', + services: [{ oauthClientId: 'client-2' }], + }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'user@example.com': ['2027-01-01', ''] }, + }, + ], + }, + ]); + + const result = await manager.findCapabilitiesForEmail( + 'user@example.com' + ); + expect([...result['client-1']].sort()).toEqual([ + 'cap-bar', + 'cap-baz', + 'cap-foo', + ]); + expect(result['client-2']).toEqual(['cap-qux']); + }); + + it('filters out records whose expiresAt is in the past', async () => { + // YYYY-MM-DD '2025-12-31' expires at 2026-01-01T00:00:00Z (start of + // next day in UTC) — already passed relative to NOW = 2026-06-01. + withStrapiAccesses([ + { + documentId: 'ent-stale', + internalName: 'Stale', + offerings: [ + { + capabilities: [ + { slug: 'vpn', services: [{ oauthClientId: 'client-a' }] }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'user@example.com': ['2025-12-31', ''] }, + }, + ], + }, + ]); + + expect( + await manager.findCapabilitiesForEmail('user@example.com') + ).toEqual({}); + }); + }); + + describe('findOfferingIdsForEmail', () => { + it('returns an empty array for null/undefined email without querying Strapi', async () => { + expect(await manager.findOfferingIdsForEmail(null)).toEqual([]); + expect(await manager.findOfferingIdsForEmail(undefined)).toEqual([]); + expect(strapiClient.queryUncached).not.toHaveBeenCalled(); + }); + + it('returns the deduped offering apiIdentifiers for matching active records', async () => { + withStrapiAccesses([ + { + documentId: 'ent-a', + internalName: 'A', + 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': ['2027-01-01', ''] }, + }, + ], + }, + { + documentId: 'ent-b', + internalName: 'B', + offerings: [ + { + apiIdentifier: 'vpn', // duplicates ent-a's vpn → must dedupe + capabilities: [ + { slug: 'vpn', services: [{ oauthClientId: 'client-a' }] }, + ], + }, + { + apiIdentifier: 'monitor', + capabilities: [ + { slug: 'monitor', services: [{ oauthClientId: 'client-c' }] }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'user@example.com': ['2027-01-01', ''] }, + }, + ], + }, + ]); + + const result = await manager.findOfferingIdsForEmail( + 'user@example.com' + ); + expect(result.sort()).toEqual(['monitor', 'relay', 'vpn']); + }); + + it('excludes offerings from expired records', async () => { + withStrapiAccesses([ + { + documentId: 'ent-stale', + internalName: 'Stale', + offerings: [ + { + apiIdentifier: 'vpn', + capabilities: [ + { slug: 'vpn', services: [{ oauthClientId: 'client-a' }] }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'user@example.com': ['2025-12-31', ''] }, + }, + ], + }, + ]); + + expect( + await manager.findOfferingIdsForEmail('user@example.com') + ).toEqual([]); + }); + }); + + describe('getFreshProjection', () => { + it('returns the projected snapshot keyed by entitlement_email', async () => { + withStrapiAccesses([ + { + documentId: 'ent-1', + internalName: 'VPN', + offerings: [ + { + capabilities: [ + { slug: 'vpn', services: [{ oauthClientId: 'client-a' }] }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'alice@example.com': ['2027-01-01', 'VIP'] }, + }, + ], + }, + ]); + + const snapshot = await manager.getFreshProjection(); + const key = buildSnapshotKey('ent-1', 'alice@example.com'); + expect(snapshot[key]).toBeDefined(); + expect(snapshot[key]?.email).toBe('alice@example.com'); + expect(snapshot[key]?.description).toBe('VIP'); + }); + + it('skips entries the projector rejects (e.g. past-expiry)', async () => { + withStrapiAccesses([ + { + documentId: 'ent-stale', + internalName: 'Stale', + offerings: [ + { + capabilities: [ + { slug: 'vpn', services: [{ oauthClientId: 'client-a' }] }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'user@example.com': ['2024-01-01', ''] }, + }, + ], + }, + ]); + + const snapshot = await manager.getFreshProjection(); + expect(Object.keys(snapshot)).toHaveLength(0); + }); + + it('emits one snapshot entry per email per access', async () => { + withStrapiAccesses([ + { + documentId: 'ent-1', + internalName: 'VPN', + offerings: [ + { + capabilities: [ + { slug: 'vpn', services: [{ oauthClientId: 'client-a' }] }, + ], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { + 'alice@example.com': ['2027-01-01', ''], + 'bob@example.com': ['2027-01-01', ''], + }, + }, + ], + }, + ]); + + const snapshot = await manager.getFreshProjection(); + expect(Object.keys(snapshot).sort()).toEqual([ + buildSnapshotKey('ent-1', 'alice@example.com'), + buildSnapshotKey('ent-1', 'bob@example.com'), + ]); + }); + }); + + describe('invalidateSnapshotCache', () => { + it('resolves without throwing (decorators stripped in unit context)', async () => { + await expect(manager.invalidateSnapshotCache()).resolves.toBeUndefined(); + }); + }); + + function withStrapiAccesses(accesses: any[]) { + strapiClient.queryUncached.mockResolvedValue({ accesses }); + } + + // Type marker for consumers reading from the returned snapshot. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type _Snapshot = FreeAccessSnapshot; +}); diff --git a/libs/free-access-program/src/lib/free-access-program.manager.ts b/libs/free-access-program/src/lib/free-access-program.manager.ts new file mode 100644 index 00000000000..502882d8fab --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.manager.ts @@ -0,0 +1,194 @@ +/* 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 } from '@nestjs/common'; +import type { LoggerService } from '@nestjs/common'; +import { Cacheable, CacheClear } from '@type-cacheable/core'; +import { Firestore } from '@google-cloud/firestore'; +import * as Sentry from '@sentry/node'; + +import { accessesQuery, StrapiClient } from '@fxa/shared/cms'; +import { FirestoreService } from '@fxa/shared/db/firestore'; +import { + CacheFirstStrategy, + FirestoreAdapter, + MemoryAdapter, + StaleWhileRevalidateWithFallbackStrategy, +} from '@fxa/shared/db/type-cacheable'; + +import { FreeAccessProgramClientConfig } from './free-access-program.client.config'; +import type { + ClientIdCapabilityMap, + FreeAccessRecord, + FreeAccessSnapshot, +} from './free-access-program.types'; +import { buildSnapshotKey } from './util/buildSnapshotKey'; +import { mergeCapabilities } from './util/mergeCapabilities'; +import { normalizeGraphQLAccess } from './util/normalizeGraphQLAccess'; +import { projectAccess } from './util/projectAccess'; + +const SNAPSHOT_CACHE_KEY = 'freeAccessProgramSnapshot'; + +// Defaults match StrapiClientConfig (libs/shared/cms/src/lib/strapi.client.ts) +// so a single Strapi-degraded window stays survivable end-to-end. +const DEFAULT_FIRESTORE_OFFLINE_CACHE_TTL_SECONDS = 604800; // 7 days +const DEFAULT_FIRESTORE_CACHE_TTL_SECONDS = 1800; // 30 minutes +const DEFAULT_MEM_CACHE_TTL_SECONDS = 300; // 5 minutes + +/** + * Source of truth for the projected free-access-program snapshot used by + * auth-server's per-request capability lookup and by the reconciler's + * diff-and-notify path. + * + * Reads go through a two-layer `type-cacheable` stack identical to + * `libs/shared/cms/src/lib/strapi.client.ts`: + * - inner: `MemoryAdapter` + `CacheFirstStrategy` for hot single-instance reads. + * - outer: `FirestoreAdapter` + `StaleWhileRevalidateWithFallbackStrategy` + * for cross-instance sharing, cold-cache fill, and degraded-Strapi + * fallback (the 7-day offline TTL keeps lookups working). + * + * The snapshot is a singleton — every read returns the full projection and + * filters in-memory. + */ +@Injectable() +export class FreeAccessProgramManager { + private memoryCacheAdapter: MemoryAdapter; + private firestoreCacheAdapter: FirestoreAdapter; + + constructor( + private config: FreeAccessProgramClientConfig, + private strapiClient: StrapiClient, + @Inject(FirestoreService) firestore: Firestore, + @Inject(Logger) private log: LoggerService + ) { + this.memoryCacheAdapter = new MemoryAdapter(); + this.firestoreCacheAdapter = new FirestoreAdapter( + firestore, + this.config.firestoreCacheCollectionName + ); + } + + /** + * Resolve `{ clientId → capabilities[] }` for the given email by reading + * the cached snapshot. Expired grants are filtered out by `expiresAt` + * so the read path doesn't depend on an external sweeper. + */ + async findCapabilitiesForEmail( + email?: string | null + ): Promise { + if (!email) return {}; + return mergeCapabilities(await this.findActiveRecordsForEmail(email)); + } + + /** + * Deduped list of offering `apiIdentifier`s the email currently has + * free access to. Used by the subscription-management page to render + * one card per offering, after filtering against the user's active + * stripe subscriptions. + */ + async findOfferingIdsForEmail( + email?: string | null + ): Promise { + if (!email) return []; + const records = await this.findActiveRecordsForEmail(email); + const ids = new Set(); + for (const record of records) { + for (const id of record.offeringApiIdentifiers ?? []) { + if (id) ids.add(id); + } + } + return [...ids]; + } + + private async findActiveRecordsForEmail( + email: string + ): Promise { + const snapshot = await this.getCachedProjection(); + const normalizedEmail = email.toLowerCase(); + const now = Date.now(); + const matching: FreeAccessRecord[] = []; + for (const record of Object.values(snapshot)) { + if (record.email !== normalizedEmail) continue; + if (record.expiresAt <= now) continue; + matching.push(record); + } + return matching; + } + + /** + * Read-path entry point: returns the cached projection, refreshing + * through Strapi when both cache layers miss. + */ + @Cacheable({ + cacheKey: () => SNAPSHOT_CACHE_KEY, + strategy: (_args: any, context: FreeAccessProgramManager) => + new CacheFirstStrategy( + (err) => Sentry.captureException(err), + () => {}, + context.log + ), + ttlSeconds: (_args: any, context: FreeAccessProgramManager) => + context.config.memCacheTTL ?? DEFAULT_MEM_CACHE_TTL_SECONDS, + client: (_args: any, context: FreeAccessProgramManager) => + context.memoryCacheAdapter, + }) + @Cacheable({ + cacheKey: () => SNAPSHOT_CACHE_KEY, + strategy: (_args: any, context: FreeAccessProgramManager) => + new StaleWhileRevalidateWithFallbackStrategy( + context.config.firestoreCacheTTL ?? DEFAULT_FIRESTORE_CACHE_TTL_SECONDS, + (err) => Sentry.captureException(err), + () => {}, + context.log + ), + ttlSeconds: (_args: any, context: FreeAccessProgramManager) => + context.config.firestoreOfflineCacheTTL ?? + DEFAULT_FIRESTORE_OFFLINE_CACHE_TTL_SECONDS, + client: (_args: any, context: FreeAccessProgramManager) => + context.firestoreCacheAdapter, + }) + async getCachedProjection(): Promise { + return this.fetchAndProject(); + } + + /** + * Reconciler-side entry point: always re-fetches from Strapi, skipping + * the cache. Used to compute the "after" state when diffing a webhook or + * cron sweep against the cached "before". + */ + async getFreshProjection(): Promise { + return this.fetchAndProject(); + } + + /** + * Invalidate both cache layers for the snapshot key. Called by the + * reconciler after fan-out so the next read picks up the new state. + */ + @CacheClear({ + cacheKey: () => SNAPSHOT_CACHE_KEY, + client: (_args: any, context: FreeAccessProgramManager) => + context.memoryCacheAdapter, + }) + @CacheClear({ + cacheKey: () => SNAPSHOT_CACHE_KEY, + client: (_args: any, context: FreeAccessProgramManager) => + context.firestoreCacheAdapter, + }) + async invalidateSnapshotCache(): Promise {} + + private async fetchAndProject(): Promise { + const result = await this.strapiClient.queryUncached(accessesQuery, {}); + const snapshot: FreeAccessSnapshot = {}; + const now = new Date(); + for (const access of result.accesses ?? []) { + if (!access) continue; + const { records } = projectAccess(normalizeGraphQLAccess(access), now); + for (const record of records) { + snapshot[buildSnapshotKey(record.entitlementId, record.email)] = + record; + } + } + return snapshot; + } +} diff --git a/libs/free-access-program/src/lib/free-access-program.reconciler.service.spec.ts b/libs/free-access-program/src/lib/free-access-program.reconciler.service.spec.ts new file mode 100644 index 00000000000..774eea6346e --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.reconciler.service.spec.ts @@ -0,0 +1,309 @@ +/* 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 } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import type { StatsD } from 'hot-shots'; + +import { StatsDService } from '@fxa/shared/metrics/statsd'; + +import { + FreeAccessRecordFactory, + FreeAccessSnapshotFactory, +} from './free-access-program.factories'; +import { FreeAccessProgramManager } from './free-access-program.manager'; +import { FreeAccessProgramReconcilerService } from './free-access-program.reconciler.service'; +import { + FREE_ACCESS_NOTIFIER, + type CapabilityChange, + type FreeAccessNotifier, + type FreeAccessRecord, +} from './free-access-program.types'; + +describe('FreeAccessProgramReconcilerService', () => { + let service: FreeAccessProgramReconcilerService; + let manager: { + getCachedProjection: jest.Mock; + getFreshProjection: jest.Mock; + invalidateSnapshotCache: jest.Mock; + }; + let notifier: { notifyCapabilityChange: jest.Mock }; + let statsd: { increment: jest.Mock; timing: jest.Mock }; + let logger: { error: jest.Mock; log: jest.Mock }; + + const record = (over: Partial = {}): FreeAccessRecord => + FreeAccessRecordFactory({ + entitlementId: 'ent-1', + email: 'alice@example.com', + capabilities: { 'client-a': ['vpn-beta'] }, + expiresAt: new Date('2027-01-01T00:00:00.000Z').getTime(), + createdAt: new Date('2026-06-01T00:00:00.000Z').getTime(), + description: 'VIP', + internalName: 'VPN beta', + ...over, + }); + + const snapshotFor = FreeAccessSnapshotFactory; + + function callsByEmail(): Map< + string, + { added?: string[]; removed?: string[] } + > { + const map = new Map< + string, + { added?: string[]; removed?: string[] } + >(); + for (const call of notifier.notifyCapabilityChange.mock.calls) { + const [arg] = call as [CapabilityChange]; + const existing = map.get(arg.email) ?? {}; + if (arg.added) existing.added = [...arg.added].sort(); + if (arg.removed) existing.removed = [...arg.removed].sort(); + map.set(arg.email, existing); + } + return map; + } + + beforeEach(async () => { + manager = { + getCachedProjection: jest.fn().mockResolvedValue({}), + getFreshProjection: jest.fn().mockResolvedValue({}), + invalidateSnapshotCache: jest.fn().mockResolvedValue(undefined), + }; + notifier = { + notifyCapabilityChange: jest.fn().mockResolvedValue(undefined), + }; + statsd = { increment: jest.fn(), timing: jest.fn() }; + logger = { error: jest.fn(), log: jest.fn() }; + + const moduleRef = await Test.createTestingModule({ + providers: [ + { provide: FreeAccessProgramManager, useValue: manager }, + { provide: FREE_ACCESS_NOTIFIER, useValue: notifier }, + { provide: StatsDService, useValue: statsd as unknown as StatsD }, + { provide: Logger, useValue: logger }, + FreeAccessProgramReconcilerService, + ], + }).compile(); + service = moduleRef.get(FreeAccessProgramReconcilerService); + }); + + describe('reconcileEntitlement', () => { + it('fires added-only notification for a brand new record', async () => { + manager.getCachedProjection.mockResolvedValue({}); + manager.getFreshProjection.mockResolvedValue(snapshotFor([record()])); + + const result = await service.reconcileEntitlement('ent-1'); + + expect(notifier.notifyCapabilityChange).toHaveBeenCalledTimes(1); + expect(notifier.notifyCapabilityChange.mock.calls[0][0]).toMatchObject({ + email: 'alice@example.com', + entitlementId: 'ent-1', + added: ['vpn-beta'], + }); + expect( + notifier.notifyCapabilityChange.mock.calls[0][0].removed + ).toBeUndefined(); + expect(result).toEqual({ upserted: 1, deleted: 0 }); + }); + + it('fires removed-only notification for a record absent from the fresh fetch', async () => { + manager.getCachedProjection.mockResolvedValue( + snapshotFor([ + record({ email: 'alice@example.com' }), + record({ + email: 'stale@example.com', + capabilities: { 'client-a': ['vpn-beta', 'vpn-extra'] }, + }), + ]) + ); + manager.getFreshProjection.mockResolvedValue( + snapshotFor([record({ email: 'alice@example.com' })]) + ); + + await service.reconcileEntitlement('ent-1'); + + const stale = callsByEmail().get('stale@example.com'); + expect(stale).toEqual({ removed: ['vpn-beta', 'vpn-extra'] }); + expect(callsByEmail().has('alice@example.com')).toBe(false); + }); + + it("fires both added and removed when a record's capabilities change", async () => { + manager.getCachedProjection.mockResolvedValue( + snapshotFor([ + record({ + email: 'alice@example.com', + capabilities: { 'client-a': ['vpn-old'] }, + }), + ]) + ); + manager.getFreshProjection.mockResolvedValue( + snapshotFor([ + record({ + email: 'alice@example.com', + capabilities: { 'client-a': ['vpn-new'] }, + }), + ]) + ); + + await service.reconcileEntitlement('ent-1'); + + expect(notifier.notifyCapabilityChange).toHaveBeenCalledTimes(1); + const change = notifier.notifyCapabilityChange.mock.calls[0][0]; + expect(change.email).toBe('alice@example.com'); + expect([...change.added].sort()).toEqual(['vpn-new']); + expect([...change.removed].sort()).toEqual(['vpn-old']); + }); + + it('emits no notifications when cached and fresh state match', async () => { + const snap = snapshotFor([record()]); + manager.getCachedProjection.mockResolvedValue(snap); + manager.getFreshProjection.mockResolvedValue(snap); + + await service.reconcileEntitlement('ent-1'); + + expect(notifier.notifyCapabilityChange).not.toHaveBeenCalled(); + }); + + it('filters by entitlementId so other entitlements are untouched', async () => { + const other = record({ + entitlementId: 'ent-other', + email: 'other@example.com', + capabilities: { 'client-a': ['other-cap'] }, + }); + manager.getCachedProjection.mockResolvedValue(snapshotFor([other])); + manager.getFreshProjection.mockResolvedValue(snapshotFor([other])); + + await service.reconcileEntitlement('ent-1'); + + expect(notifier.notifyCapabilityChange).not.toHaveBeenCalled(); + }); + + it('treats a missing-in-fresh entitlement as deletion', async () => { + manager.getCachedProjection.mockResolvedValue(snapshotFor([record()])); + manager.getFreshProjection.mockResolvedValue({}); + + const result = await service.reconcileEntitlement('ent-1'); + + expect(callsByEmail().get('alice@example.com')).toEqual({ + removed: ['vpn-beta'], + }); + expect(result).toEqual({ upserted: 0, deleted: 1 }); + }); + + it('invalidates the cache after the fan-out completes', async () => { + manager.getCachedProjection.mockResolvedValue({}); + manager.getFreshProjection.mockResolvedValue(snapshotFor([record()])); + + await service.reconcileEntitlement('ent-1'); + + expect(manager.invalidateSnapshotCache).toHaveBeenCalledTimes(1); + }); + + it('does not bubble notifier failures; logs and continues', async () => { + manager.getCachedProjection.mockResolvedValue({}); + manager.getFreshProjection.mockResolvedValue( + snapshotFor([ + record({ email: 'a@example.com' }), + record({ email: 'b@example.com' }), + ]) + ); + notifier.notifyCapabilityChange.mockRejectedValueOnce( + new Error('boom') + ); + + await service.reconcileEntitlement('ent-1'); + + expect(notifier.notifyCapabilityChange).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.reconcile.notify.error' + ); + }); + }); + + describe('reconcileEntitlementDeletion', () => { + it('fires removed notifications for every cached record on the entitlement', async () => { + manager.getCachedProjection.mockResolvedValue( + snapshotFor([ + record({ email: 'a@example.com' }), + record({ email: 'b@example.com' }), + ]) + ); + manager.getFreshProjection.mockResolvedValue({}); + + const result = await service.reconcileEntitlementDeletion('ent-1'); + + expect(callsByEmail().get('a@example.com')).toEqual({ + removed: ['vpn-beta'], + }); + expect(callsByEmail().get('b@example.com')).toEqual({ + removed: ['vpn-beta'], + }); + expect(result).toEqual({ upserted: 0, deleted: 2 }); + }); + + it('fires no notifications when the cache has no records for the entitlement', async () => { + manager.getCachedProjection.mockResolvedValue({}); + manager.getFreshProjection.mockResolvedValue({}); + + await service.reconcileEntitlementDeletion('ent-1'); + + expect(notifier.notifyCapabilityChange).not.toHaveBeenCalled(); + }); + }); + + describe('reconcileAll', () => { + it('diffs the full snapshot and fires notifications', async () => { + manager.getCachedProjection.mockResolvedValue( + snapshotFor([record({ email: 'a@example.com' })]) + ); + manager.getFreshProjection.mockResolvedValue( + snapshotFor([ + record({ + email: 'a@example.com', + capabilities: { 'client-a': ['vpn-new'] }, + }), + ]) + ); + + const result = await service.reconcileAll(); + + const change = callsByEmail().get('a@example.com'); + expect(change?.added).toEqual(['vpn-new']); + expect(change?.removed).toEqual(['vpn-beta']); + expect(result.upserted).toBe(1); + }); + + it('emits success metric on completion', async () => { + manager.getCachedProjection.mockResolvedValue({}); + manager.getFreshProjection.mockResolvedValue({}); + + await service.reconcileAll(); + + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.reconcile_all.success' + ); + expect(statsd.timing).toHaveBeenCalledWith( + 'free_access_program.reconcile_all.duration_ms', + expect.any(Number) + ); + }); + + it('emits failure metric and rethrows on error', async () => { + manager.getCachedProjection.mockRejectedValue(new Error('strapi-down')); + + await expect(service.reconcileAll()).rejects.toThrow('strapi-down'); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.reconcile_all.failure' + ); + }); + }); + + // Compile-time check that FreeAccessNotifier stays a 1-method interface + // — guards against silent surface drift. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _notifierCheck: FreeAccessNotifier = { + notifyCapabilityChange: async () => {}, + }; +}); diff --git a/libs/free-access-program/src/lib/free-access-program.reconciler.service.ts b/libs/free-access-program/src/lib/free-access-program.reconciler.service.ts new file mode 100644 index 00000000000..446e0103392 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.reconciler.service.ts @@ -0,0 +1,195 @@ +/* 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 } from '@nestjs/common'; +import * as Sentry from '@sentry/node'; +import type { StatsD } from 'hot-shots'; + +import { StatsDService } from '@fxa/shared/metrics/statsd'; + +import { FreeAccessProgramManager } from './free-access-program.manager'; +import { + FREE_ACCESS_NOTIFIER, + type CapabilityChange, + type FreeAccessNotifier, + type FreeAccessSnapshot, + type ReconcileResult, +} from './free-access-program.types'; +import { diffCapabilities } from './util/diffCapabilities'; +import { filterByEntitlement } from './util/filterByEntitlement'; +import { flattenCapabilities } from './util/flattenCapabilities'; + +/** + * Diffs cached vs fresh Strapi state and fans out capability deltas via + * the injected notifier. Three entry points cover the trigger paths: + * - `reconcileEntitlement(id)` — webhook publish/update. + * - `reconcileEntitlementDeletion(id)` — webhook unpublish/delete. + * - `reconcileAll()` — periodic cron, full-snapshot scope. + * + * For each record produced by the diff: + * - new record → `added: [all caps]` + * - removed record → `removed: [all caps]` + * - changed record → `added: [newly granted], removed: [newly revoked]` + * + * The cache is invalidated after fan-out so the next read picks up the + * fresh state. Failed notifications are isolated per record so one bad + * email can't block the rest. + */ +@Injectable() +export class FreeAccessProgramReconcilerService { + constructor( + private manager: FreeAccessProgramManager, + @Inject(FREE_ACCESS_NOTIFIER) private notifier: FreeAccessNotifier, + @Inject(StatsDService) private statsd: StatsD, + private logger: Logger + ) {} + + async reconcileEntitlement(entitlementId: string): Promise { + const [before, after] = await this.snapshotPair(); + const beforeForEnt = filterByEntitlement(before, entitlementId); + const afterForEnt = filterByEntitlement(after, entitlementId); + if (Object.keys(afterForEnt).length === 0) { + // Entry is gone from Strapi — treat as deletion, same shape as the + // explicit unpublish/delete webhook path. + return this.handleDiff(beforeForEnt, {}); + } + return this.handleDiff(beforeForEnt, afterForEnt); + } + + async reconcileEntitlementDeletion( + entitlementId: string + ): Promise { + const [before] = await this.snapshotPair(); + const beforeForEnt = filterByEntitlement(before, entitlementId); + return this.handleDiff(beforeForEnt, {}); + } + + async reconcileAll(): Promise { + const startedAt = Date.now(); + try { + const [before, after] = await this.snapshotPair(); + const result = await this.handleDiff(before, after); + const durationMs = Date.now() - startedAt; + this.statsd.timing( + 'free_access_program.reconcile_all.duration_ms', + durationMs + ); + this.statsd.increment('free_access_program.reconcile_all.success'); + return result; + } catch (err) { + this.statsd.timing( + 'free_access_program.reconcile_all.duration_ms', + Date.now() - startedAt + ); + this.statsd.increment('free_access_program.reconcile_all.failure'); + throw err; + } + } + + private async snapshotPair(): Promise<[FreeAccessSnapshot, FreeAccessSnapshot]> { + return Promise.all([ + this.manager.getCachedProjection(), + this.manager.getFreshProjection(), + ]); + } + + private async handleDiff( + before: FreeAccessSnapshot, + after: FreeAccessSnapshot + ): Promise { + const notifications: CapabilityChange[] = []; + let upserted = 0; + let deleted = 0; + + // Walk after-state: new records get `added`-only, changed records get + // both lists, unchanged records emit nothing. + for (const [id, afterRecord] of Object.entries(after)) { + upserted += 1; + const beforeRecord = before[id]; + if (!beforeRecord) { + const added = [...flattenCapabilities(afterRecord.capabilities)]; + if (added.length > 0) { + notifications.push({ + email: afterRecord.email, + entitlementId: afterRecord.entitlementId, + added, + }); + } + continue; + } + const { added, removed } = diffCapabilities( + beforeRecord.capabilities, + afterRecord.capabilities + ); + if (added.length > 0 || removed.length > 0) { + notifications.push({ + email: afterRecord.email, + entitlementId: afterRecord.entitlementId, + ...(added.length > 0 ? { added } : {}), + ...(removed.length > 0 ? { removed } : {}), + }); + } + } + + // Walk before-state: anything missing from after is a pure delete. + for (const [id, beforeRecord] of Object.entries(before)) { + if (id in after) continue; + deleted += 1; + const removed = [...flattenCapabilities(beforeRecord.capabilities)]; + if (removed.length > 0) { + notifications.push({ + email: beforeRecord.email, + entitlementId: beforeRecord.entitlementId, + removed, + }); + } + } + + this.statsd.increment( + 'free_access_program.reconcile.upserted', + upserted + ); + this.statsd.increment('free_access_program.reconcile.deleted', deleted); + + await this.fireNotifications(notifications); + await this.invalidateAfterFanout(); + + return { upserted, deleted }; + } + + /** + * Issue one notification per delta. Per-record failures are isolated so + * one bad record can't block the rest. + */ + private async fireNotifications( + changes: ReadonlyArray + ): Promise { + for (const change of changes) { + try { + await this.notifier.notifyCapabilityChange(change); + } catch (err) { + this.statsd.increment('free_access_program.reconcile.notify.error'); + this.logger.error(err); + Sentry.captureException(err); + } + } + } + + /** + * Cache invalidation is best-effort: if it fails the next read will + * either hit stale-while-revalidate (which auto-refreshes) or eat the + * TTL window. Either way correctness is preserved by the next sweep. + */ + private async invalidateAfterFanout(): Promise { + try { + await this.manager.invalidateSnapshotCache(); + } catch (err) { + 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 new file mode 100644 index 00000000000..03b4892b154 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.types.ts @@ -0,0 +1,113 @@ +/* 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/. */ + +/** + * Per-RP capability grants for a single (email, entitlement) pair. + * Mirrors `fxa-shared/subscriptions/types#ClientIdCapabilityMap`. + */ +export type ClientIdCapabilityMap = Record; + +/** + * Projected free-access program grant for a single email tied to a single + * Strapi access entry. Lives in the cached snapshot the manager exposes — + * not persisted to Firestore. Expired grants are filtered out at read time. + */ +export interface FreeAccessRecord { + /** Strapi access `documentId`. */ + entitlementId: string; + /** Lowercased email address granted access. */ + email: string; + /** + * Stable `apiIdentifier` of every Strapi offering this access grants. The + * subscription-management page keys cards by offering; auth-server's + * capability merge uses the per-client capability map below. + */ + offeringApiIdentifiers: readonly string[]; + /** Capabilities to apply per oauthClientId for this email. */ + capabilities: ClientIdCapabilityMap; + /** When the grant expires (ms since epoch, UTC). */ + expiresAt: number; + /** Free-form description carried over from the Strapi email matcher. */ + description: string; + /** Strapi entitlement display name; kept for debugging. */ + internalName: string; + /** When this projection was generated (ms since epoch, UTC). */ + createdAt: number; +} + +/** Plain-object snapshot serializable by the type-cacheable Firestore adapter. */ +export type FreeAccessSnapshot = Record; + +/* ---------------- Projector types ---------------- */ + +/** + * Source-agnostic shape the projector consumes. Both the GraphQL and REST + * webhook adapters normalize to this so the core projection routine never + * has to branch on field naming (`__typename` vs `__component`, etc.). + */ +export interface NormalizedAccess { + documentId?: string | null; + internalName?: string | null; + /** Stable `apiIdentifier`s of the offerings this access grants. */ + offeringApiIdentifiers?: ReadonlyArray; + capabilities?: ReadonlyArray<{ + slug?: string | null; + services?: ReadonlyArray<{ oauthClientId?: string | null } | null> | null; + } | null> | null; + /** Each entry is the raw `emails` JSON scalar from one matcher component. */ + emailLists?: ReadonlyArray; +} + +export type ProjectionSkipReason = + | 'missing-document-id' + | 'no-capabilities' + | 'malformed-emails' + | 'array-email-form' + | 'empty-email' + | 'malformed-tuple' + | 'invalid-date' + | 'past-expiry'; + +export interface ProjectionSkip { + reason: ProjectionSkipReason; + detail?: Record; +} + +export interface ProjectionResult { + records: FreeAccessRecord[]; + skipped: ProjectionSkip[]; +} + +/* ---------------- Reconciler types ---------------- */ + +/** Single email's capability delta, in `processEmailListChange` shape. */ +export interface CapabilityChange { + email: string; + added?: readonly string[]; + removed?: readonly string[]; + /** Kept for log/metric attribution; consumers don't otherwise read it. */ + entitlementId?: string; +} + +/** + * Injection point for the side-effect a `CapabilityChange` should trigger. + * In auth-server it resolves email→uid and calls + * `CapabilityService.processEmailListChange`. The reconciler stays + * framework-agnostic by delegating through this interface. + */ +export interface FreeAccessNotifier { + notifyCapabilityChange(change: CapabilityChange): Promise; +} + +/** + * DI token for the notifier interface. Uses a string so the same token + * works for both NestJS DI (`@Inject('FreeAccessNotifier')`) and TypeDI + * (`Container.set('FreeAccessNotifier', impl)`). + */ +export const FREE_ACCESS_NOTIFIER = 'FreeAccessNotifier'; + +export interface ReconcileResult { + upserted: number; + deleted: number; +} diff --git a/libs/free-access-program/src/lib/free-access-program.webhook.factories.ts b/libs/free-access-program/src/lib/free-access-program.webhook.factories.ts new file mode 100644 index 00000000000..b3616f69c69 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.webhook.factories.ts @@ -0,0 +1,75 @@ +/* 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 type { StrapiAccessWebhookPayload } from './free-access-program.webhook.types'; + +type WebhookEntry = NonNullable; +type WebhookOffering = NonNullable< + NonNullable[number] +>; +type WebhookCapability = NonNullable< + NonNullable[number] +>; +type WebhookMatcher = NonNullable< + NonNullable[number] +>; + +export const StrapiWebhookCapabilityFactory = ( + override?: Partial +): WebhookCapability => ({ + id: faker.number.int({ min: 1, max: 1_000_000 }), + slug: `cap-${faker.string.alphanumeric({ length: 8 })}`, + services: [ + { + oauthClientId: `client-${faker.string + .alphanumeric({ length: 8 }) + .toLowerCase()}`, + }, + ], + ...override, +}); + +export const StrapiWebhookOfferingFactory = ( + override?: Partial +): WebhookOffering => ({ + id: faker.number.int({ min: 1, max: 1_000_000 }), + documentId: faker.string.uuid(), + apiIdentifier: `offering-${faker.string.alphanumeric({ length: 8 })}`, + capabilities: [StrapiWebhookCapabilityFactory()], + ...override, +}); + +export const StrapiWebhookEmailListMatcherFactory = ( + override?: Partial +): WebhookMatcher => ({ + __component: 'matchers.email-list', + id: faker.number.int({ min: 1, max: 1_000_000 }), + emails: { [faker.internet.email().toLowerCase()]: ['2099-12-31', ''] }, + ...override, +}); + +export const StrapiAccessWebhookEntryFactory = ( + override?: Partial +): WebhookEntry => ({ + id: faker.number.int({ min: 1, max: 1_000_000 }), + documentId: faker.string.uuid(), + internalName: faker.company.name(), + description: faker.lorem.sentence(), + publishedAt: faker.date.recent().toISOString(), + offerings: [StrapiWebhookOfferingFactory()], + matchers: [StrapiWebhookEmailListMatcherFactory()], + ...override, +}); + +export const StrapiAccessWebhookPayloadFactory = ( + override?: Partial +): StrapiAccessWebhookPayload => ({ + event: 'entry.publish', + model: 'access', + uid: faker.string.uuid(), + createdAt: faker.date.recent().toISOString(), + entry: StrapiAccessWebhookEntryFactory(), + ...override, +}); diff --git a/libs/free-access-program/src/lib/free-access-program.webhook.types.ts b/libs/free-access-program/src/lib/free-access-program.webhook.types.ts new file mode 100644 index 00000000000..88f3ade2ea8 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.webhook.types.ts @@ -0,0 +1,48 @@ +/* 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/. */ + +/** + * Webhook payload Strapi sends when an `access` entry is created / + * updated / published / unpublished / deleted. Capabilities live under the + * linked `offerings` (the `access` content type no longer carries them + * directly, and an access may link to multiple offerings); the matchers + * stay on `entry`. + * + * Component shapes inside the dynamic-zone `matchers` field use + * `__component: '.'` (REST serialization), different from + * the GraphQL `__typename` form. The projector dispatches by field presence + * (`emails`) to stay resilient to component name changes. + */ +export interface StrapiAccessWebhookPayload { + event: string; + model?: string; + uid?: string; + createdAt?: string; + entry?: { + id?: number; + documentId?: string; + internalName?: string; + description?: string | null; + publishedAt?: string | null; + offerings?: Array<{ + id?: number; + documentId?: string; + apiIdentifier?: string; + capabilities?: Array<{ + id?: number; + slug?: string; + services?: Array<{ oauthClientId?: string } | null> | null; + [k: string]: unknown; + }>; + [k: string]: unknown; + } | null> | null; + matchers?: Array<{ + __component?: string; + id?: number; + emails?: unknown; + [k: string]: unknown; + }>; + [k: string]: unknown; + }; +} diff --git a/libs/free-access-program/src/lib/util/buildSnapshotKey.spec.ts b/libs/free-access-program/src/lib/util/buildSnapshotKey.spec.ts new file mode 100644 index 00000000000..f780d6fb98f --- /dev/null +++ b/libs/free-access-program/src/lib/util/buildSnapshotKey.spec.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/. */ + +import { buildSnapshotKey } from './buildSnapshotKey'; + +describe('buildSnapshotKey', () => { + it.each([ + { + entitlementId: 'ent-1', + email: 'user@example.com', + expected: 'ent-1_user@example.com', + }, + { + entitlementId: 'ent-1', + email: 'USER@example.com', + expected: 'ent-1_user@example.com', + }, + { + entitlementId: 'ent-2', + email: 'User+Tag@Example.COM', + expected: 'ent-2_user+tag@example.com', + }, + ])( + 'composes "$expected" from ($entitlementId, $email)', + ({ entitlementId, email, expected }) => { + expect(buildSnapshotKey(entitlementId, email)).toBe(expected); + } + ); +}); diff --git a/libs/free-access-program/src/lib/util/buildSnapshotKey.ts b/libs/free-access-program/src/lib/util/buildSnapshotKey.ts new file mode 100644 index 00000000000..42aed23c7ad --- /dev/null +++ b/libs/free-access-program/src/lib/util/buildSnapshotKey.ts @@ -0,0 +1,15 @@ +/* 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/. */ + +/** + * Composite key used to address a single (entitlement, email) grant in the + * snapshot. Matches the doc-id shape the prior Firestore repository used so + * any operator tooling that reads keys can target the same identity. + */ +export function buildSnapshotKey( + entitlementId: string, + email: string +): string { + return `${entitlementId}_${email.toLowerCase()}`; +} diff --git a/libs/free-access-program/src/lib/util/collectCapabilityMap.spec.ts b/libs/free-access-program/src/lib/util/collectCapabilityMap.spec.ts new file mode 100644 index 00000000000..756cd447f42 --- /dev/null +++ b/libs/free-access-program/src/lib/util/collectCapabilityMap.spec.ts @@ -0,0 +1,61 @@ +/* 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 { collectCapabilityMap } from './collectCapabilityMap'; + +describe('collectCapabilityMap', () => { + it('returns an empty map for an empty input', () => { + expect(collectCapabilityMap([])).toEqual({}); + }); + + it('builds a map from slug + services', () => { + const result = collectCapabilityMap([ + { slug: 'vpn-beta', services: [{ oauthClientId: 'CLIENT-A' }] }, + ]); + expect(result).toEqual({ 'client-a': ['vpn-beta'] }); + }); + + it('lowercases clientIds and dedupes slugs across capabilities', () => { + const result = collectCapabilityMap([ + { slug: 'vpn-beta', services: [{ oauthClientId: 'CLIENT-A' }] }, + { slug: 'vpn-beta', services: [{ oauthClientId: 'client-a' }] }, + { slug: 'early-access', services: [{ oauthClientId: 'CLIENT-A' }] }, + ]); + expect([...result['client-a']].sort()).toEqual([ + 'early-access', + 'vpn-beta', + ]); + }); + + it('fans a single capability across multiple services', () => { + const result = collectCapabilityMap([ + { + slug: 'vpn-beta', + services: [ + { oauthClientId: 'client-a' }, + { oauthClientId: 'client-b' }, + ], + }, + ]); + expect(result).toEqual({ + 'client-a': ['vpn-beta'], + 'client-b': ['vpn-beta'], + }); + }); + + it('drops capabilities missing a slug', () => { + const result = collectCapabilityMap([ + { slug: '', services: [{ oauthClientId: 'client-a' }] }, + ]); + expect(result).toEqual({}); + }); + + it('drops services missing an oauthClientId', () => { + const result = collectCapabilityMap([ + { slug: 'vpn-beta', services: [{ oauthClientId: '' }] }, + { slug: 'orphan', services: [] }, + ]); + expect(result).toEqual({}); + }); +}); diff --git a/libs/free-access-program/src/lib/util/collectCapabilityMap.ts b/libs/free-access-program/src/lib/util/collectCapabilityMap.ts new file mode 100644 index 00000000000..999fc64af24 --- /dev/null +++ b/libs/free-access-program/src/lib/util/collectCapabilityMap.ts @@ -0,0 +1,39 @@ +/* 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 { + ClientIdCapabilityMap, + NormalizedAccess, +} from '../free-access-program.types'; + +/** + * Walk Strapi's per-capability service refs and collapse them into the + * `{ clientId → caps[] }` shape the read-path stores. Slugs are deduped + * per clientId, and clientIds are lowercased to match the storage + * convention. Capabilities without a slug or with no usable services are + * dropped silently — they'd never resolve to a real grant. + */ +export function collectCapabilityMap( + capabilities: NonNullable +): ClientIdCapabilityMap { + const builder = new Map>(); + for (const capability of capabilities) { + if (!capability?.slug) continue; + for (const service of capability.services ?? []) { + if (!service?.oauthClientId) continue; + const key = service.oauthClientId.toLowerCase(); + let set = builder.get(key); + if (!set) { + set = new Set(); + builder.set(key, set); + } + set.add(capability.slug); + } + } + const out: Record = {}; + for (const [clientId, slugs] of builder) { + out[clientId] = Object.freeze(Array.from(slugs)); + } + return out; +} diff --git a/libs/free-access-program/src/lib/util/collectOfferingApiIdentifiers.spec.ts b/libs/free-access-program/src/lib/util/collectOfferingApiIdentifiers.spec.ts new file mode 100644 index 00000000000..8a83a5208a4 --- /dev/null +++ b/libs/free-access-program/src/lib/util/collectOfferingApiIdentifiers.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 { collectOfferingApiIdentifiers } from './collectOfferingApiIdentifiers'; + +describe('collectOfferingApiIdentifiers', () => { + it('returns an empty array for null / undefined input', () => { + expect(collectOfferingApiIdentifiers(null)).toEqual([]); + expect(collectOfferingApiIdentifiers(undefined)).toEqual([]); + }); + + it('returns the list of apiIdentifiers in source order', () => { + expect( + collectOfferingApiIdentifiers([ + { apiIdentifier: 'vpn' }, + { apiIdentifier: 'relay' }, + ]) + ).toEqual(['vpn', 'relay']); + }); + + it('skips offerings missing or with an empty apiIdentifier', () => { + expect( + collectOfferingApiIdentifiers([ + { apiIdentifier: 'vpn' }, + null, + { apiIdentifier: '' }, + { apiIdentifier: null }, + undefined, + { apiIdentifier: 'relay' }, + ]) + ).toEqual(['vpn', 'relay']); + }); +}); diff --git a/libs/free-access-program/src/lib/util/collectOfferingApiIdentifiers.ts b/libs/free-access-program/src/lib/util/collectOfferingApiIdentifiers.ts new file mode 100644 index 00000000000..2a349476d56 --- /dev/null +++ b/libs/free-access-program/src/lib/util/collectOfferingApiIdentifiers.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/. */ + +/** + * Collect the stable `apiIdentifier` of every offering linked to an access. + * Skips offerings missing one — they can't be cross-referenced against + * stripe subs without it, so they're useless for the management page. + */ +export function collectOfferingApiIdentifiers( + offerings: + | ReadonlyArray<{ apiIdentifier?: string | null } | null | undefined> + | null + | undefined +): string[] { + const out: string[] = []; + for (const offering of offerings ?? []) { + const id = offering?.apiIdentifier; + if (typeof id === 'string' && id.length > 0) out.push(id); + } + return out; +} diff --git a/libs/free-access-program/src/lib/util/diffCapabilities.spec.ts b/libs/free-access-program/src/lib/util/diffCapabilities.spec.ts new file mode 100644 index 00000000000..ca6abdf603b --- /dev/null +++ b/libs/free-access-program/src/lib/util/diffCapabilities.spec.ts @@ -0,0 +1,53 @@ +/* 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 { diffCapabilities } from './diffCapabilities'; + +describe('diffCapabilities', () => { + it('returns empty diffs when before and after match', () => { + expect( + diffCapabilities( + { 'client-a': ['vpn-beta'] }, + { 'client-a': ['vpn-beta'] } + ) + ).toEqual({ added: [], removed: [] }); + }); + + it('classifies a brand-new slug as added', () => { + expect( + diffCapabilities( + {}, + { 'client-a': ['vpn-beta'] } + ) + ).toEqual({ added: ['vpn-beta'], removed: [] }); + }); + + it('classifies a removed slug as removed', () => { + expect( + diffCapabilities( + { 'client-a': ['vpn-beta'] }, + {} + ) + ).toEqual({ added: [], removed: ['vpn-beta'] }); + }); + + it('reports both add and remove for a swapped slug', () => { + const result = diffCapabilities( + { 'client-a': ['vpn-old'] }, + { 'client-a': ['vpn-new'] } + ); + expect(result.added.sort()).toEqual(['vpn-new']); + expect(result.removed.sort()).toEqual(['vpn-old']); + }); + + it('deduplicates slugs across clients before diffing', () => { + // Same slug on multiple clients before, same slug on one client after + // — at the slug-flat level there's nothing to report. + const result = diffCapabilities( + { 'client-a': ['vpn'], 'client-b': ['vpn'] }, + { 'client-a': ['vpn'] } + ); + expect(result).toEqual({ added: [], removed: [] }); + }); +}); diff --git a/libs/free-access-program/src/lib/util/diffCapabilities.ts b/libs/free-access-program/src/lib/util/diffCapabilities.ts new file mode 100644 index 00000000000..df898e53729 --- /dev/null +++ b/libs/free-access-program/src/lib/util/diffCapabilities.ts @@ -0,0 +1,27 @@ +/* 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 { ClientIdCapabilityMap } from '../free-access-program.types'; +import { flattenCapabilities } from './flattenCapabilities'; + +/** + * Diff two capability maps at the slug level. Used by the reconciler to + * compute the `added` / `removed` deltas the notifier fans out to RPs. + */ +export function diffCapabilities( + before: ClientIdCapabilityMap, + after: ClientIdCapabilityMap +): { added: string[]; removed: string[] } { + const beforeSet = flattenCapabilities(before); + const afterSet = flattenCapabilities(after); + const added: string[] = []; + const removed: string[] = []; + for (const slug of afterSet) { + if (!beforeSet.has(slug)) added.push(slug); + } + for (const slug of beforeSet) { + if (!afterSet.has(slug)) removed.push(slug); + } + return { added, removed }; +} diff --git a/libs/free-access-program/src/lib/util/filterByEntitlement.spec.ts b/libs/free-access-program/src/lib/util/filterByEntitlement.spec.ts new file mode 100644 index 00000000000..3a8754c4518 --- /dev/null +++ b/libs/free-access-program/src/lib/util/filterByEntitlement.spec.ts @@ -0,0 +1,46 @@ +/* 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 { FreeAccessRecordFactory } from '../free-access-program.factories'; +import type { FreeAccessSnapshot } from '../free-access-program.types'; +import { buildSnapshotKey } from './buildSnapshotKey'; +import { filterByEntitlement } from './filterByEntitlement'; + +describe('filterByEntitlement', () => { + const snapshot = (): FreeAccessSnapshot => { + const a = FreeAccessRecordFactory({ + entitlementId: 'ent-1', + email: 'alice@example.com', + }); + const b = FreeAccessRecordFactory({ + entitlementId: 'ent-1', + email: 'bob@example.com', + }); + const c = FreeAccessRecordFactory({ + entitlementId: 'ent-2', + email: 'carol@example.com', + }); + return { + [buildSnapshotKey(a.entitlementId, a.email)]: a, + [buildSnapshotKey(b.entitlementId, b.email)]: b, + [buildSnapshotKey(c.entitlementId, c.email)]: c, + }; + }; + + it('returns only the records matching the requested entitlement', () => { + const result = filterByEntitlement(snapshot(), 'ent-1'); + expect(Object.keys(result).sort()).toEqual([ + 'ent-1_alice@example.com', + 'ent-1_bob@example.com', + ]); + }); + + it('returns an empty snapshot when nothing matches', () => { + expect(filterByEntitlement(snapshot(), 'ent-missing')).toEqual({}); + }); + + it('returns an empty snapshot for empty input', () => { + expect(filterByEntitlement({}, 'anything')).toEqual({}); + }); +}); diff --git a/libs/free-access-program/src/lib/util/filterByEntitlement.ts b/libs/free-access-program/src/lib/util/filterByEntitlement.ts new file mode 100644 index 00000000000..e0e9d5d4d15 --- /dev/null +++ b/libs/free-access-program/src/lib/util/filterByEntitlement.ts @@ -0,0 +1,23 @@ +/* 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 { FreeAccessSnapshot } from '../free-access-program.types'; + +/** + * Restrict a snapshot to the records belonging to a single entitlement. + * Used by the webhook-driven reconcile paths so the diff doesn't touch + * unrelated entitlements. + */ +export function filterByEntitlement( + snapshot: FreeAccessSnapshot, + entitlementId: string +): FreeAccessSnapshot { + const out: FreeAccessSnapshot = {}; + for (const [id, record] of Object.entries(snapshot)) { + if (record.entitlementId === entitlementId) { + out[id] = record; + } + } + return out; +} diff --git a/libs/free-access-program/src/lib/util/flattenCapabilities.spec.ts b/libs/free-access-program/src/lib/util/flattenCapabilities.spec.ts new file mode 100644 index 00000000000..2f1a4f84b7a --- /dev/null +++ b/libs/free-access-program/src/lib/util/flattenCapabilities.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 { flattenCapabilities } from './flattenCapabilities'; + +describe('flattenCapabilities', () => { + it('returns an empty set for an empty map', () => { + expect(flattenCapabilities({})).toEqual(new Set()); + }); + + it('unions slugs across clients', () => { + expect( + flattenCapabilities({ + 'client-a': ['vpn-beta', 'early-access'], + 'client-b': ['vpn-beta'], + }) + ).toEqual(new Set(['vpn-beta', 'early-access'])); + }); + + it('skips empty and non-string slugs', () => { + expect( + flattenCapabilities({ + 'client-a': ['vpn-beta', ''] as unknown as readonly string[], + }) + ).toEqual(new Set(['vpn-beta'])); + }); + + it('tolerates a null/undefined map', () => { + expect( + flattenCapabilities(undefined as unknown as Record) + ).toEqual(new Set()); + }); +}); diff --git a/libs/free-access-program/src/lib/util/flattenCapabilities.ts b/libs/free-access-program/src/lib/util/flattenCapabilities.ts new file mode 100644 index 00000000000..f95609b49ec --- /dev/null +++ b/libs/free-access-program/src/lib/util/flattenCapabilities.ts @@ -0,0 +1,20 @@ +/* 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 { ClientIdCapabilityMap } from '../free-access-program.types'; + +/** + * Project a per-clientId capability map into a flat `Set` — useful + * for cross-snapshot diffing where the per-client structure carries no + * extra meaning. + */ +export function flattenCapabilities(map: ClientIdCapabilityMap): Set { + const set = new Set(); + for (const slugs of Object.values(map ?? {})) { + for (const slug of slugs ?? []) { + if (typeof slug === 'string' && slug.length > 0) set.add(slug); + } + } + return set; +} diff --git a/libs/free-access-program/src/lib/util/flattenOfferingCapabilities.spec.ts b/libs/free-access-program/src/lib/util/flattenOfferingCapabilities.spec.ts new file mode 100644 index 00000000000..37ea33bbf7d --- /dev/null +++ b/libs/free-access-program/src/lib/util/flattenOfferingCapabilities.spec.ts @@ -0,0 +1,57 @@ +/* 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 { flattenOfferingCapabilities } from './flattenOfferingCapabilities'; + +describe('flattenOfferingCapabilities', () => { + it('returns null for null / undefined input', () => { + expect(flattenOfferingCapabilities(null)).toBeNull(); + expect(flattenOfferingCapabilities(undefined)).toBeNull(); + }); + + it('returns an empty array when no offering has capabilities', () => { + expect( + flattenOfferingCapabilities([ + { capabilities: [] }, + { capabilities: null }, + ]) + ).toEqual([]); + }); + + it('concatenates capabilities across offerings in source order', () => { + expect( + flattenOfferingCapabilities([ + { + capabilities: [ + { slug: 'vpn-beta', services: [{ oauthClientId: 'cli-a' }] }, + ], + }, + { + capabilities: [ + { slug: 'relay-beta', services: [{ oauthClientId: 'cli-b' }] }, + ], + }, + ]) + ).toEqual([ + { slug: 'vpn-beta', services: [{ oauthClientId: 'cli-a' }] }, + { slug: 'relay-beta', services: [{ oauthClientId: 'cli-b' }] }, + ]); + }); + + it('tolerates null offerings inside the array', () => { + expect( + flattenOfferingCapabilities([ + null, + { + capabilities: [ + { slug: 'vpn-beta', services: [{ oauthClientId: 'cli-a' }] }, + ], + }, + undefined, + ]) + ).toEqual([ + { slug: 'vpn-beta', services: [{ oauthClientId: 'cli-a' }] }, + ]); + }); +}); diff --git a/libs/free-access-program/src/lib/util/flattenOfferingCapabilities.ts b/libs/free-access-program/src/lib/util/flattenOfferingCapabilities.ts new file mode 100644 index 00000000000..f626bb3fe10 --- /dev/null +++ b/libs/free-access-program/src/lib/util/flattenOfferingCapabilities.ts @@ -0,0 +1,33 @@ +/* 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 { NormalizedAccess } from '../free-access-program.types'; + +/** + * Strapi links each `access` to zero or more offerings, each with its own + * capabilities. The projector treats capabilities as a flat list keyed by + * client id, so we concatenate them up-front and let `collectCapabilityMap` + * dedupe by slug. + */ +export function flattenOfferingCapabilities( + offerings: + | ReadonlyArray< + | { + capabilities?: NormalizedAccess['capabilities']; + } + | null + | undefined + > + | null + | undefined +): NormalizedAccess['capabilities'] { + if (!offerings) return null; + const flat: NonNullable[number][] = []; + for (const offering of offerings) { + for (const capability of offering?.capabilities ?? []) { + flat.push(capability); + } + } + return flat; +} diff --git a/libs/free-access-program/src/lib/util/mergeCapabilities.spec.ts b/libs/free-access-program/src/lib/util/mergeCapabilities.spec.ts new file mode 100644 index 00000000000..bc9967ebf0b --- /dev/null +++ b/libs/free-access-program/src/lib/util/mergeCapabilities.spec.ts @@ -0,0 +1,62 @@ +/* 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 { FreeAccessRecordFactory } from '../free-access-program.factories'; +import { mergeCapabilities } from './mergeCapabilities'; + +describe('mergeCapabilities', () => { + it('returns an empty map for an empty input', () => { + expect(mergeCapabilities([])).toEqual({}); + }); + + it('unions slugs from a single record verbatim', () => { + const record = FreeAccessRecordFactory({ + capabilities: { 'client-a': ['vpn-beta'] }, + }); + expect(mergeCapabilities([record])).toEqual({ + 'client-a': ['vpn-beta'], + }); + }); + + it('dedupes slugs across records that grant the same clientId', () => { + const records = [ + FreeAccessRecordFactory({ + capabilities: { 'client-a': ['cap-foo', 'cap-bar'] }, + }), + FreeAccessRecordFactory({ + capabilities: { 'client-a': ['cap-bar', 'cap-baz'] }, + }), + ]; + const result = mergeCapabilities(records); + expect([...result['client-a']].sort()).toEqual([ + 'cap-bar', + 'cap-baz', + 'cap-foo', + ]); + }); + + it('merges across different clientIds without crosstalk', () => { + const records = [ + FreeAccessRecordFactory({ + capabilities: { 'client-a': ['cap-1'] }, + }), + FreeAccessRecordFactory({ + capabilities: { 'client-b': ['cap-2'] }, + }), + ]; + expect(mergeCapabilities(records)).toEqual({ + 'client-a': ['cap-1'], + 'client-b': ['cap-2'], + }); + }); + + it('lowercases the clientId key', () => { + const record = FreeAccessRecordFactory({ + capabilities: { 'CLIENT-A': ['cap-1'] }, + }); + expect(mergeCapabilities([record])).toEqual({ + 'client-a': ['cap-1'], + }); + }); +}); diff --git a/libs/free-access-program/src/lib/util/mergeCapabilities.ts b/libs/free-access-program/src/lib/util/mergeCapabilities.ts new file mode 100644 index 00000000000..2dac0dd936c --- /dev/null +++ b/libs/free-access-program/src/lib/util/mergeCapabilities.ts @@ -0,0 +1,37 @@ +/* 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 { + ClientIdCapabilityMap, + FreeAccessRecord, +} from '../free-access-program.types'; + +/** + * Union a set of `FreeAccessRecord`s into a single `{ clientId → caps[] }` + * map. Slugs are deduped per clientId so multiple entitlements granting + * the same email don't double-report. ClientIds are lowercased to match + * the storage convention. + */ +export function mergeCapabilities( + records: ReadonlyArray +): ClientIdCapabilityMap { + if (records.length === 0) return {}; + const builder = new Map>(); + for (const record of records) { + for (const [clientId, slugs] of Object.entries(record.capabilities)) { + const key = clientId.toLowerCase(); + let set = builder.get(key); + if (!set) { + set = new Set(); + builder.set(key, set); + } + for (const slug of slugs) set.add(slug); + } + } + const result: Record = {}; + for (const [clientId, slugs] of builder) { + result[clientId] = Object.freeze(Array.from(slugs)); + } + return result; +} diff --git a/libs/free-access-program/src/lib/util/normalizeGraphQLAccess.spec.ts b/libs/free-access-program/src/lib/util/normalizeGraphQLAccess.spec.ts new file mode 100644 index 00000000000..a52a8004790 --- /dev/null +++ b/libs/free-access-program/src/lib/util/normalizeGraphQLAccess.spec.ts @@ -0,0 +1,98 @@ +/* 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 { + AccessCapabilityFactory, + AccessEmailListMatcherFactory, + AccessOfferingFactory, + AccessResultFactory, + AccessServiceFactory, +} from '@fxa/shared/cms'; + +import { normalizeGraphQLAccess } from './normalizeGraphQLAccess'; + +describe('normalizeGraphQLAccess', () => { + it('keeps only `ComponentMatchersEmailList` matchers', () => { + const normalized = normalizeGraphQLAccess( + AccessResultFactory({ + documentId: 'ent-1', + internalName: 'VPN beta', + offerings: [ + AccessOfferingFactory({ + apiIdentifier: 'vpn', + capabilities: [ + AccessCapabilityFactory({ + slug: 'vpn-beta', + services: [AccessServiceFactory({ oauthClientId: 'cli' })], + }), + ], + }), + ], + matchers: [ + AccessEmailListMatcherFactory({ + emails: { 'a@example.com': ['2026-12-31', 'd'] }, + }), + ], + }) + ); + + expect(normalized.documentId).toBe('ent-1'); + expect(normalized.emailLists).toEqual([ + { 'a@example.com': ['2026-12-31', 'd'] }, + ]); + expect(normalized.offeringApiIdentifiers).toEqual(['vpn']); + }); + + it('flattens capabilities across multiple offerings', () => { + const normalized = normalizeGraphQLAccess( + AccessResultFactory({ + offerings: [ + AccessOfferingFactory({ + apiIdentifier: 'vpn', + capabilities: [ + AccessCapabilityFactory({ + slug: 'vpn-beta', + services: [AccessServiceFactory({ oauthClientId: 'cli-a' })], + }), + ], + }), + AccessOfferingFactory({ + apiIdentifier: 'relay', + capabilities: [ + AccessCapabilityFactory({ + slug: 'relay-beta', + services: [AccessServiceFactory({ oauthClientId: 'cli-b' })], + }), + ], + }), + ], + matchers: [], + }) + ); + + expect(normalized.offeringApiIdentifiers).toEqual(['vpn', 'relay']); + // The cms factories tag rows with `__typename` so consumers can + // discriminate dynamic-zone unions; the projector preserves the shape + // verbatim until it filters in `collectCapabilityMap`. + expect(normalized.capabilities).toEqual([ + { + __typename: 'Capability', + slug: 'vpn-beta', + services: [{ __typename: 'Service', oauthClientId: 'cli-a' }], + }, + { + __typename: 'Capability', + slug: 'relay-beta', + services: [{ __typename: 'Service', oauthClientId: 'cli-b' }], + }, + ]); + }); + + it('returns an empty capabilities list when no offerings are linked', () => { + const normalized = normalizeGraphQLAccess( + AccessResultFactory({ offerings: [], matchers: [] }) + ); + expect(normalized.capabilities).toEqual([]); + }); +}); diff --git a/libs/free-access-program/src/lib/util/normalizeGraphQLAccess.ts b/libs/free-access-program/src/lib/util/normalizeGraphQLAccess.ts new file mode 100644 index 00000000000..0e6492140a6 --- /dev/null +++ b/libs/free-access-program/src/lib/util/normalizeGraphQLAccess.ts @@ -0,0 +1,40 @@ +/* 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 { AccessesQuery } from '@fxa/shared/cms'; + +import type { NormalizedAccess } from '../free-access-program.types'; +import { collectOfferingApiIdentifiers } from './collectOfferingApiIdentifiers'; +import { flattenOfferingCapabilities } from './flattenOfferingCapabilities'; + +/** + * Single row from the `accessesQuery` result. Sourced from `@fxa/shared/cms` + * so any change to the query's selection set propagates in. + */ +export type GraphQLAccessRow = NonNullable< + AccessesQuery['accesses'][number] +>; + +/** + * Adapter: Strapi GraphQL `accesses` row → `NormalizedAccess`. Keeps only + * `ComponentMatchersEmailList` matchers — anything else (e.g. a future + * domain-list matcher) is not recognized by the projector yet. + */ +export function normalizeGraphQLAccess( + access: GraphQLAccessRow +): NormalizedAccess { + const emailLists: unknown[] = []; + for (const matcher of access.matchers ?? []) { + if (matcher?.__typename === 'ComponentMatchersEmailList') { + emailLists.push(matcher.emails); + } + } + return { + documentId: access.documentId, + internalName: access.internalName, + offeringApiIdentifiers: collectOfferingApiIdentifiers(access.offerings), + capabilities: flattenOfferingCapabilities(access.offerings), + emailLists, + }; +} diff --git a/libs/free-access-program/src/lib/util/normalizeWebhookEntry.spec.ts b/libs/free-access-program/src/lib/util/normalizeWebhookEntry.spec.ts new file mode 100644 index 00000000000..60943facf2b --- /dev/null +++ b/libs/free-access-program/src/lib/util/normalizeWebhookEntry.spec.ts @@ -0,0 +1,85 @@ +/* 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 { + StrapiAccessWebhookEntryFactory, + StrapiWebhookCapabilityFactory, + StrapiWebhookEmailListMatcherFactory, + StrapiWebhookOfferingFactory, +} from '../free-access-program.webhook.factories'; +import { normalizeWebhookEntry } from './normalizeWebhookEntry'; + +describe('normalizeWebhookEntry', () => { + it('collects emails from every matcher with an `emails` field, ignoring component name', () => { + const normalized = normalizeWebhookEntry( + StrapiAccessWebhookEntryFactory({ + documentId: 'ent-1', + internalName: 'VPN beta', + offerings: [ + StrapiWebhookOfferingFactory({ + apiIdentifier: 'vpn', + capabilities: [ + StrapiWebhookCapabilityFactory({ + slug: 'vpn-beta', + services: [{ oauthClientId: 'cli' }], + }), + ], + }), + ], + matchers: [ + StrapiWebhookEmailListMatcherFactory({ + emails: { 'a@example.com': ['2026-12-31', 'd'] }, + }), + { + __component: 'matchers.future-renamed-component', + emails: { 'b@example.com': ['2026-12-31', 'd'] }, + }, + { __component: 'matchers.domain-list', domain: 'example.com' }, + ], + }) + ); + + expect(normalized.documentId).toBe('ent-1'); + expect(normalized.internalName).toBe('VPN beta'); + expect(normalized.offeringApiIdentifiers).toEqual(['vpn']); + expect(normalized.emailLists).toEqual([ + { 'a@example.com': ['2026-12-31', 'd'] }, + { 'b@example.com': ['2026-12-31', 'd'] }, + ]); + }); + + it('extracts apiIdentifier from each offering on the webhook entry', () => { + const normalized = normalizeWebhookEntry( + StrapiAccessWebhookEntryFactory({ + offerings: [ + StrapiWebhookOfferingFactory({ + apiIdentifier: 'vpn', + capabilities: [], + }), + StrapiWebhookOfferingFactory({ + apiIdentifier: 'relay', + capabilities: [], + }), + ], + matchers: [], + }) + ); + + expect(normalized.offeringApiIdentifiers).toEqual(['vpn', 'relay']); + }); + + it('returns null capabilities when offerings is missing', () => { + const normalized = normalizeWebhookEntry( + StrapiAccessWebhookEntryFactory({ + offerings: undefined, + matchers: [ + StrapiWebhookEmailListMatcherFactory({ + emails: { 'a@example.com': ['2026-12-31', 'd'] }, + }), + ], + }) + ); + expect(normalized.capabilities).toBeNull(); + }); +}); diff --git a/libs/free-access-program/src/lib/util/normalizeWebhookEntry.ts b/libs/free-access-program/src/lib/util/normalizeWebhookEntry.ts new file mode 100644 index 00000000000..bafc88234d2 --- /dev/null +++ b/libs/free-access-program/src/lib/util/normalizeWebhookEntry.ts @@ -0,0 +1,31 @@ +/* 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 { NormalizedAccess } from '../free-access-program.types'; +import type { StrapiAccessWebhookPayload } from '../free-access-program.webhook.types'; +import { collectOfferingApiIdentifiers } from './collectOfferingApiIdentifiers'; +import { flattenOfferingCapabilities } from './flattenOfferingCapabilities'; + +/** + * Adapter: Strapi REST webhook `entry` → `NormalizedAccess`. Dispatches on + * matcher field presence (`emails`) rather than `__component` name so a + * Strapi component rename doesn't silently break ingestion. + */ +export function normalizeWebhookEntry( + entry: NonNullable +): NormalizedAccess { + const emailLists: unknown[] = []; + for (const matcher of entry.matchers ?? []) { + if (matcher && typeof matcher === 'object' && 'emails' in matcher) { + emailLists.push((matcher as { emails?: unknown }).emails); + } + } + return { + documentId: entry.documentId, + internalName: entry.internalName, + offeringApiIdentifiers: collectOfferingApiIdentifiers(entry.offerings), + capabilities: flattenOfferingCapabilities(entry.offerings), + emailLists, + }; +} diff --git a/libs/free-access-program/src/lib/util/parseMatcherValue.spec.ts b/libs/free-access-program/src/lib/util/parseMatcherValue.spec.ts new file mode 100644 index 00000000000..323d2bb31b0 --- /dev/null +++ b/libs/free-access-program/src/lib/util/parseMatcherValue.spec.ts @@ -0,0 +1,41 @@ +/* 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 { parseMatcherValue } from './parseMatcherValue'; + +describe('parseMatcherValue', () => { + it('parses a [date, description] tuple', () => { + expect(parseMatcherValue(['2026-12-31', 'VIP'])).toEqual({ + dateStr: '2026-12-31', + description: 'VIP', + }); + }); + + it('accepts a date-only single-element tuple', () => { + expect(parseMatcherValue(['2026-12-31'])).toEqual({ + dateStr: '2026-12-31', + description: undefined, + }); + }); + + it('drops a non-string description', () => { + expect(parseMatcherValue(['2026-12-31', 42])).toEqual({ + dateStr: '2026-12-31', + description: undefined, + }); + }); + + it.each([ + { value: null, label: 'null' }, + { value: undefined, label: 'undefined' }, + { value: 'plain-string', label: 'string' }, + { value: 42, label: 'number' }, + { value: { foo: 'bar' }, label: 'object' }, + { value: [], label: 'empty array' }, + { value: ['', 'desc'], label: 'empty date string' }, + { value: [42, 'desc'], label: 'non-string date' }, + ])('returns undefined for $label input', ({ value }) => { + expect(parseMatcherValue(value)).toBeUndefined(); + }); +}); diff --git a/libs/free-access-program/src/lib/util/parseMatcherValue.ts b/libs/free-access-program/src/lib/util/parseMatcherValue.ts new file mode 100644 index 00000000000..ce5029f21a0 --- /dev/null +++ b/libs/free-access-program/src/lib/util/parseMatcherValue.ts @@ -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/. */ + +/** + * Parse the `[dateStr, description]` tuple Strapi stores under each email + * key in the email-list matcher. The description is optional. Returns + * `undefined` for any shape the projector should treat as malformed. + */ +export function parseMatcherValue( + value: unknown +): { dateStr: string; description?: string } | undefined { + if (!Array.isArray(value) || value.length === 0) return undefined; + const dateStr = value[0]; + if (typeof dateStr !== 'string' || dateStr.length === 0) return undefined; + const description = value[1]; + return { + dateStr, + description: typeof description === 'string' ? description : undefined, + }; +} diff --git a/libs/free-access-program/src/lib/util/parseStrictDate.spec.ts b/libs/free-access-program/src/lib/util/parseStrictDate.spec.ts new file mode 100644 index 00000000000..7fbf0eb2188 --- /dev/null +++ b/libs/free-access-program/src/lib/util/parseStrictDate.spec.ts @@ -0,0 +1,36 @@ +/* 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 { parseStrictDate } from './parseStrictDate'; + +describe('parseStrictDate', () => { + it('returns the start of the day after the named date in UTC', () => { + expect(parseStrictDate('2026-12-31')?.toISOString()).toBe( + '2027-01-01T00:00:00.000Z' + ); + }); + + it.each([ + { dateStr: '12/31/2026' }, // wrong format + { dateStr: '2026-1-1' }, // single-digit components + { dateStr: '2026/12/31' }, // wrong separator + { dateStr: '' }, // empty + { dateStr: '2026-13-01' }, // month out of range + { dateStr: '2026-02-30' }, // invalid calendar date + { dateStr: '2026-04-31' }, // invalid calendar date (April has 30) + ])('returns undefined for invalid input "$dateStr"', ({ dateStr }) => { + expect(parseStrictDate(dateStr)).toBeUndefined(); + }); + + it('rolls Feb-29 in a leap year to March 1 UTC', () => { + // 2028 is a leap year. + expect(parseStrictDate('2028-02-29')?.toISOString()).toBe( + '2028-03-01T00:00:00.000Z' + ); + }); + + it('rejects Feb-29 in a non-leap year', () => { + expect(parseStrictDate('2026-02-29')).toBeUndefined(); + }); +}); diff --git a/libs/free-access-program/src/lib/util/parseStrictDate.ts b/libs/free-access-program/src/lib/util/parseStrictDate.ts new file mode 100644 index 00000000000..f3b6f650f68 --- /dev/null +++ b/libs/free-access-program/src/lib/util/parseStrictDate.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/. */ + +const YYYY_MM_DD_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Parse a strict `YYYY-MM-DD` calendar date as the start of the day _after_ + * the named day in UTC, so the named day stays fully valid in every + * timezone (including those west of UTC). Returns `undefined` when the + * input doesn't match `YYYY-MM-DD` or names an invalid calendar date such + * as `2026-02-30`. + */ +export function parseStrictDate(dateStr: string): Date | undefined { + if (!YYYY_MM_DD_REGEX.test(dateStr)) return undefined; + const [yearStr, monthStr, dayStr] = dateStr.split('-'); + const year = Number(yearStr); + const month = Number(monthStr); + const day = Number(dayStr); + + // Catch invalid calendar dates like 2026-02-30 — `Date.UTC` happily rolls + // them over to a later month, so round-trip and verify we got the day we + // asked for. + const startOfDay = new Date(Date.UTC(year, month - 1, day)); + if ( + startOfDay.getUTCFullYear() !== year || + startOfDay.getUTCMonth() !== month - 1 || + startOfDay.getUTCDate() !== day + ) { + return undefined; + } + + return new Date(Date.UTC(year, month - 1, day + 1)); +} diff --git a/libs/free-access-program/src/lib/util/projectAccess.spec.ts b/libs/free-access-program/src/lib/util/projectAccess.spec.ts new file mode 100644 index 00000000000..5cf21422493 --- /dev/null +++ b/libs/free-access-program/src/lib/util/projectAccess.spec.ts @@ -0,0 +1,241 @@ +/* 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 { NormalizedAccessFactory } from '../free-access-program.factories'; +import { projectAccess } from './projectAccess'; + +describe('projectAccess', () => { + const NOW = new Date('2026-06-01T12:00:00.000Z'); + + it('emits one record per email with merged capabilities and end-of-day expiry', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + internalName: 'VPN beta', + offeringApiIdentifiers: ['vpn', 'relay'], + capabilities: [ + { + slug: 'vpn-beta', + services: [ + { oauthClientId: 'CLIENT-A' }, + { oauthClientId: 'client-b' }, + ], + }, + { slug: 'early-access', services: [{ oauthClientId: 'CLIENT-A' }] }, + ], + emailLists: [ + { + 'Alice@Example.com': ['2026-12-31', 'VIP'], + 'bob@example.com': ['2027-06-15', 'Beta tester'], + }, + ], + }); + + const result = projectAccess(input, NOW); + + expect(result.skipped).toEqual([]); + expect(result.records).toHaveLength(2); + + const alice = result.records.find((r) => r.email === 'alice@example.com'); + expect(alice).toBeDefined(); + expect(alice?.entitlementId).toBe('ent-1'); + expect(alice?.internalName).toBe('VPN beta'); + expect(alice?.description).toBe('VIP'); + expect([...(alice?.capabilities['client-a'] ?? [])].sort()).toEqual([ + 'early-access', + 'vpn-beta', + ]); + expect(alice?.capabilities['client-b']).toEqual(['vpn-beta']); + // Expiry is start of day _after_ the named day so the named day stays + // fully valid in every timezone. + expect(alice?.expiresAt).toBe( + new Date('2027-01-01T00:00:00.000Z').getTime() + ); + expect(alice?.createdAt).toBe(NOW.getTime()); + expect([...(alice?.offeringApiIdentifiers ?? [])].sort()).toEqual([ + 'relay', + 'vpn', + ]); + }); + + it('dedupes offering apiIdentifiers and skips empty entries', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + offeringApiIdentifiers: ['vpn', 'vpn', '', 'relay'], + emailLists: [{ 'user@example.com': ['2027-12-31', ''] }], + }); + const result = projectAccess(input, NOW); + expect(result.records[0]?.offeringApiIdentifiers).toEqual([ + 'vpn', + 'relay', + ]); + }); + + it('emits an empty offering list when none are linked', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + offeringApiIdentifiers: [], + emailLists: [{ 'user@example.com': ['2027-12-31', ''] }], + }); + const result = projectAccess(input, NOW); + expect(result.records[0]?.offeringApiIdentifiers).toEqual([]); + }); + + it('skips entitlements missing a documentId', () => { + const result = projectAccess( + NormalizedAccessFactory({ documentId: '' }), + NOW + ); + expect(result.records).toEqual([]); + expect(result.skipped).toEqual([{ reason: 'missing-document-id' }]); + }); + + it('skips entitlements with no resolvable capabilities (no services)', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + capabilities: [{ slug: 'orphan', services: [] }], + }); + const result = projectAccess(input, NOW); + expect(result.records).toEqual([]); + expect(result.skipped).toEqual([ + { reason: 'no-capabilities', detail: { documentId: 'ent-1' } }, + ]); + }); + + it('skips the legacy plain-array email shape (no expiry available)', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + emailLists: [['alice@example.com', 'bob@example.com']], + }); + const result = projectAccess(input, NOW); + expect(result.records).toEqual([]); + expect(result.skipped).toEqual([ + { reason: 'array-email-form', detail: { documentId: 'ent-1' } }, + ]); + }); + + it('skips matchers with non-object emails', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + emailLists: [null, 42, 'string'] as unknown as object[], + }); + const result = projectAccess(input, NOW); + expect(result.records).toEqual([]); + expect(result.skipped.map((s) => s.reason)).toEqual([ + 'malformed-emails', + 'malformed-emails', + 'malformed-emails', + ]); + }); + + it('skips an empty email key', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + emailLists: [{ '': ['2026-12-31', 'd'] }], + }); + const result = projectAccess(input, NOW); + expect(result.records).toEqual([]); + expect(result.skipped).toEqual([ + { reason: 'empty-email', detail: { documentId: 'ent-1' } }, + ]); + }); + + it('skips values that are not a [date, description] tuple', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + emailLists: [ + { + 'a@example.com': 'just-a-string', + 'b@example.com': [], + }, + ], + }); + const result = projectAccess(input, NOW); + expect(result.records).toEqual([]); + expect(result.skipped.map((s) => s.reason)).toEqual([ + 'malformed-tuple', + 'malformed-tuple', + ]); + }); + + it('accepts a missing description (date-only tuple) and writes empty string', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + emailLists: [{ 'a@example.com': ['2026-12-31'] }], + }); + const result = projectAccess(input, NOW); + expect(result.records).toHaveLength(1); + expect(result.records[0]?.description).toBe(''); + }); + + it('accepts an explicit empty description and writes empty string', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + emailLists: [{ 'a@example.com': ['2026-12-31', ''] }], + }); + const result = projectAccess(input, NOW); + expect(result.records).toHaveLength(1); + expect(result.records[0]?.description).toBe(''); + }); + + it('rejects invalid calendar dates (e.g. 2026-02-30)', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + emailLists: [{ 'a@example.com': ['2026-02-30', 'desc'] }], + }); + const result = projectAccess(input, NOW); + expect(result.records).toEqual([]); + expect(result.skipped).toEqual([ + { + reason: 'invalid-date', + detail: { documentId: 'ent-1', email: 'a@example.com', value: '2026-02-30' }, + }, + ]); + }); + + it('rejects dates that do not match YYYY-MM-DD', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + emailLists: [ + { 'a@example.com': ['12/31/2026', 'desc'] }, + { 'b@example.com': ['2026-1-1', 'desc'] }, + ], + }); + const result = projectAccess(input, NOW); + expect(result.records).toEqual([]); + expect(result.skipped.map((s) => s.reason)).toEqual([ + 'invalid-date', + 'invalid-date', + ]); + }); + + it('skips entries whose expiry has already passed', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + emailLists: [{ 'a@example.com': ['2026-05-01', 'desc'] }], + }); + const result = projectAccess(input, NOW); + expect(result.records).toEqual([]); + expect(result.skipped).toEqual([ + { reason: 'past-expiry', detail: { documentId: 'ent-1', email: 'a@example.com' } }, + ]); + }); + + it('drops capabilities whose slug or services are missing', () => { + const input = NormalizedAccessFactory({ + documentId: 'ent-1', + capabilities: [ + { slug: 'vpn-beta', services: [{ oauthClientId: 'CLIENT-A' }] }, + { slug: '', services: [{ oauthClientId: 'CLIENT-X' }] }, + { slug: 'orphan-cap', services: [] }, + { slug: 'no-client', services: [{ oauthClientId: '' }] }, + ], + emailLists: [{ 'a@example.com': ['2026-12-31', ''] }], + }); + const result = projectAccess(input, NOW); + expect(Object.keys(result.records[0]?.capabilities ?? {})).toEqual([ + 'client-a', + ]); + expect(result.records[0]?.capabilities['client-a']).toEqual(['vpn-beta']); + }); +}); diff --git a/libs/free-access-program/src/lib/util/projectAccess.ts b/libs/free-access-program/src/lib/util/projectAccess.ts new file mode 100644 index 00000000000..f1af92c82de --- /dev/null +++ b/libs/free-access-program/src/lib/util/projectAccess.ts @@ -0,0 +1,107 @@ +/* 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 { + FreeAccessRecord, + NormalizedAccess, + ProjectionResult, + ProjectionSkip, +} from '../free-access-program.types'; +import { collectCapabilityMap } from './collectCapabilityMap'; +import { parseMatcherValue } from './parseMatcherValue'; +import { parseStrictDate } from './parseStrictDate'; + +/** + * Project a single normalized Strapi `access` entry into one + * `FreeAccessRecord` per (email, entitlement) grant. + * + * Pure function — returns both the records to upsert and an explicit list + * of skipped entries with reasons so the caller can emit metrics. `now` is + * injected so tests stay deterministic. + */ +export function projectAccess( + input: NormalizedAccess, + now: Date +): ProjectionResult { + const skipped: ProjectionSkip[] = []; + + if (!input.documentId) { + skipped.push({ reason: 'missing-document-id' }); + return { records: [], skipped }; + } + + const documentId = input.documentId; + const capabilityMap = collectCapabilityMap(input.capabilities ?? []); + if (Object.keys(capabilityMap).length === 0) { + skipped.push({ reason: 'no-capabilities', detail: { documentId } }); + return { records: [], skipped }; + } + + const records: FreeAccessRecord[] = []; + const createdAt = now.getTime(); + const offeringApiIdentifiers = Object.freeze([ + ...new Set( + (input.offeringApiIdentifiers ?? []).filter( + (id): id is string => typeof id === 'string' && id.length > 0 + ) + ), + ]); + + for (const rawEmails of input.emailLists ?? []) { + if (Array.isArray(rawEmails)) { + // Legacy plain-array shape carries no expiry — there's no way to + // expire the resulting record, so skip rather than emit a forever- + // valid grant. + skipped.push({ reason: 'array-email-form', detail: { documentId } }); + continue; + } + if (!rawEmails || typeof rawEmails !== 'object') { + skipped.push({ reason: 'malformed-emails', detail: { documentId } }); + continue; + } + + for (const [rawEmail, rawValue] of Object.entries( + rawEmails as Record + )) { + const email = rawEmail.trim().toLowerCase(); + if (!email) { + skipped.push({ reason: 'empty-email', detail: { documentId } }); + continue; + } + const parsed = parseMatcherValue(rawValue); + if (!parsed) { + skipped.push({ + reason: 'malformed-tuple', + detail: { documentId, email }, + }); + continue; + } + const { dateStr, description } = parsed; + const expiresAtDate = parseStrictDate(dateStr); + if (!expiresAtDate) { + skipped.push({ + reason: 'invalid-date', + detail: { documentId, email, value: dateStr }, + }); + continue; + } + if (expiresAtDate.getTime() <= now.getTime()) { + skipped.push({ reason: 'past-expiry', detail: { documentId, email } }); + continue; + } + records.push({ + entitlementId: documentId, + email, + offeringApiIdentifiers, + capabilities: capabilityMap, + expiresAt: expiresAtDate.getTime(), + description: description ?? '', + internalName: input.internalName ?? '', + createdAt, + }); + } + } + + return { records, skipped }; +} diff --git a/libs/free-access-program/tsconfig.json b/libs/free-access-program/tsconfig.json new file mode 100644 index 00000000000..19b9eece4df --- /dev/null +++ b/libs/free-access-program/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/free-access-program/tsconfig.lib.json b/libs/free-access-program/tsconfig.lib.json new file mode 100644 index 00000000000..33eca2c2cdf --- /dev/null +++ b/libs/free-access-program/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/free-access-program/tsconfig.spec.json b/libs/free-access-program/tsconfig.spec.json new file mode 100644 index 00000000000..9b2a121d114 --- /dev/null +++ b/libs/free-access-program/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.in.spec.ts b/libs/payments/management/src/lib/subscriptionManagement.service.in.spec.ts index 524f341207f..331471017b8 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.in.spec.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.in.spec.ts @@ -77,6 +77,10 @@ import { ProductConfigurationManager, StrapiClient, } from '@fxa/shared/cms'; +import { + FreeAccessProgramManager, + MockFreeAccessProgramClientConfigProvider, +} from '@fxa/free-access-program'; import { ChurnInterventionService } from './churn-intervention.service'; import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; @@ -145,6 +149,7 @@ describe('SubscriptionManagementService integration', () => { CustomerManager, EligibilityManager, EligibilityService, + FreeAccessProgramManager, GoogleIapClient, GoogleIapPurchaseManager, InvoiceManager, @@ -153,6 +158,7 @@ describe('SubscriptionManagementService integration', () => { MockAppleIapClientConfigProvider, MockCurrencyConfigProvider, MockFirestoreProvider, + MockFreeAccessProgramClientConfigProvider, MockGoogleIapClientConfigProvider, MockNotifierSnsConfigProvider, MockPaypalClientConfigProvider, @@ -833,6 +839,7 @@ describe('SubscriptionManagementService integration', () => { }), appleIapSubscriptions: [], defaultPaymentMethod: undefined, + freeAccess: [], googleIapSubscriptions: [], isStripeCustomer: false, subscriptions: [], diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts index f2d9b4293a3..14c573d3506 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts @@ -91,6 +91,10 @@ import { StrapiClient, ChurnInterventionByProductIdResultFactory, } from '@fxa/shared/cms'; +import { + FreeAccessProgramManager, + MockFreeAccessProgramClientConfigProvider, +} from '@fxa/free-access-program'; import { ChurnInterventionService } from './churn-intervention.service'; import { ManagePaymentMethodTaxAddressRequiredError } from './manage-payment-method.error'; import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; @@ -143,6 +147,7 @@ describe('SubscriptionManagementService', () => { let churnInterventionService: ChurnInterventionService; let paymentMethodManager: PaymentMethodManager; let productConfigurationManager: ProductConfigurationManager; + let freeAccessProgramManager: FreeAccessProgramManager; let subscriptionManager: SubscriptionManager; let subscriptionManagementService: SubscriptionManagementService; let setupIntentManager: SetupIntentManager; @@ -176,10 +181,12 @@ describe('SubscriptionManagementService', () => { GoogleIapPurchaseManager, InvoiceManager, LocationConfig, + FreeAccessProgramManager, MockAccountDatabaseNestFactory, MockAppleIapClientConfigProvider, MockCurrencyConfigProvider, MockFirestoreProvider, + MockFreeAccessProgramClientConfigProvider, MockGoogleIapClientConfigProvider, MockNotifierSnsConfigProvider, MockPaypalClientConfigProvider, @@ -224,6 +231,7 @@ describe('SubscriptionManagementService', () => { invoiceManager = moduleRef.get(InvoiceManager); paymentMethodManager = moduleRef.get(PaymentMethodManager); productConfigurationManager = moduleRef.get(ProductConfigurationManager); + freeAccessProgramManager = moduleRef.get(FreeAccessProgramManager); subscriptionManager = moduleRef.get(SubscriptionManager); subscriptionManagementService = moduleRef.get( SubscriptionManagementService @@ -501,6 +509,7 @@ describe('SubscriptionManagementService', () => { trialSubscriptions: [], appleIapSubscriptions: [mockAppleIapSubscriptionContent], googleIapSubscriptions: [mockGoogleIapSubscriptionContent], + freeAccess: [], }); }); @@ -542,6 +551,7 @@ describe('SubscriptionManagementService', () => { trialSubscriptions: [], appleIapSubscriptions: [], googleIapSubscriptions: [], + freeAccess: [], }); }); @@ -593,6 +603,7 @@ describe('SubscriptionManagementService', () => { trialSubscriptions: [], appleIapSubscriptions: [], googleIapSubscriptions: [], + freeAccess: [], }); }); @@ -762,6 +773,7 @@ describe('SubscriptionManagementService', () => { trialSubscriptions: [], appleIapSubscriptions: [mockAppleIapSubscriptionContent], googleIapSubscriptions: [mockGoogleIapSubscriptionContent], + freeAccess: [], }); }); @@ -1336,6 +1348,102 @@ describe('SubscriptionManagementService', () => { expect(result.subscriptions).toEqual([mockSubscriptionContent]); expect(result.trialSubscriptions).toEqual([]); }); + + describe('free access', () => { + function mockEmptyAccountFlow() { + const mockUid = faker.string.uuid(); + const mockAccountCustomer = ResultAccountCustomerFactory({ + stripeCustomerId: null, + }); + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue(mockAccountCustomer); + jest + .spyOn(subscriptionManager, 'listForCustomer') + .mockResolvedValue([]); + jest + .spyOn(subscriptionManagementService as any, 'getAppleIapPurchases') + .mockResolvedValue( + AppleIapPurchaseResultFactory({ storeIds: [], purchaseDetails: [] }) + ); + jest + .spyOn(subscriptionManagementService as any, 'getGoogleIapPurchases') + .mockResolvedValue( + GoogleIapPurchaseResultFactory({ + storeIds: [], + purchaseDetails: [], + }) + ); + return mockUid; + } + + function mockOfferingsForEmail(offeringIds: readonly string[]) { + jest + .spyOn(freeAccessProgramManager, 'findOfferingIdsForEmail') + .mockResolvedValue([...offeringIds]); + } + + function mockFreeAccessCards( + cards: Array<{ + apiIdentifier: string; + productName: string; + description: string | null; + }> + ) { + jest + .spyOn( + productConfigurationManager, + 'getFreeAccessCardsByApiIdentifiers' + ) + .mockResolvedValue(cards); + } + + it('returns no free-access cards when email is omitted', async () => { + const mockUid = mockEmptyAccountFlow(); + const result = + await subscriptionManagementService.getPageContent(mockUid); + expect(result.freeAccess).toEqual([]); + }); + + it('returns no free-access cards when the user has no free-access grants', async () => { + const mockUid = mockEmptyAccountFlow(); + mockOfferingsForEmail([]); + + const result = await subscriptionManagementService.getPageContent( + mockUid, + undefined, + undefined, + 'user@example.com' + ); + expect(result.freeAccess).toEqual([]); + }); + + it('returns one card per granted offering, hydrated with productName and description', async () => { + const mockUid = mockEmptyAccountFlow(); + mockOfferingsForEmail(['vpn']); + mockFreeAccessCards([ + { + apiIdentifier: 'vpn', + productName: 'Mozilla VPN', + description: 'Encrypted tunnels.', + }, + ]); + + const result = await subscriptionManagementService.getPageContent( + mockUid, + undefined, + undefined, + 'user@example.com' + ); + expect(result.freeAccess).toEqual([ + { + offeringApiIdentifier: 'vpn', + productName: 'Mozilla VPN', + description: 'Encrypted tunnels.', + }, + ]); + }); + }); }); describe('getTrialSubscriptionContent', () => { diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.ts b/libs/payments/management/src/lib/subscriptionManagement.service.ts index 3bddde6fea4..a40c614aae5 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.ts @@ -32,6 +32,7 @@ import type { } from '@fxa/payments/stripe'; import { SanitizeExceptions } from '@fxa/shared/error'; import { CurrencyManager } from '@fxa/payments/currency'; +import { FreeAccessProgramManager } from '@fxa/free-access-program'; import { AppleIapPurchaseManager, GoogleIapPurchaseManager, @@ -76,6 +77,7 @@ import { AppleIapPurchaseResult, AppleIapSubscriptionContent, CancelFlowResult, + FreeAccessContent, GoogleIapPurchaseResult, GoogleIapSubscriptionContent, StaySubscribedFlowResult, @@ -99,6 +101,7 @@ export class SubscriptionManagementService { private currencyManager: CurrencyManager, private customerManager: CustomerManager, private customerSessionManager: CustomerSessionManager, + private freeAccessProgramManager: FreeAccessProgramManager, private googleIapPurchaseManager: GoogleIapPurchaseManager, private invoiceManager: InvoiceManager, private notifierService: NotifierService, @@ -221,7 +224,8 @@ export class SubscriptionManagementService { async getPageContent( uid: string, acceptLanguage?: string, - selectedLanguage?: string + selectedLanguage?: string, + email?: string ) { const subscriptions: SubscriptionContent[] = []; const appleIapSubscriptions: AppleIapSubscriptionContent[] = []; @@ -282,6 +286,12 @@ export class SubscriptionManagementService { const hasGoogleIap = googleIapSubs.purchaseDetails.length > 0; if (!hasStripe && !hasAppleIap && !hasGoogleIap) { + const freeAccess = await this.resolveFreeAccess( + email, + [], + acceptLanguage, + selectedLanguage + ); return { accountCreditBalance, defaultPaymentMethod, @@ -290,9 +300,15 @@ export class SubscriptionManagementService { trialSubscriptions: [], appleIapSubscriptions: [], googleIapSubscriptions: [], + freeAccess, }; } + // Tracked alongside the stripe sub iteration so we can exclude these + // offerings from the free-access section — paid subscribers shouldn't + // see a "free access" card for something they already pay for. + const subscribedOfferingApiIdentifiers = new Set(); + if (hasStripe && stripeCustomer) { const stripePriceIds = stripeSubs.flatMap((sub) => sub.items.data.map((item) => item.price.id) @@ -319,6 +335,7 @@ export class SubscriptionManagementService { cmsPurchase.purchaseDetails.localizations[0]?.productName || cmsPurchase.purchaseDetails.productName; const apiIdentifier = cmsPurchase.offering.apiIdentifier; + subscribedOfferingApiIdentifiers.add(apiIdentifier); const webIcon = cmsPurchase.purchaseDetails.webIcon; const supportUrl = cmsPurchase.offering.commonContent.supportUrl; @@ -420,6 +437,13 @@ export class SubscriptionManagementService { } } + const freeAccess = await this.resolveFreeAccess( + email, + [...subscribedOfferingApiIdentifiers], + acceptLanguage, + selectedLanguage + ); + return { accountCreditBalance, defaultPaymentMethod, @@ -428,9 +452,45 @@ export class SubscriptionManagementService { trialSubscriptions, appleIapSubscriptions, googleIapSubscriptions, + freeAccess, }; } + /** + * Compute the free-access cards to render: the user's free-access + * offering IDs minus the ones they already have an active stripe + * subscription to, then hydrate from CMS for productName + description. + */ + private async resolveFreeAccess( + email: string | undefined, + subscribedOfferingApiIdentifiers: ReadonlyArray, + acceptLanguage?: string, + selectedLanguage?: string + ): Promise { + if (!email) return []; + + const offeringIds = + await this.freeAccessProgramManager.findOfferingIdsForEmail(email); + if (offeringIds.length === 0) return []; + + const excluded = new Set(subscribedOfferingApiIdentifiers); + const eligibleIds = offeringIds.filter((id) => !excluded.has(id)); + if (eligibleIds.length === 0) return []; + + const cards = + await this.productConfigurationManager.getFreeAccessCardsByApiIdentifiers( + eligibleIds, + acceptLanguage, + selectedLanguage + ); + + return cards.map((card) => ({ + offeringApiIdentifier: card.apiIdentifier, + productName: card.productName, + description: card.description, + })); + } + private async getAppleIapPurchases(uid: string) { const purchases: AppleIapPurchaseResult = { storeIds: [], diff --git a/libs/payments/management/src/lib/types.ts b/libs/payments/management/src/lib/types.ts index 4665dfb5b45..871b65a48ff 100644 --- a/libs/payments/management/src/lib/types.ts +++ b/libs/payments/management/src/lib/types.ts @@ -7,6 +7,17 @@ import { SubplatInterval, } from '@fxa/payments/customer'; +/** + * One free-access card on the subscription management page. Keyed by the + * Strapi offering's `apiIdentifier` so the UI can cross-reference against + * the user's stripe subscriptions (which already key by the same value). + */ +export interface FreeAccessContent { + offeringApiIdentifier: string; + productName: string; + description: string | null; +} + export interface AppleIapPurchase { storeId: string; expiresDate?: number; diff --git a/libs/payments/ui-auth/src/index.ts b/libs/payments/ui-auth/src/index.ts index 0f60ecc46f5..b2062dd08f1 100644 --- a/libs/payments/ui-auth/src/index.ts +++ b/libs/payments/ui-auth/src/index.ts @@ -6,5 +6,9 @@ export { setupAuth, getAuthInstance } from './lib/auth'; export type { UiAuthOptions } from './lib/auth'; export { authConfig } from './lib/auth.config'; export { AuthError, UnauthenticatedError } from './lib/auth.error'; -export { getSessionUid, requireSessionUid } from './lib/session'; +export { + getSessionEmail, + getSessionUid, + requireSessionUid, +} from './lib/session'; export { SessionFactory } from './lib/session.factory'; diff --git a/libs/payments/ui-auth/src/lib/session.spec.ts b/libs/payments/ui-auth/src/lib/session.spec.ts new file mode 100644 index 00000000000..9406086ff4a --- /dev/null +++ b/libs/payments/ui-auth/src/lib/session.spec.ts @@ -0,0 +1,70 @@ +/* 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/. */ + +jest.mock('./auth', () => ({ + getAuthInstance: jest.fn(), +})); + +import { getAuthInstance } from './auth'; +import { UnauthenticatedError } from './auth.error'; +import { + getSessionEmail, + getSessionUid, + requireSessionUid, +} from './session'; + +const mockGetAuthInstance = getAuthInstance as jest.MockedFunction< + typeof getAuthInstance +>; + +function mockSession(session: unknown) { + mockGetAuthInstance.mockReturnValue({ + auth: jest.fn().mockResolvedValue(session), + } as unknown as ReturnType); +} + +describe('session helpers', () => { + describe('getSessionUid', () => { + it('returns the uid when present on the session', async () => { + mockSession({ user: { id: 'uid-123', email: 'user@example.com' } }); + await expect(getSessionUid()).resolves.toBe('uid-123'); + }); + + it('returns undefined when the session is null', async () => { + mockSession(null); + await expect(getSessionUid()).resolves.toBeUndefined(); + }); + }); + + describe('requireSessionUid', () => { + it('returns the uid when present', async () => { + mockSession({ user: { id: 'uid-456' } }); + await expect(requireSessionUid()).resolves.toBe('uid-456'); + }); + + it('throws UnauthenticatedError when the uid is missing', async () => { + mockSession(null); + await expect(requireSessionUid()).rejects.toBeInstanceOf( + UnauthenticatedError + ); + }); + }); + + describe('getSessionEmail', () => { + it('returns the email when present on the session', async () => { + mockSession({ user: { id: 'uid-789', email: 'user@example.com' } }); + await expect(getSessionEmail()).resolves.toBe('user@example.com'); + }); + + it('returns undefined when the email is missing', async () => { + mockSession({ user: { id: 'uid-789' } }); + await expect(getSessionEmail()).resolves.toBeUndefined(); + }); + + it('returns undefined when the session itself is null', async () => { + mockSession(null); + await expect(getSessionEmail()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/libs/payments/ui-auth/src/lib/session.ts b/libs/payments/ui-auth/src/lib/session.ts index 97e2b9f5cc7..8557b056564 100644 --- a/libs/payments/ui-auth/src/lib/session.ts +++ b/libs/payments/ui-auth/src/lib/session.ts @@ -17,3 +17,8 @@ export async function requireSessionUid(): Promise { } return uid; } + +export async function getSessionEmail(): Promise { + const session = await getAuthInstance().auth(); + return session?.user?.email ?? undefined; +} diff --git a/libs/payments/ui/src/index.ts b/libs/payments/ui/src/index.ts index 1fdf8e0b789..d4d5c01e12f 100644 --- a/libs/payments/ui/src/index.ts +++ b/libs/payments/ui/src/index.ts @@ -12,6 +12,7 @@ export * from './lib/client/components/BaseButton'; export * from './lib/client/components/Breadcrumbs'; export * from './lib/client/components/CancelSubscription'; export * from './lib/client/components/CheckoutForm'; +export * from './lib/client/components/FreeAccessContent'; export * from './lib/client/components/CheckoutCheckbox'; export * from './lib/client/components/ChurnCancel'; export * from './lib/client/components/ChurnStaySubscribed'; diff --git a/libs/payments/ui/src/lib/actions/getSubManPageContent.ts b/libs/payments/ui/src/lib/actions/getSubManPageContent.ts index af9da4615ff..966ab542a73 100644 --- a/libs/payments/ui/src/lib/actions/getSubManPageContent.ts +++ b/libs/payments/ui/src/lib/actions/getSubManPageContent.ts @@ -4,7 +4,7 @@ 'use server'; -import { requireSessionUid } from '@fxa/payments/ui-auth'; +import { getSessionEmail, requireSessionUid } from '@fxa/payments/ui-auth'; import { getApp } from '../nestapp/app'; import { flattenRouteParams } from '../utils/flatParam'; import { getAdditionalRequestArgs } from '../utils/getAdditionalRequestArgs'; @@ -16,6 +16,7 @@ export const getSubManPageContentAction = async ( selectedLanguage?: string ) => { const uid = await requireSessionUid(); + const email = await getSessionEmail(); const requestArgs = { ...(await getAdditionalRequestArgs()), params: flattenRouteParams(params), @@ -26,6 +27,7 @@ export const getSubManPageContentAction = async ( .getActionsService() .getSubManPageContent({ uid, + email, requestArgs, acceptLanguage, selectedLanguage, diff --git a/libs/payments/ui/src/lib/client/components/FreeAccessContent/en.ftl b/libs/payments/ui/src/lib/client/components/FreeAccessContent/en.ftl new file mode 100644 index 00000000000..e529b386305 --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/FreeAccessContent/en.ftl @@ -0,0 +1,6 @@ +## Free Access +## Shown on the subscription management page for services a user has access +## to via an email allowlist (no Stripe subscription required). Purely +## informational — no actions. + +free-access-content-access-granted = You have access to this service through your organization. diff --git a/libs/payments/ui/src/lib/client/components/FreeAccessContent/index.test.tsx b/libs/payments/ui/src/lib/client/components/FreeAccessContent/index.test.tsx new file mode 100644 index 00000000000..6399f70fa4d --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/FreeAccessContent/index.test.tsx @@ -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 { render, screen } from '@testing-library/react'; + +jest.mock('@fluent/react', () => ({ + __esModule: true, + Localized: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ alt, className }: { alt?: string; className?: string }) => ( + {alt + ), +})); + +import { FreeAccessContent } from './index'; + +describe('FreeAccessContent', () => { + const baseFreeAccess = { + offeringApiIdentifier: 'vpn', + productName: 'Mozilla VPN', + description: null as string | null, + }; + + it('renders the productName as a heading', () => { + render(); + expect( + screen.getByRole('heading', { name: 'Mozilla VPN' }) + ).toBeInTheDocument(); + }); + + it('renders the description paragraph when present', () => { + render( + + ); + expect(screen.getByText('Encrypted tunnels.')).toBeInTheDocument(); + }); + + it('omits the description paragraph when null', () => { + render(); + expect(screen.queryByText('Encrypted tunnels.')).not.toBeInTheDocument(); + }); + + it('renders the static access-granted message', () => { + render(); + expect( + screen.getByText( + 'You have access to this service through your organization.' + ) + ).toBeInTheDocument(); + }); + + it('renders no interactive controls', () => { + const { container } = render( + + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(container.querySelector('form')).toBeNull(); + }); +}); diff --git a/libs/payments/ui/src/lib/client/components/FreeAccessContent/index.tsx b/libs/payments/ui/src/lib/client/components/FreeAccessContent/index.tsx new file mode 100644 index 00000000000..6f75de20c68 --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/FreeAccessContent/index.tsx @@ -0,0 +1,51 @@ +/* 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/. */ + +'use client'; + +import Image from 'next/image'; +import { Localized } from '@fluent/react'; + +import infoIcon from '@fxa/shared/assets/images/infoBlack.svg'; + +interface FreeAccess { + offeringApiIdentifier: string; + productName: string; + description?: string | null; +} + +interface FreeAccessContentProps { + freeAccess: FreeAccess; +} + +export const FreeAccessContent = ({ + freeAccess, +}: FreeAccessContentProps) => { + const { productName, description } = freeAccess; + + return ( +
+
+

{productName}

+
+
+
+ + +

+ You have access to this service through your organization. +

+
+
+ {description &&

{description}

} +
+
+ ); +}; diff --git a/libs/payments/ui/src/lib/nestapp/app.module.ts b/libs/payments/ui/src/lib/nestapp/app.module.ts index e9492151dcd..4c5208db315 100644 --- a/libs/payments/ui/src/lib/nestapp/app.module.ts +++ b/libs/payments/ui/src/lib/nestapp/app.module.ts @@ -64,6 +64,7 @@ import { LocalizerRscFactoryProvider } from '@fxa/shared/l10n/server'; import { logger, LOGGER_PROVIDER } from '@fxa/shared/log'; import { StatsDProvider } from '@fxa/shared/metrics/statsd'; import { NotifierService, NotifierSnsProvider } from '@fxa/shared/notifier'; +import { FreeAccessProgramManager } from '@fxa/free-access-program'; import { SubscriptionManagementService, ChurnInterventionService, @@ -131,6 +132,7 @@ import { NimbusManager } from '@fxa/payments/experiments'; GoogleIapPurchaseManager, GoogleIapClient, FirestoreProvider, + FreeAccessProgramManager, GeoDBManager, GeoDBNestFactory, GoogleClient, diff --git a/libs/payments/ui/src/lib/nestapp/config.ts b/libs/payments/ui/src/lib/nestapp/config.ts index 15f4ab4219f..68f470ed3cb 100644 --- a/libs/payments/ui/src/lib/nestapp/config.ts +++ b/libs/payments/ui/src/lib/nestapp/config.ts @@ -18,6 +18,7 @@ import { CurrencyConfig } from '@fxa/payments/currency'; import { ProfileClientConfig } from '@fxa/profile/client'; import { ContentServerClientConfig } from '@fxa/payments/content-server'; import { NotifierSnsConfig } from '@fxa/shared/notifier'; +import { FreeAccessProgramClientConfig } from '@fxa/free-access-program'; import { AppleIapClientConfig, GoogleIapClientConfig } from '@fxa/payments/iap'; import { ChurnInterventionConfig, FreeTrialConfig } from '@fxa/payments/cart'; import { TracingConfig } from './tracing.config'; @@ -76,6 +77,11 @@ export class RootConfig { @IsDefined() public readonly googleIapClientConfig!: Partial; + @Type(() => FreeAccessProgramClientConfig) + @ValidateNested() + @IsDefined() + public readonly freeAccessProgramClientConfig!: Partial; + @Type(() => ChurnInterventionConfig) @ValidateNested() @IsDefined() diff --git a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts index 0f1b6da6da0..0691f91a5af 100644 --- a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts +++ b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts @@ -667,6 +667,7 @@ export class NextJSActionsService { @CaptureTimingWithStatsD() async getSubManPageContent(args: { uid: string; + email?: string; requestArgs: CommonMetrics; acceptLanguage?: string | null; selectedLanguage?: string; @@ -674,7 +675,8 @@ export class NextJSActionsService { const result = await this.subscriptionManagementService.getPageContent( args.uid, args.acceptLanguage || undefined, - args.selectedLanguage + args.selectedLanguage, + args.email ); result.subscriptions.forEach((subscription) => { diff --git a/libs/payments/ui/src/lib/nestapp/validators/GetSubManPageContentActionArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/GetSubManPageContentActionArgs.ts index 50ba3ed6acb..c888eb8ced1 100644 --- a/libs/payments/ui/src/lib/nestapp/validators/GetSubManPageContentActionArgs.ts +++ b/libs/payments/ui/src/lib/nestapp/validators/GetSubManPageContentActionArgs.ts @@ -10,6 +10,10 @@ export class GetSubManPageContentActionArgs { @IsString() uid!: string; + @IsString() + @IsOptional() + email?: string; + @Type(() => RequestArgs) @ValidateNested() requestArgs!: RequestArgs; diff --git a/libs/payments/ui/src/lib/nestapp/validators/GetSubManPageContentActionResult.ts b/libs/payments/ui/src/lib/nestapp/validators/GetSubManPageContentActionResult.ts index 54e6825b7a9..804c4e31439 100644 --- a/libs/payments/ui/src/lib/nestapp/validators/GetSubManPageContentActionResult.ts +++ b/libs/payments/ui/src/lib/nestapp/validators/GetSubManPageContentActionResult.ts @@ -170,6 +170,18 @@ class AppleIapSubscriptionContent { storeId!: string; } +class FreeAccess { + @IsString() + offeringApiIdentifier!: string; + + @IsString() + productName!: string; + + @IsOptional() + @IsString() + description?: string | null; +} + class GoogleIapSubscriptionContent { @IsBoolean() autoRenewing!: boolean; @@ -218,4 +230,8 @@ export class GetSubManPageContentActionResult { @ValidateNested() @Type(() => GoogleIapSubscriptionContent) googleIapSubscriptions!: GoogleIapSubscriptionContent[]; + + @ValidateNested() + @Type(() => FreeAccess) + freeAccess!: FreeAccess[]; } diff --git a/libs/payments/webhooks/src/lib/cms-webhooks.controller.spec.ts b/libs/payments/webhooks/src/lib/cms-webhooks.controller.spec.ts index 4751b37a49d..6e1655878b2 100644 --- a/libs/payments/webhooks/src/lib/cms-webhooks.controller.spec.ts +++ b/libs/payments/webhooks/src/lib/cms-webhooks.controller.spec.ts @@ -5,7 +5,11 @@ import { Test } from '@nestjs/testing'; import { CmsWebhooksController } from './cms-webhooks.controller'; import { CmsWebhookService } from './cms-webhooks.service'; -import { CmsContentValidationManager, MockStrapiClientConfigProvider, StrapiClient } from '@fxa/shared/cms'; +import { + CmsContentValidationManager, + MockStrapiClientConfigProvider, + StrapiClient, +} from '@fxa/shared/cms'; import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; import { Logger } from '@nestjs/common'; diff --git a/libs/shared/cms/src/__generated__/gql.ts b/libs/shared/cms/src/__generated__/gql.ts index 0a83b3024cc..ea3417e63f6 100644 --- a/libs/shared/cms/src/__generated__/gql.ts +++ b/libs/shared/cms/src/__generated__/gql.ts @@ -14,6 +14,7 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/ * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size */ type Documents = { + "\n query Accesses {\n accesses(pagination: { limit: 200 }) {\n documentId\n internalName\n offerings {\n apiIdentifier\n capabilities {\n slug\n services {\n oauthClientId\n }\n }\n }\n matchers {\n __typename\n ... on ComponentMatchersEmailList {\n emails\n }\n }\n }\n }\n": typeof types.AccessesDocument, "\n query CancelInterstitialOffer(\n $offeringApiIdentifier: String!\n $currentInterval: String!\n $upgradeInterval: String!\n $locale: String!\n ) {\n cancelInterstitialOffers(\n filters: {\n offeringApiIdentifier: { eq: $offeringApiIdentifier }\n currentInterval: { eq: $currentInterval }\n upgradeInterval: { eq: $upgradeInterval }\n }\n ) {\n offeringApiIdentifier\n currentInterval\n upgradeInterval\n modalHeading1\n modalMessage\n productPageUrl\n upgradeButtonLabel\n upgradeButtonUrl\n localizations(filters: { locale: { eq: $locale } }) {\n modalHeading1\n modalMessage\n productPageUrl\n upgradeButtonLabel\n upgradeButtonUrl\n }\n offering {\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n productName\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n webIcon\n }\n }\n }\n }\n }\n }\n": typeof types.CancelInterstitialOfferDocument, "\n query CapabilityServiceByPlanIds($stripePlanIds: [String]!) {\n purchases(\n filters: {\n or: [\n { stripePlanChoices: { stripePlanChoice: { in: $stripePlanIds } } }\n {\n offering: {\n stripeLegacyPlans: { stripeLegacyPlan: { in: $stripePlanIds } }\n }\n }\n ]\n }\n pagination: { limit: 200 }\n ) {\n stripePlanChoices {\n stripePlanChoice\n }\n offering {\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n capabilities {\n slug\n services {\n oauthClientId\n }\n }\n }\n }\n }\n": typeof types.CapabilityServiceByPlanIdsDocument, "\n query ChurnInterventionByProductId(\n $offeringApiIdentifier: String\n $stripeProductId: String\n $interval: String!\n $locale: String!\n $churnType: String!\n ) {\n offerings(\n filters: {\n or: [\n { stripeProductId: { eq: $stripeProductId } }\n { apiIdentifier: { eq: $offeringApiIdentifier } }\n ]\n }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n defaultPurchase {\n purchaseDetails {\n productName\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n webIcon\n }\n }\n }\n commonContent {\n successActionButtonUrl\n supportUrl\n }\n churnInterventions(\n filters: { interval: { eq: $interval }, churnType: { eq: $churnType } }\n pagination: { limit: 200 }\n ) {\n localizations(filters: { locale: { eq: $locale } }) {\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n }\n }\n": typeof types.ChurnInterventionByProductIdDocument, @@ -44,6 +45,7 @@ type Documents = { "\n query ValidationCouponConfigs {\n couponConfigs(pagination: { limit: 500 }) {\n internalName\n stripePromotionCodes {\n PromoCode\n }\n }\n }\n": typeof types.ValidationCouponConfigsDocument, }; const documents: Documents = { + "\n query Accesses {\n accesses(pagination: { limit: 200 }) {\n documentId\n internalName\n offerings {\n apiIdentifier\n capabilities {\n slug\n services {\n oauthClientId\n }\n }\n }\n matchers {\n __typename\n ... on ComponentMatchersEmailList {\n emails\n }\n }\n }\n }\n": types.AccessesDocument, "\n query CancelInterstitialOffer(\n $offeringApiIdentifier: String!\n $currentInterval: String!\n $upgradeInterval: String!\n $locale: String!\n ) {\n cancelInterstitialOffers(\n filters: {\n offeringApiIdentifier: { eq: $offeringApiIdentifier }\n currentInterval: { eq: $currentInterval }\n upgradeInterval: { eq: $upgradeInterval }\n }\n ) {\n offeringApiIdentifier\n currentInterval\n upgradeInterval\n modalHeading1\n modalMessage\n productPageUrl\n upgradeButtonLabel\n upgradeButtonUrl\n localizations(filters: { locale: { eq: $locale } }) {\n modalHeading1\n modalMessage\n productPageUrl\n upgradeButtonLabel\n upgradeButtonUrl\n }\n offering {\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n productName\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n webIcon\n }\n }\n }\n }\n }\n }\n": types.CancelInterstitialOfferDocument, "\n query CapabilityServiceByPlanIds($stripePlanIds: [String]!) {\n purchases(\n filters: {\n or: [\n { stripePlanChoices: { stripePlanChoice: { in: $stripePlanIds } } }\n {\n offering: {\n stripeLegacyPlans: { stripeLegacyPlan: { in: $stripePlanIds } }\n }\n }\n ]\n }\n pagination: { limit: 200 }\n ) {\n stripePlanChoices {\n stripePlanChoice\n }\n offering {\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n capabilities {\n slug\n services {\n oauthClientId\n }\n }\n }\n }\n }\n": types.CapabilityServiceByPlanIdsDocument, "\n query ChurnInterventionByProductId(\n $offeringApiIdentifier: String\n $stripeProductId: String\n $interval: String!\n $locale: String!\n $churnType: String!\n ) {\n offerings(\n filters: {\n or: [\n { stripeProductId: { eq: $stripeProductId } }\n { apiIdentifier: { eq: $offeringApiIdentifier } }\n ]\n }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n defaultPurchase {\n purchaseDetails {\n productName\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n webIcon\n }\n }\n }\n commonContent {\n successActionButtonUrl\n supportUrl\n }\n churnInterventions(\n filters: { interval: { eq: $interval }, churnType: { eq: $churnType } }\n pagination: { limit: 200 }\n ) {\n localizations(filters: { locale: { eq: $locale } }) {\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n }\n }\n": types.ChurnInterventionByProductIdDocument, @@ -88,6 +90,10 @@ const documents: Documents = { */ export function graphql(source: string): unknown; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query Accesses {\n accesses(pagination: { limit: 200 }) {\n documentId\n internalName\n offerings {\n apiIdentifier\n capabilities {\n slug\n services {\n oauthClientId\n }\n }\n }\n matchers {\n __typename\n ... on ComponentMatchersEmailList {\n emails\n }\n }\n }\n }\n"): (typeof documents)["\n query Accesses {\n accesses(pagination: { limit: 200 }) {\n documentId\n internalName\n offerings {\n apiIdentifier\n capabilities {\n slug\n services {\n oauthClientId\n }\n }\n }\n matchers {\n __typename\n ... on ComponentMatchersEmailList {\n emails\n }\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/libs/shared/cms/src/__generated__/graphql.ts b/libs/shared/cms/src/__generated__/graphql.ts index 1ee9e792dba..1432f57c2e5 100644 --- a/libs/shared/cms/src/__generated__/graphql.ts +++ b/libs/shared/cms/src/__generated__/graphql.ts @@ -14,12 +14,79 @@ export type Scalars = { Boolean: { input: boolean; output: boolean; } Int: { input: number; output: number; } Float: { input: number; output: number; } + AccessMatchersDynamicZoneInput: { input: any; output: any; } /** A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. */ DateTime: { input: any; output: any; } /** A string used to identify an i18n locale */ I18NLocaleCode: { input: any; output: any; } /** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ JSON: { input: any; output: any; } + /** The `BigInt` scalar type represents non-fractional signed whole numeric values. */ + Long: { input: any; output: any; } +}; + +export type Access = { + __typename?: 'Access'; + createdAt: Maybe; + description: Maybe; + documentId: Scalars['ID']['output']; + freeAccessProgram: Maybe; + internalName: Scalars['String']['output']; + matchers: Array>; + offerings: Array>; + offerings_connection: Maybe; + publishedAt: Maybe; + updatedAt: Maybe; +}; + + +export type AccessOfferingsArgs = { + filters: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe>>; +}; + + +export type AccessOfferings_ConnectionArgs = { + filters: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe>>; +}; + +export type AccessEntityResponseCollection = { + __typename?: 'AccessEntityResponseCollection'; + nodes: Array; + pageInfo: Pagination; +}; + +export type AccessFiltersInput = { + and: InputMaybe>>; + createdAt: InputMaybe; + description: InputMaybe; + documentId: InputMaybe; + freeAccessProgram: InputMaybe; + internalName: InputMaybe; + not: InputMaybe; + offerings: InputMaybe; + or: InputMaybe>>; + publishedAt: InputMaybe; + updatedAt: InputMaybe; +}; + +export type AccessInput = { + description: InputMaybe; + freeAccessProgram: InputMaybe; + internalName: InputMaybe; + matchers: InputMaybe>; + offerings: InputMaybe>>; + publishedAt: InputMaybe; +}; + +export type AccessMatchersDynamicZone = ComponentMatchersEmailList | Error; + +export type AccessRelationResponseCollection = { + __typename?: 'AccessRelationResponseCollection'; + nodes: Array; }; export type BooleanFilterInput = { @@ -713,6 +780,12 @@ export type ComponentIapStripePlanChoices = { stripePlanChoices: Scalars['String']['output']; }; +export type ComponentMatchersEmailList = { + __typename?: 'ComponentMatchersEmailList'; + emails: Scalars['JSON']['output']; + id: Scalars['ID']['output']; +}; + export type ComponentStripeStripeLegacyPlans = { __typename?: 'ComponentStripeStripeLegacyPlans'; id: Scalars['ID']['output']; @@ -920,6 +993,12 @@ export enum Enum_Meter_Window { Weekly = 'weekly' } +export type Error = { + __typename?: 'Error'; + code: Scalars['String']['output']; + message: Maybe; +}; + export type FileInfoInput = { alternativeText: InputMaybe; caption: InputMaybe; @@ -951,6 +1030,58 @@ export type FloatFilterInput = { startsWith: InputMaybe; }; +export type FreeAccessProgram = { + __typename?: 'FreeAccessProgram'; + accesses: Array>; + accesses_connection: Maybe; + createdAt: Maybe; + displayName: Scalars['String']['output']; + documentId: Scalars['ID']['output']; + internalName: Scalars['String']['output']; + publishedAt: Maybe; + updatedAt: Maybe; +}; + + +export type FreeAccessProgramAccessesArgs = { + filters: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe>>; +}; + + +export type FreeAccessProgramAccesses_ConnectionArgs = { + filters: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe>>; +}; + +export type FreeAccessProgramEntityResponseCollection = { + __typename?: 'FreeAccessProgramEntityResponseCollection'; + nodes: Array; + pageInfo: Pagination; +}; + +export type FreeAccessProgramFiltersInput = { + accesses: InputMaybe; + and: InputMaybe>>; + createdAt: InputMaybe; + displayName: InputMaybe; + documentId: InputMaybe; + internalName: InputMaybe; + not: InputMaybe; + or: InputMaybe>>; + publishedAt: InputMaybe; + updatedAt: InputMaybe; +}; + +export type FreeAccessProgramInput = { + accesses: InputMaybe>>; + displayName: InputMaybe; + internalName: InputMaybe; + publishedAt: InputMaybe; +}; + export type FreeTrial = { __typename?: 'FreeTrial'; cooldownPeriodMonths: Scalars['Int']['output']; @@ -1017,7 +1148,7 @@ export type FreeTrialRelationResponseCollection = { nodes: Array; }; -export type GenericMorph = CancelInterstitialOffer | Capability | ChurnIntervention | CommonContent | ComponentAccountsEmailConfig | ComponentAccountsFeatureFlags | ComponentAccountsIllustrationsTheme | ComponentAccountsImage | ComponentAccountsPageConfig | ComponentAccountsShared | ComponentAccountsSharedBackgrounds | ComponentAccountsTosAndPrivacyNoticeDetails | ComponentEntitlementsWebhooks | ComponentIapAppleProductIDs | ComponentIapGoogleSkUs | ComponentIapStripeLegacyIapPrices | ComponentIapStripePlanChoices | ComponentStripeStripeLegacyPlans | ComponentStripeStripePlanChoices | ComponentStripeStripePromoCodes | CouponConfig | Default | FreeTrial | I18NLocale | Iap | LegalNotice | Meter | Offering | Purchase | PurchaseDetail | RelyingParty | ReviewWorkflowsWorkflow | ReviewWorkflowsWorkflowStage | Service | Subgroup | UploadFile | UsersPermissionsPermission | UsersPermissionsRole | UsersPermissionsUser; +export type GenericMorph = Access | CancelInterstitialOffer | Capability | ChurnIntervention | CommonContent | ComponentAccountsEmailConfig | ComponentAccountsFeatureFlags | ComponentAccountsIllustrationsTheme | ComponentAccountsImage | ComponentAccountsPageConfig | ComponentAccountsShared | ComponentAccountsSharedBackgrounds | ComponentAccountsTosAndPrivacyNoticeDetails | ComponentEntitlementsWebhooks | ComponentIapAppleProductIDs | ComponentIapGoogleSkUs | ComponentIapStripeLegacyIapPrices | ComponentIapStripePlanChoices | ComponentMatchersEmailList | ComponentStripeStripeLegacyPlans | ComponentStripeStripePlanChoices | ComponentStripeStripePromoCodes | CouponConfig | Default | FreeAccessProgram | FreeTrial | I18NLocale | Iap | LegalNotice | Meter | Offering | Purchase | PurchaseDetail | RelyingParty | ReviewWorkflowsWorkflow | ReviewWorkflowsWorkflowStage | Service | Subgroup | UploadFile | UsersPermissionsPermission | UsersPermissionsRole | UsersPermissionsUser; export type I18NLocale = { __typename?: 'I18NLocale'; @@ -1204,17 +1335,42 @@ export type LegalNoticeInput = { serviceOrClientId: InputMaybe; }; +export type LongFilterInput = { + and: InputMaybe>>; + between: InputMaybe>>; + contains: InputMaybe; + containsi: InputMaybe; + endsWith: InputMaybe; + eq: InputMaybe; + eqi: InputMaybe; + gt: InputMaybe; + gte: InputMaybe; + in: InputMaybe>>; + lt: InputMaybe; + lte: InputMaybe; + ne: InputMaybe; + nei: InputMaybe; + not: InputMaybe; + notContains: InputMaybe; + notContainsi: InputMaybe; + notIn: InputMaybe>>; + notNull: InputMaybe; + null: InputMaybe; + or: InputMaybe>>; + startsWith: InputMaybe; +}; + export type Meter = { __typename?: 'Meter'; createdAt: Maybe; documentId: Scalars['ID']['output']; - limit: Scalars['Int']['output']; - notificationThresholds: Scalars['String']['output']; + limit: Scalars['Long']['output']; + notificationThresholds: Maybe; publishedAt: Maybe; slug: Scalars['String']['output']; unit: Scalars['String']['output']; updatedAt: Maybe; - webhooks: Array>; + webhooks: Maybe>>; window: Enum_Meter_Window; }; @@ -1235,7 +1391,7 @@ export type MeterFiltersInput = { and: InputMaybe>>; createdAt: InputMaybe; documentId: InputMaybe; - limit: InputMaybe; + limit: InputMaybe; not: InputMaybe; notificationThresholds: InputMaybe; or: InputMaybe>>; @@ -1248,7 +1404,7 @@ export type MeterFiltersInput = { }; export type MeterInput = { - limit: InputMaybe; + limit: InputMaybe; notificationThresholds: InputMaybe; publishedAt: InputMaybe; slug: InputMaybe; @@ -1261,11 +1417,13 @@ export type Mutation = { __typename?: 'Mutation'; /** Change user password. Confirm with the current password. */ changePassword: Maybe; + createAccess: Maybe; createCancelInterstitialOffer: Maybe; createCapability: Maybe; createChurnIntervention: Maybe; createCommonContent: Maybe; createCouponConfig: Maybe; + createFreeAccessProgram: Maybe; createFreeTrial: Maybe; createIap: Maybe; createLegalNotice: Maybe; @@ -1282,12 +1440,14 @@ export type Mutation = { createUsersPermissionsRole: Maybe; /** Create a new user */ createUsersPermissionsUser: UsersPermissionsUserEntityResponse; + deleteAccess: Maybe; deleteCancelInterstitialOffer: Maybe; deleteCapability: Maybe; deleteChurnIntervention: Maybe; deleteCommonContent: Maybe; deleteCouponConfig: Maybe; deleteDefault: Maybe; + deleteFreeAccessProgram: Maybe; deleteFreeTrial: Maybe; deleteIap: Maybe; deleteLegalNotice: Maybe; @@ -1314,12 +1474,14 @@ export type Mutation = { register: UsersPermissionsLoginPayload; /** Reset user password. Confirm with a code (resetToken from forgotPassword) */ resetPassword: Maybe; + updateAccess: Maybe; updateCancelInterstitialOffer: Maybe; updateCapability: Maybe; updateChurnIntervention: Maybe; updateCommonContent: Maybe; updateCouponConfig: Maybe; updateDefault: Maybe; + updateFreeAccessProgram: Maybe; updateFreeTrial: Maybe; updateIap: Maybe; updateLegalNotice: Maybe; @@ -1347,6 +1509,12 @@ export type MutationChangePasswordArgs = { }; +export type MutationCreateAccessArgs = { + data: AccessInput; + status?: InputMaybe; +}; + + export type MutationCreateCancelInterstitialOfferArgs = { data: CancelInterstitialOfferInput; locale: InputMaybe; @@ -1380,6 +1548,12 @@ export type MutationCreateCouponConfigArgs = { }; +export type MutationCreateFreeAccessProgramArgs = { + data: FreeAccessProgramInput; + status?: InputMaybe; +}; + + export type MutationCreateFreeTrialArgs = { data: FreeTrialInput; status?: InputMaybe; @@ -1463,6 +1637,11 @@ export type MutationCreateUsersPermissionsUserArgs = { }; +export type MutationDeleteAccessArgs = { + documentId: Scalars['ID']['input']; +}; + + export type MutationDeleteCancelInterstitialOfferArgs = { documentId: Scalars['ID']['input']; locale: InputMaybe; @@ -1496,6 +1675,11 @@ export type MutationDeleteDefaultArgs = { }; +export type MutationDeleteFreeAccessProgramArgs = { + documentId: Scalars['ID']['input']; +}; + + export type MutationDeleteFreeTrialArgs = { documentId: Scalars['ID']['input']; }; @@ -1599,6 +1783,13 @@ export type MutationResetPasswordArgs = { }; +export type MutationUpdateAccessArgs = { + data: AccessInput; + documentId: Scalars['ID']['input']; + status?: InputMaybe; +}; + + export type MutationUpdateCancelInterstitialOfferArgs = { data: CancelInterstitialOfferInput; documentId: Scalars['ID']['input']; @@ -1644,6 +1835,13 @@ export type MutationUpdateDefaultArgs = { }; +export type MutationUpdateFreeAccessProgramArgs = { + data: FreeAccessProgramInput; + documentId: Scalars['ID']['input']; + status?: InputMaybe; +}; + + export type MutationUpdateFreeTrialArgs = { data: FreeTrialInput; documentId: Scalars['ID']['input']; @@ -2063,6 +2261,9 @@ export type PurchaseInput = { export type Query = { __typename?: 'Query'; + access: Maybe; + accesses: Array>; + accesses_connection: Maybe; cancelInterstitialOffer: Maybe; cancelInterstitialOffers: Array>; cancelInterstitialOffers_connection: Maybe; @@ -2079,6 +2280,9 @@ export type Query = { couponConfigs: Array>; couponConfigs_connection: Maybe; default: Maybe; + freeAccessProgram: Maybe; + freeAccessPrograms: Array>; + freeAccessPrograms_connection: Maybe; freeTrial: Maybe; freeTrials: Array>; freeTrials_connection: Maybe; @@ -2131,6 +2335,31 @@ export type Query = { }; +export type QueryAccessArgs = { + documentId: Scalars['ID']['input']; + hasPublishedVersion: InputMaybe; + status?: InputMaybe; +}; + + +export type QueryAccessesArgs = { + filters: InputMaybe; + hasPublishedVersion: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe>>; + status?: InputMaybe; +}; + + +export type QueryAccesses_ConnectionArgs = { + filters: InputMaybe; + hasPublishedVersion: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe>>; + status?: InputMaybe; +}; + + export type QueryCancelInterstitialOfferArgs = { documentId: Scalars['ID']['input']; hasPublishedVersion: InputMaybe; @@ -2272,6 +2501,31 @@ export type QueryDefaultArgs = { }; +export type QueryFreeAccessProgramArgs = { + documentId: Scalars['ID']['input']; + hasPublishedVersion: InputMaybe; + status?: InputMaybe; +}; + + +export type QueryFreeAccessProgramsArgs = { + filters: InputMaybe; + hasPublishedVersion: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe>>; + status?: InputMaybe; +}; + + +export type QueryFreeAccessPrograms_ConnectionArgs = { + filters: InputMaybe; + hasPublishedVersion: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe>>; + status?: InputMaybe; +}; + + export type QueryFreeTrialArgs = { documentId: Scalars['ID']['input']; hasPublishedVersion: InputMaybe; @@ -3288,6 +3542,11 @@ export type UsersPermissionsUserRelationResponseCollection = { nodes: Array; }; +export type AccessesQueryVariables = Exact<{ [key: string]: never; }>; + + +export type AccessesQuery = { __typename?: 'Query', accesses: Array<{ __typename?: 'Access', documentId: string, internalName: string, offerings: Array<{ __typename?: 'Offering', apiIdentifier: string, capabilities: Array<{ __typename?: 'Capability', slug: string, services: Array<{ __typename?: 'Service', oauthClientId: string } | null> } | null> } | null>, matchers: Array<{ __typename: 'ComponentMatchersEmailList', emails: any } | { __typename: 'Error' } | null> } | null> }; + export type CancelInterstitialOfferQueryVariables = Exact<{ offeringApiIdentifier: Scalars['String']['input']; currentInterval: Scalars['String']['input']; @@ -3367,7 +3626,7 @@ export type MeterBySlugQueryVariables = Exact<{ }>; -export type MeterBySlugQuery = { __typename?: 'Query', meters: Array<{ __typename?: 'Meter', slug: string, unit: string, limit: number, window: Enum_Meter_Window, notificationThresholds: string, webhooks: Array<{ __typename?: 'ComponentEntitlementsWebhooks', url: string, signingClientId: string } | null> } | null> }; +export type MeterBySlugQuery = { __typename?: 'Query', meters: Array<{ __typename?: 'Meter', slug: string, unit: string, limit: any, window: Enum_Meter_Window, notificationThresholds: string | null, webhooks: Array<{ __typename?: 'ComponentEntitlementsWebhooks', url: string, signingClientId: string } | null> | null } | null> }; export type OfferingQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -3470,6 +3729,7 @@ export type ValidationCouponConfigsQueryVariables = Exact<{ [key: string]: never export type ValidationCouponConfigsQuery = { __typename?: 'Query', couponConfigs: Array<{ __typename?: 'CouponConfig', internalName: string, stripePromotionCodes: Array<{ __typename?: 'ComponentStripeStripePromoCodes', PromoCode: string } | null> | null } | null> }; +export const AccessesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Accesses"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accesses"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"internalName"}},{"kind":"Field","name":{"kind":"Name","value":"offerings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"capabilities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"oauthClientId"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"matchers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ComponentMatchersEmailList"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"emails"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const CancelInterstitialOfferDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CancelInterstitialOffer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offeringApiIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"currentInterval"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"upgradeInterval"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelInterstitialOffers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"offeringApiIdentifier"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offeringApiIdentifier"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"currentInterval"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"currentInterval"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"upgradeInterval"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"upgradeInterval"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"offeringApiIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"currentInterval"}},{"kind":"Field","name":{"kind":"Name","value":"upgradeInterval"}},{"kind":"Field","name":{"kind":"Name","value":"modalHeading1"}},{"kind":"Field","name":{"kind":"Name","value":"modalMessage"}},{"kind":"Field","name":{"kind":"Name","value":"productPageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"upgradeButtonLabel"}},{"kind":"Field","name":{"kind":"Name","value":"upgradeButtonUrl"}},{"kind":"Field","name":{"kind":"Name","value":"localizations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"locale"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modalHeading1"}},{"kind":"Field","name":{"kind":"Name","value":"modalMessage"}},{"kind":"Field","name":{"kind":"Name","value":"productPageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"upgradeButtonLabel"}},{"kind":"Field","name":{"kind":"Name","value":"upgradeButtonUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"offering"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeProductId"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPurchase"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"purchaseDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}},{"kind":"Field","name":{"kind":"Name","value":"localizations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"locale"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const CapabilityServiceByPlanIdsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CapabilityServiceByPlanIds"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stripePlanIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"purchases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripePlanChoices"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripePlanChoice"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"in"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stripePlanIds"}}}]}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"offering"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripeLegacyPlans"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripeLegacyPlan"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"in"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stripePlanIds"}}}]}}]}}]}}]}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePlanChoices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePlanChoice"}}]}},{"kind":"Field","name":{"kind":"Name","value":"offering"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeLegacyPlans"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeLegacyPlan"}}]}},{"kind":"Field","name":{"kind":"Name","value":"capabilities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"oauthClientId"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ChurnInterventionByProductIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ChurnInterventionByProductId"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offeringApiIdentifier"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stripeProductId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"interval"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"churnType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"offerings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripeProductId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stripeProductId"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"apiIdentifier"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offeringApiIdentifier"}}}]}}]}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPurchase"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"purchaseDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}},{"kind":"Field","name":{"kind":"Name","value":"localizations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"locale"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"commonContent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"successActionButtonUrl"}},{"kind":"Field","name":{"kind":"Name","value":"supportUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"churnInterventions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"interval"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"interval"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"churnType"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"churnType"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"localizations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"locale"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"churnInterventionId"}},{"kind":"Field","name":{"kind":"Name","value":"churnType"}},{"kind":"Field","name":{"kind":"Name","value":"redemptionLimit"}},{"kind":"Field","name":{"kind":"Name","value":"stripeCouponId"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"discountAmount"}},{"kind":"Field","name":{"kind":"Name","value":"ctaMessage"}},{"kind":"Field","name":{"kind":"Name","value":"modalHeading"}},{"kind":"Field","name":{"kind":"Name","value":"modalMessage"}},{"kind":"Field","name":{"kind":"Name","value":"productPageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsHeading"}},{"kind":"Field","name":{"kind":"Name","value":"termsDetails"}}]}},{"kind":"Field","name":{"kind":"Name","value":"churnInterventionId"}},{"kind":"Field","name":{"kind":"Name","value":"churnType"}},{"kind":"Field","name":{"kind":"Name","value":"redemptionLimit"}},{"kind":"Field","name":{"kind":"Name","value":"stripeCouponId"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"discountAmount"}},{"kind":"Field","name":{"kind":"Name","value":"ctaMessage"}},{"kind":"Field","name":{"kind":"Name","value":"modalHeading"}},{"kind":"Field","name":{"kind":"Name","value":"modalMessage"}},{"kind":"Field","name":{"kind":"Name","value":"productPageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsHeading"}},{"kind":"Field","name":{"kind":"Name","value":"termsDetails"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/libs/shared/cms/src/index.ts b/libs/shared/cms/src/index.ts index 9663b141ca3..d16878f7006 100644 --- a/libs/shared/cms/src/index.ts +++ b/libs/shared/cms/src/index.ts @@ -10,6 +10,7 @@ export * from './lib/product-configuration.manager'; export * from './lib/relying-party-configuration.manager'; export * from './lib/default-configuration.manager'; export * from './lib/metering-configuration.manager'; +export * from './lib/queries/accesses'; export * from './lib/queries/cancel-interstitial-offer'; export * from './lib/queries/capability-service-by-plan-ids'; export * from './lib/queries/churn-intervention-by-product-id'; diff --git a/libs/shared/cms/src/lib/product-configuration.manager.spec.ts b/libs/shared/cms/src/lib/product-configuration.manager.spec.ts index d74dbd5aa1e..b5b7976bd58 100644 --- a/libs/shared/cms/src/lib/product-configuration.manager.spec.ts +++ b/libs/shared/cms/src/lib/product-configuration.manager.spec.ts @@ -41,6 +41,7 @@ import { ServicesWithCapabilitiesQueryFactory, ServicesWithCapabilitiesResultUtil, } from '../../src'; +import { ContentOfferingNotFoundError } from './cms.error'; import { ProductConfigurationManager } from './product-configuration.manager'; import { EligibilityContentByOfferingQueryFactory, @@ -273,6 +274,158 @@ describe('productConfigurationManager', () => { }); }); + describe('getFreeAccessCardsByApiIdentifiers', () => { + type CardsArg = Parameters< + typeof productConfigurationManager.getFreeAccessCardsByApiIdentifiers + >; + + // Build a minimal `PageContentForOfferingResultUtil`-shaped stub. + // Avoids depending on the full factory tree — these tests only care + // about what `getOffering()` returns. + function utilWith( + productName: string, + localizedProductName?: string, + subtitle: string | null = null, + localizedSubtitle: string | null = null + ) { + return { + getOffering: () => ({ + defaultPurchase: { + purchaseDetails: { + productName, + subtitle, + localizations: localizedProductName + ? [{ productName: localizedProductName, subtitle: localizedSubtitle }] + : [], + }, + }, + }), + } as any; + } + + function utilThrowing(err: Error) { + return { + getOffering: () => { + throw err; + }, + } as any; + } + + it('returns an empty array when called with no identifiers, without hitting Strapi', async () => { + const spy = jest + .spyOn(productConfigurationManager, 'getPageContentForOffering') + .mockResolvedValue(utilWith('unused')); + const cards = + await productConfigurationManager.getFreeAccessCardsByApiIdentifiers( + [] + ); + expect(cards).toEqual([]); + expect(spy).not.toHaveBeenCalled(); + }); + + it('maps each id to its localized productName and localized subtitle', async () => { + jest + .spyOn(productConfigurationManager, 'getPageContentForOffering') + .mockImplementation(async (id: string) => { + if (id === 'vpn') { + return utilWith( + 'Mozilla VPN', + 'VPN Mozilla', + 'Base subtitle', + 'Localized subtitle' + ); + } + return utilWith('Firefox Relay'); + }); + + const cards = + await productConfigurationManager.getFreeAccessCardsByApiIdentifiers( + ['vpn', 'relay'] as CardsArg[0], + 'en', + 'fr' + ); + + expect(cards).toEqual([ + { + apiIdentifier: 'vpn', + productName: 'VPN Mozilla', + description: 'Localized subtitle', + }, + { + apiIdentifier: 'relay', + productName: 'Firefox Relay', + description: null, + }, + ]); + }); + + it('falls back to the base productName when no localization is present', async () => { + jest + .spyOn(productConfigurationManager, 'getPageContentForOffering') + .mockResolvedValue(utilWith('Mozilla VPN')); + + const cards = + await productConfigurationManager.getFreeAccessCardsByApiIdentifiers( + ['vpn'] + ); + expect(cards).toEqual([ + { apiIdentifier: 'vpn', productName: 'Mozilla VPN', description: null }, + ]); + }); + + it('skips offerings the underlying query reports as not found', async () => { + jest + .spyOn(productConfigurationManager, 'getPageContentForOffering') + .mockImplementation(async (id: string) => { + if (id === 'missing') { + return utilThrowing(new ContentOfferingNotFoundError()); + } + return utilWith('Mozilla VPN'); + }); + + const cards = + await productConfigurationManager.getFreeAccessCardsByApiIdentifiers([ + 'vpn', + 'missing', + ]); + expect(cards).toEqual([ + { apiIdentifier: 'vpn', productName: 'Mozilla VPN', description: null }, + ]); + }); + + it('skips offerings whose lookup outright rejects, without failing the batch', async () => { + jest + .spyOn(productConfigurationManager, 'getPageContentForOffering') + .mockImplementation(async (id: string) => { + if (id === 'boom') { + throw new Error('network'); + } + return utilWith('Mozilla VPN'); + }); + + const cards = + await productConfigurationManager.getFreeAccessCardsByApiIdentifiers([ + 'vpn', + 'boom', + ]); + expect(cards).toEqual([ + { apiIdentifier: 'vpn', productName: 'Mozilla VPN', description: null }, + ]); + }); + + it('rethrows unexpected errors raised by `.getOffering()`', async () => { + jest + .spyOn(productConfigurationManager, 'getPageContentForOffering') + .mockResolvedValue(utilThrowing(new Error('weird-internal-error'))); + + await expect( + productConfigurationManager.getFreeAccessCardsByApiIdentifiers([ + 'vpn', + ]) + ).rejects.toThrow('weird-internal-error'); + }); + }); + describe('getPageContentByPriceIds', () => { it('should return empty result', async () => { const queryData = PageContentByPriceIdsQueryFactory({ @@ -786,4 +939,5 @@ describe('productConfigurationManager', () => { expect(result.freeTrial.freeTrials).toHaveLength(1); }); }); + }); diff --git a/libs/shared/cms/src/lib/product-configuration.manager.ts b/libs/shared/cms/src/lib/product-configuration.manager.ts index d7857201141..585aebac672 100644 --- a/libs/shared/cms/src/lib/product-configuration.manager.ts +++ b/libs/shared/cms/src/lib/product-configuration.manager.ts @@ -25,7 +25,9 @@ import { ChurnInterventionByProductIdQuery, } from '../__generated__/graphql'; import { + ContentOfferingNotFoundError, FetchCmsInvalidOfferingError, + MultipleContentOfferingResultsError, QueriesUtilError, RetrieveStripePriceInvalidOfferingError, RetrieveStripePriceNotFoundError, @@ -62,7 +64,7 @@ import { servicesWithCapabilitiesQuery, } from './queries/services-with-capabilities'; import { StrapiClient, type StrapiClientEventResponse } from './strapi.client'; -import { DeepNonNullable } from './types'; +import { DeepNonNullable, FreeAccessCardContent } from './types'; import { iapOfferingsByStoreIDsQuery, IapOfferingsByStoreIDsResultUtil, @@ -167,6 +169,61 @@ export class ProductConfigurationManager { ); } + /** + * Resolves the localized card fields used to render free-access offering + * tiles on the subscription-management page. Wraps `getPageContentForOffering` + * per identifier — Strapi caching handles the per-offering reads, so a + * user with N grants pays N concurrent (cached) reads. + * + * Missing or duplicated offerings are dropped rather than thrown: one bad + * offering must not break the whole management page. `description` is + * sourced from the localized `subtitle` field on `purchaseDetails`. + */ + async getFreeAccessCardsByApiIdentifiers( + apiIdentifiers: ReadonlyArray, + acceptLanguage?: string, + selectedLanguage?: string + ): Promise { + if (apiIdentifiers.length === 0) return []; + + const results = await Promise.allSettled( + apiIdentifiers.map((apiIdentifier) => + this.getPageContentForOffering( + apiIdentifier, + acceptLanguage, + selectedLanguage + ) + ) + ); + + const cards: FreeAccessCardContent[] = []; + results.forEach((result, index) => { + if (result.status !== 'fulfilled') return; + let offering; + try { + offering = result.value.getOffering(); + } catch (err) { + if ( + err instanceof ContentOfferingNotFoundError || + err instanceof MultipleContentOfferingResultsError + ) { + return; + } + throw err; + } + const details = offering.defaultPurchase.purchaseDetails; + const localized = details.localizations[0]; + const productName = localized?.productName || details.productName; + if (!productName) return; + cards.push({ + apiIdentifier: apiIdentifiers[index], + productName, + description: localized?.subtitle ?? details.subtitle ?? null, + }); + }); + return cards; + } + async getPageContentByPriceIds( stripePlanIds: string[], acceptLanguage?: string, diff --git a/libs/shared/cms/src/lib/queries/accesses/factories.ts b/libs/shared/cms/src/lib/queries/accesses/factories.ts new file mode 100644 index 00000000000..978ddf22531 --- /dev/null +++ b/libs/shared/cms/src/lib/queries/accesses/factories.ts @@ -0,0 +1,68 @@ +/* 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 { AccessesQuery } from '../../../__generated__/graphql'; + +type Access = NonNullable; +type Offering = NonNullable; +type Capability = NonNullable; +type Service = NonNullable; +type Matcher = NonNullable; + +export const AccessServiceFactory = ( + override?: Partial +): Service => ({ + __typename: 'Service', + oauthClientId: faker.string.alphanumeric({ length: 16 }), + ...override, +}); + +export const AccessCapabilityFactory = ( + override?: Partial +): Capability => ({ + __typename: 'Capability', + slug: `cap-${faker.string.alphanumeric({ length: 8 })}`, + services: [AccessServiceFactory()], + ...override, +}); + +export const AccessOfferingFactory = ( + override?: Partial +): Offering => ({ + __typename: 'Offering', + apiIdentifier: `offering-${faker.string.alphanumeric({ length: 8 })}`, + capabilities: [AccessCapabilityFactory()], + ...override, +}); + +export const AccessEmailListMatcherFactory = ( + override?: Partial< + Extract + > +): Matcher => ({ + __typename: 'ComponentMatchersEmailList', + emails: [faker.internet.email().toLowerCase()], + ...override, +}); + +export const AccessResultFactory = ( + override?: Partial +): Access => ({ + __typename: 'Access', + documentId: faker.string.uuid(), + internalName: faker.company.name(), + offerings: [AccessOfferingFactory()], + matchers: [AccessEmailListMatcherFactory()], + ...override, +}); + +export const AccessesQueryFactory = ( + override?: Partial +): AccessesQuery => ({ + __typename: 'Query', + accesses: [AccessResultFactory()], + ...override, +}); diff --git a/libs/shared/cms/src/lib/queries/accesses/index.ts b/libs/shared/cms/src/lib/queries/accesses/index.ts new file mode 100644 index 00000000000..72b31b8f98c --- /dev/null +++ b/libs/shared/cms/src/lib/queries/accesses/index.ts @@ -0,0 +1,13 @@ +/* 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 './factories'; +export * from './query'; + +/** + * Re-exported so downstream consumers (e.g. free-access-program) can type + * against the canonical query result instead of redeclaring a structural + * subset. + */ +export type { AccessesQuery } from '../../../__generated__/graphql'; diff --git a/libs/shared/cms/src/lib/queries/accesses/query.ts b/libs/shared/cms/src/lib/queries/accesses/query.ts new file mode 100644 index 00000000000..649512efc57 --- /dev/null +++ b/libs/shared/cms/src/lib/queries/accesses/query.ts @@ -0,0 +1,36 @@ +/* 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 { graphql } from '../../../__generated__/gql'; + +/** + * Fetches all Strapi `access` entries (the child of free-access-program) + * with their matchers and the capabilities attached to each linked + * offering. An access may carry multiple offerings; capabilities are + * flattened across them downstream. Auth-server / payments-api filter + * in memory against the user's email. + */ +export const accessesQuery = graphql(` + query Accesses { + accesses(pagination: { limit: 200 }) { + documentId + internalName + offerings { + apiIdentifier + capabilities { + slug + services { + oauthClientId + } + } + } + matchers { + __typename + ... on ComponentMatchersEmailList { + emails + } + } + } + } +`); diff --git a/libs/shared/cms/src/lib/strapi.client.config.ts b/libs/shared/cms/src/lib/strapi.client.config.ts index 6e589372385..875ee451623 100644 --- a/libs/shared/cms/src/lib/strapi.client.config.ts +++ b/libs/shared/cms/src/lib/strapi.client.config.ts @@ -8,7 +8,7 @@ import { Type } from 'class-transformer'; import { IsNumber, IsOptional, IsString, IsUrl } from 'class-validator'; export class StrapiClientConfig { - @IsUrl() + @IsUrl({ require_tld: false }) public readonly graphqlApiUri!: string; @IsString() diff --git a/libs/shared/cms/src/lib/types.ts b/libs/shared/cms/src/lib/types.ts index bff337d38c6..1598d522e73 100644 --- a/libs/shared/cms/src/lib/types.ts +++ b/libs/shared/cms/src/lib/types.ts @@ -5,6 +5,19 @@ export type DeepNonNullable = { : Exclude, null | undefined>; }; +/** + * Slim shape used to render free-access offering cards on the subscription + * management page. Sourced from the existing `page-content-for-offering` + * query — `description` maps to the localized `subtitle` field. Locale + * resolution happens inside the manager so the caller doesn't have to + * touch the localizations array. + */ +export interface FreeAccessCardContent { + apiIdentifier: string; + productName: string; + description: string | null; +} + /* https://www.contentful.com/developers/docs/references/errors/ */ diff --git a/libs/shared/db/firestore/src/index.ts b/libs/shared/db/firestore/src/index.ts index 8c3d540b95b..62e7e425dba 100644 --- a/libs/shared/db/firestore/src/index.ts +++ b/libs/shared/db/firestore/src/index.ts @@ -1,5 +1,6 @@ /* 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/firestore.config'; export * from './lib/firestore.provider'; export * from './lib/firestore-typedi-token'; diff --git a/packages/fxa-auth-server/bin/key_server.js b/packages/fxa-auth-server/bin/key_server.js index 5b2d0490c45..95a20fe2642 100755 --- a/packages/fxa-auth-server/bin/key_server.js +++ b/packages/fxa-auth-server/bin/key_server.js @@ -11,6 +11,19 @@ const Sentry = require('@sentry/node'); const { config } = require('../config'); const Redis = require('ioredis'); const { CapabilityManager } = require('@fxa/payments/capability'); +const { + FreeAccessProgramClientConfig, + FreeAccessProgramManager, + FreeAccessProgramReconcilerService, + FREE_ACCESS_NOTIFIER, +} = require('@fxa/free-access-program'); +const { Logger } = require('@nestjs/common'); +const { FirestoreService } = require('@fxa/shared/db/firestore'); +const { StatsDService } = require('@fxa/shared/metrics/statsd'); +const { + FreeAccessInProcessNotifier, +} = require('../lib/payments/free-access-in-process-notifier'); +const { CapabilityService } = require('../lib/payments/capability'); const { EligibilityManager } = require('@fxa/payments/eligibility'); const { PriceManager, @@ -194,6 +207,7 @@ async function run(config) { } const firestore = Container.get(AuthFirestore); const strapiClient = new StrapiClient(strapiClientConfig, firestore); + Container.set(StrapiClient, strapiClient); const productConfigurationManager = new ProductConfigurationManager( strapiClient, priceManager, @@ -231,7 +245,41 @@ async function run(config) { log ); Container.set(DefaultCmsConfigurationManager, defaultCmsManager); + + const freeAccessProgramConfig = Object.assign( + new FreeAccessProgramClientConfig(), + { + firestoreCacheCollectionName: + config.subscriptions.freeAccessProgram + .firestoreCacheCollectionName, + memCacheTTL: + config.subscriptions.freeAccessProgram.memCacheTTL, + firestoreCacheTTL: + config.subscriptions.freeAccessProgram.firestoreCacheTTL, + firestoreOfflineCacheTTL: + config.subscriptions.freeAccessProgram.firestoreOfflineCacheTTL, + } + ); + const freeAccessProgramManager = new FreeAccessProgramManager( + freeAccessProgramConfig, + strapiClient, + firestore, + log + ); + Container.set(FreeAccessProgramManager, freeAccessProgramManager); + Container.set(FreeAccessProgramClientConfig, freeAccessProgramConfig); + Container.set(FirestoreService, firestore); + Container.set(StatsDService, statsd); + Container.set(Logger, log); + + // The FreeAccessInProcessNotifier + reconciler depend on a fully- + // wired CapabilityService (specifically ProfileClient), which isn't + // in Container yet at this point. We finish their wiring below, + // after `Container.set(ProfileClient, ...)`. } + // CMS-disabled branch: no FreeAccessProgramManager is registered, so + // CapabilityService's `Container.has` guard short-circuits the lookup + // and returns no free-access capabilities. No no-op shim needed. const { createStripeHelper } = require('../lib/payments/stripe'); stripeHelper = createStripeHelper(log, config, statsd); @@ -367,6 +415,30 @@ async function run(config) { }); Container.set(ProfileClient, profile); + // Wire the free-access notifier + reconciler now that CapabilityService's + // required deps (ProfileClient) are in Container. CapabilityService is + // auto-instantiated by TypeDI on first `Container.get` and captures its + // deps at construction time — calling it before ProfileClient was set + // would have permanently baked in the missing dep. + if (Container.has(FreeAccessProgramManager)) { + const freeAccessProgramManager = Container.get(FreeAccessProgramManager); + const capabilityService = Container.get(CapabilityService); + const freeAccessNotifier = new FreeAccessInProcessNotifier( + database, + capabilityService + ); + Container.set(FREE_ACCESS_NOTIFIER, freeAccessNotifier); + Container.set( + FreeAccessProgramReconcilerService, + new FreeAccessProgramReconcilerService( + freeAccessProgramManager, + freeAccessNotifier, + statsd, + log + ) + ); + } + const bounces = new Bounces(config.smtp.bounces, { // libs expectation for db is a bit simpler so we just pass through the // existing function diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index d2bbe2f0410..2dcad38b5d1 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -1153,6 +1153,32 @@ const convictConf = convict({ env: 'SUBSCRIPTIONS_BILLING_PRICE_INFO_FEATURE', default: false, }, + freeAccessProgram: { + firestoreCacheCollectionName: { + doc: 'Firestore collection used by type-cacheable as the cross-instance / cold-start fallback for the projected Strapi snapshot. Not a source of truth — purely a cache store.', + format: String, + env: 'FREE_ACCESS_PROGRAM_FIRESTORE_CACHE_COLLECTION_NAME', + default: 'subplat-free-access-program-cache', + }, + memCacheTTL: { + doc: 'Per-instance in-memory cache TTL for the free-access-program snapshot, in seconds. Defaults to the manager-local default (300s).', + format: Number, + env: 'FREE_ACCESS_PROGRAM_MEM_CACHE_TTL', + default: 300, + }, + firestoreCacheTTL: { + doc: 'Stale-while-revalidate window for the Firestore-backed snapshot cache, in seconds. Defaults to the manager-local default (1800s).', + format: Number, + env: 'FREE_ACCESS_PROGRAM_FIRESTORE_CACHE_TTL', + default: 1800, + }, + firestoreOfflineCacheTTL: { + doc: 'Offline fallback TTL for the Firestore-backed snapshot cache, in seconds (degraded-Strapi survival window). Defaults to the manager-local default (604800s / 7 days).', + format: Number, + env: 'FREE_ACCESS_PROGRAM_FIRESTORE_OFFLINE_CACHE_TTL', + default: 604800, + }, + }, }, currenciesToCountries: { doc: 'Mapping from ISO 4217 three-letter currency codes to list of ISO 3166-1 alpha-2 two-letter country codes: {"EUR": ["DE", "FR"], "USD": ["CA", "GB", "US" ]} Requirement for only one currency per country. Tested at runtime. Must be uppercased.', @@ -2438,6 +2464,12 @@ const convictConf = convict({ env: 'STRAPI_CLIENT_FIRESTORE_OFFLINE_CACHE_TTL', format: Number, }, + webhookSecret: { + default: 'PLACEHOLDER', + doc: 'Strapi client webhook secret', + env: 'STRAPI_CLIENT_WEBHOOK_SECRET', + format: String, + }, }, }, cloudTasks: CloudTasksConvictConfigFactory(), diff --git a/packages/fxa-auth-server/jest.config.js b/packages/fxa-auth-server/jest.config.js index bee5880f336..291c4788555 100644 --- a/packages/fxa-auth-server/jest.config.js +++ b/packages/fxa-auth-server/jest.config.js @@ -20,6 +20,8 @@ module.exports = { '/node_modules/(?!(@fxa|fxa-shared|p-queue|p-timeout|eventemitter3)/)', ], moduleNameMapper: { + '^@fxa/free-access-program$': + '/../../libs/free-access-program/src', '^@fxa/shared/(.*)$': '/../../libs/shared/$1/src', '^@fxa/accounts/(.*)$': '/../../libs/accounts/$1/src', '^@fxa/payments/(.*)$': '/../../libs/payments/$1/src', diff --git a/packages/fxa-auth-server/jest.setup.js b/packages/fxa-auth-server/jest.setup.js index c948081ec7c..8a50b40e822 100644 --- a/packages/fxa-auth-server/jest.setup.js +++ b/packages/fxa-auth-server/jest.setup.js @@ -5,6 +5,15 @@ /** * Jest global setup - runs before each test file. * + * Load `reflect-metadata` before anything else so any module that pulls in + * a class-transformer / class-validator decorator at load time (e.g. + * `@fxa/shared/db/firestore`'s `FirestoreConfig`) finds `Reflect.getMetadata` + * available. Production loads it via NestJS bootstrap; the unit-test + * harness doesn't. + */ +require('reflect-metadata'); + +/** * Set NODE_ENV=dev so that the config module loads config/dev.json, * which includes OAuth keys (config/key.json), authServerSecrets, * and other values required by unit tests. This matches the CI test diff --git a/packages/fxa-auth-server/lib/oauth/grant.js b/packages/fxa-auth-server/lib/oauth/grant.js index 962125fa933..8e1f97e00e2 100644 --- a/packages/fxa-auth-server/lib/oauth/grant.js +++ b/packages/fxa-auth-server/lib/oauth/grant.js @@ -31,6 +31,7 @@ const JWT_ACCESS_TOKENS_ENABLED = config.get( const JWT_ACCESS_TOKENS_CLIENT_IDS = new Set( config.get('oauthServer.jwtAccessTokens.enabledClientIds') ); +const SUBSCRIPTIONS_ENABLED = !!config.get('subscriptions.enabled'); const UNTRUSTED_CLIENT_ALLOWED_SCOPES = ScopeSet.fromArray([ 'openid', @@ -264,7 +265,14 @@ exports.generateAccessToken = async function generateAccessToken(grant) { return accessToken; } - if (grant.scope.contains('profile:subscriptions')) { + // Skip when subscriptions are disabled. Mirrors the gate + // `lib/routes/account.ts` already applies before calling + // `capabilityService.subscriptionCapabilities` / `hasFreeAccess` — + // without it, this path drives a chain that ends in + // `planIdsToClientCapabilities` throwing (errno 998) because no + // `CapabilityManager` is registered in Container when subscriptions + // are off. + if (SUBSCRIPTIONS_ENABLED && grant.scope.contains('profile:subscriptions')) { const capabilities = await capabilityService.determineClientVisibleSubscriptionCapabilities( clientId, diff --git a/packages/fxa-auth-server/lib/oauth/grant.spec.ts b/packages/fxa-auth-server/lib/oauth/grant.spec.ts index bf4a6d8a91d..7695b881cf1 100644 --- a/packages/fxa-auth-server/lib/oauth/grant.spec.ts +++ b/packages/fxa-auth-server/lib/oauth/grant.spec.ts @@ -2,9 +2,10 @@ * 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/. */ -// `grant.js` captures `JWT_ACCESS_TOKENS_ENABLED` and the allow-list at -// module load, so the config is fixed once here. validateRequestedGrant -// tests don't depend on those values; generateTokens tests do. +// `grant.js` captures `JWT_ACCESS_TOKENS_ENABLED`, the allow-list, and +// `SUBSCRIPTIONS_ENABLED` at module load, so the config is fixed once +// here. validateRequestedGrant tests don't depend on those values; +// generateTokens tests do. jest.mock('../../config', () => { const realConfig = jest.requireActual('../../config').config; return { @@ -15,6 +16,8 @@ jest.mock('../../config', () => { return true; case 'oauthServer.jwtAccessTokens.enabledClientIds': return ['9876543210']; + case 'subscriptions.enabled': + return true; default: return realConfig.get(key); } diff --git a/packages/fxa-auth-server/lib/payments/capability.spec.ts b/packages/fxa-auth-server/lib/payments/capability.spec.ts index 7596b5ea256..a030d41fa7f 100644 --- a/packages/fxa-auth-server/lib/payments/capability.spec.ts +++ b/packages/fxa-auth-server/lib/payments/capability.spec.ts @@ -36,6 +36,7 @@ const { const authDbModule = require('fxa-shared/db/models/auth'); const { PurchaseQueryError } = require('./iap/google-play/types'); const { CapabilityManager } = require('@fxa/payments/capability'); +const { FreeAccessProgramManager } = require('@fxa/free-access-program'); const { EligibilityManager } = require('@fxa/payments/eligibility'); const { SubscriptionEligibilityResult, @@ -411,6 +412,71 @@ describe('CapabilityService', () => { }); }); + describe('processEmailListChange', () => { + it('broadcasts added capabilities with isActive=true', async () => { + await capabilityService.processEmailListChange({ + uid: UID, + added: ['capA', 'capB'], + removed: [], + eventCreatedAt: 1_700_000_000, + }); + + expect(mockProfileClient.deleteCache).toHaveBeenCalledWith(UID); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( + 'subscription:update', + expect.anything(), + expect.objectContaining({ + uid: UID, + isActive: true, + productCapabilities: ['capA', 'capB'], + eventCreatedAt: 1_700_000_000, + }) + ); + }); + + it('broadcasts removed capabilities with isActive=false', async () => { + await capabilityService.processEmailListChange({ + uid: UID, + added: [], + removed: ['capX'], + }); + + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( + 'subscription:update', + expect.anything(), + expect.objectContaining({ + uid: UID, + isActive: false, + productCapabilities: ['capX'], + }) + ); + }); + + it('broadcasts both events when added and removed are non-empty', async () => { + await capabilityService.processEmailListChange({ + uid: UID, + added: ['capA'], + removed: ['capX'], + }); + + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(2); + expect(mockProfileClient.deleteCache).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when no capabilities are added or removed', async () => { + await capabilityService.processEmailListChange({ + uid: UID, + added: [], + removed: [], + }); + + expect(log.notifyAttachedServices).not.toHaveBeenCalled(); + expect(mockProfileClient.deleteCache).not.toHaveBeenCalled(); + }); + }); + describe('getPlanEligibility', () => { const mockAbbrevPlans = [ { @@ -837,6 +903,125 @@ describe('CapabilityService', () => { }); }); + describe('subscriptionCapabilities — email-list merge', () => { + beforeEach(() => { + // Stub the price-ID lookup so it returns a single placeholder price. + // The merge tests rely on the `priceIdsToClientCapabilities` mock + // returning subscription caps; `planIdsToClientCapabilities` + // short-circuits on an empty input, so we need a non-empty array + // here for the CapabilityManager path to be exercised at all. + jest + .spyOn(CapabilityService.prototype, 'subscribedPriceIds') + .mockResolvedValue(['price_test']); + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({}); + }); + + it('returns only subscription caps when no FreeAccessProgramManager is registered', async () => { + Container.remove(FreeAccessProgramManager); + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ c1: ['capSub'] }); + const svc = new CapabilityService(); + const caps = await svc.subscriptionCapabilities(UID, EMAIL); + expect(caps).toEqual({ c1: ['capSub'] }); + }); + + const buildFapManager = (caps: Record) => ({ + findCapabilitiesForEmail: jest.fn().mockResolvedValue(caps), + }); + + it('merges free-access caps with subscription caps', async () => { + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ c1: ['capSub'] }); + Container.set( + FreeAccessProgramManager, + buildFapManager({ c1: ['capEmail'], c2: ['capExtra'] }) + ); + const svc = new CapabilityService(); + const caps = await svc.subscriptionCapabilities(UID, EMAIL); + expect(caps).toEqual({ + c1: ['capSub', 'capEmail'], + c2: ['capExtra'], + }); + }); + + it('honors ALL_RPS_CAPABILITIES_KEY ("*") when merging', async () => { + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ '*': ['capAll'] }); + Container.set( + FreeAccessProgramManager, + buildFapManager({ '*': ['capAllFromEmail'] }) + ); + const svc = new CapabilityService(); + const caps = await svc.subscriptionCapabilities(UID, EMAIL); + expect(caps).toEqual({ '*': ['capAll', 'capAllFromEmail'] }); + }); + + it('returns subscription caps unchanged when email has no free-access grant', async () => { + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ c1: ['capSub'] }); + Container.set(FreeAccessProgramManager, buildFapManager({})); + const svc = new CapabilityService(); + const caps = await svc.subscriptionCapabilities(UID, EMAIL); + expect(caps).toEqual({ c1: ['capSub'] }); + }); + + it('skips the free-access lookup entirely when no email is passed', async () => { + const findCapabilitiesForEmail = jest.fn().mockResolvedValue({}); + Container.set(FreeAccessProgramManager, { findCapabilitiesForEmail }); + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ c1: ['capSub'] }); + const svc = new CapabilityService(); + const caps = await svc.subscriptionCapabilities(UID); + expect(caps).toEqual({ c1: ['capSub'] }); + expect(findCapabilitiesForEmail).not.toHaveBeenCalled(); + }); + }); + + describe('hasFreeAccess', () => { + const buildFapManager = (caps: Record) => ({ + findCapabilitiesForEmail: jest.fn().mockResolvedValue(caps), + }); + + it('returns true when the email has a free-access grant', async () => { + Container.set( + FreeAccessProgramManager, + buildFapManager({ c1: ['capEmail'] }) + ); + const svc = new CapabilityService(); + await expect(svc.hasFreeAccess(EMAIL)).resolves.toBe(true); + }); + + it('returns false when the email has no free-access grant', async () => { + Container.set(FreeAccessProgramManager, buildFapManager({})); + const svc = new CapabilityService(); + await expect(svc.hasFreeAccess(EMAIL)).resolves.toBe(false); + }); + + it('returns false when no email is supplied', async () => { + Container.set( + FreeAccessProgramManager, + buildFapManager({ c1: ['capEmail'] }) + ); + const svc = new CapabilityService(); + await expect(svc.hasFreeAccess(undefined)).resolves.toBe(false); + await expect(svc.hasFreeAccess(null)).resolves.toBe(false); + await expect(svc.hasFreeAccess('')).resolves.toBe(false); + }); + + it('returns false when FreeAccessProgramManager is not registered', async () => { + Container.remove(FreeAccessProgramManager); + const svc = new CapabilityService(); + await expect(svc.hasFreeAccess(EMAIL)).resolves.toBe(false); + }); + }); + describe('determineClientVisibleSubscriptionCapabilities', () => { beforeEach(() => { mockStripeHelper.fetchCustomer = jest.fn(async () => ({ diff --git a/packages/fxa-auth-server/lib/payments/capability.ts b/packages/fxa-auth-server/lib/payments/capability.ts index cb227954dc3..f0bfc3ea634 100644 --- a/packages/fxa-auth-server/lib/payments/capability.ts +++ b/packages/fxa-auth-server/lib/payments/capability.ts @@ -14,6 +14,7 @@ import Stripe from 'stripe'; import Container from 'typedi'; import { CapabilityManager } from '@fxa/payments/capability'; +import { FreeAccessProgramManager } from '@fxa/free-access-program'; import { EligibilityManager, IntervalComparison, @@ -58,6 +59,7 @@ export class CapabilityService { private stripeHelper: StripeHelper; private profileClient: ProfileClient; private capabilityManager?: CapabilityManager; + private freeAccessProgramManager?: FreeAccessProgramManager; private eligibilityManager?: EligibilityManager; constructor() { @@ -81,6 +83,9 @@ export class CapabilityService { if (Container.has(CapabilityManager)) { this.capabilityManager = Container.get(CapabilityManager); } + if (Container.has(FreeAccessProgramManager)) { + this.freeAccessProgramManager = Container.get(FreeAccessProgramManager); + } if (Container.has(EligibilityManager)) { this.eligibilityManager = Container.get(EligibilityManager); } @@ -217,12 +222,39 @@ export class CapabilityService { /** * Return a map of capabilities to client ids for the user. + * + * Capabilities come from two sources, unioned together: + * 1. Active subscriptions (Stripe / IAP) resolved via CapabilityManager. + * 2. The Strapi-managed free-access allowlist + * (`FreeAccessProgramManager`). Pass `email` when the caller already + * has it to avoid an extra DB fetch. */ public async subscriptionCapabilities( - uid: string + uid: string, + email?: string | null ): Promise { const subscribedPrices = await this.subscribedPriceIds(uid); - return this.planIdsToClientCapabilities(subscribedPrices); + const subscriptionCaps = + await this.planIdsToClientCapabilities(subscribedPrices); + const emailCaps = + email && this.freeAccessProgramManager + ? await this.freeAccessProgramManager.findCapabilitiesForEmail(email) + : {}; + return ClientIdCapabilityMap.merge(subscriptionCaps, emailCaps); + } + + /** + * Returns true if the user's email is on the Strapi-managed free-access + * allowlist. Distinct from `subscriptionCapabilities`, which merges + * Stripe + free-access sources — this surfaces *only* the free-access + * signal, for UI gates that need to differentiate "user is paid" from + * "user is on the allowlist". + */ + public async hasFreeAccess(email?: string | null): Promise { + if (!email || !this.freeAccessProgramManager) return false; + const caps = + await this.freeAccessProgramManager.findCapabilitiesForEmail(email); + return Object.keys(caps).length > 0; } /** @@ -501,6 +533,47 @@ export class CapabilityService { }; } + /** + * Apply a change to the email-allowlist capability source for a single + * user: invalidate their profile cache and broadcast the added/removed + * capabilities to attached services via the existing SQS pipeline. + * + * TODO(FXA-XXXXX): Replace this with an event-driven flow where + * payments-api emits a "capability list changed" event that auth-server + * consumes. This method exists because payments-api currently calls + * auth-server over HTTP to drive the broadcast (see + * `/oauth/subscriptions/email-capability-changed`). + */ + public async processEmailListChange(options: { + uid: string; + added: string[]; + removed: string[]; + eventCreatedAt?: number; + request?: AuthRequest; + }) { + const { uid, added, removed, eventCreatedAt, request } = options; + if (added.length === 0 && removed.length === 0) { + return; + } + await this.profileClient.deleteCache(uid); + if (added.length > 0) { + this.broadcastCapabilitiesAdded({ + uid, + capabilities: added, + eventCreatedAt, + request, + }); + } + if (removed.length > 0) { + this.broadcastCapabilitiesRemoved({ + uid, + capabilities: removed, + eventCreatedAt, + request, + }); + } + } + /** * Diff a list of prior price ids to the list of current price ids * and emit the necessary events for added/removed capabilities. @@ -716,11 +789,7 @@ export class CapabilityService { try { return await this.capabilityManager.getClients(); } catch (err) { - throw error.internalValidationError( - 'subscriptions.getClients', - {}, - err - ); + throw error.internalValidationError('subscriptions.getClients', {}, err); } } @@ -730,6 +799,14 @@ export class CapabilityService { async planIdsToClientCapabilities( subscribedPrices: string[] ): Promise { + // No prices to resolve → no capabilities. Short-circuit before the + // CapabilityManager guard so callers that pass through with an empty + // list (e.g. `/v1/oauth/token` for an account with no Stripe subs) + // don't hit a 500 just because CMS isn't wired in this environment. + if (subscribedPrices.length === 0) { + return {}; + } + if (!this.capabilityManager) { throw error.internalValidationError( 'planIdsToClientCapabilities', diff --git a/packages/fxa-auth-server/lib/payments/free-access-in-process-notifier.spec.ts b/packages/fxa-auth-server/lib/payments/free-access-in-process-notifier.spec.ts new file mode 100644 index 00000000000..89545ce4883 --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/free-access-in-process-notifier.spec.ts @@ -0,0 +1,93 @@ +/* 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 { AppError as error } from '@fxa/accounts/errors'; + +import { FreeAccessInProcessNotifier } from './free-access-in-process-notifier'; + +describe('FreeAccessInProcessNotifier', () => { + let db: { accountRecord: jest.Mock }; + let capabilityService: { processEmailListChange: jest.Mock }; + let notifier: FreeAccessInProcessNotifier; + + const UID = 'a'.repeat(32); + + beforeEach(() => { + db = { + accountRecord: jest.fn().mockResolvedValue({ uid: UID }), + }; + capabilityService = { + processEmailListChange: jest.fn().mockResolvedValue(undefined), + }; + notifier = new FreeAccessInProcessNotifier(db, capabilityService as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('resolves email to uid and forwards added/removed to CapabilityService', async () => { + await notifier.notifyCapabilityChange({ + email: 'user@example.com', + added: ['cap-a'], + removed: ['cap-b'], + }); + + expect(db.accountRecord).toHaveBeenCalledWith('user@example.com'); + expect(capabilityService.processEmailListChange).toHaveBeenCalledWith({ + uid: UID, + added: ['cap-a'], + removed: ['cap-b'], + eventCreatedAt: expect.any(Number), + }); + }); + + it('stamps eventCreatedAt at call time, not construction time', async () => { + const beforeCall = Math.floor(Date.now() / 1000); + await notifier.notifyCapabilityChange({ + email: 'user@example.com', + added: ['cap-a'], + }); + const afterCall = Math.floor(Date.now() / 1000); + const stamped = + capabilityService.processEmailListChange.mock.calls[0][0].eventCreatedAt; + expect(stamped).toBeGreaterThanOrEqual(beforeCall); + expect(stamped).toBeLessThanOrEqual(afterCall); + }); + + it('short-circuits when both added and removed are empty', async () => { + await notifier.notifyCapabilityChange({ + email: 'user@example.com', + }); + + expect(db.accountRecord).not.toHaveBeenCalled(); + expect(capabilityService.processEmailListChange).not.toHaveBeenCalled(); + }); + + it('swallows ACCOUNT_UNKNOWN errors so the batch keeps going', async () => { + const err: any = new Error('Unknown account'); + err.errno = error.ERRNO.ACCOUNT_UNKNOWN; + db.accountRecord.mockRejectedValue(err); + + await expect( + notifier.notifyCapabilityChange({ + email: 'ghost@example.com', + added: ['cap-a'], + }) + ).resolves.toBeUndefined(); + expect(capabilityService.processEmailListChange).not.toHaveBeenCalled(); + }); + + it('rethrows non-ACCOUNT_UNKNOWN account-lookup errors', async () => { + db.accountRecord.mockRejectedValue(new Error('db-broken')); + + await expect( + notifier.notifyCapabilityChange({ + email: 'user@example.com', + added: ['cap-a'], + }) + ).rejects.toThrow('db-broken'); + expect(capabilityService.processEmailListChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/fxa-auth-server/lib/payments/free-access-in-process-notifier.ts b/packages/fxa-auth-server/lib/payments/free-access-in-process-notifier.ts new file mode 100644 index 00000000000..b4e7c90317a --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/free-access-in-process-notifier.ts @@ -0,0 +1,57 @@ +/* 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 { AppError as error } from '@fxa/accounts/errors'; +import type { + CapabilityChange, + FreeAccessNotifier, +} from '@fxa/free-access-program'; + +import { CapabilityService } from './capability'; + +/** + * Resolves `CapabilityChange` deltas into the in-process side effects + * `CapabilityService.processEmailListChange` already exposes: email→uid + * lookup, profile cache invalidation, and `subscription:update` SNS fan-out. + * + * Replaces the prior `payments-api → /oauth/subscriptions/email-capability-changed` + * HTTP hop. The webhook controller and the periodic cron both inject this + * notifier into the reconciler. + * + * Unknown emails are swallowed: free-access lists can legitimately include + * not-yet-registered accounts; we let those drop through silently so the + * rest of the batch lands. + */ +export class FreeAccessInProcessNotifier implements FreeAccessNotifier { + constructor( + private db: any, + private capabilityService: CapabilityService + ) {} + + async notifyCapabilityChange(change: CapabilityChange): Promise { + const added = change.added ?? []; + const removed = change.removed ?? []; + if (added.length === 0 && removed.length === 0) { + return; + } + + let uid: string; + try { + const account = await this.db.accountRecord(change.email); + uid = account.uid; + } catch (err: any) { + if (err?.errno === error.ERRNO.ACCOUNT_UNKNOWN) { + return; + } + throw err; + } + + await this.capabilityService.processEmailListChange({ + uid, + added: [...added], + removed: [...removed], + eventCreatedAt: Math.floor(Date.now() / 1000), + }); + } +} diff --git a/packages/fxa-auth-server/lib/payments/processing-tasks-setup.ts b/packages/fxa-auth-server/lib/payments/processing-tasks-setup.ts index d162fb15a5d..4da62adcc96 100644 --- a/packages/fxa-auth-server/lib/payments/processing-tasks-setup.ts +++ b/packages/fxa-auth-server/lib/payments/processing-tasks-setup.ts @@ -1,6 +1,15 @@ /* 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/. */ + +// MUST be loaded before any module that evaluates a class-validator or +// class-transformer decorator at import time — e.g. `firestore.config.ts` +// reachable via `../firestore-db` below. CLI scripts that spawn through +// `node -r ts-node/register scripts/X.ts` don't get the polyfill from +// NestJS bootstrap the way the running auth-server does, so we load it +// here at the entry point shared by every processing-task script. +import 'reflect-metadata'; + import { setupAuthDatabase } from 'fxa-shared/db'; import { StatsD } from 'hot-shots'; import Container from 'typedi'; diff --git a/packages/fxa-auth-server/lib/routes/account.spec.ts b/packages/fxa-auth-server/lib/routes/account.spec.ts index 181245c53ab..f832513c115 100644 --- a/packages/fxa-auth-server/lib/routes/account.spec.ts +++ b/packages/fxa-auth-server/lib/routes/account.spec.ts @@ -150,7 +150,12 @@ const mockGetAccountCustomerByUid = jest.fn().mockResolvedValue({ }); const makeRoutes = function (options: any = {}, requireMocks: any = {}) { - Container.set(CapabilityService, options.capabilityService || jest.fn()); + Container.set( + CapabilityService, + options.capabilityService || { + hasFreeAccess: jest.fn().mockResolvedValue(false), + } + ); const config = options.config || {}; config.oauth = config.oauth || {}; config.verifierVersion = config.verifierVersion || 0; @@ -2195,6 +2200,7 @@ describe('/account/set_password', () => { }; const mockCapabilityService = options.mockCapabilityService || { subscribedPriceIds: jest.fn().mockResolvedValue([fakePlan.id]), + hasFreeAccess: jest.fn().mockResolvedValue(false), }; const accountRoutes = makeRoutes({ config, @@ -2506,7 +2512,9 @@ describe('/account/login', () => { Container.set(AppConfig, config); Container.set(AuthLogger, mockLog); Container.set(AccountEventsManager, new AccountEventsManager()); - Container.set(CapabilityService, jest.fn().mockResolvedValue(undefined)); + Container.set(CapabilityService, { + hasFreeAccess: jest.fn().mockResolvedValue(false), + }); Container.set(OAuthClientInfoServiceName, mockOAuthClientInfo); Container.set(FxaMailer, mockFxaMailer); Container.set(RelyingPartyConfigurationManager, rpConfigManager); @@ -4417,7 +4425,9 @@ describe('/account', () => { mockStripeHelper.removeFirestoreCustomer = jest .fn() .mockResolvedValue(undefined); - Container.set(CapabilityService, jest.fn()); + Container.set(CapabilityService, { + hasFreeAccess: jest.fn().mockResolvedValue(false), + }); }); describe('web subscriptions', () => { @@ -4437,7 +4447,9 @@ describe('/account', () => { mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); - Container.set(CapabilityService, jest.fn()); + Container.set(CapabilityService, { + hasFreeAccess: jest.fn().mockResolvedValue(false), + }); }); it('should return formatted Stripe subscriptions when subscriptions are enabled', () => { @@ -4558,7 +4570,9 @@ describe('/account', () => { ); Container.set(OAuthClientInfoServiceName, mockOAuthClientInfoLocal); Container.set(FxaMailer, mockFxaMailerLocal); - Container.set(CapabilityService, jest.fn()); + Container.set(CapabilityService, { + hasFreeAccess: jest.fn().mockResolvedValue(false), + }); mockPlaySubscriptions = mocks.mockPlaySubscriptions(['getSubscriptions']); Container.set(PlaySubscriptions, mockPlaySubscriptions); mockPlaySubscriptions.getSubscriptions = jest.fn(async (uid: any) => [ @@ -4710,7 +4724,9 @@ describe('/account', () => { mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); - Container.set(CapabilityService, jest.fn()); + Container.set(CapabilityService, { + hasFreeAccess: jest.fn().mockResolvedValue(false), + }); mockAppStoreSubscriptions = mocks.mockAppStoreSubscriptions([ 'getSubscriptions', ]); @@ -5151,12 +5167,13 @@ describe('/account/emails', () => { beforeEach(() => { jest.clearAllMocks(); log = mocks.mockLog(); + installMockFxaMailer(); mocks.mockOAuthClientInfo(); db = { account: jest.fn().mockResolvedValue({ uid: 'account-123', email: 'signup@example.com', - primaryEmail: { email: 'signup+1@example.com' } + primaryEmail: { email: 'signup+1@example.com' }, }), }; config = {}; @@ -5172,7 +5189,7 @@ describe('/account/emails', () => { expect(db.account).toHaveBeenCalledWith('account-123'); expect(resp).toEqual({ originalEmail: 'signup@example.com', - primaryEmail: 'signup+1@example.com' + primaryEmail: 'signup+1@example.com', }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/account.ts b/packages/fxa-auth-server/lib/routes/account.ts index 710b8f7399e..0f2a49168c7 100644 --- a/packages/fxa-auth-server/lib/routes/account.ts +++ b/packages/fxa-auth-server/lib/routes/account.ts @@ -1908,7 +1908,10 @@ export class AccountHandler { scope.contains('profile:subscriptions') ) { const capabilities = - await this.capabilityService.subscriptionCapabilities(uid as string); + await this.capabilityService.subscriptionCapabilities( + uid as string, + account.primaryEmail?.email + ); if (Object.keys(capabilities).length > 0) { res.subscriptionsByClientId = capabilities; } @@ -2679,6 +2682,19 @@ export class AccountHandler { } } + // Free-access allowlist signal — keyed on the primary email, decoupled + // from Stripe/IAP state above. Defensive: the Settings home page reads + // this to show the "Paid Subscriptions" link; a CMS hiccup must not 500 + // `/account` for everyone, so swallow into `false`. + const primaryEmail = + formattedEmails.find((e) => e.isPrimary)?.email ?? + account.primaryEmail?.email; + const hasFreeAccess = this.config.subscriptions?.enabled + ? await this.capabilityService + .hasFreeAccess(primaryEmail) + .catch(() => false) + : false; + return { createdAt: account.createdAt, passwordCreatedAt: account.verifierSetAt, @@ -2697,6 +2713,7 @@ export class AccountHandler { ...iapAppStoreSubscriptions, ...webSubscriptions, ], + hasFreeAccess, }; } } @@ -3377,6 +3394,7 @@ export const accountRoutes = ( validators.subscriptionsGooglePlaySubscriptionValidator, validators.subscriptionsAppStoreSubscriptionValidator ), + hasFreeAccess: isA.boolean().optional(), }), }, }, diff --git a/packages/fxa-auth-server/lib/routes/password.spec.ts b/packages/fxa-auth-server/lib/routes/password.spec.ts index d3ef249cb95..4bfdbdf926a 100644 --- a/packages/fxa-auth-server/lib/routes/password.spec.ts +++ b/packages/fxa-auth-server/lib/routes/password.spec.ts @@ -635,16 +635,15 @@ describe('/password', () => { email: 'primary-now@example.com', oldAuthPW: crypto.randomBytes(32).toString('hex'), }, - log: mocks.mockLog(), + log: createMock(), }); - const mockStatsd = createMock(); const passwordRoutes = makeRoutes({ db: mockDB, push: mocks.mockPush(), mailer: mocks.mockMailer(), - log: mocks.mockLog(), + log: createMock(), customs: mocks.mockCustoms(), - statsd: mockStatsd, + statsd: createMock(), }); let err: any; 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 new file mode 100644 index 00000000000..ede7911ce41 --- /dev/null +++ b/packages/fxa-auth-server/lib/routes/subscriptions/free-access-program-webhook.spec.ts @@ -0,0 +1,132 @@ +/* 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 { createMock } from '@golevelup/ts-jest'; +import Boom from '@hapi/boom'; + +import { FreeAccessProgramWebhookHandler } from './free-access-program-webhook'; +import { AuthLogger } from '../../types'; + +describe('FreeAccessProgramWebhookHandler', () => { + let handler: FreeAccessProgramWebhookHandler; + let log: any; + let strapiClient: { verifyWebhookSignature: jest.Mock }; + let reconciler: { + reconcileEntitlement: jest.Mock; + reconcileEntitlementDeletion: jest.Mock; + }; + + beforeEach(() => { + log = createMock(); + strapiClient = { verifyWebhookSignature: jest.fn().mockReturnValue(true) }; + reconciler = { + reconcileEntitlement: jest.fn().mockResolvedValue({ + upserted: 0, + deleted: 0, + }), + reconcileEntitlementDeletion: jest.fn().mockResolvedValue({ + upserted: 0, + deleted: 0, + }), + }; + handler = new FreeAccessProgramWebhookHandler( + log, + strapiClient as any, + reconciler as any + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function buildRequest( + overrides: Record = {} + ): any { + return { + headers: { authorization: 'Bearer secret' }, + payload: { + event: 'entry.publish', + model: 'access', + entry: { documentId: 'ent-1' }, + ...overrides, + }, + }; + } + + it('throws unauthorized when the Strapi signature is invalid', async () => { + strapiClient.verifyWebhookSignature.mockReturnValue(false); + + await expect(handler.postAccess(buildRequest())).rejects.toMatchObject({ + isBoom: true, + output: { statusCode: 401 }, + }); + expect(reconciler.reconcileEntitlement).not.toHaveBeenCalled(); + }); + + it('returns the unauthorized Boom for an empty authorization header', async () => { + strapiClient.verifyWebhookSignature.mockReturnValue(false); + const req = buildRequest(); + req.headers.authorization = ''; + + await expect(handler.postAccess(req)).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.reconcileEntitlement).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.reconcileEntitlement).not.toHaveBeenCalled(); + }); + + it('routes entry.publish to reconcileEntitlement', async () => { + const result = await handler.postAccess( + buildRequest({ event: 'entry.publish' }) + ); + expect(reconciler.reconcileEntitlement).toHaveBeenCalledWith('ent-1'); + expect(result).toEqual({ handled: true }); + }); + + it('routes entry.update to reconcileEntitlement', async () => { + await handler.postAccess(buildRequest({ event: 'entry.update' })); + expect(reconciler.reconcileEntitlement).toHaveBeenCalledWith('ent-1'); + }); + + it('routes entry.unpublish to reconcileEntitlementDeletion', async () => { + await handler.postAccess(buildRequest({ event: 'entry.unpublish' })); + expect(reconciler.reconcileEntitlementDeletion).toHaveBeenCalledWith( + 'ent-1' + ); + }); + + it('routes entry.delete to reconcileEntitlementDeletion', async () => { + await handler.postAccess(buildRequest({ event: 'entry.delete' })); + expect(reconciler.reconcileEntitlementDeletion).toHaveBeenCalledWith( + 'ent-1' + ); + }); + + 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 200 even when the reconciler throws (Strapi must not retry)', async () => { + reconciler.reconcileEntitlement.mockRejectedValue(new Error('boom')); + const result = await handler.postAccess(buildRequest()); + expect(result).toEqual({ handled: true }); + expect(log.error).toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 00000000000..5630b4ea167 --- /dev/null +++ b/packages/fxa-auth-server/lib/routes/subscriptions/free-access-program-webhook.ts @@ -0,0 +1,145 @@ +/* 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 Boom from '@hapi/boom'; +import { ServerRoute } from '@hapi/hapi'; +import isA from 'joi'; + +import { FreeAccessProgramReconcilerService } 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']); + +/** + * Mirrors the structural shape of `StrapiAccessWebhookPayload` from + * `@fxa/free-access-program`, kept inline so the Hapi route doesn't pull + * the lib type into Joi validation. The reconciler re-validates by walking + * the payload via the projector's adapter, so this schema is intentionally + * loose — anything `model` / `event` / `entry.documentId` shaped will + * route, and the projector skips malformed entries with explicit metrics. + */ +type StrapiAccessWebhookPayload = { + event: string; + model?: string; + entry?: { + documentId?: string; + [k: string]: unknown; + }; + [k: string]: unknown; +}; + +/** + * Strapi-facing webhook for the free-access-program `access` content type. + * The handler validates the shared Bearer secret via `StrapiClient`, filters + * on the target model, and dispatches publish/update vs unpublish/delete + * events to the reconciler. The reconciler diffs cached vs fresh state and + * fires per-email capability deltas through the injected in-process + * notifier. + * + * Strapi expects 200 on every accepted call; downstream errors are logged + * but not bubbled so a single misbehaving entry can't block subsequent + * webhooks. The periodic cron is the backstop for any dropped events. + */ +export class FreeAccessProgramWebhookHandler { + constructor( + private log: AuthLogger, + private strapiClient: StrapiClient, + private reconciler: FreeAccessProgramReconcilerService + ) {} + + 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 }; + } + + try { + if (UPSERT_EVENTS.has(payload.event)) { + await this.reconciler.reconcileEntitlement(documentId); + } else if (DELETE_EVENTS.has(payload.event)) { + await this.reconciler.reconcileEntitlementDeletion(documentId); + } else { + return { handled: false, reason: 'event' as const }; + } + } catch (err) { + // Already returned 200 from Strapi's perspective; log and let the + // periodic sweep correct any drift on the next tick. + this.log.error( + 'subscriptions.freeAccessProgramWebhook.reconcile.error', + { err } + ); + } + + return { handled: true }; + } +} + +const payloadSchema = isA + .object({ + event: isA.string().required(), + model: isA.string().optional(), + uid: isA.string().optional(), + createdAt: isA.string().optional(), + entry: isA + .object({ + documentId: isA.string().optional(), + }) + .unknown(true) + .optional(), + }) + .unknown(true); + +export const freeAccessProgramWebhookRoutes = ( + log: AuthLogger, + strapiClient: StrapiClient, + reconciler: FreeAccessProgramReconcilerService +): ServerRoute[] => { + const handler = new FreeAccessProgramWebhookHandler( + log, + strapiClient, + reconciler + ); + + return [ + { + method: 'POST', + path: '/webhooks/strapi/free-access-program/access', + options: { + auth: false, + validate: { + payload: payloadSchema as any, + }, + response: { + schema: isA + .object({ + handled: isA.boolean().required(), + reason: isA + .string() + .valid('model', 'no_document_id', 'event') + .optional(), + }) + .unknown(false) as any, + }, + }, + handler: (request: AuthRequest) => handler.postAccess(request), + }, + ]; +}; diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/index.ts b/packages/fxa-auth-server/lib/routes/subscriptions/index.ts index 7c90a0f3ee5..df6ad4f5034 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/index.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/index.ts @@ -3,11 +3,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { ServerRoute } from '@hapi/hapi'; import zendesk from 'node-zendesk'; +import Container from 'typedi'; + +import { FreeAccessProgramReconcilerService } from '@fxa/free-access-program'; +import { StrapiClient } from '@fxa/shared/cms'; import { ConfigType } from '../../../config'; import { StripeHelper } from '../../payments/stripe'; import { AuthLogger } from '../../types'; import { appleIapRoutes } from './apple'; +import { freeAccessProgramWebhookRoutes } from './free-access-program-webhook'; import { googleIapRoutes } from './google'; import { mozillaSubscriptionRoutes } from './mozilla'; import { paypalNotificationRoutes } from './paypal-notifications'; @@ -35,6 +40,20 @@ export const createRoutes = ( return routes; } + // Free-access webhook is only useful when the FAP stack is wired + // (CMS-enabled branch in `bin/key_server.js` registers the reconciler + // service in Container). Skip the route entirely otherwise — there'd + // be no reconciler to dispatch to. + if (Container.has(FreeAccessProgramReconcilerService)) { + routes.push( + ...freeAccessProgramWebhookRoutes( + log, + Container.get(StrapiClient), + Container.get(FreeAccessProgramReconcilerService) + ) + ); + } + if (stripeHelper) { routes.push( ...stripeRoutes( diff --git a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax.ts b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax.ts index 65d4a7ccd5c..fb60ce5a4fd 100644 --- a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax.ts +++ b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax.ts @@ -1,6 +1,14 @@ /* 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/. */ + +// MUST be first — `../lib/types` (next import) re-exports from +// `@fxa/shared/db/firestore`, which evaluates class-validator decorators +// on `firestore.config.ts` at load time. Without this polyfill the +// child-process spawned by the in-spec test crashes with +// "Reflect.getMetadata is not a function". +import 'reflect-metadata'; + import program from 'commander'; import { ConfigType } from '../config'; import { AppConfig } from '../lib/types'; diff --git a/packages/fxa-auth-server/scripts/free-access-program-reconcile.ts b/packages/fxa-auth-server/scripts/free-access-program-reconcile.ts new file mode 100644 index 00000000000..9aedd807605 --- /dev/null +++ b/packages/fxa-auth-server/scripts/free-access-program-reconcile.ts @@ -0,0 +1,109 @@ +/* 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 Container from 'typedi'; +import { StatsD } from 'hot-shots'; + +import { StrapiClient } from '@fxa/shared/cms'; +import { + FREE_ACCESS_NOTIFIER, + FreeAccessProgramClientConfig, + FreeAccessProgramManager, + FreeAccessProgramReconcilerService, +} from '@fxa/free-access-program'; + +import { setupProcessingTaskObjects } from '../lib/payments/processing-tasks-setup'; +import { FreeAccessInProcessNotifier } from '../lib/payments/free-access-in-process-notifier'; +import { CapabilityService } from '../lib/payments/capability'; +import { AuthFirestore } from '../lib/types'; + +/** + * Periodic safety-net sweep for the free-access program. Computes the + * email-level diff between the cached projection and a fresh Strapi pull, + * fires `subscription:update` SNS events for any drift, and invalidates + * the cache on completion. + * + * Runs in-process as an auth-server script so it can call + * `CapabilityService.processEmailListChange` directly. Schedule via the + * same cron mechanism that drives the other auth-server periodic scripts. + */ +async function main() { + const { log, database, config } = await setupProcessingTaskObjects( + 'free-access-program-reconcile' + ); + + if (!config.subscriptions?.enabled) { + log.warn('free-access-program-reconcile.skipped', { + reason: 'subscriptions-disabled', + }); + return 0; + } + + const strapiClientConfig = config.cms?.strapiClient; + if ( + !strapiClientConfig?.graphqlApiUri || + !strapiClientConfig?.apiKey || + !strapiClientConfig?.firestoreCacheCollectionName + ) { + log.warn('free-access-program-reconcile.skipped', { + reason: 'cms-disabled', + }); + return 0; + } + + const firestore = Container.get(AuthFirestore); + const strapiClient = new StrapiClient( + strapiClientConfig, + firestore, + log as any + ); + Container.set(StrapiClient, strapiClient); + + const fapConfig = Object.assign(new FreeAccessProgramClientConfig(), { + firestoreCacheCollectionName: + config.subscriptions.freeAccessProgram.firestoreCacheCollectionName, + memCacheTTL: config.subscriptions.freeAccessProgram.memCacheTTL, + firestoreCacheTTL: + config.subscriptions.freeAccessProgram.firestoreCacheTTL, + firestoreOfflineCacheTTL: + config.subscriptions.freeAccessProgram.firestoreOfflineCacheTTL, + }); + const manager = new FreeAccessProgramManager( + fapConfig, + strapiClient, + firestore, + log + ); + Container.set(FreeAccessProgramManager, manager); + + const statsd = Container.get(StatsD); + // `setupProcessingTaskObjects` registers ProfileClient before returning, + // so `Container.get(CapabilityService)` here resolves with all of its + // required deps already in place. + const capabilityService = Container.get(CapabilityService); + const notifier = new FreeAccessInProcessNotifier( + database, + capabilityService + ); + Container.set(FREE_ACCESS_NOTIFIER, notifier); + + const reconciler = new FreeAccessProgramReconcilerService( + manager, + notifier, + statsd, + log as any + ); + + const result = await reconciler.reconcileAll(); + log.info('free-access-program-reconcile.complete', result); + return 0; +} + +main() + .then((exitCode) => process.exit(exitCode)) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('free-access-program-reconcile.fatal', err); + process.exit(1); + }); diff --git a/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts index 411de929a7f..3063efe7d80 100644 --- a/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts @@ -186,7 +186,25 @@ describe('#integration - remote subscriptions (enabled)', () => { config.subscriptions.stripeApiKey = 'sk_test_fake'; config.subscriptions.paypalNvpSigCredentials = { enabled: false }; config.subscriptions.productConfigsFirestore = { enabled: true }; - config.cms = { ...config.cms, enabled: true }; + // bin/key_server.js throws "Missing required configuration for CMS + // Strapi Client" when `cms.enabled` is true but apiKey is empty. The + // managers that talk to Strapi are mocked above (CapabilityManager, + // ProductConfigurationManager etc.), so no network calls are issued + // — we just satisfy the guard with placeholder values. + config.cms = { + ...config.cms, + enabled: true, + strapiClient: { + ...config.cms.strapiClient, + graphqlApiUri: + config.cms.strapiClient?.graphqlApiUri || + 'http://localhost:1337/graphql', + apiKey: config.cms.strapiClient?.apiKey || 'test-fake-strapi-key', + firestoreCacheCollectionName: + config.cms.strapiClient?.firestoreCacheCollectionName || + 'test-cms-cache', + }, + }; config.customsUrl = 'none'; config.rateLimit = { ...config.rateLimit, diff --git a/packages/fxa-auth-server/test/support/jest-setup-env.ts b/packages/fxa-auth-server/test/support/jest-setup-env.ts index 6358dff8ef4..2a108f630e9 100644 --- a/packages/fxa-auth-server/test/support/jest-setup-env.ts +++ b/packages/fxa-auth-server/test/support/jest-setup-env.ts @@ -7,6 +7,12 @@ * Sets environment variables that affect module loading. */ +// Load reflect-metadata before anything else so any module that pulls in a +// class-transformer / class-validator decorator at load time finds +// `Reflect.getMetadata` available. Production loads it via NestJS bootstrap; +// the integration-test harness doesn't. +import 'reflect-metadata'; + process.env.NODE_ENV = 'dev'; process.env.FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY = 'true'; process.env.TRACING_SERVICE_NAME = 'fxa-auth-server-test'; diff --git a/packages/fxa-settings/src/components/Settings/Nav/index.test.tsx b/packages/fxa-settings/src/components/Settings/Nav/index.test.tsx index d25f54f6932..a7ade2f1bc2 100644 --- a/packages/fxa-settings/src/components/Settings/Nav/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/Nav/index.test.tsx @@ -99,6 +99,30 @@ describe('Nav', () => { ); }); + it('renders the subscriptions link for a B2B-allowlisted user with no paid subs', () => { + const account = { + primaryEmail: { + email: 'stomlinson@mozilla.com', + }, + subscriptions: [], + hasFreeAccess: true, + linkedAccounts: [], + } as unknown as Account; + renderWithLocalizationProvider( + +