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' + )} + + + {freeAccess.map((grant, index) => ( + + + + + + + + ))} + + + )} + {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 }) => ( + + ), +})); + +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
+ You have access to this service through your organization. +
{description}