From 840a20e54a947bbb3cc610d6c7ccceaf81944f0e Mon Sep 17 00:00:00 2001 From: Reino Muhl <10620585+StaberindeZA@users.noreply.github.com> Date: Thu, 14 May 2026 16:21:22 -0400 Subject: [PATCH 1/7] feat(subplat): add support for free access program Because - Transfer ownership of the Mozilla VPN Free Access program to the SubPlat and EntPlat teams, which aligns with the goal of evolving the subscirption platform beyond subscriptions. - Allow other Mozilla services to also utilize the Free Access Program. This commit - Adds support for the Free Access Program, by broadcasting capabilities for enabled customers, even if they don't have a subscription. - Read list of emails from Strapi and what capabilities should be provided to these users. - On auth-server /profile query, return the relevant capability if the user email is in the list configured in Strapi. - Add webhook listener to `payments-api` that listens for changes in Strapi and then calls an API in auth-server, which broadcasts capability added or removed via event-broker to RPs. - Show Paid Subscriptions option to user that have access via a B2B subscription. On the subscription management page added a new section indicating services provided by the customers organization. Closes PAY-3780 --- apps/payments/api/.env | 8 + apps/payments/api/src/app/app.module.ts | 14 + apps/payments/api/src/config/index.ts | 16 +- apps/payments/next/.env | 4 + .../app/[locale]/subscriptions/manage/en.ftl | 2 + .../[locale]/subscriptions/manage/page.tsx | 40 ++ libs/free-access-program/.swcrc | 14 + libs/free-access-program/README.md | 45 ++ libs/free-access-program/jest.config.ts | 38 ++ libs/free-access-program/package.json | 4 + libs/free-access-program/project.json | 55 +++ libs/free-access-program/src/index.ts | 20 + .../auth-server-email-capability.client.ts | 68 +++ .../auth-server-email-capability.config.ts | 36 ++ libs/free-access-program/src/lib/factories.ts | 24 ++ .../free-access-program-reconciler.module.ts | 93 +++++ .../lib/free-access-program.client.config.ts | 21 + .../lib/free-access-program.manager.spec.ts | 203 +++++++++ .../src/lib/free-access-program.manager.ts | 146 +++++++ ...ee-access-program.notifier.service.spec.ts | 150 +++++++ .../free-access-program.notifier.service.ts | 82 ++++ .../lib/free-access-program.projector.spec.ts | 263 ++++++++++++ .../src/lib/free-access-program.projector.ts | 258 ++++++++++++ ...-access-program.reconciler.service.spec.ts | 389 ++++++++++++++++++ .../free-access-program.reconciler.service.ts | 363 ++++++++++++++++ .../free-access-program.repository.spec.ts | 198 +++++++++ .../src/lib/free-access-program.repository.ts | 213 ++++++++++ .../free-access-program.webhook.controller.ts | 25 ++ ...ree-access-program.webhook.service.spec.ts | 174 ++++++++ .../free-access-program.webhook.service.ts | 99 +++++ .../lib/free-access-program.webhook.types.ts | 40 ++ libs/free-access-program/src/lib/types.ts | 37 ++ .../src/scripts/reconcile-all.ts | 63 +++ libs/free-access-program/tsconfig.json | 16 + libs/free-access-program/tsconfig.lib.json | 10 + libs/free-access-program/tsconfig.spec.json | 14 + .../management/src/lib/freeAccess.spec.ts | 102 +++++ .../payments/management/src/lib/freeAccess.ts | 47 +++ .../subscriptionManagement.service.spec.ts | 113 +++++ .../src/lib/subscriptionManagement.service.ts | 56 ++- libs/payments/ui-auth/src/index.ts | 6 +- libs/payments/ui-auth/src/lib/session.spec.ts | 70 ++++ libs/payments/ui-auth/src/lib/session.ts | 5 + libs/payments/ui/src/index.ts | 1 + .../src/lib/actions/getSubManPageContent.ts | 4 +- .../components/FreeAccessContent/en.ftl | 6 + .../FreeAccessContent/index.test.tsx | 67 +++ .../components/FreeAccessContent/index.tsx | 52 +++ .../payments/ui/src/lib/nestapp/app.module.ts | 2 + libs/payments/ui/src/lib/nestapp/config.ts | 6 + .../src/lib/nestapp/nextjs-actions.service.ts | 4 +- .../GetSubManPageContentActionArgs.ts | 4 + .../GetSubManPageContentActionResult.ts | 21 + .../src/lib/cms-webhooks.controller.spec.ts | 6 +- .../account/src/lib/account.manager.ts | 2 +- libs/shared/cms/src/__generated__/gql.ts | 12 +- libs/shared/cms/src/__generated__/graphql.ts | 240 ++++++++++- libs/shared/cms/src/index.ts | 1 + .../lib/product-configuration.manager.spec.ts | 1 + .../cms/src/lib/queries/accesses/factories.ts | 58 +++ .../cms/src/lib/queries/accesses/index.ts | 6 + .../cms/src/lib/queries/accesses/query.ts | 31 ++ .../services-with-capabilities/factories.ts | 2 + .../services-with-capabilities/query.ts | 2 + .../services-with-capabilities/types.ts | 2 + .../services-with-capabilities/util.spec.ts | 35 ++ .../services-with-capabilities/util.ts | 15 +- .../cms/src/lib/strapi.client.config.ts | 2 +- libs/shared/db/firestore/src/index.ts | 1 + packages/fxa-auth-server/bin/key_server.js | 21 + packages/fxa-auth-server/config/index.ts | 16 + packages/fxa-auth-server/jest.setup.js | 9 + .../lib/payments/capability.spec.ts | 189 +++++++++ .../lib/payments/capability.ts | 81 +++- .../payments/email-capability-list.spec.ts | 57 +++ .../lib/payments/email-capability-list.ts | 41 ++ .../lib/routes/account.spec.ts | 33 +- .../fxa-auth-server/lib/routes/account.ts | 20 +- .../lib/routes/password.spec.ts | 6 +- .../subscriptions/email-capability.spec.ts | 141 +++++++ .../routes/subscriptions/email-capability.ts | 125 ++++++ .../lib/routes/subscriptions/index.ts | 3 + .../test/support/jest-setup-env.ts | 6 + .../components/Settings/Nav/index.test.tsx | 24 ++ .../src/components/Settings/Nav/index.tsx | 8 +- .../fxa-settings/src/lib/account-storage.ts | 6 + .../src/lib/hooks/useAccountData.ts | 2 + packages/fxa-settings/src/models/Account.ts | 8 + .../models/contexts/AccountStateContext.tsx | 3 + .../src/models/contexts/AppContext.ts | 1 + tsconfig.base.json | 1 + 91 files changed, 4991 insertions(+), 37 deletions(-) create mode 100644 libs/free-access-program/.swcrc create mode 100644 libs/free-access-program/README.md create mode 100644 libs/free-access-program/jest.config.ts create mode 100644 libs/free-access-program/package.json create mode 100644 libs/free-access-program/project.json create mode 100644 libs/free-access-program/src/index.ts create mode 100644 libs/free-access-program/src/lib/auth-server-email-capability.client.ts create mode 100644 libs/free-access-program/src/lib/auth-server-email-capability.config.ts create mode 100644 libs/free-access-program/src/lib/factories.ts create mode 100644 libs/free-access-program/src/lib/free-access-program-reconciler.module.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.client.config.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.manager.spec.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.manager.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.notifier.service.spec.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.notifier.service.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.projector.spec.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.projector.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.reconciler.service.spec.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.reconciler.service.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.repository.spec.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.repository.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.webhook.controller.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.webhook.service.spec.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.webhook.service.ts create mode 100644 libs/free-access-program/src/lib/free-access-program.webhook.types.ts create mode 100644 libs/free-access-program/src/lib/types.ts create mode 100644 libs/free-access-program/src/scripts/reconcile-all.ts create mode 100644 libs/free-access-program/tsconfig.json create mode 100644 libs/free-access-program/tsconfig.lib.json create mode 100644 libs/free-access-program/tsconfig.spec.json create mode 100644 libs/payments/management/src/lib/freeAccess.spec.ts create mode 100644 libs/payments/management/src/lib/freeAccess.ts create mode 100644 libs/payments/ui-auth/src/lib/session.spec.ts create mode 100644 libs/payments/ui/src/lib/client/components/FreeAccessContent/en.ftl create mode 100644 libs/payments/ui/src/lib/client/components/FreeAccessContent/index.test.tsx create mode 100644 libs/payments/ui/src/lib/client/components/FreeAccessContent/index.tsx create mode 100644 libs/shared/cms/src/lib/queries/accesses/factories.ts create mode 100644 libs/shared/cms/src/lib/queries/accesses/index.ts create mode 100644 libs/shared/cms/src/lib/queries/accesses/query.ts create mode 100644 packages/fxa-auth-server/lib/payments/email-capability-list.spec.ts create mode 100644 packages/fxa-auth-server/lib/payments/email-capability-list.ts create mode 100644 packages/fxa-auth-server/lib/routes/subscriptions/email-capability.spec.ts create mode 100644 packages/fxa-auth-server/lib/routes/subscriptions/email-capability.ts diff --git a/apps/payments/api/.env b/apps/payments/api/.env index e329dc533ae..7c2ef0f1d1f 100644 --- a/apps/payments/api/.env +++ b/apps/payments/api/.env @@ -1,3 +1,7 @@ +# Auth Server +AUTH_SERVER_EMAIL_CAPABILITY_CONFIG__BASE_URL=http://localhost:9000/v1 +AUTH_SERVER_EMAIL_CAPABILITY_CONFIG__SUBSCRIPTIONS_SECRET=devsecret + # MySQLConfig MYSQL_CONFIG__DATABASE=fxa MYSQL_CONFIG__HOST=::1 @@ -100,3 +104,7 @@ METERING_CONFIG__CLOUD_TASKS__THRESHOLD__TASK_URL=http://127.0.0.1:3000/v1/meter METERING_CONFIG__CLOUD_TASKS__THRESHOLD__QUEUE_NAME=metering-threshold-checks METERING_CONFIG__CLOUD_TASKS__THRESHOLD__BUCKET_SIZE_MS=300000 METERING_CONFIG__CLOUD_TASKS__THRESHOLD__SCHEDULE_DELAY_MS=420000 + +# Free Access Program Client Config +# NB: This needs to match config in payments-next +FREE_ACCESS_PROGRAM_CLIENT_CONFIG__COLLECTION_NAME=subplat-free-access-program diff --git a/apps/payments/api/src/app/app.module.ts b/apps/payments/api/src/app/app.module.ts index d0f211c339b..cb544d3bbb2 100644 --- a/apps/payments/api/src/app/app.module.ts +++ b/apps/payments/api/src/app/app.module.ts @@ -49,6 +49,14 @@ import { PaypalCustomerManager, } from '@fxa/payments/paypal'; import { CurrencyManager } from '@fxa/payments/currency'; +import { + AuthServerEmailCapabilityClient, + FreeAccessProgramManager, + FreeAccessProgramNotifierService, + FreeAccessProgramReconcilerService, + FreeAccessProgramWebhookController, + FreeAccessProgramWebhookService, +} from '@fxa/free-access-program'; import { AccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import { AccountManager } from '@fxa/shared/account/account'; import { CartManager } from '@fxa/payments/cart'; @@ -84,6 +92,7 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat AppController, BillingAndSubscriptionsController, CmsWebhooksController, + FreeAccessProgramWebhookController, FxaWebhooksController, StripeWebhooksController, ], @@ -106,7 +115,12 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat PaymentsEmitterService, PriceManager, ProductManager, + AuthServerEmailCapabilityClient, FirestoreProvider, + FreeAccessProgramManager, + FreeAccessProgramNotifierService, + FreeAccessProgramReconcilerService, + FreeAccessProgramWebhookService, GoogleIapClient, GoogleIapPurchaseManager, StatsDProvider, diff --git a/apps/payments/api/src/config/index.ts b/apps/payments/api/src/config/index.ts index 3bdf0edba9a..7d1942667e8 100644 --- a/apps/payments/api/src/config/index.ts +++ b/apps/payments/api/src/config/index.ts @@ -3,6 +3,10 @@ import { IsDefined, ValidateNested } from 'class-validator'; import { CurrencyConfig } from '@fxa/payments/currency'; import { MeteringConfig } from '@fxa/entitlements/metering'; +import { + AuthServerEmailCapabilityConfig, + FreeAccessProgramClientConfig, +} from '@fxa/free-access-program'; import { AppleIapClientConfig, GoogleIapClientConfig } from '@fxa/payments/iap'; import { PaymentsGleanConfig } from '@fxa/payments/metrics'; import { PaypalClientConfig } from '@fxa/payments/paypal'; @@ -11,7 +15,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 { @@ -84,4 +88,14 @@ export class RootConfig { @ValidateNested() @IsDefined() public readonly meteringConfig!: Partial; + + @Type(() => FreeAccessProgramClientConfig) + @ValidateNested() + @IsDefined() + public readonly freeAccessProgramClientConfig!: Partial; + + @Type(() => AuthServerEmailCapabilityConfig) + @ValidateNested() + @IsDefined() + public readonly authServerEmailCapabilityConfig!: Partial; } diff --git a/apps/payments/next/.env b/apps/payments/next/.env index b8f390f63f2..412d331b924 100644 --- a/apps/payments/next/.env +++ b/apps/payments/next/.env @@ -99,6 +99,10 @@ CHURN_INTERVENTION_CONFIG__ENABLED= # Free Trial Config FREE_TRIAL_CONFIG__FIRESTORE_COLLECTION_NAME=freeTrials +# Free Access Program Client Config +# NB: This needs to match config in payments-api +FREE_ACCESS_PROGRAM_CLIENT_CONFIG__COLLECTION_NAME=subplat-free-access-program + # 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..97a8adf6041 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 && (
{ + const url = `${this.config.baseUrl.replace(/\/$/, '')}/oauth/subscriptions/email-capability-changed`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: this.config.subscriptionsSecret, + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new AuthServerEmailCapabilityClientError(response.status, body); + } + + return (await response.json()) as EmailCapabilityChangeResponse; + } +} diff --git a/libs/free-access-program/src/lib/auth-server-email-capability.config.ts b/libs/free-access-program/src/lib/auth-server-email-capability.config.ts new file mode 100644 index 00000000000..563e21196d0 --- /dev/null +++ b/libs/free-access-program/src/lib/auth-server-email-capability.config.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 { faker } from '@faker-js/faker'; +import { Provider } from '@nestjs/common'; +import { IsString } from 'class-validator'; + +/** + * Config for the payments-api → auth-server `email-capability-changed` + * call. Auth-server authenticates with its existing + * `subscriptionsSecret` strategy (Bearer header), the same one its other + * internal `/oauth/subscriptions/*` routes share. + * + * Routing through auth-server gives us profile-cache invalidation for free + * (auth-server's `CapabilityService.processEmailListChange` invalidates + * the cache and broadcasts the `subscription:update` SNS event); the + * tradeoff is one extra hop and a runtime dependency on auth-server. + */ +export class AuthServerEmailCapabilityConfig { + @IsString() + public readonly baseUrl!: string; + + @IsString() + public readonly subscriptionsSecret!: string; +} + +export const MockAuthServerEmailCapabilityConfig = { + baseUrl: faker.internet.url(), + subscriptionsSecret: faker.string.hexadecimal({ length: 32 }), +} satisfies AuthServerEmailCapabilityConfig; + +export const MockAuthServerEmailCapabilityConfigProvider = { + provide: AuthServerEmailCapabilityConfig, + useValue: MockAuthServerEmailCapabilityConfig, +} satisfies Provider; diff --git a/libs/free-access-program/src/lib/factories.ts b/libs/free-access-program/src/lib/factories.ts new file mode 100644 index 00000000000..58821a80338 --- /dev/null +++ b/libs/free-access-program/src/lib/factories.ts @@ -0,0 +1,24 @@ +/* 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 { Timestamp } from '@google-cloud/firestore'; +import type { FirestoreFreeAccessProgramRecord } from './types'; + +export const FirestoreFreeAccessProgramRecordFactory = ( + override?: Partial +): FirestoreFreeAccessProgramRecord => ({ + entitlementId: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + capabilities: { + [faker.string.alphanumeric({ length: 16 })]: [ + `cap-${faker.string.alphanumeric({ length: 8 })}`, + ], + }, + expiresAt: Timestamp.fromDate(faker.date.future()), + description: faker.lorem.sentence(), + internalName: faker.company.name(), + createdAt: Timestamp.fromDate(faker.date.recent()), + ...override, +}); diff --git a/libs/free-access-program/src/lib/free-access-program-reconciler.module.ts b/libs/free-access-program/src/lib/free-access-program-reconciler.module.ts new file mode 100644 index 00000000000..8b88902f3e1 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program-reconciler.module.ts @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Logger, Module } from '@nestjs/common'; +import { Type } from 'class-transformer'; +import { IsDefined, ValidateNested } from 'class-validator'; +import { TypedConfigModule, dotenvLoader } from 'nest-typed-config'; + +import { StrapiClient, StrapiClientConfig } from '@fxa/shared/cms'; +import { FirestoreConfig, FirestoreProvider } from '@fxa/shared/db/firestore'; +import { StatsDConfig, StatsDProvider } from '@fxa/shared/metrics/statsd'; + +import { AuthServerEmailCapabilityClient } from './auth-server-email-capability.client'; +import { AuthServerEmailCapabilityConfig } from './auth-server-email-capability.config'; +import { FreeAccessProgramClientConfig } from './free-access-program.client.config'; +import { FreeAccessProgramManager } from './free-access-program.manager'; +import { FreeAccessProgramNotifierService } from './free-access-program.notifier.service'; +import { FreeAccessProgramReconcilerService } from './free-access-program.reconciler.service'; + +/** + * Minimal root config for a standalone reconciler bootstrap. Covers the + * Strapi pull, the Firestore projection, StatsD metrics, and the + * auth-server `email-capability-changed` post the notifier uses to fan + * out revocations. + * + * No MySQL or SNS config is needed in this script because the notifier + * defers email→uid lookup and `subscription:update` broadcast to + * auth-server. + */ +export class FreeAccessProgramReconcilerRootConfig { + @Type(() => FirestoreConfig) + @ValidateNested() + @IsDefined() + public readonly firestoreConfig!: Partial; + + @Type(() => StrapiClientConfig) + @ValidateNested() + @IsDefined() + public readonly strapiClientConfig!: Partial; + + @Type(() => FreeAccessProgramClientConfig) + @ValidateNested() + @IsDefined() + public readonly freeAccessProgramClientConfig!: Partial; + + @Type(() => StatsDConfig) + @ValidateNested() + @IsDefined() + public readonly statsDConfig!: Partial; + + @Type(() => AuthServerEmailCapabilityConfig) + @ValidateNested() + @IsDefined() + public readonly authServerEmailCapabilityConfig!: Partial; +} + +@Module({ + imports: [ + TypedConfigModule.forRoot({ + schema: FreeAccessProgramReconcilerRootConfig, + load: dotenvLoader({ + separator: '__', + keyTransformer: (key) => { + const temp = key + .toLowerCase() + .replace(/(? p1.toUpperCase()); + return temp; + }, + // Paths are relative to the cwd nx invokes the script from (the + // workspace root). Lib-local overrides come first; the shared + // payments-api env is the fallback so the cron uses the same + // Firestore/Strapi/auth-server config the running service does. + envFilePath: [ + 'libs/free-access-program/.env.local', + 'apps/payments/api/.env.local', + 'apps/payments/api/.env', + ], + }), + }), + ], + providers: [ + Logger, + FirestoreProvider, + StatsDProvider, + StrapiClient, + AuthServerEmailCapabilityClient, + FreeAccessProgramManager, + FreeAccessProgramNotifierService, + FreeAccessProgramReconcilerService, + ], +}) +export class FreeAccessProgramReconcilerModule {} diff --git a/libs/free-access-program/src/lib/free-access-program.client.config.ts b/libs/free-access-program/src/lib/free-access-program.client.config.ts new file mode 100644 index 00000000000..03749ea27d0 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.client.config.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/. */ + +import { faker } from '@faker-js/faker'; +import { Provider } from '@nestjs/common'; +import { IsString } from 'class-validator'; + +export class FreeAccessProgramClientConfig { + @IsString() + public readonly collectionName!: string; +} + +export const MockFreeAccessProgramClientConfig = { + collectionName: 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.manager.spec.ts b/libs/free-access-program/src/lib/free-access-program.manager.spec.ts new file mode 100644 index 00000000000..7f3399ef222 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.manager.spec.ts @@ -0,0 +1,203 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Test } from '@nestjs/testing'; +import { + CollectionReference, + DocumentReference, + Firestore, + QuerySnapshot, + Timestamp, + WriteBatch, +} from '@google-cloud/firestore'; +import { FirestoreService } from '@fxa/shared/db/firestore'; +import { FirestoreFreeAccessProgramRecordFactory } from './factories'; +import { + FreeAccessProgramClientConfig, + MockFreeAccessProgramClientConfigProvider, +} from './free-access-program.client.config'; +import { FreeAccessProgramManager } from './free-access-program.manager'; +import type { FirestoreFreeAccessProgramRecord } from './types'; + +jest.mock('@google-cloud/firestore'); + +describe('FreeAccessProgramManager', () => { + let manager: FreeAccessProgramManager; + let mockDb: jest.Mocked; + let mockFirestore: jest.Mocked; + let mockDoc: jest.Mocked; + let mockBatch: jest.Mocked; + let config: FreeAccessProgramClientConfig; + + const mockRecord = FirestoreFreeAccessProgramRecordFactory({ + email: 'user@example.com', + entitlementId: 'ent-1', + }); + + function withRecords(records: FirestoreFreeAccessProgramRecord[]) { + mockDb.get = jest.fn().mockResolvedValue({ + docs: records.map((r) => ({ ref: mockDoc, data: () => r })), + } as unknown as QuerySnapshot); + } + + beforeEach(async () => { + mockFirestore = new Firestore() as jest.Mocked; + + mockDoc = { + set: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue({ data: () => mockRecord }), + delete: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + mockBatch = { + delete: jest.fn(), + commit: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + mockDb = { + doc: jest.fn().mockReturnValue(mockDoc), + where: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ + docs: [{ ref: mockDoc, data: () => mockRecord }], + } as unknown as QuerySnapshot), + firestore: mockFirestore, + } as unknown as jest.Mocked; + + mockFirestore.batch = jest.fn().mockReturnValue(mockBatch); + mockFirestore.collection = jest.fn().mockReturnValue(mockDb); + + const moduleRef = await Test.createTestingModule({ + providers: [ + MockFreeAccessProgramClientConfigProvider, + { provide: FirestoreService, useValue: mockFirestore }, + FreeAccessProgramManager, + ], + }).compile(); + + manager = moduleRef.get(FreeAccessProgramManager); + config = moduleRef.get(FreeAccessProgramClientConfig); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('collectionRef', () => { + it('resolves the configured collection', () => { + void manager.collectionRef; + expect(mockFirestore.collection).toHaveBeenCalledWith( + config.collectionName + ); + }); + }); + + describe('findCapabilitiesForEmail', () => { + it('returns an empty map when email is null or undefined without querying', async () => { + expect(await manager.findCapabilitiesForEmail(null)).toEqual({}); + expect(await manager.findCapabilitiesForEmail(undefined)).toEqual({}); + expect(mockDb.where).not.toHaveBeenCalled(); + }); + + it('returns an empty map when no records match', async () => { + withRecords([]); + expect(await manager.findCapabilitiesForEmail('nobody@example.com')).toEqual( + {} + ); + expect(mockDb.where).toHaveBeenCalledWith( + 'email', + '==', + 'nobody@example.com' + ); + }); + + it('queries Firestore with a lowercased email', async () => { + withRecords([mockRecord]); + await manager.findCapabilitiesForEmail('User@Example.com'); + expect(mockDb.where).toHaveBeenCalledWith( + 'email', + '==', + 'user@example.com' + ); + }); + + it('merges and dedups capabilities across multiple matching records', async () => { + withRecords([ + { + entitlementId: 'ent-a', + email: 'user@example.com', + capabilities: { 'client-1': ['cap-foo', 'cap-bar'] }, + expiresAt: Timestamp.fromDate(new Date('2030-01-01')), + createdAt: Timestamp.fromDate(new Date('2026-01-01')), + }, + { + entitlementId: 'ent-b', + email: 'user@example.com', + capabilities: { + 'client-1': ['cap-bar', 'cap-baz'], + 'client-2': ['cap-qux'], + }, + expiresAt: Timestamp.fromDate(new Date('2030-01-01')), + createdAt: Timestamp.fromDate(new Date('2026-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']); + }); + }); + + describe('getRecord', () => { + it('delegates to the repository with the composite key', async () => { + const result = await manager.getRecord('ent-1', 'user@example.com'); + expect(mockDb.doc).toHaveBeenCalledWith('ent-1_user@example.com'); + expect(result).toEqual(mockRecord); + }); + }); + + describe('getRecordsForEmail', () => { + it('queries the repository for the email', async () => { + const result = await manager.getRecordsForEmail('user@example.com'); + expect(mockDb.where).toHaveBeenCalledWith( + 'email', + '==', + 'user@example.com' + ); + expect(result).toEqual([mockRecord]); + }); + }); + + describe('upsertRecord', () => { + it('writes the record', async () => { + await manager.upsertRecord(mockRecord); + expect(mockDoc.set).toHaveBeenCalled(); + }); + }); + + describe('deleteRecord', () => { + it('deletes the doc keyed by composite id', async () => { + await manager.deleteRecord('ent-1', 'user@example.com'); + expect(mockDb.doc).toHaveBeenCalledWith('ent-1_user@example.com'); + expect(mockDoc.delete).toHaveBeenCalled(); + }); + }); + + describe('deleteRecordsByEntitlementId', () => { + it('batch-deletes every doc tied to the entitlement', async () => { + await manager.deleteRecordsByEntitlementId('ent-1'); + expect(mockDb.where).toHaveBeenCalledWith( + 'entitlementId', + '==', + 'ent-1' + ); + expect(mockBatch.commit).toHaveBeenCalled(); + }); + }); +}); 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..c65d212b9c2 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.manager.ts @@ -0,0 +1,146 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Inject, Injectable } from '@nestjs/common'; +import { FirestoreService } from '@fxa/shared/db/firestore'; +import type { CollectionReference, Firestore } from '@google-cloud/firestore'; +import { FreeAccessProgramClientConfig } from './free-access-program.client.config'; +import { + batchDeleteFreeAccessProgramRecordsById, + batchUpsertFreeAccessProgramRecords, + deleteFreeAccessProgramRecord, + deleteFreeAccessProgramRecordsByEntitlementId, + getFreeAccessProgramRecord, + getFreeAccessProgramRecordsForEmail, + listAllFreeAccessProgramRecords, + listFreeAccessProgramRecordsForEntitlement, + type ReconcileHeartbeat, + upsertFreeAccessProgramRecord, + writeReconcileHeartbeat, +} from './free-access-program.repository'; +import type { + ClientIdCapabilityMap, + FirestoreFreeAccessProgramRecord, +} from './types'; + +@Injectable() +export class FreeAccessProgramManager { + constructor( + private config: FreeAccessProgramClientConfig, + @Inject(FirestoreService) private firestore: Firestore + ) {} + + get collectionRef(): CollectionReference { + return this.firestore.collection(this.config.collectionName); + } + + /** + * Resolves the `{ clientId → capabilities[] }` map for an email by issuing a + * single indexed Firestore query and merging the matching records. Returns + * an empty map for any email with no live grants — the Firestore TTL policy + * ensures expired grants are absent from the result set. + */ + async findCapabilitiesForEmail( + email?: string | null + ): Promise { + if (!email) return {}; + const records = await this.getRecordsForEmail(email); + return mergeCapabilities(records); + } + + async getRecord( + entitlementId: string, + email: string + ): Promise { + return getFreeAccessProgramRecord(this.collectionRef, entitlementId, email); + } + + async getRecordsForEmail( + email: string + ): Promise { + return getFreeAccessProgramRecordsForEmail(this.collectionRef, email); + } + + async listAllRecords(): Promise { + return listAllFreeAccessProgramRecords(this.collectionRef); + } + + async listRecordsForEntitlement( + entitlementId: string + ): Promise { + return listFreeAccessProgramRecordsForEntitlement( + this.collectionRef, + entitlementId + ); + } + + async upsertRecord(data: FirestoreFreeAccessProgramRecord): Promise { + return upsertFreeAccessProgramRecord(this.collectionRef, data); + } + + async batchUpsertRecords( + records: ReadonlyArray + ): Promise { + return batchUpsertFreeAccessProgramRecords(this.collectionRef, records); + } + + async batchDeleteRecordsById( + docIds: ReadonlyArray + ): Promise { + return batchDeleteFreeAccessProgramRecordsById(this.collectionRef, docIds); + } + + async deleteRecord(entitlementId: string, email: string): Promise { + return deleteFreeAccessProgramRecord( + this.collectionRef, + entitlementId, + email + ); + } + + async deleteRecordsByEntitlementId(entitlementId: string): Promise { + return deleteFreeAccessProgramRecordsByEntitlementId( + this.collectionRef, + entitlementId + ); + } + + /** + * Persist the most recent successful reconcile to the sibling `-meta` + * collection. Used by ops tooling to confirm the sweep is still firing + * without scraping metrics. + */ + async writeReconcileHeartbeat(data: ReconcileHeartbeat): Promise { + return writeReconcileHeartbeat( + this.firestore, + this.config.collectionName, + data + ); + } +} + +function mergeCapabilities( + records: ReadonlyArray +): ClientIdCapabilityMap { + if (records.length === 0) return {}; + // Set per clientId so slugs are deduped across multiple entitlements that + // grant access to the same email. + 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/free-access-program.notifier.service.spec.ts b/libs/free-access-program/src/lib/free-access-program.notifier.service.spec.ts new file mode 100644 index 00000000000..f7239fe5754 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.notifier.service.spec.ts @@ -0,0 +1,150 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Test } from '@nestjs/testing'; +import type { StatsD } from 'hot-shots'; + +import { StatsDService } from '@fxa/shared/metrics/statsd'; + +import { AuthServerEmailCapabilityClient } from './auth-server-email-capability.client'; +import { FreeAccessProgramNotifierService } from './free-access-program.notifier.service'; + +describe('FreeAccessProgramNotifierService', () => { + let service: FreeAccessProgramNotifierService; + let authServer: { notifyChange: jest.Mock }; + let statsd: { increment: jest.Mock }; + + const realDateNow = Date.now; + + beforeEach(async () => { + Date.now = jest.fn(() => new Date('2026-06-17T00:00:00.000Z').getTime()); + + authServer = { + notifyChange: jest + .fn() + .mockResolvedValue({ applied: 1, unknownAccount: 0 }), + }; + statsd = { increment: jest.fn() }; + + const moduleRef = await Test.createTestingModule({ + providers: [ + { provide: AuthServerEmailCapabilityClient, useValue: authServer }, + { provide: StatsDService, useValue: statsd as unknown as StatsD }, + FreeAccessProgramNotifierService, + ], + }).compile(); + + service = moduleRef.get(FreeAccessProgramNotifierService); + }); + + afterEach(() => { + Date.now = realDateNow; + jest.clearAllMocks(); + }); + + it('posts an added-only change when only added is supplied', async () => { + await service.notifyCapabilityChange({ + email: 'user@example.com', + added: ['cap-a', 'cap-b'], + }); + + expect(authServer.notifyChange).toHaveBeenCalledTimes(1); + const [payload] = authServer.notifyChange.mock.calls[0]; + expect(payload.changes).toEqual([ + { email: 'user@example.com', added: ['cap-a', 'cap-b'] }, + ]); + expect(payload.eventCreatedAt).toBe( + Math.floor(new Date('2026-06-17T00:00:00.000Z').getTime() / 1000) + ); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.notifier.published', + { applied: '1', unknown: '0', direction: 'added' } + ); + }); + + it('posts a removed-only change when only removed is supplied', async () => { + await service.notifyCapabilityChange({ + email: 'user@example.com', + removed: ['cap-a'], + }); + + expect(authServer.notifyChange).toHaveBeenCalledTimes(1); + expect(authServer.notifyChange.mock.calls[0][0].changes).toEqual([ + { email: 'user@example.com', removed: ['cap-a'] }, + ]); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.notifier.published', + expect.objectContaining({ direction: 'removed' }) + ); + }); + + it('posts both added and removed when both are non-empty', async () => { + await service.notifyCapabilityChange({ + email: 'user@example.com', + added: ['cap-new'], + removed: ['cap-old'], + }); + + expect(authServer.notifyChange.mock.calls[0][0].changes).toEqual([ + { email: 'user@example.com', added: ['cap-new'], removed: ['cap-old'] }, + ]); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.notifier.published', + expect.objectContaining({ direction: 'both' }) + ); + }); + + it('skips without HTTP when added and removed are both empty', async () => { + await service.notifyCapabilityChange({ + email: 'user@example.com', + }); + + expect(authServer.notifyChange).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.notifier.skipped', + { reason: 'no_capabilities' } + ); + }); + + it('drops empty / non-string slug entries from both lists', async () => { + await service.notifyCapabilityChange({ + email: 'user@example.com', + added: ['cap-a', ''] as unknown as readonly string[], + removed: ['', 'cap-z'] as unknown as readonly string[], + }); + + expect(authServer.notifyChange.mock.calls[0][0].changes).toEqual([ + { email: 'user@example.com', added: ['cap-a'], removed: ['cap-z'] }, + ]); + }); + + it('records the auth-server response counts including unknownAccount', async () => { + authServer.notifyChange.mockResolvedValue({ + applied: 0, + unknownAccount: 1, + }); + + await service.notifyCapabilityChange({ + email: 'ghost@example.com', + added: ['cap-a'], + }); + + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.notifier.published', + expect.objectContaining({ applied: '0', unknown: '1' }) + ); + }); + + it('lets HTTP failures bubble to the caller (reconciler isolates per-record)', async () => { + const boom = new Error('auth-server down'); + authServer.notifyChange.mockRejectedValue(boom); + + await expect( + service.notifyCapabilityChange({ + email: 'user@example.com', + added: ['cap-a'], + }) + ).rejects.toThrow('auth-server down'); + }); +}); diff --git a/libs/free-access-program/src/lib/free-access-program.notifier.service.ts b/libs/free-access-program/src/lib/free-access-program.notifier.service.ts new file mode 100644 index 00000000000..3e331d6d9f8 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.notifier.service.ts @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { StatsD } from 'hot-shots'; + +import { StatsDService } from '@fxa/shared/metrics/statsd'; + +import { AuthServerEmailCapabilityClient } from './auth-server-email-capability.client'; + +/** + * A single email's capability delta, expressed in auth-server's + * `email-capability-changed` shape. Either `added` or `removed` (or both) + * carries the actual change; the notifier short-circuits when both are + * empty. + */ +export interface CapabilityChange { + email: string; + added?: readonly string[]; + removed?: readonly string[]; + /** Kept for log/metric attribution; auth-server doesn't consume it. */ + entitlementId?: string; +} + +/** + * Fans out a capability delta to auth-server's + * `/oauth/subscriptions/email-capability-changed` endpoint. Auth-server + * resolves the email → uid, invalidates the FxA profile cache, and + * broadcasts a `subscription:update` SNS event for the affected uid. + * + * Used for all three reconciler outcomes: + * - newly granted record → `added` only + * - removed / expired record → `removed` only + * - record whose caps changed → both, with the per-slug diff + */ +@Injectable() +export class FreeAccessProgramNotifierService { + constructor( + private readonly authServerClient: AuthServerEmailCapabilityClient, + @Inject(StatsDService) private readonly statsd: StatsD + ) {} + + async notifyCapabilityChange(change: CapabilityChange): Promise { + const added = (change.added ?? []).filter(isNonEmptyString); + const removed = (change.removed ?? []).filter(isNonEmptyString); + + if (added.length === 0 && removed.length === 0) { + this.statsd.increment('free_access_program.notifier.skipped', { + reason: 'no_capabilities', + }); + return; + } + + const result = await this.authServerClient.notifyChange({ + eventCreatedAt: Math.floor(Date.now() / 1000), + changes: [ + { + email: change.email, + ...(added.length > 0 ? { added } : {}), + ...(removed.length > 0 ? { removed } : {}), + }, + ], + }); + + this.statsd.increment('free_access_program.notifier.published', { + applied: String(result.applied), + unknown: String(result.unknownAccount), + // Tag the dominant direction so dashboards can split add/remove/both. + direction: + added.length > 0 && removed.length > 0 + ? 'both' + : added.length > 0 + ? 'added' + : 'removed', + }); + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/libs/free-access-program/src/lib/free-access-program.projector.spec.ts b/libs/free-access-program/src/lib/free-access-program.projector.spec.ts new file mode 100644 index 00000000000..05a16017ed7 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.projector.spec.ts @@ -0,0 +1,263 @@ +/* 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 { Timestamp } from '@google-cloud/firestore'; +import { + type NormalizedAccess, + normalizeGraphQLAccess, + normalizeWebhookEntry, + projectAccess, +} from './free-access-program.projector'; + +describe('projectAccess', () => { + const NOW = new Date('2026-06-01T12:00:00.000Z'); + + const baseInput = (): NormalizedAccess => ({ + documentId: 'ent-1', + internalName: 'VPN beta', + 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'], + }, + ], + }); + + it('emits one record per email with merged capabilities and end-of-day expiry', () => { + const result = projectAccess(baseInput(), 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.toMillis()).toBe( + Timestamp.fromDate(new Date('2027-01-01T00:00:00.000Z')).toMillis() + ); + expect(alice?.createdAt.toMillis()).toBe( + Timestamp.fromDate(NOW).toMillis() + ); + }); + + it('skips entitlements missing a documentId', () => { + const input = baseInput(); + input.documentId = ''; + const result = projectAccess(input, NOW); + expect(result.records).toEqual([]); + expect(result.skipped).toEqual([{ reason: 'missing-document-id' }]); + }); + + it('skips entitlements with no resolvable capabilities (no services)', () => { + const input = baseInput(); + input.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 = baseInput(); + input.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 a matcher with non-object emails', () => { + const input = baseInput(); + input.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 = baseInput(); + input.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 a value that is not a [date, description] tuple', () => { + const input = baseInput(); + input.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 = baseInput(); + input.emailLists = [{ 'a@example.com': ['2026-12-31'] }]; + const result = projectAccess(input, NOW); + expect(result.records).toHaveLength(1); + // An absent description value is preserved as '' so the Firestore + // write doesn't trip on `undefined`. + expect(result.records[0]?.description).toBe(''); + }); + + it('accepts an explicit empty description and writes empty string', () => { + const input = baseInput(); + input.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 = baseInput(); + input.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 = baseInput(); + input.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 = baseInput(); + input.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 = baseInput(); + input.capabilities = [ + { slug: 'vpn-beta', services: [{ oauthClientId: 'CLIENT-A' }] }, + { slug: '', services: [{ oauthClientId: 'CLIENT-X' }] }, + { slug: 'orphan-cap', services: [] }, + { slug: 'no-client', services: [{ oauthClientId: '' }] }, + ]; + 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']); + }); +}); + +describe('normalizeWebhookEntry', () => { + it('collects emails from every matcher with an `emails` field, ignoring component name', () => { + const normalized = normalizeWebhookEntry({ + id: 1, + documentId: 'ent-1', + internalName: 'VPN beta', + capabilities: [ + { id: 1, slug: 'vpn-beta', services: [{ oauthClientId: 'cli' }] }, + ], + matchers: [ + { + __component: 'matchers.email-list', + id: 1, + 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).toEqual({ + documentId: 'ent-1', + internalName: 'VPN beta', + capabilities: [ + { id: 1, slug: 'vpn-beta', services: [{ oauthClientId: 'cli' }] }, + ], + emailLists: [ + { 'a@example.com': ['2026-12-31', 'd'] }, + { 'b@example.com': ['2026-12-31', 'd'] }, + ], + }); + }); +}); + +describe('normalizeGraphQLAccess', () => { + it('keeps only `ComponentMatchersEmailList` matchers', () => { + const normalized = normalizeGraphQLAccess({ + __typename: 'Access', + documentId: 'ent-1', + internalName: 'VPN beta', + capabilities: [ + { + __typename: 'Capability', + slug: 'vpn-beta', + services: [{ __typename: 'Service', oauthClientId: 'cli' }], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + 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'] }, + ]); + }); +}); diff --git a/libs/free-access-program/src/lib/free-access-program.projector.ts b/libs/free-access-program/src/lib/free-access-program.projector.ts new file mode 100644 index 00000000000..01e88be681d --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.projector.ts @@ -0,0 +1,258 @@ +/* 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 { Timestamp } from '@google-cloud/firestore'; +import type { + ClientIdCapabilityMap, + FirestoreFreeAccessProgramRecord, +} from './types'; +import type { StrapiAccessWebhookPayload } from './free-access-program.webhook.types'; + +/** + * Structural subset of a Strapi `accesses` GraphQL row. Defined locally to + * avoid a private import of the generated graphql.ts type from + * `@fxa/shared/cms`; the live GraphQL response is structurally assignable. + */ +export interface GraphQLAccessRow { + documentId?: string | null; + internalName?: string | null; + capabilities?: ReadonlyArray<{ + slug?: string | null; + services?: ReadonlyArray<{ oauthClientId?: string | null } | null> | null; + } | null> | null; + matchers?: ReadonlyArray< + | { + __typename?: string; + emails?: unknown; + } + | null + > | null; +} + +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: FirestoreFreeAccessProgramRecord[]; + skipped: ProjectionSkip[]; +} + +/** + * 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; + 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; +} + +const YYYY_MM_DD_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Project a single Strapi `access` entry into one + * `FirestoreFreeAccessProgramRecord` 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: FirestoreFreeAccessProgramRecord[] = []; + const createdAt = Timestamp.fromDate(now); + + for (const rawEmails of input.emailLists ?? []) { + if (Array.isArray(rawEmails)) { + // Legacy plain-array shape carries no expiry — there's no way to set a + // TTL on the resulting doc, so skip rather than write an + // un-expiring record. + 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, + capabilities: capabilityMap, + expiresAt: Timestamp.fromDate(expiresAtDate), + // Coerce missing/empty values to '' so the Firestore SDK doesn't + // reject the write — `undefined` is not a valid Firestore field + // value. An empty description / internalName is a valid input. + description: description ?? '', + internalName: input.internalName ?? '', + createdAt, + }); + } + } + + return { records, skipped }; +} + +/** Adapter: Strapi GraphQL `accesses` row → normalized. */ +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, + capabilities: access.capabilities, + emailLists, + }; +} + +/** Adapter: Strapi REST webhook `entry` → normalized. */ +export function normalizeWebhookEntry( + entry: NonNullable +): NormalizedAccess { + // Dispatch by field presence rather than `__component` name (same approach + // the previous email-capability webhook used) so a Strapi component rename + // doesn't silently break ingestion. + 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, + capabilities: entry.capabilities, + emailLists, + }; +} + +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; +} + +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, + }; +} + +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; + } + + // Expire at the start of the next day UTC so the named day is fully + // valid for everyone, including users west of UTC. + return new Date(Date.UTC(year, month - 1, day + 1)); +} 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..aa4043c32d4 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.reconciler.service.spec.ts @@ -0,0 +1,389 @@ +/* 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 * as Sentry from '@sentry/node'; +import { Timestamp } from '@google-cloud/firestore'; +import type { StatsD } from 'hot-shots'; + +import { StrapiClient } from '@fxa/shared/cms'; +import { StatsDService } from '@fxa/shared/metrics/statsd'; + +import { FreeAccessProgramManager } from './free-access-program.manager'; +import { FreeAccessProgramNotifierService } from './free-access-program.notifier.service'; +import { FreeAccessProgramReconcilerService } from './free-access-program.reconciler.service'; +import type { FirestoreFreeAccessProgramRecord } from './types'; + +jest.mock('@sentry/node', () => { + const actual = jest.requireActual('@sentry/node'); + return { ...actual, captureException: jest.fn() }; +}); + +describe('FreeAccessProgramReconcilerService', () => { + let service: FreeAccessProgramReconcilerService; + let strapi: { queryUncached: jest.Mock }; + let manager: { + listAllRecords: jest.Mock; + listRecordsForEntitlement: jest.Mock; + batchUpsertRecords: jest.Mock; + batchDeleteRecordsById: jest.Mock; + deleteRecordsByEntitlementId: jest.Mock; + writeReconcileHeartbeat: jest.Mock; + }; + let notifier: { notifyCapabilityChange: jest.Mock }; + let statsd: { increment: jest.Mock; timing: jest.Mock }; + let logger: { error: jest.Mock; log: jest.Mock }; + + // 2026-06-01 is current "today" per test fixtures; expiry of 2026-12-31 + // resolves to 2027-01-01T00:00:00Z (start of next day). + const realDateNow = Date.now; + + const entitlement = (override: Partial> = {}) => ({ + __typename: 'Access', + documentId: 'ent-1', + internalName: 'VPN beta', + capabilities: [ + { + __typename: 'Capability', + slug: 'vpn-beta', + services: [{ __typename: 'Service', oauthClientId: 'client-a' }], + }, + ], + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { + 'alice@example.com': ['2026-12-31', 'VIP'], + }, + }, + ], + ...override, + }); + + const record = ( + over: Partial = {} + ): FirestoreFreeAccessProgramRecord => ({ + entitlementId: 'ent-1', + email: 'alice@example.com', + capabilities: { 'client-a': ['vpn-beta'] }, + expiresAt: Timestamp.fromDate(new Date('2027-01-01T00:00:00.000Z')), + createdAt: Timestamp.fromDate(new Date('2026-06-01T00:00:00.000Z')), + ...over, + }); + + function callsByEmail(): Map { + const map = new Map< + string, + { added?: string[]; removed?: string[] } + >(); + for (const call of notifier.notifyCapabilityChange.mock.calls) { + const [arg] = call; + 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 () => { + Date.now = jest.fn(() => new Date('2026-06-01T12:00:00.000Z').getTime()); + + strapi = { queryUncached: jest.fn() }; + manager = { + listAllRecords: jest.fn().mockResolvedValue([]), + listRecordsForEntitlement: jest.fn().mockResolvedValue([]), + batchUpsertRecords: jest.fn().mockResolvedValue(undefined), + batchDeleteRecordsById: jest.fn().mockResolvedValue(undefined), + deleteRecordsByEntitlementId: jest.fn().mockResolvedValue(undefined), + writeReconcileHeartbeat: 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: StrapiClient, useValue: strapi }, + { provide: FreeAccessProgramManager, useValue: manager }, + { provide: FreeAccessProgramNotifierService, useValue: notifier }, + { provide: StatsDService, useValue: statsd as unknown as StatsD }, + { provide: Logger, useValue: logger }, + FreeAccessProgramReconcilerService, + ], + }).compile(); + service = moduleRef.get(FreeAccessProgramReconcilerService); + }); + + afterEach(() => { + Date.now = realDateNow; + jest.clearAllMocks(); + }); + + describe('reconcileEntitlement', () => { + it('fires added-only notification for a brand new record', async () => { + strapi.queryUncached.mockResolvedValue({ + accesses: [entitlement()], + }); + manager.listRecordsForEntitlement.mockResolvedValue([]); + + 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, skipped: [] }); + }); + + it('fires removed-only notification for a stale record', async () => { + strapi.queryUncached.mockResolvedValue({ + accesses: [entitlement()], + }); + manager.listRecordsForEntitlement.mockResolvedValue([ + record({ email: 'alice@example.com' }), + record({ + email: 'stale@example.com', + capabilities: { 'client-a': ['vpn-beta', 'vpn-extra'] }, + }), + ]); + + await service.reconcileEntitlement('ent-1'); + + const stale = callsByEmail().get('stale@example.com'); + expect(stale).toEqual({ removed: ['vpn-beta', 'vpn-extra'] }); + // Alice's record is unchanged → no notification for her + expect(callsByEmail().has('alice@example.com')).toBe(false); + }); + + it('fires both added and removed when a record\'s capabilities change', async () => { + strapi.queryUncached.mockResolvedValue({ + accesses: [ + entitlement({ + capabilities: [ + { + __typename: 'Capability', + slug: 'vpn-new', + services: [ + { __typename: 'Service', oauthClientId: 'client-a' }, + ], + }, + ], + }), + ], + }); + manager.listRecordsForEntitlement.mockResolvedValue([ + record({ + email: 'alice@example.com', + capabilities: { 'client-a': ['vpn-old'] }, + }), + ]); + + 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('does not notify when a record is rewritten with identical capabilities', async () => { + strapi.queryUncached.mockResolvedValue({ + accesses: [entitlement()], + }); + // Same capabilities, but a different expiresAt would still trigger + // an upsert. The notification should still be skipped because nothing + // RP-visible changed. + manager.listRecordsForEntitlement.mockResolvedValue([ + record({ + email: 'alice@example.com', + capabilities: { 'client-a': ['vpn-beta'] }, + expiresAt: Timestamp.fromDate(new Date('2027-01-01T00:00:00.000Z')), + }), + ]); + + await service.reconcileEntitlement('ent-1'); + + expect(notifier.notifyCapabilityChange).not.toHaveBeenCalled(); + // Still re-upserted to refresh the record body. + expect(manager.batchUpsertRecords.mock.calls[0][0]).toHaveLength(1); + }); + + it('delegates to deletion when the entitlement is no longer in Strapi', async () => { + strapi.queryUncached.mockResolvedValue({ accesses: [] }); + manager.listRecordsForEntitlement.mockResolvedValue([ + record({ email: 'a@example.com' }), + record({ email: 'b@example.com' }), + ]); + + const result = await service.reconcileEntitlement('ent-1'); + + expect(manager.deleteRecordsByEntitlementId).toHaveBeenCalledWith( + 'ent-1' + ); + expect(notifier.notifyCapabilityChange).toHaveBeenCalledTimes(2); + expect( + notifier.notifyCapabilityChange.mock.calls.every( + (c) => c[0].removed && !c[0].added + ) + ).toBe(true); + expect(result).toEqual({ upserted: 0, deleted: 2, skipped: [] }); + }); + }); + + describe('reconcileEntitlementDeletion', () => { + it('deletes every record and fires removed-only per record', async () => { + manager.listRecordsForEntitlement.mockResolvedValue([ + record({ email: 'a@example.com' }), + record({ email: 'b@example.com' }), + ]); + + const result = await service.reconcileEntitlementDeletion('ent-1'); + + expect(manager.deleteRecordsByEntitlementId).toHaveBeenCalledWith( + 'ent-1' + ); + expect(notifier.notifyCapabilityChange).toHaveBeenCalledTimes(2); + for (const call of notifier.notifyCapabilityChange.mock.calls) { + expect(call[0].removed).toEqual(['vpn-beta']); + expect(call[0].added).toBeUndefined(); + } + expect(result).toEqual({ upserted: 0, deleted: 2, skipped: [] }); + }); + }); + + describe('reconcileAll', () => { + it('emits add + remove notifications across all entitlements', async () => { + strapi.queryUncached.mockResolvedValue({ + accesses: [ + entitlement({ + documentId: 'ent-1', + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'alice@example.com': ['2026-12-31', 'd'] }, + }, + ], + }), + entitlement({ + documentId: 'ent-2', + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'bob@example.com': ['2026-12-31', 'd'] }, + }, + ], + }), + ], + }); + manager.listAllRecords.mockResolvedValue([ + record({ entitlementId: 'ent-1', email: 'stale@example.com' }), + ]); + + await service.reconcileAll(); + + const byEmail = callsByEmail(); + expect(byEmail.get('alice@example.com')).toEqual({ added: ['vpn-beta'] }); + expect(byEmail.get('bob@example.com')).toEqual({ added: ['vpn-beta'] }); + expect(byEmail.get('stale@example.com')).toEqual({ + removed: ['vpn-beta'], + }); + }); + + it('aborts without notifying when Strapi returns empty but Firestore has docs', async () => { + strapi.queryUncached.mockResolvedValue({ accesses: [] }); + manager.listAllRecords.mockResolvedValue([ + record({ email: 'a@example.com' }), + ]); + + await expect(service.reconcileAll()).rejects.toThrow( + /Refusing to delete/ + ); + + expect(notifier.notifyCapabilityChange).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.reconcile_all.failure' + ); + }); + + it('runs without notifying when both Strapi and Firestore are empty', async () => { + strapi.queryUncached.mockResolvedValue({ accesses: [] }); + manager.listAllRecords.mockResolvedValue([]); + + await service.reconcileAll(); + + expect(notifier.notifyCapabilityChange).not.toHaveBeenCalled(); + }); + + it('emits success counter + duration timer and writes heartbeat on success', async () => { + strapi.queryUncached.mockResolvedValue({ accesses: [] }); + manager.listAllRecords.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) + ); + expect(manager.writeReconcileHeartbeat).toHaveBeenCalledTimes(1); + }); + + it('emits failure counter and skips heartbeat on a failed run', async () => { + strapi.queryUncached.mockResolvedValue({ accesses: [] }); + manager.listAllRecords.mockResolvedValue([ + record({ email: 'x@example.com' }), + ]); + + await expect(service.reconcileAll()).rejects.toThrow(); + + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.reconcile_all.failure' + ); + expect(manager.writeReconcileHeartbeat).not.toHaveBeenCalled(); + }); + + it('isolates a failing notification so the run still reports success', async () => { + strapi.queryUncached.mockResolvedValue({ + accesses: [ + entitlement({ + documentId: 'ent-keep', + matchers: [ + { + __typename: 'ComponentMatchersEmailList', + emails: { 'keep@example.com': ['2026-12-31', 'd'] }, + }, + ], + }), + ], + }); + manager.listAllRecords.mockResolvedValue([ + record({ entitlementId: 'ent-x', email: 'a@example.com' }), + record({ entitlementId: 'ent-x', email: 'b@example.com' }), + ]); + notifier.notifyCapabilityChange + .mockRejectedValueOnce(new Error('auth-server blip')) + .mockResolvedValue(undefined); + + const result = await service.reconcileAll(); + + expect(notifier.notifyCapabilityChange).toHaveBeenCalledTimes(3); + expect(Sentry.captureException).toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.reconcile.notify.error' + ); + expect(result.deleted).toBe(2); + expect(result.upserted).toBe(1); + }); + }); +}); 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..a1380378ffc --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.reconciler.service.ts @@ -0,0 +1,363 @@ +/* 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 { Timestamp } from '@google-cloud/firestore'; +import type { StatsD } from 'hot-shots'; + +import { accessesQuery, StrapiClient } from '@fxa/shared/cms'; +import { StatsDService } from '@fxa/shared/metrics/statsd'; + +import { FreeAccessProgramManager } from './free-access-program.manager'; +import { + type CapabilityChange, + FreeAccessProgramNotifierService, +} from './free-access-program.notifier.service'; +import { + type ProjectionSkip, + normalizeGraphQLAccess, + projectAccess, +} from './free-access-program.projector'; +import { buildFreeAccessDocId } from './free-access-program.repository'; +import type { + ClientIdCapabilityMap, + FirestoreFreeAccessProgramRecord, +} from './types'; + +export interface ReconcileResult { + upserted: number; + deleted: number; + skipped: ProjectionSkip[]; +} + +/** + * Drives the Strapi → Firestore projection of free-access program grants + * and fans out the per-email capability deltas to RPs via auth-server. + * + * Three entry points cover the trigger paths: + * - `reconcileEntitlement(id)` — single-entitlement publish/update webhook. + * - `reconcileEntitlementDeletion(id)` — single-entitlement unpublish/delete + * webhook; removes the entitlement's docs and notifies for each. + * - `reconcileAll()` — full sweep used by the periodic scheduled job. + * Past-expiry rows fall out of `expected` and into the deletion set on + * the same cadence as the cron. + * + * For every record the diff produces, the reconciler decides which of + * three notification cases applies: + * - new record → `added: [all caps]` + * - removed record → `removed: [all caps]` + * - changed record → `added: [newly granted], removed: [newly revoked]` + * + * The notifications are issued after the batched Firestore writes commit — + * a failed write must not fire a phantom RP event. + */ +@Injectable() +export class FreeAccessProgramReconcilerService { + constructor( + private strapiClient: StrapiClient, + private manager: FreeAccessProgramManager, + private notifier: FreeAccessProgramNotifierService, + @Inject(StatsDService) private statsd: StatsD, + private logger: Logger + ) {} + + async reconcileEntitlement(entitlementId: string): Promise { + const accesses = await this.fetchAllAccesses(); + const target = accesses.find((a) => a?.documentId === entitlementId); + if (!target) { + // Access is gone from Strapi — treat the publish/update event the + // same as a deletion. Keeps the webhook handler from needing to know + // the difference. + return this.reconcileEntitlementDeletion(entitlementId); + } + + const { records, skipped } = projectAccess( + normalizeGraphQLAccess(target), + new Date() + ); + this.emitSkippedMetrics(skipped); + + const expected = this.buildExpectedMap(records); + const existing = this.indexById( + await this.manager.listRecordsForEntitlement(entitlementId) + ); + return this.applyDiff(expected, existing, skipped); + } + + async reconcileEntitlementDeletion( + entitlementId: string + ): Promise { + const existing = await this.manager.listRecordsForEntitlement( + entitlementId + ); + await this.manager.deleteRecordsByEntitlementId(entitlementId); + this.statsd.increment( + 'free_access_program.reconcile.deleted', + existing.length + ); + + const notifications: CapabilityChange[] = existing.map((record) => ({ + email: record.email, + entitlementId: record.entitlementId, + removed: [...flattenCapabilities(record.capabilities)], + })); + await this.fireNotifications(notifications); + + return { upserted: 0, deleted: existing.length, skipped: [] }; + } + + async reconcileAll(): Promise { + const startedAt = Date.now(); + try { + const result = await this.runReconcileAll(); + 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'); + await this.persistHeartbeat(result, durationMs); + return result; + } catch (err) { + const durationMs = Date.now() - startedAt; + this.statsd.timing( + 'free_access_program.reconcile_all.duration_ms', + durationMs + ); + this.statsd.increment('free_access_program.reconcile_all.failure'); + throw err; + } + } + + private async runReconcileAll(): Promise { + const accesses = await this.fetchAllAccesses(); + const now = new Date(); + const allSkipped: ProjectionSkip[] = []; + const expected = new Map(); + + for (const access of accesses) { + if (!access) continue; + const projection = projectAccess( + normalizeGraphQLAccess(access), + now + ); + allSkipped.push(...projection.skipped); + for (const record of projection.records) { + expected.set( + buildFreeAccessDocId(record.entitlementId, record.email), + record + ); + } + } + + const existing = this.indexById(await this.manager.listAllRecords()); + + // Defensive guard: a degraded Strapi fetch must never empty the entire + // collection. If Strapi truly has no accesses, the operator can run a + // one-off explicit cleanup; we won't infer it from a possibly-bad + // upstream read. + if (expected.size === 0 && existing.size > 0) { + const err = new Error( + `Refusing to delete ${existing.size} free-access docs: ` + + `Strapi returned 0 accesses` + ); + this.logger.error(err); + Sentry.captureException(err); + this.statsd.increment('free_access_program.reconcile.aborted', { + reason: 'empty_strapi', + }); + throw err; + } + + this.emitSkippedMetrics(allSkipped); + return this.applyDiff(expected, existing, allSkipped); + } + + private async persistHeartbeat( + result: ReconcileResult, + durationMs: number + ): Promise { + try { + await this.manager.writeReconcileHeartbeat({ + lastSuccessAt: Timestamp.fromMillis(Date.now()), + durationMs, + upserted: result.upserted, + deleted: result.deleted, + skipped: result.skipped.length, + }); + } catch (err) { + this.statsd.increment( + 'free_access_program.reconcile_all.heartbeat.error' + ); + this.logger.error(err); + Sentry.captureException(err); + } + } + + private async fetchAllAccesses() { + const result = await this.strapiClient.queryUncached( + accessesQuery, + {} + ); + return result.accesses ?? []; + } + + private buildExpectedMap( + records: ReadonlyArray + ): Map { + const map = new Map(); + for (const record of records) { + map.set( + buildFreeAccessDocId(record.entitlementId, record.email), + record + ); + } + return map; + } + + private indexById( + records: ReadonlyArray + ): Map { + const map = new Map(); + for (const record of records) { + map.set( + buildFreeAccessDocId(record.entitlementId, record.email), + record + ); + } + return map; + } + + private async applyDiff( + expected: Map, + existing: Map, + skipped: ProjectionSkip[] + ): Promise { + const upserts: FirestoreFreeAccessProgramRecord[] = []; + const toDeleteIds: string[] = []; + const notifications: CapabilityChange[] = []; + + // Walk expected: capture every doc to write and, for each, decide + // whether it's a brand new grant (added-only) or a changed grant + // (added + removed). Unchanged records still get re-written by the + // batched upsert but emit no notification. + for (const [id, after] of expected) { + upserts.push(after); + const before = existing.get(id); + if (!before) { + const added = [...flattenCapabilities(after.capabilities)]; + if (added.length > 0) { + notifications.push({ + email: after.email, + entitlementId: after.entitlementId, + added, + }); + } + continue; + } + const { added, removed } = diffCapabilities( + before.capabilities, + after.capabilities + ); + if (added.length > 0 || removed.length > 0) { + notifications.push({ + email: after.email, + entitlementId: after.entitlementId, + ...(added.length > 0 ? { added } : {}), + ...(removed.length > 0 ? { removed } : {}), + }); + } + } + + // Walk existing: anything not in expected is a pure delete. + for (const [id, before] of existing) { + if (expected.has(id)) continue; + toDeleteIds.push(id); + const removed = [...flattenCapabilities(before.capabilities)]; + if (removed.length > 0) { + notifications.push({ + email: before.email, + entitlementId: before.entitlementId, + removed, + }); + } + } + + await this.manager.batchUpsertRecords(upserts); + await this.manager.batchDeleteRecordsById(toDeleteIds); + + this.statsd.increment( + 'free_access_program.reconcile.upserted', + upserts.length + ); + this.statsd.increment( + 'free_access_program.reconcile.deleted', + toDeleteIds.length + ); + + await this.fireNotifications(notifications); + + return { + upserted: upserts.length, + deleted: toDeleteIds.length, + skipped, + }; + } + + /** + * Issue one auth-server call per capability delta. Per-record failures + * are isolated so one bad record can't block the rest. Called after + * batched Firestore writes commit — a failed write must not fire a + * phantom RP event. + */ + 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); + } + } + } + + private emitSkippedMetrics(skipped: ProjectionSkip[]): void { + for (const entry of skipped) { + this.statsd.increment('free_access_program.reconcile.skipped', { + reason: entry.reason, + }); + } + } +} + +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; +} + +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/free-access-program.repository.spec.ts b/libs/free-access-program/src/lib/free-access-program.repository.spec.ts new file mode 100644 index 00000000000..e4795c4e7b4 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.repository.spec.ts @@ -0,0 +1,198 @@ +/* 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 { + CollectionReference, + DocumentReference, + Firestore, + QuerySnapshot, + Timestamp, + WriteBatch, +} from '@google-cloud/firestore'; +import { FirestoreFreeAccessProgramRecordFactory } from './factories'; +import { + buildFreeAccessDocId, + deleteFreeAccessProgramRecord, + deleteFreeAccessProgramRecordsByEntitlementId, + getFreeAccessProgramRecord, + getFreeAccessProgramRecordsForEmail, + listAllFreeAccessProgramRecords, + listFreeAccessProgramRecordsForEntitlement, + META_COLLECTION_SUFFIX, + RECONCILE_HEARTBEAT_DOC_ID, + upsertFreeAccessProgramRecord, + writeReconcileHeartbeat, +} from './free-access-program.repository'; + +jest.mock('@google-cloud/firestore'); + +describe('Free Access Program Repository', () => { + let mockDb: jest.Mocked; + let mockFirestore: jest.Mocked; + let mockDoc: jest.Mocked; + let mockBatch: jest.Mocked; + + const mockRecord = FirestoreFreeAccessProgramRecordFactory({ + email: 'User@Example.com', + entitlementId: 'ent-1', + }); + + beforeEach(() => { + mockFirestore = new Firestore() as jest.Mocked; + + mockDoc = { + set: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue({ data: () => mockRecord }), + delete: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + mockBatch = { + delete: jest.fn(), + commit: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + mockDb = { + doc: jest.fn().mockReturnValue(mockDoc), + where: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ + docs: [{ ref: mockDoc, data: () => mockRecord }], + } as unknown as QuerySnapshot), + firestore: mockFirestore, + } as unknown as jest.Mocked; + + mockFirestore.batch = jest.fn().mockReturnValue(mockBatch); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('buildFreeAccessDocId', () => { + it('lowercases the email and joins it with the entitlementId', () => { + expect(buildFreeAccessDocId('ent-1', 'User@Example.com')).toBe( + 'ent-1_user@example.com' + ); + }); + }); + + describe('upsertFreeAccessProgramRecord', () => { + it('writes the record under the composite doc id with a lowercased email', async () => { + await upsertFreeAccessProgramRecord(mockDb, mockRecord); + expect(mockDb.doc).toHaveBeenCalledWith('ent-1_user@example.com'); + expect(mockDoc.set).toHaveBeenCalledWith({ + entitlementId: mockRecord.entitlementId, + email: 'user@example.com', + capabilities: mockRecord.capabilities, + expiresAt: mockRecord.expiresAt, + description: mockRecord.description, + internalName: mockRecord.internalName, + createdAt: mockRecord.createdAt, + }); + }); + }); + + describe('getFreeAccessProgramRecord', () => { + it('fetches a record by composite key', async () => { + const result = await getFreeAccessProgramRecord( + mockDb, + mockRecord.entitlementId, + mockRecord.email + ); + expect(mockDb.doc).toHaveBeenCalledWith('ent-1_user@example.com'); + expect(result).toEqual(mockRecord); + }); + }); + + describe('listAllFreeAccessProgramRecords', () => { + it('returns every record body in the collection', async () => { + const result = await listAllFreeAccessProgramRecords(mockDb); + expect(mockDb.get).toHaveBeenCalled(); + expect(result).toEqual([mockRecord]); + }); + }); + + describe('listFreeAccessProgramRecordsForEntitlement', () => { + it('queries by entitlementId and returns full record bodies', async () => { + const result = await listFreeAccessProgramRecordsForEntitlement( + mockDb, + 'ent-1' + ); + expect(mockDb.where).toHaveBeenCalledWith( + 'entitlementId', + '==', + 'ent-1' + ); + expect(result).toEqual([mockRecord]); + }); + }); + + describe('getFreeAccessProgramRecordsForEmail', () => { + it('queries by lowercased email', async () => { + const result = await getFreeAccessProgramRecordsForEmail( + mockDb, + 'User@Example.com' + ); + expect(mockDb.where).toHaveBeenCalledWith( + 'email', + '==', + 'user@example.com' + ); + expect(result).toEqual([mockRecord]); + }); + }); + + describe('deleteFreeAccessProgramRecord', () => { + it('deletes the doc keyed by composite id', async () => { + await deleteFreeAccessProgramRecord( + mockDb, + mockRecord.entitlementId, + mockRecord.email + ); + expect(mockDb.doc).toHaveBeenCalledWith('ent-1_user@example.com'); + expect(mockDoc.delete).toHaveBeenCalled(); + }); + }); + + describe('deleteFreeAccessProgramRecordsByEntitlementId', () => { + it('batch-deletes every doc tied to the entitlement', async () => { + await deleteFreeAccessProgramRecordsByEntitlementId(mockDb, 'ent-1'); + expect(mockDb.where).toHaveBeenCalledWith( + 'entitlementId', + '==', + 'ent-1' + ); + expect(mockBatch.delete).toHaveBeenCalledWith(mockDoc); + expect(mockBatch.commit).toHaveBeenCalled(); + }); + }); + + describe('writeReconcileHeartbeat', () => { + it('writes the singleton doc to the sibling meta collection', async () => { + const metaDoc = { + set: jest.fn().mockResolvedValue(undefined), + } as unknown as DocumentReference; + const metaCollection = { + doc: jest.fn().mockReturnValue(metaDoc), + }; + mockFirestore.collection = jest.fn().mockReturnValue(metaCollection); + + const heartbeat = { + lastSuccessAt: Timestamp.fromDate(new Date('2026-06-17T00:00:00Z')), + durationMs: 1234, + upserted: 5, + deleted: 1, + skipped: 0, + }; + await writeReconcileHeartbeat(mockFirestore, 'free-access', heartbeat); + + expect(mockFirestore.collection).toHaveBeenCalledWith( + `free-access${META_COLLECTION_SUFFIX}` + ); + expect(metaCollection.doc).toHaveBeenCalledWith( + RECONCILE_HEARTBEAT_DOC_ID + ); + expect(metaDoc.set).toHaveBeenCalledWith(heartbeat); + }); + }); +}); diff --git a/libs/free-access-program/src/lib/free-access-program.repository.ts b/libs/free-access-program/src/lib/free-access-program.repository.ts new file mode 100644 index 00000000000..9390f5939e6 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.repository.ts @@ -0,0 +1,213 @@ +/* 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 { + CollectionReference, + Firestore, + Timestamp, +} from '@google-cloud/firestore'; +import type { FirestoreFreeAccessProgramRecord } from './types'; + +/** + * Stored snapshot of the most recent successful `reconcileAll()` run. + * Persisted to a sibling Firestore collection so it doesn't appear in + * `listAllRecords` and isn't affected by the TTL policy on `expiresAt`. + */ +export interface ReconcileHeartbeat { + lastSuccessAt: Timestamp; + durationMs: number; + upserted: number; + deleted: number; + skipped: number; +} + +/** Doc id for the singleton heartbeat record inside the meta collection. */ +export const RECONCILE_HEARTBEAT_DOC_ID = 'reconcile-heartbeat'; + +/** Suffix appended to the main collection name to derive the meta one. */ +export const META_COLLECTION_SUFFIX = '-meta'; + +/** + * Write the singleton heartbeat doc for the most recent successful sweep. + * `set` (full overwrite) is intentional — we only keep the latest snapshot. + */ +export async function writeReconcileHeartbeat( + firestore: Firestore, + collectionName: string, + data: ReconcileHeartbeat +): Promise { + await firestore + .collection(`${collectionName}${META_COLLECTION_SUFFIX}`) + .doc(RECONCILE_HEARTBEAT_DOC_ID) + .set(data); +} + +/** + * Composite doc id keying a single (entitlement, email) grant. + * + * Entitlement-first keeps all docs for a given entitlement range-queryable on + * documentId, which is handy for bulk cleanup when Strapi removes an + * entitlement. + */ +export function buildFreeAccessDocId( + entitlementId: string, + email: string +): string { + return `${entitlementId}_${email.toLowerCase()}`; +} + +// Firestore caps a single batched write at 500 operations. +export const FIRESTORE_BATCH_LIMIT = 500; + +function toFirestoreDoc(data: FirestoreFreeAccessProgramRecord) { + return { + entitlementId: data.entitlementId, + email: data.email.toLowerCase(), + capabilities: data.capabilities, + expiresAt: data.expiresAt, + description: data.description, + internalName: data.internalName, + createdAt: data.createdAt, + }; +} + +/** + * Insert or replace a free-access program record. + */ +export async function upsertFreeAccessProgramRecord( + db: CollectionReference, + data: FirestoreFreeAccessProgramRecord +): Promise { + const id = buildFreeAccessDocId(data.entitlementId, data.email); + await db.doc(id).set(toFirestoreDoc(data)); +} + +/** + * Upsert many records in batches of up to 500. Used by the reconciler so a + * large diff doesn't issue thousands of independent round-trips. + */ +export async function batchUpsertFreeAccessProgramRecords( + db: CollectionReference, + records: ReadonlyArray +): Promise { + for (let i = 0; i < records.length; i += FIRESTORE_BATCH_LIMIT) { + const chunk = records.slice(i, i + FIRESTORE_BATCH_LIMIT); + const batch = db.firestore.batch(); + for (const record of chunk) { + const id = buildFreeAccessDocId(record.entitlementId, record.email); + batch.set(db.doc(id), toFirestoreDoc(record)); + } + await batch.commit(); + } +} + +/** + * Delete many docs by composite id in batches of up to 500. Used by the + * reconciler to clear out entries that no longer have a counterpart in + * Strapi. + */ +export async function batchDeleteFreeAccessProgramRecordsById( + db: CollectionReference, + docIds: ReadonlyArray +): Promise { + for (let i = 0; i < docIds.length; i += FIRESTORE_BATCH_LIMIT) { + const chunk = docIds.slice(i, i + FIRESTORE_BATCH_LIMIT); + const batch = db.firestore.batch(); + for (const id of chunk) { + batch.delete(db.doc(id)); + } + await batch.commit(); + } +} + +/** + * Fetch every grant in the collection. Used by the reconciler's full sweep + * — bodies are needed (not just IDs) because the reconciler fires deletion + * notifications carrying the email + capabilities of each removed record. + */ +export async function listAllFreeAccessProgramRecords( + db: CollectionReference +): Promise { + const result = await db.get(); + return result.docs.map( + (x) => x.data() as FirestoreFreeAccessProgramRecord + ); +} + +/** + * Fetch every grant tied to a single Strapi entitlement. Used by the + * per-entitlement reconcile path to compute the deletion set and to gather + * the bodies needed for notification. + */ +export async function listFreeAccessProgramRecordsForEntitlement( + db: CollectionReference, + entitlementId: string +): Promise { + const result = await db + .where('entitlementId', '==', entitlementId) + .get(); + return result.docs.map( + (x) => x.data() as FirestoreFreeAccessProgramRecord + ); +} + +/** + * Fetch a single grant by composite key. + */ +export async function getFreeAccessProgramRecord( + db: CollectionReference, + entitlementId: string, + email: string +): Promise { + const id = buildFreeAccessDocId(entitlementId, email); + const result = await db.doc(id).get(); + return result.data() as FirestoreFreeAccessProgramRecord | undefined; +} + +/** + * Fetch every grant for a single email across all entitlements. Backs the + * per-request read path; expired grants are absent once the Firestore TTL + * policy has reaped them. + */ +export async function getFreeAccessProgramRecordsForEmail( + db: CollectionReference, + email: string +): Promise { + const result = await db.where('email', '==', email.toLowerCase()).get(); + return result.docs.map( + (x) => x.data() as FirestoreFreeAccessProgramRecord + ); +} + +/** + * Delete a single grant. Manual deletion and TTL-driven reaping are both + * surfaced as deletion notifications by the reconciler, so the read-time + * effect is identical for downstream consumers. + */ +export async function deleteFreeAccessProgramRecord( + db: CollectionReference, + entitlementId: string, + email: string +): Promise { + const id = buildFreeAccessDocId(entitlementId, email); + await db.doc(id).delete(); +} + +/** + * Delete every grant tied to a given Strapi entitlement, e.g. when the + * entitlement is removed entirely from Strapi. + */ +export async function deleteFreeAccessProgramRecordsByEntitlementId( + db: CollectionReference, + entitlementId: string +): Promise { + const records = await db + .where('entitlementId', '==', entitlementId) + .get(); + const batch = db.firestore.batch(); + for (const record of records.docs) { + batch.delete(record.ref); + } + await batch.commit(); +} diff --git a/libs/free-access-program/src/lib/free-access-program.webhook.controller.ts b/libs/free-access-program/src/lib/free-access-program.webhook.controller.ts new file mode 100644 index 00000000000..746c0494946 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.webhook.controller.ts @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Body, Controller, Headers, HttpCode, Post } from '@nestjs/common'; + +import { FreeAccessProgramWebhookService } from './free-access-program.webhook.service'; +import type { StrapiAccessWebhookPayload } from './free-access-program.webhook.types'; + +@Controller('webhooks') +export class FreeAccessProgramWebhookController { + constructor( + private webhookService: FreeAccessProgramWebhookService + ) {} + + @Post('strapi/access') + @HttpCode(200) + async postAccess( + @Headers('authorization') authorization: string, + @Body() body: StrapiAccessWebhookPayload + ) { + await this.webhookService.handleAccessWebhook(authorization, body); + return { success: true }; + } +} diff --git a/libs/free-access-program/src/lib/free-access-program.webhook.service.spec.ts b/libs/free-access-program/src/lib/free-access-program.webhook.service.spec.ts new file mode 100644 index 00000000000..d75fad23a78 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.webhook.service.spec.ts @@ -0,0 +1,174 @@ +/* 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 * as Sentry from '@sentry/node'; +import type { StatsD } from 'hot-shots'; + +import { StrapiClient } from '@fxa/shared/cms'; +import { StatsDService } from '@fxa/shared/metrics/statsd'; + +import { FreeAccessProgramReconcilerService } from './free-access-program.reconciler.service'; +import { FreeAccessProgramWebhookService } from './free-access-program.webhook.service'; +import type { StrapiAccessWebhookPayload } from './free-access-program.webhook.types'; + +jest.mock('@sentry/node', () => { + const actual = jest.requireActual('@sentry/node'); + return { ...actual, captureException: jest.fn() }; +}); + +describe('FreeAccessProgramWebhookService', () => { + let service: FreeAccessProgramWebhookService; + let strapi: { verifyWebhookSignature: jest.Mock }; + let reconciler: { + reconcileEntitlement: jest.Mock; + reconcileEntitlementDeletion: jest.Mock; + }; + let statsd: { increment: jest.Mock }; + let logger: { error: jest.Mock; log: jest.Mock }; + + const payload = ( + over: Partial = {} + ): StrapiAccessWebhookPayload => ({ + event: 'entry.publish', + model: 'access', + uid: 'api::access.access', + entry: { id: 1, documentId: 'ent-1' }, + ...over, + }); + + beforeEach(async () => { + strapi = { verifyWebhookSignature: jest.fn().mockReturnValue(true) }; + reconciler = { + reconcileEntitlement: jest + .fn() + .mockResolvedValue({ upserted: 1, deleted: 0, skipped: [] }), + reconcileEntitlementDeletion: jest + .fn() + .mockResolvedValue({ upserted: 0, deleted: 1, skipped: [] }), + }; + statsd = { increment: jest.fn() }; + logger = { error: jest.fn(), log: jest.fn() }; + + const moduleRef = await Test.createTestingModule({ + providers: [ + { provide: StrapiClient, useValue: strapi }, + { provide: FreeAccessProgramReconcilerService, useValue: reconciler }, + { provide: StatsDService, useValue: statsd as unknown as StatsD }, + { provide: Logger, useValue: logger }, + FreeAccessProgramWebhookService, + ], + }).compile(); + service = moduleRef.get(FreeAccessProgramWebhookService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('rejects an invalid Strapi signature', async () => { + strapi.verifyWebhookSignature.mockReturnValue(false); + + await service.handleAccessWebhook('Bearer bad', payload()); + + expect(reconciler.reconcileEntitlement).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.webhook.auth.error' + ); + }); + + it('skips webhooks for other models', async () => { + await service.handleAccessWebhook( + 'Bearer ok', + payload({ model: 'something-else' }) + ); + + expect(reconciler.reconcileEntitlement).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.webhook.skipped', + { reason: 'model' } + ); + }); + + it('skips webhooks with no documentId', async () => { + await service.handleAccessWebhook( + 'Bearer ok', + payload({ entry: { id: 1 } }) + ); + + expect(reconciler.reconcileEntitlement).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.webhook.skipped', + { reason: 'no_document_id' } + ); + }); + + it.each(['entry.publish', 'entry.update'])( + 'dispatches %s to reconcileEntitlement', + async (event) => { + await service.handleAccessWebhook( + 'Bearer ok', + payload({ event }) + ); + expect(reconciler.reconcileEntitlement).toHaveBeenCalledWith('ent-1'); + } + ); + + it.each(['entry.unpublish', 'entry.delete'])( + 'dispatches %s to reconcileEntitlementDeletion', + async (event) => { + await service.handleAccessWebhook( + 'Bearer ok', + payload({ event }) + ); + expect(reconciler.reconcileEntitlementDeletion).toHaveBeenCalledWith( + 'ent-1' + ); + } + ); + + it('skips events outside the handled set', async () => { + await service.handleAccessWebhook( + 'Bearer ok', + payload({ event: 'entry.create' }) + ); + + expect(reconciler.reconcileEntitlement).not.toHaveBeenCalled(); + expect(reconciler.reconcileEntitlementDeletion).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.webhook.skipped', + { reason: 'event' } + ); + }); + + it('captures reconciler errors without throwing', async () => { + const boom = new Error('strapi down'); + reconciler.reconcileEntitlement.mockRejectedValue(boom); + + await expect( + service.handleAccessWebhook('Bearer ok', payload()) + ).resolves.toBeUndefined(); + + expect(Sentry.captureException).toHaveBeenCalledWith(boom); + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.webhook.error' + ); + }); + + it('records a metric on successful handling with operation counts', async () => { + reconciler.reconcileEntitlement.mockResolvedValue({ + upserted: 3, + deleted: 2, + skipped: [], + }); + + await service.handleAccessWebhook('Bearer ok', payload()); + + expect(statsd.increment).toHaveBeenCalledWith( + 'free_access_program.webhook.handled', + { event: 'entry.publish', upserted: '3', deleted: '2' } + ); + }); +}); diff --git a/libs/free-access-program/src/lib/free-access-program.webhook.service.ts b/libs/free-access-program/src/lib/free-access-program.webhook.service.ts new file mode 100644 index 00000000000..6a978c316d8 --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.webhook.service.ts @@ -0,0 +1,99 @@ +/* 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 { StrapiClient } from '@fxa/shared/cms'; +import { StatsDService } from '@fxa/shared/metrics/statsd'; + +import { FreeAccessProgramReconcilerService } from './free-access-program.reconciler.service'; +import type { StrapiAccessWebhookPayload } from './free-access-program.webhook.types'; + +const TARGET_MODEL = 'access'; +const UPSERT_EVENTS = new Set(['entry.publish', 'entry.update']); +const DELETE_EVENTS = new Set([ + 'entry.unpublish', + 'entry.delete', +]); + +export class FreeAccessProgramWebhookAuthError extends Error { + constructor() { + super('Strapi free-access-program webhook signature mismatch'); + this.name = 'FreeAccessProgramWebhookAuthError'; + } +} + +/** + * Handles Strapi webhooks for `access` entries and dispatches to the + * reconciler. The webhook payload is treated as a *trigger*, not a source + * of truth — the reconciler always re-fetches the entry from Strapi (via + * `queryUncached`) before writing. + */ +@Injectable() +export class FreeAccessProgramWebhookService { + constructor( + private strapiClient: StrapiClient, + private reconciler: FreeAccessProgramReconcilerService, + @Inject(StatsDService) private statsd: StatsD, + private logger: Logger + ) {} + + async handleAccessWebhook( + authorization: string, + payload: StrapiAccessWebhookPayload + ): Promise { + if (!this.strapiClient.verifyWebhookSignature(authorization)) { + this.statsd.increment('free_access_program.webhook.auth.error'); + this.logger.error(new FreeAccessProgramWebhookAuthError()); + return; + } + + if (payload.model !== TARGET_MODEL) { + this.statsd.increment('free_access_program.webhook.skipped', { + reason: 'model', + }); + return; + } + + const event = payload.event; + const documentId = payload.entry?.documentId; + if (!documentId) { + this.statsd.increment('free_access_program.webhook.skipped', { + reason: 'no_document_id', + }); + return; + } + + try { + if (UPSERT_EVENTS.has(event)) { + const result = await this.reconciler.reconcileEntitlement(documentId); + this.statsd.increment('free_access_program.webhook.handled', { + event, + upserted: String(result.upserted), + deleted: String(result.deleted), + }); + } else if (DELETE_EVENTS.has(event)) { + const result = await this.reconciler.reconcileEntitlementDeletion( + documentId + ); + this.statsd.increment('free_access_program.webhook.handled', { + event, + deleted: String(result.deleted), + }); + } else { + this.statsd.increment('free_access_program.webhook.skipped', { + reason: 'event', + }); + } + } catch (error) { + // Don't bubble — we already returned 200 to Strapi, and the periodic + // sweep will repair on the next tick. + this.statsd.increment('free_access_program.webhook.error'); + this.logger.error(error); + Sentry.captureException(error); + } + } +} 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..3a0c357652b --- /dev/null +++ b/libs/free-access-program/src/lib/free-access-program.webhook.types.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/. */ + +/** + * Webhook payload Strapi sends when an `access` entry is created / + * updated / published / unpublished / deleted. The matchers and + * capabilities are nested under `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; + capabilities?: Array<{ + id?: number; + slug?: string; + services?: Array<{ oauthClientId?: string } | null> | null; + [k: string]: unknown; + }>; + matchers?: Array<{ + __component?: string; + id?: number; + emails?: unknown; + [k: string]: unknown; + }>; + [k: string]: unknown; + }; +} diff --git a/libs/free-access-program/src/lib/types.ts b/libs/free-access-program/src/lib/types.ts new file mode 100644 index 00000000000..8269b4bde49 --- /dev/null +++ b/libs/free-access-program/src/lib/types.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 { Timestamp } from '@google-cloud/firestore'; + +/** + * Per-RP capability grants for a single (email, entitlement) pair. + * Mirrors `fxa-shared/subscriptions/types#ClientIdCapabilityMap`. + */ +export type ClientIdCapabilityMap = Record; + +/** + * Firestore representation of a free-access program grant for a single email + * tied to a single Strapi access entry. + * + * The Firestore TTL policy is configured on `expiresAt`; once that timestamp + * passes, Firestore will delete the document (best-effort, typically within + * ~24h), which fires an Eventarc `onDelete` event that the notifier consumes + * to fan out RP capability updates. + */ +export interface FirestoreFreeAccessProgramRecord { + /** Strapi access `documentId`. */ + entitlementId: string; + /** Lowercased email address granted access. */ + email: string; + /** Capabilities to apply per oauthClientId for this email. */ + capabilities: ClientIdCapabilityMap; + /** When the grant expires. Targeted by the Firestore TTL policy. */ + expiresAt: Timestamp; + /** 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 written. */ + createdAt: Timestamp; +} diff --git a/libs/free-access-program/src/scripts/reconcile-all.ts b/libs/free-access-program/src/scripts/reconcile-all.ts new file mode 100644 index 00000000000..d87e080fb69 --- /dev/null +++ b/libs/free-access-program/src/scripts/reconcile-all.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/* 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/. */ + +/** + * Runs a full Strapi → Firestore reconciliation pass. + * + * Designed to be invoked by an external scheduler (Cloud Scheduler / + * Kubernetes CronJob) on a cadence — the script bootstraps the lib's own + * NestJS module, runs `reconcileAll()`, prints the result, and exits. + * + * Usage: + * npx tsx libs/free-access-program/src/scripts/reconcile-all.ts + * + * Exit codes: + * 0 reconciliation completed (zero or more upserts/deletes) + * 1 reconciliation aborted (e.g. defensive guard tripped) or failed + */ + +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; + +import { FreeAccessProgramReconcilerModule } from '../lib/free-access-program-reconciler.module'; +import { FreeAccessProgramReconcilerService } from '../lib/free-access-program.reconciler.service'; + +async function main(): Promise { + const app = await NestFactory.createApplicationContext( + FreeAccessProgramReconcilerModule, + { + logger: ['log', 'warn', 'error'], + } + ); + try { + const reconciler = app.get(FreeAccessProgramReconcilerService); + const start = Date.now(); + const result = await reconciler.reconcileAll(); + const elapsedMs = Date.now() - start; + + console.log( + JSON.stringify({ + event: 'free_access_program.reconcile_all.completed', + upserted: result.upserted, + deleted: result.deleted, + skippedCount: result.skipped.length, + elapsedMs, + }) + ); + return 0; + } catch (err) { + console.error( + JSON.stringify({ + event: 'free_access_program.reconcile_all.failed', + error: err instanceof Error ? err.message : String(err), + }) + ); + return 1; + } finally { + await app.close(); + } +} + +main().then((code) => process.exit(code)); 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/freeAccess.spec.ts b/libs/payments/management/src/lib/freeAccess.spec.ts new file mode 100644 index 00000000000..0ec793d8e5a --- /dev/null +++ b/libs/payments/management/src/lib/freeAccess.spec.ts @@ -0,0 +1,102 @@ +/* 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 { + ServiceResultFactory, + ServicesWithCapabilitiesQueryFactory, + ServicesWithCapabilitiesResult, + ServicesWithCapabilitiesResultUtil, +} from '@fxa/shared/cms'; + +import { buildFreeAccessForPage } from './freeAccess'; + +describe('buildFreeAccessForPage', () => { + function catalog( + services: Parameters[0][] = [] + ): ServicesWithCapabilitiesResultUtil { + const result = ServicesWithCapabilitiesQueryFactory({ + services: services.map((s) => ServiceResultFactory(s)), + }); + return new ServicesWithCapabilitiesResultUtil( + result as ServicesWithCapabilitiesResult + ); + } + + it('returns an empty list when there is no free access', () => { + expect(buildFreeAccessForPage({}, catalog(), [])).toEqual([]); + }); + + it('emits one card per clientId with sorted capabilities', () => { + const result = buildFreeAccessForPage( + { vpn: ['pro', 'mobile'] }, + catalog([ + { + oauthClientId: 'vpn', + internalName: 'Mozilla VPN', + description: 'Secure your connection.', + }, + ]), + [] + ); + + expect(result).toEqual([ + { + clientId: 'vpn', + displayName: 'Mozilla VPN', + description: 'Secure your connection.', + capabilities: ['mobile', 'pro'], + }, + ]); + }); + + it('falls back to clientId and null description when the catalog has no entry', () => { + const result = buildFreeAccessForPage( + { 'unknown-client': ['some-cap'] }, + catalog([]), + [] + ); + + expect(result).toEqual([ + { + clientId: 'unknown-client', + displayName: 'unknown-client', + description: null, + capabilities: ['some-cap'], + }, + ]); + }); + + it('hides free-access cards when the user is already subscribed (case-insensitive)', () => { + const result = buildFreeAccessForPage( + { VPN: ['pro'], relay: ['premium'] }, + catalog([ + { oauthClientId: 'vpn', internalName: 'Mozilla VPN' }, + { oauthClientId: 'relay', internalName: 'Firefox Relay' }, + ]), + ['Vpn'] + ); + + expect(result.map((e) => e.clientId)).toEqual(['relay']); + }); + + it('returns deterministic order sorted by clientId', () => { + const result = buildFreeAccessForPage( + { relay: ['premium'], vpn: ['pro'], mdn: ['plus'] }, + catalog([]), + [] + ); + + expect(result.map((e) => e.clientId)).toEqual(['mdn', 'relay', 'vpn']); + }); + + it('skips clientIds whose free-access grant has no capabilities', () => { + const result = buildFreeAccessForPage( + { vpn: [] }, + catalog([{ oauthClientId: 'vpn' }]), + [] + ); + + expect(result).toEqual([]); + }); +}); diff --git a/libs/payments/management/src/lib/freeAccess.ts b/libs/payments/management/src/lib/freeAccess.ts new file mode 100644 index 00000000000..e6a4ee8d3ae --- /dev/null +++ b/libs/payments/management/src/lib/freeAccess.ts @@ -0,0 +1,47 @@ +/* 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 { ServicesWithCapabilitiesResultUtil } from '@fxa/shared/cms'; + +export interface FreeAccessContent { + clientId: string; + displayName: string; + description: string | null; + capabilities: string[]; +} + +/** + * Pure helper: from the user's email-resolved `{ clientId → capabilities[] }` + * map, produce the list of free-access cards to render on the subscription + * management page. Skips any clientId already covered by an active + * subscription/trial/IAP. Sorted by clientId for stable rendering. + */ +export function buildFreeAccessForPage( + capabilityMap: Record, + serviceCatalog: ServicesWithCapabilitiesResultUtil, + subscribedClientIds: Iterable +): FreeAccessContent[] { + const excluded = new Set(); + for (const id of subscribedClientIds) { + if (id) excluded.add(id.toLowerCase()); + } + + const out: FreeAccessContent[] = []; + for (const [rawClientId, capabilities] of Object.entries(capabilityMap)) { + const clientId = rawClientId.toLowerCase(); + if (excluded.has(clientId)) continue; + if (!capabilities || capabilities.length === 0) continue; + + const service = serviceCatalog.findServiceByClientId(clientId); + out.push({ + clientId, + displayName: service?.internalName ?? rawClientId, + description: service?.description ?? null, + capabilities: [...capabilities].sort(), + }); + } + + out.sort((a, b) => a.clientId.localeCompare(b.clientId)); + return out; +} diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts index f2d9b4293a3..078b6d65d6c 100644 --- a/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts +++ b/libs/payments/management/src/lib/subscriptionManagement.service.spec.ts @@ -88,9 +88,17 @@ import { ProductConfigurationManager, PageContentByPriceIdsResultUtil, PageContentByPriceIdsPurchaseResultFactory, + ServicesWithCapabilitiesQueryFactory, + ServicesWithCapabilitiesResult, + ServicesWithCapabilitiesResultUtil, + ServiceResultFactory, 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 +151,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 +185,12 @@ describe('SubscriptionManagementService', () => { GoogleIapPurchaseManager, InvoiceManager, LocationConfig, + FreeAccessProgramManager, MockAccountDatabaseNestFactory, MockAppleIapClientConfigProvider, MockCurrencyConfigProvider, MockFirestoreProvider, + MockFreeAccessProgramClientConfigProvider, MockGoogleIapClientConfigProvider, MockNotifierSnsConfigProvider, MockPaypalClientConfigProvider, @@ -224,6 +235,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 +513,7 @@ describe('SubscriptionManagementService', () => { trialSubscriptions: [], appleIapSubscriptions: [mockAppleIapSubscriptionContent], googleIapSubscriptions: [mockGoogleIapSubscriptionContent], + freeAccess: [], }); }); @@ -542,6 +555,7 @@ describe('SubscriptionManagementService', () => { trialSubscriptions: [], appleIapSubscriptions: [], googleIapSubscriptions: [], + freeAccess: [], }); }); @@ -593,6 +607,7 @@ describe('SubscriptionManagementService', () => { trialSubscriptions: [], appleIapSubscriptions: [], googleIapSubscriptions: [], + freeAccess: [], }); }); @@ -762,6 +777,7 @@ describe('SubscriptionManagementService', () => { trialSubscriptions: [], appleIapSubscriptions: [mockAppleIapSubscriptionContent], googleIapSubscriptions: [mockGoogleIapSubscriptionContent], + freeAccess: [], }); }); @@ -1336,6 +1352,103 @@ 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 mockServicesCatalog( + services: Parameters[0][] + ) { + const util = new ServicesWithCapabilitiesResultUtil( + ServicesWithCapabilitiesQueryFactory({ + services: services.map((s) => ServiceResultFactory(s)), + }) as ServicesWithCapabilitiesResult + ); + jest + .spyOn(productConfigurationManager, 'getServicesWithCapabilities') + .mockResolvedValue(util); + } + + function mockFreeAccessForEmail( + capabilitiesByClientId: Record + ) { + jest + .spyOn(freeAccessProgramManager, 'findCapabilitiesForEmail') + .mockResolvedValue(capabilitiesByClientId); + } + + 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 is not on the allowlist', async () => { + const mockUid = mockEmptyAccountFlow(); + mockFreeAccessForEmail({}); + + const result = await subscriptionManagementService.getPageContent( + mockUid, + undefined, + undefined, + 'user@example.com' + ); + expect(result.freeAccess).toEqual([]); + }); + + it('returns one free-access card per granted clientId, decorated from the service catalog', async () => { + const mockUid = mockEmptyAccountFlow(); + mockFreeAccessForEmail({ vpn: ['pro'] }); + mockServicesCatalog([ + { + oauthClientId: 'vpn', + internalName: 'Mozilla VPN', + description: 'Encrypted tunnels.', + }, + ]); + + const result = await subscriptionManagementService.getPageContent( + mockUid, + undefined, + undefined, + 'user@example.com' + ); + expect(result.freeAccess).toEqual([ + { + clientId: 'vpn', + displayName: 'Mozilla VPN', + description: 'Encrypted tunnels.', + capabilities: ['pro'], + }, + ]); + }); + }); }); describe('getTrialSubscriptionContent', () => { diff --git a/libs/payments/management/src/lib/subscriptionManagement.service.ts b/libs/payments/management/src/lib/subscriptionManagement.service.ts index 3bddde6fea4..a05156f63a4 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, @@ -87,6 +88,10 @@ import { PaypalCustomerManager, ResultPaypalCustomer, } from '@fxa/payments/paypal'; +import { + buildFreeAccessForPage, + type FreeAccessContent, +} from './freeAccess'; import { ChurnInterventionService } from './churn-intervention.service'; @Injectable() @@ -99,6 +104,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 +227,8 @@ export class SubscriptionManagementService { async getPageContent( uid: string, acceptLanguage?: string, - selectedLanguage?: string + selectedLanguage?: string, + email?: string ) { const subscriptions: SubscriptionContent[] = []; const appleIapSubscriptions: AppleIapSubscriptionContent[] = []; @@ -282,6 +289,7 @@ export class SubscriptionManagementService { const hasGoogleIap = googleIapSubs.purchaseDetails.length > 0; if (!hasStripe && !hasAppleIap && !hasGoogleIap) { + const freeAccess = await this.resolveFreeAccess(email, []); return { accountCreditBalance, defaultPaymentMethod, @@ -290,6 +298,7 @@ export class SubscriptionManagementService { trialSubscriptions: [], appleIapSubscriptions: [], googleIapSubscriptions: [], + freeAccess, }; } @@ -420,6 +429,8 @@ export class SubscriptionManagementService { } } + const freeAccess = await this.resolveFreeAccess(email, stripeSubs); + return { accountCreditBalance, defaultPaymentMethod, @@ -428,9 +439,52 @@ export class SubscriptionManagementService { trialSubscriptions, appleIapSubscriptions, googleIapSubscriptions, + freeAccess, }; } + private async resolveFreeAccess( + email: string | undefined, + stripeSubs: StripeSubscription[] + ): Promise { + if (!email) return []; + const map = await this.freeAccessProgramManager.findCapabilitiesForEmail( + email + ); + if (Object.keys(map).length === 0) return []; + + const subscribedClientIds = new Set(); + if (stripeSubs.length > 0) { + const stripePriceIds = stripeSubs.flatMap((sub) => + sub.items.data.map((item) => item.price.id) + ); + if (stripePriceIds.length > 0) { + const capabilityMap = + await this.productConfigurationManager.getPurchaseDetailsForCapabilityServiceByPlanIds( + stripePriceIds + ); + for (const priceId of stripePriceIds) { + const offering = capabilityMap.capabilityOfferingForPlanId(priceId); + for (const capability of offering?.capabilities ?? []) { + for (const service of capability.services ?? []) { + if (service?.oauthClientId) { + subscribedClientIds.add(service.oauthClientId); + } + } + } + } + } + } + + const serviceCatalog = + await this.productConfigurationManager.getServicesWithCapabilities(); + return buildFreeAccessForPage( + map, + serviceCatalog, + subscribedClientIds + ); + } + private async getAppleIapPurchases(uid: string) { const purchases: AppleIapPurchaseResult = { storeIds: [], 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..acd3750c1d0 --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/FreeAccessContent/index.test.tsx @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { render, screen } from '@testing-library/react'; + +jest.mock('@fluent/react', () => ({ + __esModule: true, + Localized: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ alt, className }: { alt?: string; className?: string }) => ( + {alt + ), +})); + +import { FreeAccessContent } from './index'; + +describe('FreeAccessContent', () => { + const baseFreeAccess = { + clientId: 'vpn', + displayName: 'Mozilla VPN', + description: null as string | null, + capabilities: ['pro'], + }; + + it('renders the displayName 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..e7a8cee86ef --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/FreeAccessContent/index.tsx @@ -0,0 +1,52 @@ +/* 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 { + clientId: string; + displayName: string; + description?: string | null; + capabilities: string[]; +} + +interface FreeAccessContentProps { + freeAccess: FreeAccess; +} + +export const FreeAccessContent = ({ + freeAccess, +}: FreeAccessContentProps) => { + const { displayName, description } = freeAccess; + + return ( +
+
+

{displayName}

+
+
+
+ + +

+ 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..fb6733968f9 100644 --- a/libs/payments/ui/src/lib/nestapp/validators/GetSubManPageContentActionResult.ts +++ b/libs/payments/ui/src/lib/nestapp/validators/GetSubManPageContentActionResult.ts @@ -4,6 +4,7 @@ import { Type } from 'class-transformer'; import { + IsArray, IsBoolean, IsEnum, IsNumber, @@ -170,6 +171,22 @@ class AppleIapSubscriptionContent { storeId!: string; } +class FreeAccess { + @IsString() + clientId!: string; + + @IsString() + displayName!: string; + + @IsOptional() + @IsString() + description?: string | null; + + @IsArray() + @IsString({ each: true }) + capabilities!: string[]; +} + class GoogleIapSubscriptionContent { @IsBoolean() autoRenewing!: boolean; @@ -218,4 +235,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/account/account/src/lib/account.manager.ts b/libs/shared/account/account/src/lib/account.manager.ts index 8a55d3d0343..fe2358b8922 100644 --- a/libs/shared/account/account/src/lib/account.manager.ts +++ b/libs/shared/account/account/src/lib/account.manager.ts @@ -13,7 +13,7 @@ import { verifyAccountSession, VerificationMethods, } from './account.repository'; -import { normalizeEmail, randomBytesAsync } from './account.util'; +import { randomBytesAsync, normalizeEmail } from './account.util'; import { uuidTransformer } from '@fxa/shared/db/mysql/core'; @Injectable() diff --git a/libs/shared/cms/src/__generated__/gql.ts b/libs/shared/cms/src/__generated__/gql.ts index 0a83b3024cc..17784064765 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 capabilities {\n slug\n services {\n oauthClientId\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, @@ -30,7 +31,7 @@ type Documents = { "\n query PageContentForOffering($locale: String!, $apiIdentifier: String!) {\n offerings(\n filters: { apiIdentifier: { eq: $apiIdentifier } }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n countries\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n": typeof types.PageContentForOfferingDocument, "\n query PurchaseWithDetailsOfferingContent(\n $locale: String!\n $stripePlanIds: [String]!\n ) {\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: 500 }\n ) {\n stripePlanChoices {\n stripePlanChoice\n }\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n offering {\n stripeProductId\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n }\n": typeof types.PurchaseWithDetailsOfferingContentDocument, "\n query RelyingParties($clientId: String!, $entrypoint: String!) {\n relyingParties(\n filters: { clientId: { eq: $clientId }, entrypoint: { eq: $entrypoint } }\n ) {\n clientId\n entrypoint\n name\n l10nId\n shared {\n buttonColor\n logoUrl\n logoAltText\n emailFromName\n emailLogoUrl\n emailLogoAltText\n emailLogoWidth\n pageTitle\n headerLogoUrl\n headerLogoAltText\n featureFlags {\n syncConfirmedPageHideCTA\n syncHidePromoAfterLogin\n }\n backgrounds {\n defaultLayout\n header\n splitLayout\n splitLayoutAltText\n }\n illustrationsTheme {\n primary\n primaryAlt\n secondary\n accentBg\n accentFg\n cloudPrimary\n cloudShadow\n hideClouds\n }\n favicon\n headlineFontSize\n headlineTextColor\n additionalAccessibilityInfo\n }\n EmailFirstPage {\n logoUrl\n logoAltText\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SignupSetPasswordPage {\n logoUrl\n logoAltText\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SignupConfirmCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SignupConfirmedSyncPage {\n headline\n description\n primaryButtonText\n pageTitle\n primaryImage {\n url\n altText\n }\n splitLayout\n }\n SignupPasswordlessCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n primaryImage {\n url\n altText\n }\n splitLayout\n }\n SigninPage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninCachedPage {\n logoUrl\n logoAltText\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninPasswordlessCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n primaryImage {\n url\n altText\n }\n splitLayout\n }\n SigninTokenCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninUnblockCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninTotpCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninRecoveryChoicePage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninRecoveryCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n primaryImage {\n url\n altText\n }\n splitLayout\n }\n SigninRecoveryPhonePage {\n headline\n description\n primaryButtonText\n pageTitle\n primaryImage {\n url\n altText\n }\n splitLayout\n }\n PostVerifySetPasswordPage {\n headline\n description\n primaryButtonText\n pageTitle\n }\n NewDeviceLoginEmail {\n subject\n headline\n description\n }\n PasswordlessSigninOtpEmail {\n subject\n headline\n description\n }\n PasswordlessSignupOtpEmail {\n subject\n headline\n description\n }\n VerifyLoginCodeEmail {\n subject\n headline\n description\n }\n VerifyShortCodeEmail {\n subject\n headline\n description\n }\n }\n }\n": typeof types.RelyingPartiesDocument, - "\n query ServicesWithCapabilities {\n services(pagination: { limit: 500 }) {\n oauthClientId\n capabilities {\n slug\n }\n }\n }\n": typeof types.ServicesWithCapabilitiesDocument, + "\n query ServicesWithCapabilities {\n services(pagination: { limit: 500 }) {\n oauthClientId\n internalName\n description\n capabilities {\n slug\n }\n }\n }\n": typeof types.ServicesWithCapabilitiesDocument, "\n query ValidationOfferings {\n offerings(pagination: { limit: 500 }) {\n apiIdentifier\n stripeProductId\n countries\n defaultPurchase {\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n }\n stripePlanChoices {\n stripePlanChoice\n }\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n supportUrl\n }\n capabilities {\n slug\n services {\n oauthClientId\n }\n }\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n couponConfig {\n internalName\n stripePromotionCodes {\n PromoCode\n }\n }\n subGroups {\n internalName\n groupName\n }\n }\n }\n": typeof types.ValidationOfferingsDocument, "\n query ValidationPurchases {\n purchases(pagination: { limit: 500 }) {\n internalName\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n }\n stripePlanChoices {\n stripePlanChoice\n }\n }\n }\n": typeof types.ValidationPurchasesDocument, "\n query ValidationPurchaseDetails {\n purchaseDetails(pagination: { limit: 500 }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n": typeof types.ValidationPurchaseDetailsDocument, @@ -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 capabilities {\n slug\n services {\n oauthClientId\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, @@ -60,7 +62,7 @@ const documents: Documents = { "\n query PageContentForOffering($locale: String!, $apiIdentifier: String!) {\n offerings(\n filters: { apiIdentifier: { eq: $apiIdentifier } }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n countries\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n": types.PageContentForOfferingDocument, "\n query PurchaseWithDetailsOfferingContent(\n $locale: String!\n $stripePlanIds: [String]!\n ) {\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: 500 }\n ) {\n stripePlanChoices {\n stripePlanChoice\n }\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n offering {\n stripeProductId\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n }\n": types.PurchaseWithDetailsOfferingContentDocument, "\n query RelyingParties($clientId: String!, $entrypoint: String!) {\n relyingParties(\n filters: { clientId: { eq: $clientId }, entrypoint: { eq: $entrypoint } }\n ) {\n clientId\n entrypoint\n name\n l10nId\n shared {\n buttonColor\n logoUrl\n logoAltText\n emailFromName\n emailLogoUrl\n emailLogoAltText\n emailLogoWidth\n pageTitle\n headerLogoUrl\n headerLogoAltText\n featureFlags {\n syncConfirmedPageHideCTA\n syncHidePromoAfterLogin\n }\n backgrounds {\n defaultLayout\n header\n splitLayout\n splitLayoutAltText\n }\n illustrationsTheme {\n primary\n primaryAlt\n secondary\n accentBg\n accentFg\n cloudPrimary\n cloudShadow\n hideClouds\n }\n favicon\n headlineFontSize\n headlineTextColor\n additionalAccessibilityInfo\n }\n EmailFirstPage {\n logoUrl\n logoAltText\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SignupSetPasswordPage {\n logoUrl\n logoAltText\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SignupConfirmCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SignupConfirmedSyncPage {\n headline\n description\n primaryButtonText\n pageTitle\n primaryImage {\n url\n altText\n }\n splitLayout\n }\n SignupPasswordlessCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n primaryImage {\n url\n altText\n }\n splitLayout\n }\n SigninPage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninCachedPage {\n logoUrl\n logoAltText\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninPasswordlessCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n primaryImage {\n url\n altText\n }\n splitLayout\n }\n SigninTokenCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninUnblockCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninTotpCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninRecoveryChoicePage {\n headline\n description\n primaryButtonText\n pageTitle\n splitLayout\n }\n SigninRecoveryCodePage {\n headline\n description\n primaryButtonText\n pageTitle\n primaryImage {\n url\n altText\n }\n splitLayout\n }\n SigninRecoveryPhonePage {\n headline\n description\n primaryButtonText\n pageTitle\n primaryImage {\n url\n altText\n }\n splitLayout\n }\n PostVerifySetPasswordPage {\n headline\n description\n primaryButtonText\n pageTitle\n }\n NewDeviceLoginEmail {\n subject\n headline\n description\n }\n PasswordlessSigninOtpEmail {\n subject\n headline\n description\n }\n PasswordlessSignupOtpEmail {\n subject\n headline\n description\n }\n VerifyLoginCodeEmail {\n subject\n headline\n description\n }\n VerifyShortCodeEmail {\n subject\n headline\n description\n }\n }\n }\n": types.RelyingPartiesDocument, - "\n query ServicesWithCapabilities {\n services(pagination: { limit: 500 }) {\n oauthClientId\n capabilities {\n slug\n }\n }\n }\n": types.ServicesWithCapabilitiesDocument, + "\n query ServicesWithCapabilities {\n services(pagination: { limit: 500 }) {\n oauthClientId\n internalName\n description\n capabilities {\n slug\n }\n }\n }\n": types.ServicesWithCapabilitiesDocument, "\n query ValidationOfferings {\n offerings(pagination: { limit: 500 }) {\n apiIdentifier\n stripeProductId\n countries\n defaultPurchase {\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n }\n stripePlanChoices {\n stripePlanChoice\n }\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n supportUrl\n }\n capabilities {\n slug\n services {\n oauthClientId\n }\n }\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n couponConfig {\n internalName\n stripePromotionCodes {\n PromoCode\n }\n }\n subGroups {\n internalName\n groupName\n }\n }\n }\n": types.ValidationOfferingsDocument, "\n query ValidationPurchases {\n purchases(pagination: { limit: 500 }) {\n internalName\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n }\n stripePlanChoices {\n stripePlanChoice\n }\n }\n }\n": types.ValidationPurchasesDocument, "\n query ValidationPurchaseDetails {\n purchaseDetails(pagination: { limit: 500 }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n": types.ValidationPurchaseDetailsDocument, @@ -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 capabilities {\n slug\n services {\n oauthClientId\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 capabilities {\n slug\n services {\n oauthClientId\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. */ @@ -155,7 +161,7 @@ export function graphql(source: "\n query RelyingParties($clientId: String!, $e /** * 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 ServicesWithCapabilities {\n services(pagination: { limit: 500 }) {\n oauthClientId\n capabilities {\n slug\n }\n }\n }\n"): (typeof documents)["\n query ServicesWithCapabilities {\n services(pagination: { limit: 500 }) {\n oauthClientId\n capabilities {\n slug\n }\n }\n }\n"]; +export function graphql(source: "\n query ServicesWithCapabilities {\n services(pagination: { limit: 500 }) {\n oauthClientId\n internalName\n description\n capabilities {\n slug\n }\n }\n }\n"): (typeof documents)["\n query ServicesWithCapabilities {\n services(pagination: { limit: 500 }) {\n oauthClientId\n internalName\n description\n capabilities {\n slug\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..1ff9c9c897c 100644 --- a/libs/shared/cms/src/__generated__/graphql.ts +++ b/libs/shared/cms/src/__generated__/graphql.ts @@ -14,6 +14,7 @@ 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 */ @@ -22,6 +23,70 @@ export type Scalars = { JSON: { input: any; output: any; } }; +export type Access = { + __typename?: 'Access'; + capabilities: Array>; + capabilities_connection: Maybe; + createdAt: Maybe; + description: Maybe; + documentId: Scalars['ID']['output']; + freeAccessProgram: Maybe; + internalName: Scalars['String']['output']; + matchers: Array>; + publishedAt: Maybe; + updatedAt: Maybe; +}; + + +export type AccessCapabilitiesArgs = { + filters: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe>>; +}; + + +export type AccessCapabilities_ConnectionArgs = { + filters: InputMaybe; + pagination?: InputMaybe; + sort?: InputMaybe>>; +}; + +export type AccessEntityResponseCollection = { + __typename?: 'AccessEntityResponseCollection'; + nodes: Array; + pageInfo: Pagination; +}; + +export type AccessFiltersInput = { + and: InputMaybe>>; + capabilities: InputMaybe; + createdAt: InputMaybe; + description: InputMaybe; + documentId: InputMaybe; + freeAccessProgram: InputMaybe; + internalName: InputMaybe; + not: InputMaybe; + or: InputMaybe>>; + publishedAt: InputMaybe; + updatedAt: InputMaybe; +}; + +export type AccessInput = { + capabilities: InputMaybe>>; + description: InputMaybe; + freeAccessProgram: InputMaybe; + internalName: InputMaybe; + matchers: InputMaybe>; + publishedAt: InputMaybe; +}; + +export type AccessMatchersDynamicZone = ComponentMatchersEmailList | Error; + +export type AccessRelationResponseCollection = { + __typename?: 'AccessRelationResponseCollection'; + nodes: Array; +}; + export type BooleanFilterInput = { and: InputMaybe>>; between: InputMaybe>>; @@ -713,6 +778,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']; @@ -919,6 +990,11 @@ export enum Enum_Meter_Window { Monthly = 'monthly', Weekly = 'weekly' } +export type Error = { + __typename?: 'Error'; + code: Scalars['String']['output']; + message: Maybe; +}; export type FileInfoInput = { alternativeText: InputMaybe; @@ -951,6 +1027,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 +1145,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 | ComponentIapAppleProductIDs | ComponentIapGoogleSkUs | ComponentIapStripeLegacyIapPrices | ComponentIapStripePlanChoices | ComponentMatchersEmailList | ComponentStripeStripeLegacyPlans | ComponentStripeStripePlanChoices | ComponentStripeStripePromoCodes | CouponConfig | Default | FreeAccessProgram | FreeTrial | I18NLocale | Iap | LegalNotice | Offering | Purchase | PurchaseDetail | RelyingParty | ReviewWorkflowsWorkflow | ReviewWorkflowsWorkflowStage | Service | Subgroup | UploadFile | UsersPermissionsPermission | UsersPermissionsRole | UsersPermissionsUser; export type I18NLocale = { __typename?: 'I18NLocale'; @@ -1261,11 +1389,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 +1412,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 +1446,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 +1481,12 @@ export type MutationChangePasswordArgs = { }; +export type MutationCreateAccessArgs = { + data: AccessInput; + status?: InputMaybe; +}; + + export type MutationCreateCancelInterstitialOfferArgs = { data: CancelInterstitialOfferInput; locale: InputMaybe; @@ -1380,6 +1520,12 @@ export type MutationCreateCouponConfigArgs = { }; +export type MutationCreateFreeAccessProgramArgs = { + data: FreeAccessProgramInput; + status?: InputMaybe; +}; + + export type MutationCreateFreeTrialArgs = { data: FreeTrialInput; status?: InputMaybe; @@ -1463,6 +1609,11 @@ export type MutationCreateUsersPermissionsUserArgs = { }; +export type MutationDeleteAccessArgs = { + documentId: Scalars['ID']['input']; +}; + + export type MutationDeleteCancelInterstitialOfferArgs = { documentId: Scalars['ID']['input']; locale: InputMaybe; @@ -1496,6 +1647,11 @@ export type MutationDeleteDefaultArgs = { }; +export type MutationDeleteFreeAccessProgramArgs = { + documentId: Scalars['ID']['input']; +}; + + export type MutationDeleteFreeTrialArgs = { documentId: Scalars['ID']['input']; }; @@ -1599,6 +1755,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 +1807,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 +2233,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 +2252,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 +2307,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 +2473,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 +3514,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, capabilities: Array<{ __typename?: 'Capability', slug: string, services: Array<{ __typename?: 'Service', oauthClientId: string } | null> } | null>, matchers: Array<{ __typename: 'ComponentMatchersEmailList', emails: any } | { __typename: 'Error' } | null> } | null> }; + export type CancelInterstitialOfferQueryVariables = Exact<{ offeringApiIdentifier: Scalars['String']['input']; currentInterval: Scalars['String']['input']; @@ -3412,7 +3643,7 @@ export type RelyingPartiesQuery = { __typename?: 'Query', relyingParties: Array< export type ServicesWithCapabilitiesQueryVariables = Exact<{ [key: string]: never; }>; -export type ServicesWithCapabilitiesQuery = { __typename?: 'Query', services: Array<{ __typename?: 'Service', oauthClientId: string, capabilities: Array<{ __typename?: 'Capability', slug: string } | null> } | null> }; +export type ServicesWithCapabilitiesQuery = { __typename?: 'Query', services: Array<{ __typename?: 'Service', oauthClientId: string, internalName: string, description: string | null, capabilities: Array<{ __typename?: 'Capability', slug: string } | null> } | null> }; export type ValidationOfferingsQueryVariables = Exact<{ [key: string]: never; }>; @@ -3470,6 +3701,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":"capabilities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"oauthClientId"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"matchers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ComponentMatchersEmailList"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"emails"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const CancelInterstitialOfferDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CancelInterstitialOffer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offeringApiIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"currentInterval"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"upgradeInterval"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelInterstitialOffers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"offeringApiIdentifier"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offeringApiIdentifier"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"currentInterval"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"currentInterval"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"upgradeInterval"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"upgradeInterval"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"offeringApiIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"currentInterval"}},{"kind":"Field","name":{"kind":"Name","value":"upgradeInterval"}},{"kind":"Field","name":{"kind":"Name","value":"modalHeading1"}},{"kind":"Field","name":{"kind":"Name","value":"modalMessage"}},{"kind":"Field","name":{"kind":"Name","value":"productPageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"upgradeButtonLabel"}},{"kind":"Field","name":{"kind":"Name","value":"upgradeButtonUrl"}},{"kind":"Field","name":{"kind":"Name","value":"localizations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"locale"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modalHeading1"}},{"kind":"Field","name":{"kind":"Name","value":"modalMessage"}},{"kind":"Field","name":{"kind":"Name","value":"productPageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"upgradeButtonLabel"}},{"kind":"Field","name":{"kind":"Name","value":"upgradeButtonUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"offering"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeProductId"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPurchase"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"purchaseDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}},{"kind":"Field","name":{"kind":"Name","value":"localizations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"locale"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const CapabilityServiceByPlanIdsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CapabilityServiceByPlanIds"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stripePlanIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"purchases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripePlanChoices"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripePlanChoice"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"in"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stripePlanIds"}}}]}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"offering"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripeLegacyPlans"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripeLegacyPlan"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"in"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stripePlanIds"}}}]}}]}}]}}]}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePlanChoices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePlanChoice"}}]}},{"kind":"Field","name":{"kind":"Name","value":"offering"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeLegacyPlans"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeLegacyPlan"}}]}},{"kind":"Field","name":{"kind":"Name","value":"capabilities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"oauthClientId"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ChurnInterventionByProductIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ChurnInterventionByProductId"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offeringApiIdentifier"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stripeProductId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"interval"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"churnType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"offerings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripeProductId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stripeProductId"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"apiIdentifier"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offeringApiIdentifier"}}}]}}]}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"defaultPurchase"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"purchaseDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}},{"kind":"Field","name":{"kind":"Name","value":"localizations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"locale"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"commonContent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"successActionButtonUrl"}},{"kind":"Field","name":{"kind":"Name","value":"supportUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"churnInterventions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"interval"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"interval"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"churnType"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"churnType"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"localizations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"locale"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"churnInterventionId"}},{"kind":"Field","name":{"kind":"Name","value":"churnType"}},{"kind":"Field","name":{"kind":"Name","value":"redemptionLimit"}},{"kind":"Field","name":{"kind":"Name","value":"stripeCouponId"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"discountAmount"}},{"kind":"Field","name":{"kind":"Name","value":"ctaMessage"}},{"kind":"Field","name":{"kind":"Name","value":"modalHeading"}},{"kind":"Field","name":{"kind":"Name","value":"modalMessage"}},{"kind":"Field","name":{"kind":"Name","value":"productPageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsHeading"}},{"kind":"Field","name":{"kind":"Name","value":"termsDetails"}}]}},{"kind":"Field","name":{"kind":"Name","value":"churnInterventionId"}},{"kind":"Field","name":{"kind":"Name","value":"churnType"}},{"kind":"Field","name":{"kind":"Name","value":"redemptionLimit"}},{"kind":"Field","name":{"kind":"Name","value":"stripeCouponId"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"discountAmount"}},{"kind":"Field","name":{"kind":"Name","value":"ctaMessage"}},{"kind":"Field","name":{"kind":"Name","value":"modalHeading"}},{"kind":"Field","name":{"kind":"Name","value":"modalMessage"}},{"kind":"Field","name":{"kind":"Name","value":"productPageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsHeading"}},{"kind":"Field","name":{"kind":"Name","value":"termsDetails"}}]}}]}}]}}]} as unknown as DocumentNode; @@ -3486,7 +3718,7 @@ export const PageContentByPriceIdsDocument = {"kind":"Document","definitions":[{ export const PageContentForOfferingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PageContentForOffering"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"apiIdentifier"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"offerings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"apiIdentifier"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"apiIdentifier"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"countries"}},{"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":"details"}},{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"subtitle"}},{"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":"details"}},{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"subtitle"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"commonContent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"privacyNoticeUrl"}},{"kind":"Field","name":{"kind":"Name","value":"privacyNoticeDownloadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfServiceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfServiceDownloadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"cancellationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"emailIcon"}},{"kind":"Field","name":{"kind":"Name","value":"successActionButtonUrl"}},{"kind":"Field","name":{"kind":"Name","value":"successActionButtonLabel"}},{"kind":"Field","name":{"kind":"Name","value":"newsletterLabelTextCode"}},{"kind":"Field","name":{"kind":"Name","value":"newsletterSlug"}},{"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":"privacyNoticeUrl"}},{"kind":"Field","name":{"kind":"Name","value":"privacyNoticeDownloadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfServiceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfServiceDownloadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"cancellationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"emailIcon"}},{"kind":"Field","name":{"kind":"Name","value":"successActionButtonUrl"}},{"kind":"Field","name":{"kind":"Name","value":"successActionButtonLabel"}},{"kind":"Field","name":{"kind":"Name","value":"newsletterLabelTextCode"}},{"kind":"Field","name":{"kind":"Name","value":"newsletterSlug"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const PurchaseWithDetailsOfferingContentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PurchaseWithDetailsOfferingContent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stripePlanIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"purchases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripePlanChoices"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripePlanChoice"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"in"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stripePlanIds"}}}]}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"offering"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripeLegacyPlans"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"stripeLegacyPlan"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"in"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stripePlanIds"}}}]}}]}}]}}]}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePlanChoices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePlanChoice"}}]}},{"kind":"Field","name":{"kind":"Name","value":"purchaseDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"details"}},{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"subtitle"}},{"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":"details"}},{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"subtitle"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"offering"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeProductId"}},{"kind":"Field","name":{"kind":"Name","value":"stripeLegacyPlans"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeLegacyPlan"}}]}},{"kind":"Field","name":{"kind":"Name","value":"commonContent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"privacyNoticeUrl"}},{"kind":"Field","name":{"kind":"Name","value":"privacyNoticeDownloadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfServiceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfServiceDownloadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"cancellationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"emailIcon"}},{"kind":"Field","name":{"kind":"Name","value":"successActionButtonUrl"}},{"kind":"Field","name":{"kind":"Name","value":"successActionButtonLabel"}},{"kind":"Field","name":{"kind":"Name","value":"newsletterLabelTextCode"}},{"kind":"Field","name":{"kind":"Name","value":"newsletterSlug"}},{"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":"privacyNoticeUrl"}},{"kind":"Field","name":{"kind":"Name","value":"privacyNoticeDownloadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfServiceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfServiceDownloadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"cancellationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"emailIcon"}},{"kind":"Field","name":{"kind":"Name","value":"successActionButtonUrl"}},{"kind":"Field","name":{"kind":"Name","value":"successActionButtonLabel"}},{"kind":"Field","name":{"kind":"Name","value":"newsletterLabelTextCode"}},{"kind":"Field","name":{"kind":"Name","value":"newsletterSlug"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const RelyingPartiesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"RelyingParties"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"clientId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entrypoint"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"relyingParties"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"clientId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"clientId"}}}]}},{"kind":"ObjectField","name":{"kind":"Name","value":"entrypoint"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entrypoint"}}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientId"}},{"kind":"Field","name":{"kind":"Name","value":"entrypoint"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"l10nId"}},{"kind":"Field","name":{"kind":"Name","value":"shared"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"buttonColor"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"logoAltText"}},{"kind":"Field","name":{"kind":"Name","value":"emailFromName"}},{"kind":"Field","name":{"kind":"Name","value":"emailLogoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"emailLogoAltText"}},{"kind":"Field","name":{"kind":"Name","value":"emailLogoWidth"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"headerLogoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"headerLogoAltText"}},{"kind":"Field","name":{"kind":"Name","value":"featureFlags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"syncConfirmedPageHideCTA"}},{"kind":"Field","name":{"kind":"Name","value":"syncHidePromoAfterLogin"}}]}},{"kind":"Field","name":{"kind":"Name","value":"backgrounds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"defaultLayout"}},{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayoutAltText"}}]}},{"kind":"Field","name":{"kind":"Name","value":"illustrationsTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"primary"}},{"kind":"Field","name":{"kind":"Name","value":"primaryAlt"}},{"kind":"Field","name":{"kind":"Name","value":"secondary"}},{"kind":"Field","name":{"kind":"Name","value":"accentBg"}},{"kind":"Field","name":{"kind":"Name","value":"accentFg"}},{"kind":"Field","name":{"kind":"Name","value":"cloudPrimary"}},{"kind":"Field","name":{"kind":"Name","value":"cloudShadow"}},{"kind":"Field","name":{"kind":"Name","value":"hideClouds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"favicon"}},{"kind":"Field","name":{"kind":"Name","value":"headlineFontSize"}},{"kind":"Field","name":{"kind":"Name","value":"headlineTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"additionalAccessibilityInfo"}}]}},{"kind":"Field","name":{"kind":"Name","value":"EmailFirstPage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"logoAltText"}},{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SignupSetPasswordPage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"logoAltText"}},{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SignupConfirmCodePage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SignupConfirmedSyncPage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"primaryImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"altText"}}]}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SignupPasswordlessCodePage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"primaryImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"altText"}}]}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SigninPage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SigninCachedPage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"logoAltText"}},{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SigninPasswordlessCodePage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"primaryImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"altText"}}]}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SigninTokenCodePage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SigninUnblockCodePage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SigninTotpCodePage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SigninRecoveryChoicePage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SigninRecoveryCodePage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"primaryImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"altText"}}]}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SigninRecoveryPhonePage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}},{"kind":"Field","name":{"kind":"Name","value":"primaryImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"altText"}}]}},{"kind":"Field","name":{"kind":"Name","value":"splitLayout"}}]}},{"kind":"Field","name":{"kind":"Name","value":"PostVerifySetPasswordPage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"primaryButtonText"}},{"kind":"Field","name":{"kind":"Name","value":"pageTitle"}}]}},{"kind":"Field","name":{"kind":"Name","value":"NewDeviceLoginEmail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"PasswordlessSigninOtpEmail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"PasswordlessSignupOtpEmail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"VerifyLoginCodeEmail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"VerifyShortCodeEmail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"headline"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]}}]} as unknown as DocumentNode; -export const ServicesWithCapabilitiesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ServicesWithCapabilities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"oauthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"capabilities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ServicesWithCapabilitiesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ServicesWithCapabilities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"oauthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"internalName"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"capabilities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]}}]}}]} as unknown as DocumentNode; export const ValidationOfferingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidationOfferings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"offerings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"stripeProductId"}},{"kind":"Field","name":{"kind":"Name","value":"countries"}},{"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":"details"}},{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"subtitle"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}}]}},{"kind":"Field","name":{"kind":"Name","value":"stripePlanChoices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePlanChoice"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"commonContent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"privacyNoticeUrl"}},{"kind":"Field","name":{"kind":"Name","value":"privacyNoticeDownloadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfServiceUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsOfServiceDownloadUrl"}},{"kind":"Field","name":{"kind":"Name","value":"cancellationUrl"}},{"kind":"Field","name":{"kind":"Name","value":"emailIcon"}},{"kind":"Field","name":{"kind":"Name","value":"successActionButtonUrl"}},{"kind":"Field","name":{"kind":"Name","value":"successActionButtonLabel"}},{"kind":"Field","name":{"kind":"Name","value":"newsletterLabelTextCode"}},{"kind":"Field","name":{"kind":"Name","value":"newsletterSlug"}},{"kind":"Field","name":{"kind":"Name","value":"supportUrl"}}]}},{"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":"stripeLegacyPlans"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"200"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeLegacyPlan"}}]}},{"kind":"Field","name":{"kind":"Name","value":"couponConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"internalName"}},{"kind":"Field","name":{"kind":"Name","value":"stripePromotionCodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"PromoCode"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"subGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"internalName"}},{"kind":"Field","name":{"kind":"Name","value":"groupName"}}]}}]}}]}}]} as unknown as DocumentNode; export const ValidationPurchasesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidationPurchases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"purchases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"internalName"}},{"kind":"Field","name":{"kind":"Name","value":"purchaseDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"details"}},{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"subtitle"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}}]}},{"kind":"Field","name":{"kind":"Name","value":"stripePlanChoices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePlanChoice"}}]}}]}}]}}]} as unknown as DocumentNode; export const ValidationPurchaseDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidationPurchaseDetails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"purchaseDetails"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"details"}},{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"subtitle"}},{"kind":"Field","name":{"kind":"Name","value":"webIcon"}}]}}]}}]} as unknown as DocumentNode; @@ -3497,4 +3729,4 @@ export const ValidationSubgroupsDocument = {"kind":"Document","definitions":[{"k export const ValidationIapsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidationIaps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"iaps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"storeID"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"offering"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiIdentifier"}}]}}]}}]}}]} as unknown as DocumentNode; export const ValidationChurnInterventionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidationChurnInterventions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"churnInterventions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"churnInterventionId"}},{"kind":"Field","name":{"kind":"Name","value":"churnType"}},{"kind":"Field","name":{"kind":"Name","value":"stripeCouponId"}},{"kind":"Field","name":{"kind":"Name","value":"interval"}},{"kind":"Field","name":{"kind":"Name","value":"discountAmount"}},{"kind":"Field","name":{"kind":"Name","value":"ctaMessage"}},{"kind":"Field","name":{"kind":"Name","value":"modalHeading"}},{"kind":"Field","name":{"kind":"Name","value":"modalMessage"}},{"kind":"Field","name":{"kind":"Name","value":"productPageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsHeading"}},{"kind":"Field","name":{"kind":"Name","value":"termsDetails"}},{"kind":"Field","name":{"kind":"Name","value":"redemptionLimit"}}]}}]}}]} as unknown as DocumentNode; export const ValidationCancelInterstitialOffersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidationCancelInterstitialOffers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelInterstitialOffers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"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":"offering"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeProductId"}}]}}]}}]}}]} as unknown as DocumentNode; -export const ValidationCouponConfigsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidationCouponConfigs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"couponConfigs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"internalName"}},{"kind":"Field","name":{"kind":"Name","value":"stripePromotionCodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"PromoCode"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const ValidationCouponConfigsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidationCouponConfigs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"couponConfigs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"500"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"internalName"}},{"kind":"Field","name":{"kind":"Name","value":"stripePromotionCodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"PromoCode"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/libs/shared/cms/src/index.ts b/libs/shared/cms/src/index.ts index 9663b141ca3..d16878f7006 100644 --- a/libs/shared/cms/src/index.ts +++ b/libs/shared/cms/src/index.ts @@ -10,6 +10,7 @@ export * from './lib/product-configuration.manager'; export * from './lib/relying-party-configuration.manager'; export * from './lib/default-configuration.manager'; export * from './lib/metering-configuration.manager'; +export * from './lib/queries/accesses'; export * from './lib/queries/cancel-interstitial-offer'; export * from './lib/queries/capability-service-by-plan-ids'; export * from './lib/queries/churn-intervention-by-product-id'; diff --git a/libs/shared/cms/src/lib/product-configuration.manager.spec.ts b/libs/shared/cms/src/lib/product-configuration.manager.spec.ts index d74dbd5aa1e..7c2d16078c6 100644 --- a/libs/shared/cms/src/lib/product-configuration.manager.spec.ts +++ b/libs/shared/cms/src/lib/product-configuration.manager.spec.ts @@ -786,4 +786,5 @@ describe('productConfigurationManager', () => { expect(result.freeTrial.freeTrials).toHaveLength(1); }); }); + }); diff --git a/libs/shared/cms/src/lib/queries/accesses/factories.ts b/libs/shared/cms/src/lib/queries/accesses/factories.ts new file mode 100644 index 00000000000..dcec93f9d40 --- /dev/null +++ b/libs/shared/cms/src/lib/queries/accesses/factories.ts @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { faker } from '@faker-js/faker'; + +import { AccessesQuery } from '../../../__generated__/graphql'; + +type Access = NonNullable; +type Capability = NonNullable; +type Service = NonNullable; +type Matcher = NonNullable; + +export const AccessServiceFactory = ( + override?: Partial +): Service => ({ + __typename: 'Service', + oauthClientId: faker.string.alphanumeric({ length: 16 }), + ...override, +}); + +export const AccessCapabilityFactory = ( + override?: Partial +): Capability => ({ + __typename: 'Capability', + slug: `cap-${faker.string.alphanumeric({ length: 8 })}`, + services: [AccessServiceFactory()], + ...override, +}); + +export const AccessEmailListMatcherFactory = ( + override?: Partial< + Extract + > +): Matcher => ({ + __typename: 'ComponentMatchersEmailList', + emails: [faker.internet.email().toLowerCase()], + ...override, +}); + +export const AccessResultFactory = ( + override?: Partial +): Access => ({ + __typename: 'Access', + documentId: faker.string.uuid(), + internalName: faker.company.name(), + capabilities: [AccessCapabilityFactory()], + matchers: [AccessEmailListMatcherFactory()], + ...override, +}); + +export const AccessesQueryFactory = ( + override?: Partial +): AccessesQuery => ({ + __typename: 'Query', + accesses: [AccessResultFactory()], + ...override, +}); diff --git a/libs/shared/cms/src/lib/queries/accesses/index.ts b/libs/shared/cms/src/lib/queries/accesses/index.ts new file mode 100644 index 00000000000..62aba7b786f --- /dev/null +++ b/libs/shared/cms/src/lib/queries/accesses/index.ts @@ -0,0 +1,6 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export * from './factories'; +export * from './query'; diff --git a/libs/shared/cms/src/lib/queries/accesses/query.ts b/libs/shared/cms/src/lib/queries/accesses/query.ts new file mode 100644 index 00000000000..b9ca502882b --- /dev/null +++ b/libs/shared/cms/src/lib/queries/accesses/query.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 { graphql } from '../../../__generated__/gql'; + +/** + * Fetches all Strapi `access` entries (the child of free-access-program) + * with their matchers and capabilities. Auth-server / payments-api filter + * in memory against the user's email. + */ +export const accessesQuery = graphql(` + query Accesses { + accesses(pagination: { limit: 200 }) { + documentId + internalName + capabilities { + slug + services { + oauthClientId + } + } + matchers { + __typename + ... on ComponentMatchersEmailList { + emails + } + } + } + } +`); diff --git a/libs/shared/cms/src/lib/queries/services-with-capabilities/factories.ts b/libs/shared/cms/src/lib/queries/services-with-capabilities/factories.ts index 3cd2120f593..01e76f73d5e 100644 --- a/libs/shared/cms/src/lib/queries/services-with-capabilities/factories.ts +++ b/libs/shared/cms/src/lib/queries/services-with-capabilities/factories.ts @@ -18,6 +18,8 @@ export const ServiceResultFactory = ( override?: Partial ): ServiceResult => ({ oauthClientId: faker.string.sample(), + internalName: faker.company.name(), + description: faker.lorem.sentence(), capabilities: [CapabilitiesResultFactory()], ...override, }); diff --git a/libs/shared/cms/src/lib/queries/services-with-capabilities/query.ts b/libs/shared/cms/src/lib/queries/services-with-capabilities/query.ts index 5655147cfbc..a70479f1c80 100644 --- a/libs/shared/cms/src/lib/queries/services-with-capabilities/query.ts +++ b/libs/shared/cms/src/lib/queries/services-with-capabilities/query.ts @@ -8,6 +8,8 @@ export const servicesWithCapabilitiesQuery = graphql(` query ServicesWithCapabilities { services(pagination: { limit: 500 }) { oauthClientId + internalName + description capabilities { slug } diff --git a/libs/shared/cms/src/lib/queries/services-with-capabilities/types.ts b/libs/shared/cms/src/lib/queries/services-with-capabilities/types.ts index bf49d5421e9..5ff64bc5da8 100644 --- a/libs/shared/cms/src/lib/queries/services-with-capabilities/types.ts +++ b/libs/shared/cms/src/lib/queries/services-with-capabilities/types.ts @@ -8,6 +8,8 @@ export interface CapabilitiesResult { export interface ServiceResult { oauthClientId: string; + internalName: string; + description: string | null; capabilities: CapabilitiesResult[]; } diff --git a/libs/shared/cms/src/lib/queries/services-with-capabilities/util.spec.ts b/libs/shared/cms/src/lib/queries/services-with-capabilities/util.spec.ts index 16b78c590de..8f7603e6443 100644 --- a/libs/shared/cms/src/lib/queries/services-with-capabilities/util.spec.ts +++ b/libs/shared/cms/src/lib/queries/services-with-capabilities/util.spec.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { + ServiceResultFactory, ServicesWithCapabilitiesQueryFactory, ServicesWithCapabilitiesResult, ServicesWithCapabilitiesResultUtil, @@ -32,4 +33,38 @@ describe('ServicesWithCapabilitiesResultUtil', () => { result.services?.[0]?.capabilities?.[0]?.slug ); }); + + describe('findServiceByClientId', () => { + it('returns the service entry matching the clientId (case-insensitive)', () => { + const result = ServicesWithCapabilitiesQueryFactory({ + services: [ + ServiceResultFactory({ + oauthClientId: 'Some-Client', + internalName: 'Some Service', + description: 'A useful service.', + }), + ], + }); + const util = new ServicesWithCapabilitiesResultUtil( + result as ServicesWithCapabilitiesResult + ); + + const match = util.findServiceByClientId('SOME-CLIENT'); + expect(match).toBeDefined(); + expect(match?.oauthClientId).toBe('Some-Client'); + expect(match?.internalName).toBe('Some Service'); + expect(match?.description).toBe('A useful service.'); + }); + + it('returns undefined for an unknown clientId', () => { + const result = ServicesWithCapabilitiesQueryFactory({ + services: [ServiceResultFactory({ oauthClientId: 'known' })], + }); + const util = new ServicesWithCapabilitiesResultUtil( + result as ServicesWithCapabilitiesResult + ); + + expect(util.findServiceByClientId('unknown')).toBeUndefined(); + }); + }); }); diff --git a/libs/shared/cms/src/lib/queries/services-with-capabilities/util.ts b/libs/shared/cms/src/lib/queries/services-with-capabilities/util.ts index f94c49cf485..5bfc754b32e 100644 --- a/libs/shared/cms/src/lib/queries/services-with-capabilities/util.ts +++ b/libs/shared/cms/src/lib/queries/services-with-capabilities/util.ts @@ -5,7 +5,16 @@ import { ServiceResult, ServicesWithCapabilitiesResult } from './types'; export class ServicesWithCapabilitiesResultUtil { - constructor(private rawResult: ServicesWithCapabilitiesResult) {} + private readonly byClientId: ReadonlyMap; + + constructor(private rawResult: ServicesWithCapabilitiesResult) { + const index = new Map(); + for (const service of this.rawResult.services ?? []) { + if (!service?.oauthClientId) continue; + index.set(service.oauthClientId.toLowerCase(), service); + } + this.byClientId = index; + } getServices(): ServiceResult[] { return this.services; @@ -14,4 +23,8 @@ export class ServicesWithCapabilitiesResultUtil { get services() { return this.rawResult.services; } + + findServiceByClientId(clientId: string): ServiceResult | undefined { + return this.byClientId.get(clientId.toLowerCase()); + } } diff --git a/libs/shared/cms/src/lib/strapi.client.config.ts b/libs/shared/cms/src/lib/strapi.client.config.ts index 6e589372385..875ee451623 100644 --- a/libs/shared/cms/src/lib/strapi.client.config.ts +++ b/libs/shared/cms/src/lib/strapi.client.config.ts @@ -8,7 +8,7 @@ import { Type } from 'class-transformer'; import { IsNumber, IsOptional, IsString, IsUrl } from 'class-validator'; export class StrapiClientConfig { - @IsUrl() + @IsUrl({ require_tld: false }) public readonly graphqlApiUri!: string; @IsString() diff --git a/libs/shared/db/firestore/src/index.ts b/libs/shared/db/firestore/src/index.ts index 8c3d540b95b..62e7e425dba 100644 --- a/libs/shared/db/firestore/src/index.ts +++ b/libs/shared/db/firestore/src/index.ts @@ -1,5 +1,6 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +export * from './lib/firestore.config'; export * from './lib/firestore.provider'; export * from './lib/firestore-typedi-token'; diff --git a/packages/fxa-auth-server/bin/key_server.js b/packages/fxa-auth-server/bin/key_server.js index 5b2d0490c45..d7175cd20bd 100755 --- a/packages/fxa-auth-server/bin/key_server.js +++ b/packages/fxa-auth-server/bin/key_server.js @@ -11,6 +11,10 @@ const Sentry = require('@sentry/node'); const { config } = require('../config'); const Redis = require('ioredis'); const { CapabilityManager } = require('@fxa/payments/capability'); +const { + EmailCapabilityList, +} = require('../lib/payments/email-capability-list'); +const { FreeAccessProgramManager } = require('@fxa/free-access-program'); const { EligibilityManager } = require('@fxa/payments/eligibility'); const { PriceManager, @@ -233,6 +237,23 @@ async function run(config) { Container.set(DefaultCmsConfigurationManager, defaultCmsManager); } + const freeAccessProgramManager = new FreeAccessProgramManager( + { + collectionName: + config.subscriptions.freeAccessProgram.firestoreCollectionName, + }, + Container.get(AuthFirestore) + ); + Container.set(FreeAccessProgramManager, freeAccessProgramManager); + + Container.set( + EmailCapabilityList, + new EmailCapabilityList( + config.subscriptions.emailCapabilityList || {}, + freeAccessProgramManager + ) + ); + const { createStripeHelper } = require('../lib/payments/stripe'); stripeHelper = createStripeHelper(log, config, statsd); Container.set(StripeHelper, stripeHelper); diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index d2bbe2f0410..8b0077dd158 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -1153,6 +1153,22 @@ const convictConf = convict({ env: 'SUBSCRIPTIONS_BILLING_PRICE_INFO_FEATURE', default: false, }, + emailCapabilityList: { + enabled: { + doc: 'Grant capabilities to users whose primary email is on the Strapi-managed allowlist (FreeAccessProgram / Access content types).', + format: Boolean, + env: 'EMAIL_CAPABILITY_LIST_ENABLED', + default: true, + }, + }, + freeAccessProgram: { + firestoreCollectionName: { + doc: 'Firestore collection backing the free-access-program projection (the email-capability-list source of truth, reconciled from Strapi by payments-api).', + format: String, + env: 'FREE_ACCESS_PROGRAM_FIRESTORE_COLLECTION_NAME', + default: 'subplat-free-access-program', + }, + }, }, currenciesToCountries: { doc: 'Mapping from ISO 4217 three-letter currency codes to list of ISO 3166-1 alpha-2 two-letter country codes: {"EUR": ["DE", "FR"], "USD": ["CA", "GB", "US" ]} Requirement for only one currency per country. Tested at runtime. Must be uppercased.', diff --git a/packages/fxa-auth-server/jest.setup.js b/packages/fxa-auth-server/jest.setup.js index c948081ec7c..8a50b40e822 100644 --- a/packages/fxa-auth-server/jest.setup.js +++ b/packages/fxa-auth-server/jest.setup.js @@ -5,6 +5,15 @@ /** * Jest global setup - runs before each test file. * + * Load `reflect-metadata` before anything else so any module that pulls in + * a class-transformer / class-validator decorator at load time (e.g. + * `@fxa/shared/db/firestore`'s `FirestoreConfig`) finds `Reflect.getMetadata` + * available. Production loads it via NestJS bootstrap; the unit-test + * harness doesn't. + */ +require('reflect-metadata'); + +/** * Set NODE_ENV=dev so that the config module loads config/dev.json, * which includes OAuth keys (config/key.json), authServerSecrets, * and other values required by unit tests. This matches the CI test diff --git a/packages/fxa-auth-server/lib/payments/capability.spec.ts b/packages/fxa-auth-server/lib/payments/capability.spec.ts index 7596b5ea256..21e2128dc89 100644 --- a/packages/fxa-auth-server/lib/payments/capability.spec.ts +++ b/packages/fxa-auth-server/lib/payments/capability.spec.ts @@ -36,6 +36,7 @@ const { const authDbModule = require('fxa-shared/db/models/auth'); const { PurchaseQueryError } = require('./iap/google-play/types'); const { CapabilityManager } = require('@fxa/payments/capability'); +const { EmailCapabilityList } = require('./email-capability-list'); const { EligibilityManager } = require('@fxa/payments/eligibility'); const { SubscriptionEligibilityResult, @@ -411,6 +412,71 @@ describe('CapabilityService', () => { }); }); + describe('processEmailListChange', () => { + it('broadcasts added capabilities with isActive=true', async () => { + await capabilityService.processEmailListChange({ + uid: UID, + added: ['capA', 'capB'], + removed: [], + eventCreatedAt: 1_700_000_000, + }); + + expect(mockProfileClient.deleteCache).toHaveBeenCalledWith(UID); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( + 'subscription:update', + expect.anything(), + expect.objectContaining({ + uid: UID, + isActive: true, + productCapabilities: ['capA', 'capB'], + eventCreatedAt: 1_700_000_000, + }) + ); + }); + + it('broadcasts removed capabilities with isActive=false', async () => { + await capabilityService.processEmailListChange({ + uid: UID, + added: [], + removed: ['capX'], + }); + + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( + 'subscription:update', + expect.anything(), + expect.objectContaining({ + uid: UID, + isActive: false, + productCapabilities: ['capX'], + }) + ); + }); + + it('broadcasts both events when added and removed are non-empty', async () => { + await capabilityService.processEmailListChange({ + uid: UID, + added: ['capA'], + removed: ['capX'], + }); + + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(2); + expect(mockProfileClient.deleteCache).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when no capabilities are added or removed', async () => { + await capabilityService.processEmailListChange({ + uid: UID, + added: [], + removed: [], + }); + + expect(log.notifyAttachedServices).not.toHaveBeenCalled(); + expect(mockProfileClient.deleteCache).not.toHaveBeenCalled(); + }); + }); + describe('getPlanEligibility', () => { const mockAbbrevPlans = [ { @@ -837,6 +903,129 @@ describe('CapabilityService', () => { }); }); + describe('subscriptionCapabilities — email-list merge', () => { + beforeEach(() => { + // No subscriptions so we isolate the email-list contribution. + mockStripeHelper.fetchCustomer = jest.fn(async () => ({ + subscriptions: { data: [] }, + })); + mockStripeHelper.iapPurchasesToPriceIds = jest.fn().mockReturnValue([]); + mockPlayBilling.userManager.queryCurrentSubscriptions = jest + .fn() + .mockResolvedValue([]); + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({}); + }); + + it('returns only subscription caps when no EmailCapabilityList is registered', async () => { + Container.remove(EmailCapabilityList); + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ c1: ['capSub'] }); + const svc = new CapabilityService(); + const caps = await svc.subscriptionCapabilities(UID, EMAIL); + expect(caps).toEqual({ c1: ['capSub'] }); + }); + + const buildEmailList = (caps: Record) => + new EmailCapabilityList( + { enabled: true }, + { + findCapabilitiesForEmail: jest.fn().mockResolvedValue(caps), + } as any + ); + + it('merges email-list caps with subscription caps', async () => { + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ c1: ['capSub'] }); + Container.set( + EmailCapabilityList, + buildEmailList({ c1: ['capEmail'], c2: ['capExtra'] }) + ); + const svc = new CapabilityService(); + const caps = await svc.subscriptionCapabilities(UID, EMAIL); + expect(caps).toEqual({ + c1: ['capSub', 'capEmail'], + c2: ['capExtra'], + }); + }); + + it('honors ALL_RPS_CAPABILITIES_KEY ("*") when merging', async () => { + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ '*': ['capAll'] }); + Container.set( + EmailCapabilityList, + buildEmailList({ '*': ['capAllFromEmail'] }) + ); + const svc = new CapabilityService(); + const caps = await svc.subscriptionCapabilities(UID, EMAIL); + expect(caps).toEqual({ '*': ['capAll', 'capAllFromEmail'] }); + }); + + it('returns subscription caps unchanged when email is not on the list', async () => { + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ c1: ['capSub'] }); + Container.set(EmailCapabilityList, buildEmailList({})); + const svc = new CapabilityService(); + const caps = await svc.subscriptionCapabilities(UID, EMAIL); + expect(caps).toEqual({ c1: ['capSub'] }); + }); + + it('skips the email lookup when no email is passed', async () => { + const getCapsForEmail = jest.fn(); + Container.set(EmailCapabilityList, { + getCapabilitiesForEmail: getCapsForEmail.mockResolvedValue({}), + }); + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ c1: ['capSub'] }); + const svc = new CapabilityService(); + const caps = await svc.subscriptionCapabilities(UID); + expect(caps).toEqual({ c1: ['capSub'] }); + expect(getCapsForEmail).toHaveBeenCalledWith(undefined); + }); + }); + + describe('hasFreeAccess', () => { + const buildEmailList = (caps: Record) => + new EmailCapabilityList( + { enabled: true }, + { + findCapabilitiesForEmail: jest.fn().mockResolvedValue(caps), + } as any + ); + + it('returns true when the email is on the allowlist', async () => { + Container.set(EmailCapabilityList, buildEmailList({ c1: ['capEmail'] })); + const svc = new CapabilityService(); + await expect(svc.hasFreeAccess(EMAIL)).resolves.toBe(true); + }); + + it('returns false when the email is not on the allowlist', async () => { + Container.set(EmailCapabilityList, buildEmailList({})); + const svc = new CapabilityService(); + await expect(svc.hasFreeAccess(EMAIL)).resolves.toBe(false); + }); + + it('returns false when no email is supplied', async () => { + Container.set(EmailCapabilityList, buildEmailList({ c1: ['capEmail'] })); + const svc = new CapabilityService(); + await expect(svc.hasFreeAccess(undefined)).resolves.toBe(false); + await expect(svc.hasFreeAccess(null)).resolves.toBe(false); + await expect(svc.hasFreeAccess('')).resolves.toBe(false); + }); + + it('returns false when EmailCapabilityList is not registered', async () => { + Container.remove(EmailCapabilityList); + const svc = new CapabilityService(); + await expect(svc.hasFreeAccess(EMAIL)).resolves.toBe(false); + }); + }); + describe('determineClientVisibleSubscriptionCapabilities', () => { beforeEach(() => { mockStripeHelper.fetchCustomer = jest.fn(async () => ({ diff --git a/packages/fxa-auth-server/lib/payments/capability.ts b/packages/fxa-auth-server/lib/payments/capability.ts index cb227954dc3..bb5f0259c74 100644 --- a/packages/fxa-auth-server/lib/payments/capability.ts +++ b/packages/fxa-auth-server/lib/payments/capability.ts @@ -14,6 +14,7 @@ import Stripe from 'stripe'; import Container from 'typedi'; import { CapabilityManager } from '@fxa/payments/capability'; +import { EmailCapabilityList } from './email-capability-list'; import { EligibilityManager, IntervalComparison, @@ -58,6 +59,7 @@ export class CapabilityService { private stripeHelper: StripeHelper; private profileClient: ProfileClient; private capabilityManager?: CapabilityManager; + private emailCapabilityList?: EmailCapabilityList; private eligibilityManager?: EligibilityManager; constructor() { @@ -81,6 +83,9 @@ export class CapabilityService { if (Container.has(CapabilityManager)) { this.capabilityManager = Container.get(CapabilityManager); } + if (Container.has(EmailCapabilityList)) { + this.emailCapabilityList = Container.get(EmailCapabilityList); + } if (Container.has(EligibilityManager)) { this.eligibilityManager = Container.get(EligibilityManager); } @@ -217,12 +222,37 @@ export class CapabilityService { /** * Return a map of capabilities to client ids for the user. + * + * Capabilities come from two sources, unioned together: + * 1. Active subscriptions (Stripe / IAP) resolved via CapabilityManager. + * 2. The email-allowlist source (EmailCapabilityList). Pass `email` when + * the caller already has it to avoid an extra DB fetch. */ public async subscriptionCapabilities( - uid: string + uid: string, + email?: string | null ): Promise { const subscribedPrices = await this.subscribedPriceIds(uid); - return this.planIdsToClientCapabilities(subscribedPrices); + const subscriptionCaps = + await this.planIdsToClientCapabilities(subscribedPrices); + const emailCaps = this.emailCapabilityList + ? await this.emailCapabilityList.getCapabilitiesForEmail(email) + : {}; + return ClientIdCapabilityMap.merge(subscriptionCaps, emailCaps); + } + + /** + * Returns true if the user's email is on the Strapi-managed free-access + * allowlist (i.e. has any `EmailCapabilityList`-derived grant). Distinct + * from `subscriptionCapabilities`, which merges Stripe + free-access + * sources — this surfaces *only* the free-access signal, for UI gates + * that need to differentiate "user is paid" from "user is on the + * allowlist". + */ + public async hasFreeAccess(email?: string | null): Promise { + if (!email || !this.emailCapabilityList) return false; + const caps = await this.emailCapabilityList.getCapabilitiesForEmail(email); + return Object.keys(caps).length > 0; } /** @@ -501,6 +531,47 @@ export class CapabilityService { }; } + /** + * Apply a change to the email-allowlist capability source for a single + * user: invalidate their profile cache and broadcast the added/removed + * capabilities to attached services via the existing SQS pipeline. + * + * TODO(FXA-XXXXX): Replace this with an event-driven flow where + * payments-api emits a "capability list changed" event that auth-server + * consumes. This method exists because payments-api currently calls + * auth-server over HTTP to drive the broadcast (see + * `/oauth/subscriptions/email-capability-changed`). + */ + public async processEmailListChange(options: { + uid: string; + added: string[]; + removed: string[]; + eventCreatedAt?: number; + request?: AuthRequest; + }) { + const { uid, added, removed, eventCreatedAt, request } = options; + if (added.length === 0 && removed.length === 0) { + return; + } + await this.profileClient.deleteCache(uid); + if (added.length > 0) { + this.broadcastCapabilitiesAdded({ + uid, + capabilities: added, + eventCreatedAt, + request, + }); + } + if (removed.length > 0) { + this.broadcastCapabilitiesRemoved({ + uid, + capabilities: removed, + eventCreatedAt, + request, + }); + } + } + /** * Diff a list of prior price ids to the list of current price ids * and emit the necessary events for added/removed capabilities. @@ -716,11 +787,7 @@ export class CapabilityService { try { return await this.capabilityManager.getClients(); } catch (err) { - throw error.internalValidationError( - 'subscriptions.getClients', - {}, - err - ); + throw error.internalValidationError('subscriptions.getClients', {}, err); } } diff --git a/packages/fxa-auth-server/lib/payments/email-capability-list.spec.ts b/packages/fxa-auth-server/lib/payments/email-capability-list.spec.ts new file mode 100644 index 00000000000..9c327530cf7 --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/email-capability-list.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 { EmailCapabilityList } from './email-capability-list'; + +describe('EmailCapabilityList', () => { + const buildManager = (caps: Record) => ({ + findCapabilitiesForEmail: jest.fn().mockResolvedValue(caps), + }); + + it('returns an empty map when disabled', async () => { + const manager = buildManager({ c1: ['cap'] }); + const list = new EmailCapabilityList( + { enabled: false }, + manager as any + ); + expect(await list.getCapabilitiesForEmail('user@example.com')).toEqual({}); + expect(manager.findCapabilitiesForEmail).not.toHaveBeenCalled(); + }); + + it('returns an empty map when no email is provided', async () => { + const manager = buildManager({ c1: ['cap'] }); + const list = new EmailCapabilityList({ enabled: true }, manager as any); + expect(await list.getCapabilitiesForEmail(undefined)).toEqual({}); + expect(await list.getCapabilitiesForEmail(null)).toEqual({}); + expect(await list.getCapabilitiesForEmail('')).toEqual({}); + expect(manager.findCapabilitiesForEmail).not.toHaveBeenCalled(); + }); + + it('returns an empty map when the manager is not wired', async () => { + const list = new EmailCapabilityList({ enabled: true }); + expect( + await list.getCapabilitiesForEmail('user@example.com') + ).toEqual({}); + }); + + it('delegates to FreeAccessProgramManager.findCapabilitiesForEmail', async () => { + const findCapabilitiesForEmail = jest + .fn() + .mockResolvedValue({ c1: ['capCms'] }); + const manager = { findCapabilitiesForEmail }; + const list = new EmailCapabilityList({ enabled: true }, manager as any); + + const result = await list.getCapabilitiesForEmail('user@example.com'); + + expect(findCapabilitiesForEmail).toHaveBeenCalledWith('user@example.com'); + expect(result).toEqual({ c1: ['capCms'] }); + }); + + it('tolerates absent config gracefully', async () => { + const list = new EmailCapabilityList({} as any); + expect(await list.getCapabilitiesForEmail('anyone@example.com')).toEqual( + {} + ); + }); +}); diff --git a/packages/fxa-auth-server/lib/payments/email-capability-list.ts b/packages/fxa-auth-server/lib/payments/email-capability-list.ts new file mode 100644 index 00000000000..c627224406a --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/email-capability-list.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 { ClientIdCapabilityMap } from 'fxa-shared/subscriptions/types'; + +import { FreeAccessProgramManager } from '@fxa/free-access-program'; + +/** + * Resolves the `{ clientId → capabilities[] }` map an FxA user receives + * because their primary email is on a free-access-program grant. + * + * Delegates to `FreeAccessProgramManager.findCapabilitiesForEmail`, which + * issues a single indexed Firestore query against the projection that + * payments-api maintains from Strapi via webhook + periodic reconciler. + * Expired grants are absent from the result by virtue of the Firestore TTL + * policy on `expiresAt`. + */ +export type EmailCapabilityListConfig = { + enabled: boolean; +}; + +export class EmailCapabilityList { + private readonly enabled: boolean; + + constructor( + config: EmailCapabilityListConfig, + private readonly freeAccessProgramManager?: FreeAccessProgramManager + ) { + this.enabled = !!config?.enabled; + } + + async getCapabilitiesForEmail( + email?: string | null + ): Promise { + if (!this.enabled || !email || !this.freeAccessProgramManager) { + return {}; + } + return this.freeAccessProgramManager.findCapabilitiesForEmail(email); + } +} diff --git a/packages/fxa-auth-server/lib/routes/account.spec.ts b/packages/fxa-auth-server/lib/routes/account.spec.ts index 181245c53ab..f832513c115 100644 --- a/packages/fxa-auth-server/lib/routes/account.spec.ts +++ b/packages/fxa-auth-server/lib/routes/account.spec.ts @@ -150,7 +150,12 @@ const mockGetAccountCustomerByUid = jest.fn().mockResolvedValue({ }); const makeRoutes = function (options: any = {}, requireMocks: any = {}) { - Container.set(CapabilityService, options.capabilityService || jest.fn()); + Container.set( + CapabilityService, + options.capabilityService || { + hasFreeAccess: jest.fn().mockResolvedValue(false), + } + ); const config = options.config || {}; config.oauth = config.oauth || {}; config.verifierVersion = config.verifierVersion || 0; @@ -2195,6 +2200,7 @@ describe('/account/set_password', () => { }; const mockCapabilityService = options.mockCapabilityService || { subscribedPriceIds: jest.fn().mockResolvedValue([fakePlan.id]), + hasFreeAccess: jest.fn().mockResolvedValue(false), }; const accountRoutes = makeRoutes({ config, @@ -2506,7 +2512,9 @@ describe('/account/login', () => { Container.set(AppConfig, config); Container.set(AuthLogger, mockLog); Container.set(AccountEventsManager, new AccountEventsManager()); - Container.set(CapabilityService, jest.fn().mockResolvedValue(undefined)); + Container.set(CapabilityService, { + hasFreeAccess: jest.fn().mockResolvedValue(false), + }); Container.set(OAuthClientInfoServiceName, mockOAuthClientInfo); Container.set(FxaMailer, mockFxaMailer); Container.set(RelyingPartyConfigurationManager, rpConfigManager); @@ -4417,7 +4425,9 @@ describe('/account', () => { mockStripeHelper.removeFirestoreCustomer = jest .fn() .mockResolvedValue(undefined); - Container.set(CapabilityService, jest.fn()); + Container.set(CapabilityService, { + hasFreeAccess: jest.fn().mockResolvedValue(false), + }); }); describe('web subscriptions', () => { @@ -4437,7 +4447,9 @@ describe('/account', () => { mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); - Container.set(CapabilityService, jest.fn()); + Container.set(CapabilityService, { + hasFreeAccess: jest.fn().mockResolvedValue(false), + }); }); it('should return formatted Stripe subscriptions when subscriptions are enabled', () => { @@ -4558,7 +4570,9 @@ describe('/account', () => { ); Container.set(OAuthClientInfoServiceName, mockOAuthClientInfoLocal); Container.set(FxaMailer, mockFxaMailerLocal); - Container.set(CapabilityService, jest.fn()); + Container.set(CapabilityService, { + hasFreeAccess: jest.fn().mockResolvedValue(false), + }); mockPlaySubscriptions = mocks.mockPlaySubscriptions(['getSubscriptions']); Container.set(PlaySubscriptions, mockPlaySubscriptions); mockPlaySubscriptions.getSubscriptions = jest.fn(async (uid: any) => [ @@ -4710,7 +4724,9 @@ describe('/account', () => { mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); - Container.set(CapabilityService, jest.fn()); + Container.set(CapabilityService, { + hasFreeAccess: jest.fn().mockResolvedValue(false), + }); mockAppStoreSubscriptions = mocks.mockAppStoreSubscriptions([ 'getSubscriptions', ]); @@ -5151,12 +5167,13 @@ describe('/account/emails', () => { beforeEach(() => { jest.clearAllMocks(); log = mocks.mockLog(); + installMockFxaMailer(); mocks.mockOAuthClientInfo(); db = { account: jest.fn().mockResolvedValue({ uid: 'account-123', email: 'signup@example.com', - primaryEmail: { email: 'signup+1@example.com' } + primaryEmail: { email: 'signup+1@example.com' }, }), }; config = {}; @@ -5172,7 +5189,7 @@ describe('/account/emails', () => { expect(db.account).toHaveBeenCalledWith('account-123'); expect(resp).toEqual({ originalEmail: 'signup@example.com', - primaryEmail: 'signup+1@example.com' + primaryEmail: 'signup+1@example.com', }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/account.ts b/packages/fxa-auth-server/lib/routes/account.ts index 710b8f7399e..0f2a49168c7 100644 --- a/packages/fxa-auth-server/lib/routes/account.ts +++ b/packages/fxa-auth-server/lib/routes/account.ts @@ -1908,7 +1908,10 @@ export class AccountHandler { scope.contains('profile:subscriptions') ) { const capabilities = - await this.capabilityService.subscriptionCapabilities(uid as string); + await this.capabilityService.subscriptionCapabilities( + uid as string, + account.primaryEmail?.email + ); if (Object.keys(capabilities).length > 0) { res.subscriptionsByClientId = capabilities; } @@ -2679,6 +2682,19 @@ export class AccountHandler { } } + // Free-access allowlist signal — keyed on the primary email, decoupled + // from Stripe/IAP state above. Defensive: the Settings home page reads + // this to show the "Paid Subscriptions" link; a CMS hiccup must not 500 + // `/account` for everyone, so swallow into `false`. + const primaryEmail = + formattedEmails.find((e) => e.isPrimary)?.email ?? + account.primaryEmail?.email; + const hasFreeAccess = this.config.subscriptions?.enabled + ? await this.capabilityService + .hasFreeAccess(primaryEmail) + .catch(() => false) + : false; + return { createdAt: account.createdAt, passwordCreatedAt: account.verifierSetAt, @@ -2697,6 +2713,7 @@ export class AccountHandler { ...iapAppStoreSubscriptions, ...webSubscriptions, ], + hasFreeAccess, }; } } @@ -3377,6 +3394,7 @@ export const accountRoutes = ( validators.subscriptionsGooglePlaySubscriptionValidator, validators.subscriptionsAppStoreSubscriptionValidator ), + hasFreeAccess: isA.boolean().optional(), }), }, }, diff --git a/packages/fxa-auth-server/lib/routes/password.spec.ts b/packages/fxa-auth-server/lib/routes/password.spec.ts index d3ef249cb95..e93e4658860 100644 --- a/packages/fxa-auth-server/lib/routes/password.spec.ts +++ b/packages/fxa-auth-server/lib/routes/password.spec.ts @@ -635,16 +635,16 @@ describe('/password', () => { email: 'primary-now@example.com', oldAuthPW: crypto.randomBytes(32).toString('hex'), }, - log: mocks.mockLog(), + log: createMock(), }); const mockStatsd = createMock(); const passwordRoutes = makeRoutes({ db: mockDB, push: mocks.mockPush(), mailer: mocks.mockMailer(), - log: mocks.mockLog(), + log: createMock(), customs: mocks.mockCustoms(), - statsd: mockStatsd, + statsd: createMock(), }); let err: any; diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/email-capability.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/email-capability.spec.ts new file mode 100644 index 00000000000..0eca987af38 --- /dev/null +++ b/packages/fxa-auth-server/lib/routes/subscriptions/email-capability.spec.ts @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Container } from 'typedi'; + +const mocks = require('../../../test/mocks'); +const { EmailCapabilityHandler } = require('./email-capability'); +const { CapabilityService } = require('../../payments/capability'); +const { AppError: error } = require('@fxa/accounts/errors'); + +describe('EmailCapabilityHandler', () => { + let log: any; + let db: any; + let mockCapabilityService: any; + let handler: any; + + beforeEach(() => { + log = mocks.mockLog(); + db = { + accountRecord: jest.fn(), + }; + mockCapabilityService = { + processEmailListChange: jest.fn().mockResolvedValue(undefined), + }; + Container.set(CapabilityService, mockCapabilityService); + handler = new EmailCapabilityHandler(log, db); + }); + + afterEach(() => { + Container.reset(); + jest.restoreAllMocks(); + }); + + const buildRequest = (payload: any) => ({ + payload, + auth: { credentials: { scope: [] } }, + }); + + it('applies each change with at least one added/removed capability', async () => { + db.accountRecord + .mockResolvedValueOnce({ uid: 'uid-a' }) + .mockResolvedValueOnce({ uid: 'uid-b' }); + + const result = await handler.postEmailCapabilityChanged( + buildRequest({ + eventCreatedAt: 1_700_000_000, + changes: [ + { email: 'a@example.com', added: ['capA'] }, + { email: 'b@example.com', removed: ['capB'] }, + ], + }) + ); + + expect(mockCapabilityService.processEmailListChange).toHaveBeenCalledTimes( + 2 + ); + expect(mockCapabilityService.processEmailListChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + uid: 'uid-a', + added: ['capA'], + removed: [], + eventCreatedAt: 1_700_000_000, + }) + ); + expect(mockCapabilityService.processEmailListChange).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + uid: 'uid-b', + added: [], + removed: ['capB'], + }) + ); + expect(result).toEqual({ applied: 2, unknownAccount: 0 }); + }); + + it('skips changes with no added or removed capabilities', async () => { + const result = await handler.postEmailCapabilityChanged( + buildRequest({ + changes: [{ email: 'a@example.com' }], + }) + ); + + expect(db.accountRecord).not.toHaveBeenCalled(); + expect(mockCapabilityService.processEmailListChange).not.toHaveBeenCalled(); + expect(result).toEqual({ applied: 0, unknownAccount: 0 }); + }); + + it('treats unknown accounts as a no-op and counts them', async () => { + db.accountRecord.mockRejectedValueOnce( + error.unknownAccount('missing@example.com') + ); + + const result = await handler.postEmailCapabilityChanged( + buildRequest({ + changes: [ + { email: 'missing@example.com', added: ['capA'] }, + ], + }) + ); + + expect(mockCapabilityService.processEmailListChange).not.toHaveBeenCalled(); + expect(result).toEqual({ applied: 0, unknownAccount: 1 }); + }); + + it('rethrows non-unknownAccount errors from the DB', async () => { + const dbError = new Error('boom'); + db.accountRecord.mockRejectedValueOnce(dbError); + + await expect( + handler.postEmailCapabilityChanged( + buildRequest({ + changes: [{ email: 'a@example.com', added: ['capA'] }], + }) + ) + ).rejects.toBe(dbError); + }); + + it('processes a mixed batch (known, unknown, both)', async () => { + db.accountRecord + .mockResolvedValueOnce({ uid: 'uid-a' }) + .mockRejectedValueOnce(error.unknownAccount('miss@example.com')) + .mockResolvedValueOnce({ uid: 'uid-c' }); + + const result = await handler.postEmailCapabilityChanged( + buildRequest({ + changes: [ + { email: 'a@example.com', added: ['capA'] }, + { email: 'miss@example.com', added: ['capX'] }, + { email: 'c@example.com', added: ['capC'], removed: ['capY'] }, + ], + }) + ); + + expect(mockCapabilityService.processEmailListChange).toHaveBeenCalledTimes( + 2 + ); + expect(result).toEqual({ applied: 2, unknownAccount: 1 }); + }); +}); diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/email-capability.ts b/packages/fxa-auth-server/lib/routes/subscriptions/email-capability.ts new file mode 100644 index 00000000000..4c23f73492e --- /dev/null +++ b/packages/fxa-auth-server/lib/routes/subscriptions/email-capability.ts @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ServerRoute } from '@hapi/hapi'; +import isA from 'joi'; +import Container from 'typedi'; + +import { AppError as error } from '@fxa/accounts/errors'; +import { CapabilityService } from '../../payments/capability'; +import { AuthLogger, AuthRequest } from '../../types'; + +/** + * Internal endpoint that lets payments-api forward Strapi email-capability + * list changes to auth-server. Auth-server resolves the email to a uid, + * invalidates the profile cache, and broadcasts the added/removed + * capabilities through the existing `subscription:update` SQS pipeline. + * + * Auth: `subscriptionsSecret` — same service-to-service strategy as the + * other `/oauth/subscriptions/*` internal routes. + * + * TODO(FXA-XXXXX): This route is a workaround until payments-api emits + * "capability list changed" events that auth-server consumes directly. + * When that event channel exists, this HTTP endpoint should be removed + * and the handler logic moved into the event consumer. + */ +export class EmailCapabilityHandler { + private capabilityService: CapabilityService; + + constructor( + private log: AuthLogger, + private db: any + ) { + this.capabilityService = Container.get(CapabilityService); + } + + async postEmailCapabilityChanged(request: AuthRequest) { + this.log.begin('subscriptions.emailCapabilityChanged', request); + const payload = request.payload as { + eventCreatedAt?: number; + changes: Array<{ + email: string; + added?: string[]; + removed?: string[]; + }>; + }; + const eventCreatedAt = payload.eventCreatedAt; + + let appliedCount = 0; + let unknownAccountCount = 0; + for (const change of payload.changes) { + const added = change.added ?? []; + const removed = change.removed ?? []; + if (added.length === 0 && removed.length === 0) { + continue; + } + + let uid: string; + try { + const account = await this.db.accountRecord(change.email); + uid = account.uid; + } catch (err) { + // accountRecord throws `unknownAccount` when the email has no + // matching FxA account. The list can legitimately pre-include + // not-yet-registered emails, so swallow this and continue. + if (err?.errno === error.ERRNO.ACCOUNT_UNKNOWN) { + unknownAccountCount += 1; + continue; + } + throw err; + } + + await this.capabilityService.processEmailListChange({ + uid, + added, + removed, + eventCreatedAt, + request, + }); + appliedCount += 1; + } + + return { applied: appliedCount, unknownAccount: unknownAccountCount }; + } +} + +export const emailCapabilityRoutes = ( + log: AuthLogger, + db: any +): ServerRoute[] => { + const handler = new EmailCapabilityHandler(log, db); + + const changeSchema = isA.object({ + email: isA.string().email().required(), + added: isA.array().items(isA.string()).optional(), + removed: isA.array().items(isA.string()).optional(), + }); + + return [ + { + method: 'POST', + path: '/oauth/subscriptions/email-capability-changed', + options: { + auth: { + payload: false, + strategy: 'subscriptionsSecret', + }, + validate: { + payload: isA.object({ + eventCreatedAt: isA.number().integer().optional(), + changes: isA.array().items(changeSchema).required(), + }) as any, + }, + response: { + schema: isA.object({ + applied: isA.number().integer().required(), + unknownAccount: isA.number().integer().required(), + }) as any, + }, + }, + handler: (request: AuthRequest) => + handler.postEmailCapabilityChanged(request), + }, + ]; +}; diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/index.ts b/packages/fxa-auth-server/lib/routes/subscriptions/index.ts index 7c90a0f3ee5..7f0a5cb7944 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/index.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/index.ts @@ -8,6 +8,7 @@ import { ConfigType } from '../../../config'; import { StripeHelper } from '../../payments/stripe'; import { AuthLogger } from '../../types'; import { appleIapRoutes } from './apple'; +import { emailCapabilityRoutes } from './email-capability'; import { googleIapRoutes } from './google'; import { mozillaSubscriptionRoutes } from './mozilla'; import { paypalNotificationRoutes } from './paypal-notifications'; @@ -35,6 +36,8 @@ export const createRoutes = ( return routes; } + routes.push(...emailCapabilityRoutes(log, db)); + if (stripeHelper) { routes.push( ...stripeRoutes( diff --git a/packages/fxa-auth-server/test/support/jest-setup-env.ts b/packages/fxa-auth-server/test/support/jest-setup-env.ts index 6358dff8ef4..2a108f630e9 100644 --- a/packages/fxa-auth-server/test/support/jest-setup-env.ts +++ b/packages/fxa-auth-server/test/support/jest-setup-env.ts @@ -7,6 +7,12 @@ * Sets environment variables that affect module loading. */ +// Load reflect-metadata before anything else so any module that pulls in a +// class-transformer / class-validator decorator at load time finds +// `Reflect.getMetadata` available. Production loads it via NestJS bootstrap; +// the integration-test harness doesn't. +import 'reflect-metadata'; + process.env.NODE_ENV = 'dev'; process.env.FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY = 'true'; process.env.TRACING_SERVICE_NAME = 'fxa-auth-server-test'; diff --git a/packages/fxa-settings/src/components/Settings/Nav/index.test.tsx b/packages/fxa-settings/src/components/Settings/Nav/index.test.tsx index d25f54f6932..a7ade2f1bc2 100644 --- a/packages/fxa-settings/src/components/Settings/Nav/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/Nav/index.test.tsx @@ -99,6 +99,30 @@ describe('Nav', () => { ); }); + it('renders the subscriptions link for a B2B-allowlisted user with no paid subs', () => { + const account = { + primaryEmail: { + email: 'stomlinson@mozilla.com', + }, + subscriptions: [], + hasFreeAccess: true, + linkedAccounts: [], + } as unknown as Account; + renderWithLocalizationProvider( + +