From 5691787eaf1b97bb6d4351cde89d7f06fd0ddd10 Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Wed, 2 Sep 2026 20:44:02 +0100 Subject: [PATCH 1/7] Add Gandalf sign-in gate rules and Auxia bypass Gandalf (the marketing name for the Guardian-managed sign-in gate journey) is a 100% rollout run entirely by Guardian rules with no Auxia involvement, active for any country listed in the new gandalfSignInGateCountries channel switch (currently New Zealand; adding a country is a configuration change with no deploy). Readers in listed countries get three free eligible pageviews - tracked by a per-country client-side counter, since campaigns differ by country group - then a hardcoded non-dismissible popup treatment. Neither consent state triggers an Auxia GetTreatments or LogTreatmentInteraction call, and the RRCP banner suppression checker short-circuits for listed countries so no banner traffic reaches Auxia either. An absent or empty country list preserves current behaviour, which is the rollback path. Eligible surfaces are the Guardian metadata values Network Front, Section, Tag, Audio, Crossword, Gallery, Interactive, LiveBlog, ImageContent and Video, minus the unified legal/customer-service/Filter exclusions. --- docs/auxia.md | 15 + docs/signinGate.md | 35 ++ src/server/api/auxiaProxyRouter.ts | 11 +- src/server/api/bannerRouter.ts | 6 +- src/server/channelSwitches.ts | 5 + src/server/lib/auxia.test.ts | 135 ++++++ src/server/lib/auxia.ts | 14 + src/server/signin-gate/libEffect.ts | 23 + src/server/signin-gate/libPure.ts | 192 ++++++++ .../enableAuxia.test.ts | 14 +- .../gandalf.test.ts | 421 ++++++++++++++++++ .../ireland.test.ts | 8 +- .../special-cases.test.ts | 16 +- .../world-without-ireland.test.ts | 12 +- .../hideSupportMessagingHasOverride.test.ts | 6 +- src/server/signin-gate/logic.md | 63 +++ src/server/signin-gate/types.ts | 40 +- 17 files changed, 984 insertions(+), 32 deletions(-) create mode 100644 src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts diff --git a/docs/auxia.md b/docs/auxia.md index 372918b01..209d113ee 100644 --- a/docs/auxia.md +++ b/docs/auxia.md @@ -1,7 +1,9 @@ ## Auxia + Auxia is a service that uses ML models to optimise messaging. We use it in support-dotcom-components. Currently there are two uses: + - [Sign-in gate](signinGate.md) - Banners @@ -12,9 +14,11 @@ support-dotcom-components uses the API to find out if a message should be displa ## Uses ### Sign-in gate + [See separate doc.](signinGate.md) ### Banners + We are trialling using Auxia for banner decision making. In the first experiment we ask Auxia whether or not to suppress the banner. If Auxia does not suppress the banner then we use the existing rules. @@ -26,3 +30,14 @@ Auxia is consulted when the `enableAuxiaForBanners` switch is on, a browserId is If Auxia is consulted and a banner is returned then we also track events on the client using Auxia's `LogTreatmentInteraction` endpoint. We proxy these requests via `/banner/interaction`. See [auxia.ts](../src/server/lib/auxia.ts) for implementation. + +### Gandalf bypass + +While the reader's country appears in the `gandalfSignInGateCountries` channel +switch list, they are never sent to Auxia on either channel: the sign-in gate +is fully Guardian-managed (see [signinGate.md](signinGate.md)) and the banner +suppression checker short-circuits before contacting Auxia, so banners are not +suppressed, the logged status stays `not-consulted`, and no Auxia treatment is +attached to the banner response (which also prevents client-side Auxia +interaction events). Removing a country from the list restores the previous +behaviour for that country. diff --git a/docs/signinGate.md b/docs/signinGate.md index 216a684eb..bde8b1af3 100644 --- a/docs/signinGate.md +++ b/docs/signinGate.md @@ -1,13 +1,16 @@ ## Sign-in gate + DCR displays sign-in gates on article pages. Some of these gates are managed by Auxia. Auxia is a third-party that uses ML to optimise messaging on the site. ### Architecture + [Architecture diagram](https://docs.google.com/drawings/d/1zynyGMqXekhNFQpLkzAdHqyt9iQy_RQ-kR7jFGsU5K0/edit). DCR article pages make a request to SDC's `/auxia/get-treatments` endpoint. This endpoint may return a "treatment", which is the configuration for a gate. SDC will decide which treatment (if any) to return based on either: + 1. An API call to Auxia, for browsers which are eligible and consented, 2. Hardcoded config in SDC, for all other browsers @@ -19,7 +22,39 @@ For details of the current configuration, see [logic.md](/src/server/signin-gate SDC also has an endpoint for tracking interactions (view/click) with the gate: `/auxia/log-treatment-interaction`. These events are forwarded on to Auxia, and are independent of the standard Ophan tracking. +### Gandalf sign-in gate (Guardian-managed journey) + +"Gandalf" is the marketing name for the Guardian-managed sign-in gate journey: +a 100% rollout run entirely by Guardian rules, with no Auxia involvement. It is +active for any reader whose country code (case-insensitively) appears in the +`gandalfSignInGateCountries` channel switch list (currently New Zealand). For +listed countries SDC owns the rules entirely and Auxia is bypassed: + +- the first three eligible pageviews are free (the response carries the + `gandalfSignInGate` marker with no treatment, so DCR counts the pageview + but shows no gate); +- from the fourth eligible pageview onwards SDC returns a hardcoded + Guardian-managed non-dismissible popup treatment + (`NONDISMISSIBLE_SIGN_IN_GATE_POPUP`); +- no Auxia GetTreatments or LogTreatmentInteraction request is made for either + consent state; +- the eligible surfaces are the Guardian metadata values Network Front, + Section, Tag, Audio, Crossword, Gallery, Interactive, LiveBlog, ImageContent + and Video, minus the exclusions listed in [logic.md](/src/server/signin-gate/logic.md); +- the client keeps one pageview counter per country (campaigns differ by + country group); +- Ophan events use a stable Gandalf identity (`GandalfSignInGate`, variant + `gandalf-`) instead of the Auxia test metadata. This is not an A/B + test. + +Adding a country is a configuration change (add its ISO code to the list in +the Channel Switches UI). Removing a country — or the field being absent from +`channel-switches.json` — restores that country's previous behaviour, which is +the rollback path. + ### Data + From the Guardian's perspective, Auxia gets its data for ML model training and analytics in two ways: + 1. ingestion of data from BigQuery (the datalake) 2. log treatment interactions - view and click events that we send to their API, via SDC diff --git a/src/server/api/auxiaProxyRouter.ts b/src/server/api/auxiaProxyRouter.ts index 2786b4964..32b227d78 100644 --- a/src/server/api/auxiaProxyRouter.ts +++ b/src/server/api/auxiaProxyRouter.ts @@ -79,8 +79,15 @@ export const buildAuxiaProxyRouter = ( try { const now = Date.now(); // current time in milliseconds since epoch const payload = req.body as GetTreatmentsRequestPayload; - const { enableAuxia } = channelSwitches.get(); - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, enableAuxia); + const { enableAuxia, gandalfSignInGateCountries } = channelSwitches.get(); + const gateType = getTreatmentsRequestPayloadToGateType( + payload, + now, + enableAuxia, + // Tolerate old switch documents without the field: an + // absent list means the Gandalf journey is off everywhere. + gandalfSignInGateCountries ?? [], + ); const envelop = await gateTypeToUserTreatmentsEnvelop(config, gateType, payload); if (envelop !== undefined) { const data = userTreatmentsEnvelopToProxyGetTreatmentsAnswerData(envelop); diff --git a/src/server/api/bannerRouter.ts b/src/server/api/bannerRouter.ts index 4ec4a281f..d1b984bb0 100644 --- a/src/server/api/bannerRouter.ts +++ b/src/server/api/bannerRouter.ts @@ -215,7 +215,11 @@ export const buildBannerRouter = ( checkAuxiaSuppression, forLogging: auxiaStatus, getTreatment, - } = auxia.getBannerSuppressedChecker(channelSwitches.get(), targeting.mvtId); + } = auxia.getBannerSuppressedChecker( + channelSwitches.get(), + targeting.mvtId, + targeting.countryCode, + ); const response = await buildBannerData( targeting, diff --git a/src/server/channelSwitches.ts b/src/server/channelSwitches.ts index 6bece8f7b..664b36ea1 100644 --- a/src/server/channelSwitches.ts +++ b/src/server/channelSwitches.ts @@ -15,6 +15,11 @@ export interface ChannelSwitches { enableMParticle: boolean; enableAuxia: boolean; // for sign-in gates enableAuxiaForBanners: boolean; + // Gandalf: marketing name for the Guardian-managed sign-in gate journey + // (100% Guardian-owned rules, no Auxia). Countries listed here (ISO codes, + // case-insensitive) run the Gandalf journey; an absent or empty list means + // it is off everywhere, which is the rollback path. + gandalfSignInGateCountries?: string[]; } const getSwitches = (): Promise => diff --git a/src/server/lib/auxia.test.ts b/src/server/lib/auxia.test.ts index af83ec5aa..5d7bb03d9 100644 --- a/src/server/lib/auxia.test.ts +++ b/src/server/lib/auxia.test.ts @@ -499,3 +499,138 @@ describe('Auxia.getBannerSuppressedChecker – mvtId rollout', () => { expect((global.fetch as jest.Mock).mock.calls.length).toBe(1); }); }); + +describe('Auxia.getBannerSuppressedChecker – Gandalf bypass', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not consult Auxia for a listed country', async () => { + const auxia = new Auxia(mockConfig); + const { checkAuxiaSuppression, forLogging, getTreatment } = + auxia.getBannerSuppressedChecker( + { ...mockChannelSwitches, gandalfSignInGateCountries: ['NZ'] }, + inRolloutMvtId, + 'NZ', + ); + + const result = await checkAuxiaSuppression('browser-id', mockAttributes); + + expect(result).toBe(false); + expect((global.fetch as jest.Mock).mock.calls.length).toBe(0); + expect(forLogging()).toBe('not-consulted'); + expect(getTreatment()).toBeUndefined(); + }); + + it('should match listed countries case-insensitively', async () => { + const auxia = new Auxia(mockConfig); + const { checkAuxiaSuppression, forLogging } = auxia.getBannerSuppressedChecker( + { ...mockChannelSwitches, gandalfSignInGateCountries: ['nz'] }, + inRolloutMvtId, + 'NZ', + ); + + const result = await checkAuxiaSuppression('browser-id', mockAttributes); + + expect(result).toBe(false); + expect((global.fetch as jest.Mock).mock.calls.length).toBe(0); + expect(forLogging()).toBe('not-consulted'); + }); + + it('should bypass Auxia for every listed country', async () => { + const auxia = new Auxia(mockConfig); + const { checkAuxiaSuppression, forLogging } = auxia.getBannerSuppressedChecker( + { ...mockChannelSwitches, gandalfSignInGateCountries: ['NZ', 'CA'] }, + inRolloutMvtId, + 'CA', + ); + + const result = await checkAuxiaSuppression('browser-id', mockAttributes); + + expect(result).toBe(false); + expect((global.fetch as jest.Mock).mock.calls.length).toBe(0); + expect(forLogging()).toBe('not-consulted'); + }); + + it('should keep consulting Auxia for listed countries when the list is empty', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + successResponse([makeUserTreatment(JSON.stringify({ show_banner: 'false' }))]), + ); + + const auxia = new Auxia(mockConfig); + const { checkAuxiaSuppression, forLogging } = auxia.getBannerSuppressedChecker( + { ...mockChannelSwitches, gandalfSignInGateCountries: [] }, + inRolloutMvtId, + 'NZ', + ); + + const result = await checkAuxiaSuppression('browser-id', mockAttributes); + + expect(result).toBe(true); + expect((global.fetch as jest.Mock).mock.calls.length).toBe(1); + expect(forLogging()).toBe('suppressed'); + }); + + it('should keep consulting Auxia for unlisted countries while the list is active', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + successResponse([makeUserTreatment(JSON.stringify({ show_banner: 'true' }))]), + ); + + const auxia = new Auxia(mockConfig); + const { checkAuxiaSuppression, forLogging, getTreatment } = + auxia.getBannerSuppressedChecker( + { ...mockChannelSwitches, gandalfSignInGateCountries: ['NZ'] }, + inRolloutMvtId, + 'GB', + ); + + const result = await checkAuxiaSuppression('browser-id', mockAttributes); + + expect(result).toBe(false); + expect((global.fetch as jest.Mock).mock.calls.length).toBe(1); + expect(forLogging()).toBe('not-suppressed'); + expect(getTreatment()).toEqual({ + treatmentId: 'tid-1', + treatmentTrackingId: 'ttid-1', + }); + }); + + it('should keep the current behaviour for a missing country code while the list is active', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + successResponse([makeUserTreatment(JSON.stringify({ show_banner: 'true' }))]), + ); + + const auxia = new Auxia(mockConfig); + const { checkAuxiaSuppression, forLogging } = auxia.getBannerSuppressedChecker( + { ...mockChannelSwitches, gandalfSignInGateCountries: ['NZ'] }, + inRolloutMvtId, + undefined, + ); + + const result = await checkAuxiaSuppression('browser-id', mockAttributes); + + // A missing country is never treated as listed: current behaviour applies. + expect((global.fetch as jest.Mock).mock.calls.length).toBe(1); + expect(forLogging()).toBe('not-suppressed'); + expect(result).toBe(false); + }); + + it('should tolerate old switch documents without the country list', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce( + successResponse([makeUserTreatment(JSON.stringify({ show_banner: 'true' }))]), + ); + + const auxia = new Auxia(mockConfig); + const { checkAuxiaSuppression, forLogging } = auxia.getBannerSuppressedChecker( + { ...mockChannelSwitches, gandalfSignInGateCountries: undefined }, + inRolloutMvtId, + 'NZ', + ); + + const result = await checkAuxiaSuppression('browser-id', mockAttributes); + + expect((global.fetch as jest.Mock).mock.calls.length).toBe(1); + expect(forLogging()).toBe('not-suppressed'); + expect(result).toBe(false); + }); +}); diff --git a/src/server/lib/auxia.ts b/src/server/lib/auxia.ts index 0cf1217f1..72f850668 100644 --- a/src/server/lib/auxia.ts +++ b/src/server/lib/auxia.ts @@ -248,10 +248,18 @@ export class Auxia { * - checkAuxiaSuppression: calls checkBannerSuppression and captures the result for logging * - forLogging: returns the cached status without making a request * - getTreatment: returns the cached auxia treatment data (if banner was not suppressed) + * + * countryCode is the reader's geolocation from the banner targeting payload. + * When the reader's country is in the gandalfSignInGateCountries channel + * switch (the Guardian-managed sign-in gate journey), Auxia is never + * consulted: the banner is not suppressed, the logged status stays + * 'not-consulted' and no treatment is exposed, so the client cannot send + * Auxia interaction events for the banner either. */ getBannerSuppressedChecker( channelSwitches: ChannelSwitches, mvtId: number, + countryCode?: string, ): { checkAuxiaSuppression: ( browserId: string, @@ -270,6 +278,12 @@ export class Auxia { if (!channelSwitches.enableAuxiaForBanners) { return false; } + const gandalfCountries = (channelSwitches.gandalfSignInGateCountries ?? []).map( + (country) => country.toUpperCase(), + ); + if (countryCode !== undefined && gandalfCountries.includes(countryCode.toUpperCase())) { + return false; + } if (!inAuxiaAudience(mvtId)) { return false; } diff --git a/src/server/signin-gate/libEffect.ts b/src/server/signin-gate/libEffect.ts index 52f92a4db..04461d63d 100644 --- a/src/server/signin-gate/libEffect.ts +++ b/src/server/signin-gate/libEffect.ts @@ -6,6 +6,7 @@ import type { AuxiaRouterConfig } from '../api/auxiaProxyRouter'; import { buildGetTreatmentsRequestPayload, buildLogTreatmentInteractionRequestPayload, + gandalfMandatoryPopupUserTreatment, guDismissibleUserTreatment, guMandatoryUserTreatment, } from './libPure'; @@ -158,6 +159,28 @@ export const gateTypeToUserTreatmentsEnvelop = async ( responseId: '', userTreatments: [guMandatoryUserTreatment()], }; + // ---------------------------------------------------------- + // Gandalf: the Guardian-managed sign-in gate journey + // (comment group: gandalf) + // + // Neither case calls Auxia. Both carry the gandalfSignInGate marker + // so the client can count the completed pageview and identify the + // Guardian-managed response. + case 'GandalfFreeView': + // No gate on this pageview: an empty userTreatments array produces + // a response with no userTreatment, but the marker still tells the + // client the pageview counted towards the free allowance. + return { + responseId: '', + userTreatments: [], + gandalfSignInGate: true, + }; + case 'GandalfMandatoryPopup': + return { + responseId: '', + userTreatments: [gandalfMandatoryPopupUserTreatment()], + gandalfSignInGate: true, + }; default: console.error('Unknown direction'); } diff --git a/src/server/signin-gate/libPure.ts b/src/server/signin-gate/libPure.ts index 9eaecc564..cf0da9b4e 100644 --- a/src/server/signin-gate/libPure.ts +++ b/src/server/signin-gate/libPure.ts @@ -124,6 +124,46 @@ export const guMandatoryUserTreatment = (): UserTreatment => { }; }; +export const gandalfMandatoryPopupUserTreatment = (): UserTreatment => { + // (comment group: gandalf) + // + // "Gandalf" is the marketing name for the Guardian-managed sign-in gate + // journey: a 100% rollout run entirely by Guardian rules with no Auxia + // involvement (currently New Zealand, extendable to further countries via + // the gandalfSignInGateCountries channel switch). + // + // The Guardian-managed hard gate. The copy matches guMandatoryUserTreatment, + // but the treatmentType uses the POPUP variant so the client renders the v2 + // modal (mounted on document.body) instead of the inline article gate. + // + // The treatmentId stays 'default-treatment-id' so the client's existing + // "do not call Auxia for default treatments" guard also applies here as + // defence in depth on top of the gandalfSignInGate response marker. + + const title = 'Sorry for the interruption'; + const subtitle = "Once you are signed in, we'll bring you back here shortly"; + const body = + 'We’re committed to keeping our quality reporting open. By registering and providing us with insight into your preferences, you’re helping us to engage with you more deeply, and that allows us to keep our journalism free for all.'; + const treatmentContent = { + title, + subtitle, + body, + first_cta_name: 'Create an account', + first_cta_link: 'https://profile.theguardian.com/signin', + second_cta_name: '', // empty string here makes the gate mandatory + }; + const treatmentContentEncoded = JSON.stringify(treatmentContent); + return { + treatmentId: 'default-treatment-id', + treatmentTrackingId: 'default-treatment-tracking-id', + rank: '1', + contentLanguageCode: 'en-GB', + treatmentContent: treatmentContentEncoded, + treatmentType: 'NONDISMISSIBLE_SIGN_IN_GATE_POPUP', + surface: 'ARTICLE_PAGE', + }; +}; + export const isValidContentType = (contentType: string): boolean => { const validTypes = ['Article']; return validTypes.includes(contentType); @@ -148,6 +188,95 @@ export const isValidTagIds = (tagIds: string[]): boolean => { return !tagIds.some((tagId: string): boolean => invalidTagIds.includes(tagId)); }; +// -------------------------------------------------------------- +// Gandalf (comment group: gandalf) +// +// "Gandalf" is the marketing name for the Guardian-managed sign-in gate +// journey: a 100% rollout, run entirely by Guardian rules with no Auxia +// involvement, currently live for New Zealand and extendable to further +// countries via the gandalfSignInGateCountries channel switch. +// +// Gandalf widens both the eligible content types and the exclusion list. +// These helpers are only consulted by the active Gandalf branch, so the +// existing global (Article-only) eligibility used by every other country is +// unchanged. + +// The exact Guardian content metadata values (see DotcomContentType in +// guardian/frontend). Note that CAPI "Picture" pages are sent as ImageContent, +// and fronts are sent as Network Front / Section / Tag. +const gandalfContentTypes = [ + 'Network Front', + 'Section', + 'Tag', + 'Audio', + 'Crossword', + 'Gallery', + 'Interactive', + 'LiveBlog', + 'ImageContent', + 'Video', +]; + +export const gandalfIsValidContentType = (contentType: string): boolean => { + // Case-insensitive so casing drift upstream cannot silently exclude a page. + const validTypes = gandalfContentTypes.map((type) => type.toLowerCase()); + return validTypes.includes(contentType.toLowerCase()); +}; + +export const gandalfIsValidSection = (sectionId: string): boolean => { + // Union of the global sign-in gate exclusions, The Filter US, and the + // legal/customer-service sections excluded across the reader revenue + // channels. + const invalidSections = [ + 'about', + 'info', + 'membership', + 'help', + 'guardian-live-australia', + 'gnm-archive', + 'thefilter', + 'thefilter-us', + ]; + return !invalidSections.includes(sectionId); +}; + +export const gandalfIsValidTagIds = (tagIds: string[]): boolean => { + const invalidTagIds = ['info/newsletter-sign-up']; + return !tagIds.some((tagId: string): boolean => invalidTagIds.includes(tagId)); +}; + +export const gandalfArticleIdentifierIsAllowed = (articleIdentifier: string): boolean => { + // Union of the global URL denials and the legal/customer-service page + // exclusions used by the wider reader revenue channels. + const denyPrefixes = [ + 'www.theguardian.com/tips', + 'www.theguardian.com/help/ng-interactive/2017/mar/17/contact-the-guardian-securely', + 'www.theguardian.com/info/privacy', + 'www.theguardian.com/info/complaints-and-corrections', + 'www.theguardian.com/the-whole-picture', + ]; + + return !denyPrefixes.some((denyIdentifer) => articleIdentifier.startsWith(denyIdentifer)); +}; + +export const gandalfPageMetadataIsEligibleForGateDisplay = ( + contentType: string, + sectionId: string, + tagIds: string[], +): boolean => { + return ( + gandalfIsValidContentType(contentType) && + gandalfIsValidSection(sectionId) && + gandalfIsValidTagIds(tagIds) + ); +}; + +// The free allowance: the first three eligible pageviews do not show a gate. +// The counter sent by the client is 0-based (number of previously completed +// eligible pageviews in the request's country), so 0, 1 and 2 are free and 3+ +// shows the hard popup. +export const GANDALF_FREE_PAGE_VIEW_COUNT = 3; + export const userTreatmentsEnvelopToProxyGetTreatmentsAnswerData = ( envelop: UserTreatmentsEnvelop, ): ProxyGetTreatmentsAnswerData | undefined => { @@ -165,6 +294,12 @@ export const userTreatmentsEnvelopToProxyGetTreatmentsAnswerData = ( return { responseId: envelop.responseId, userTreatment: envelop.userTreatments[0], + // Only include the marker when set so responses for countries outside + // the Gandalf list keep their exact previous shape (the existing tests + // use toStrictEqual). + ...(envelop.gandalfSignInGate !== undefined && { + gandalfSignInGate: envelop.gandalfSignInGate, + }), }; }; @@ -345,9 +480,13 @@ export const getTreatmentsRequestPayloadToGateType = ( payload: GetTreatmentsRequestPayload, now: number, enableAuxia: boolean, + gandalfSignInGateCountries: string[] | undefined, ): GateType => { // now: current time in milliseconds since epoch // enableAuxia: channel switch to enable/disable Auxia integration + // gandalfSignInGateCountries: channel switch listing the countries in the + // Gandalf sign-in gate journey (see channelSwitches.ts); undefined or + // empty disables the journey everywhere // This function is a pure function (without any side effects) which gets the body // of a '/auxia/get-treatments' request and returns the correct GateType @@ -355,6 +494,59 @@ export const getTreatmentsRequestPayloadToGateType = ( // which in the case of Auxia, requires an API call, but more importantly to // encapsulate and more logically test the logic of gate selection. + // -------------------------------------------------------------- + // Gandalf: the Guardian-managed sign-in gate journey + // (comment group: gandalf; "Gandalf" is the marketing name for this + // Guardian-owned, Auxia-free 100% rollout) + // + // Prerequisites: + // - the reader's country is listed in the gandalfSignInGateCountries + // channel switch (case-insensitive match on the config side; unknown or + // other countries are never treated as Gandalf countries) + // + // Effects: + // - Guardian drives the gate, Auxia is never consulted (no GetTreatments + // and no LogTreatmentInteraction for either consent state) + // - the first three eligible pageviews are free (the response carries the + // gandalfSignInGate marker with no treatment so the client counts the + // pageview but shows no gate) + // - from the fourth eligible pageview onwards the Guardian-managed + // non-dismissible popup is returned + // + // The special cases below (URL denials, page eligibility, newsshowcase + // override and the staff testing feature) are deliberately evaluated with + // the Gandalf lists. Pages excluded here return 'None' without the + // marker, so excluded pageviews neither show a gate nor consume the + // allowance. + + const gandalfCountries = (gandalfSignInGateCountries ?? []).map((country) => + country.toUpperCase(), + ); + if (gandalfCountries.includes(payload.countryCode)) { + if (!gandalfArticleIdentifierIsAllowed(payload.articleIdentifier)) { + return 'None'; + } + if ( + !gandalfPageMetadataIsEligibleForGateDisplay( + payload.contentType, + payload.sectionId, + payload.tagIds, + ) + ) { + return 'None'; + } + if (isOverridingConditionShowDismissibleGate(payload)) { + return 'GuDismissible'; + } + if (isStaffTestConditionShowDefaultGate(payload)) { + return staffTestConditionToDefaultGate(payload); + } + const gandalfPageViewCount = payload.gandalfPageViewCount ?? 0; + return gandalfPageViewCount < GANDALF_FREE_PAGE_VIEW_COUNT + ? 'GandalfFreeView' + : 'GandalfMandatoryPopup'; + } + // -------------------------------------------------------------- // We do not show the gate on some specific article urls diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/enableAuxia.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/enableAuxia.test.ts index 8ac024ac7..0e7842208 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/enableAuxia.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/enableAuxia.test.ts @@ -25,7 +25,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, []); // When Auxia is disabled, should use Guardian dismissible gate expect(gateType).toBe('GuDismissible'); @@ -52,7 +52,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); // When Auxia is enabled and user qualifies, should use Auxia expect(gateType).toBe('AuxiaAPI'); @@ -79,7 +79,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, []); expect(gateType).toBe('None'); }); @@ -104,7 +104,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, []); expect(gateType).toBe('None'); }); @@ -129,7 +129,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: now - 1000, // Less than 30 days }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, []); expect(gateType).toBe('None'); }); @@ -154,7 +154,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toBe('AuxiaAPI'); }); @@ -179,7 +179,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, []); // Falls back to Guardian logic even for Ireland expect(gateType).toBe('GuDismissible'); }); diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts new file mode 100644 index 000000000..ef804ea9c --- /dev/null +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts @@ -0,0 +1,421 @@ +import { + GANDALF_FREE_PAGE_VIEW_COUNT, + gandalfArticleIdentifierIsAllowed, + gandalfIsValidContentType, + gandalfIsValidSection, + gandalfIsValidTagIds, + getTreatmentsRequestPayloadToGateType, +} from '../../libPure'; +import type { GetTreatmentsRequestPayload } from '../../types'; + +const now = 1756568322187; // current time in milliseconds since epoch + +const buildPayload = ( + overrides: Partial = {}, +): GetTreatmentsRequestPayload => ({ + browserId: 'sample', + isSupporter: false, + dailyArticleCount: 5, + articleIdentifier: 'www.theguardian.com/world/2026/sep/01/sample-article', + editionId: 'AU', + contentType: 'LiveBlog', + sectionId: 'world', + tagIds: ['type/article'], + gateDismissCount: 0, + countryCode: 'NZ', + mvtId: 250_000, + should_show_legacy_gate_tmp: false, + hasConsented: true, + shouldServeDismissible: false, + showDefaultGate: undefined, + gateDisplayCount: 0, + hideSupportMessagingTimestamp: undefined, + gandalfPageViewCount: 0, + ...overrides, +}); + +describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { + describe('country not in the list preserves the current behaviour', () => { + it('consented readers still go to Auxia when the country is not listed', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: true, contentType: 'Article' }), + now, + true, + [], + ); + expect(gateType).toBe('AuxiaAPI'); + }); + + it('un-consented readers still get Auxia analytics then Guardian rules when the country is not listed', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + hasConsented: false, + dailyArticleCount: 5, + contentType: 'Article', + }), + now, + true, + [], + ); + expect(gateType).toBe('AuxiaAnalyticsThenGuDismissible'); + }); + + it('a missing country list is treated as empty', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: true, contentType: 'Article' }), + now, + true, + // simulates an old S3 switch document without the field + undefined, + ); + expect(gateType).toBe('AuxiaAPI'); + }); + }); + + describe('country list membership', () => { + it('activates every country in the list', () => { + const countries = ['NZ', 'CA']; + for (const countryCode of countries) { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ countryCode, gandalfPageViewCount: 0 }), + now, + true, + ['nz', 'ca'], + ); + expect(gateType).toBe('GandalfFreeView'); + } + }); + + it('matches list entries case-insensitively', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ countryCode: 'NZ' }), + now, + true, + ['nz'], + ); + expect(gateType).toBe('GandalfFreeView'); + }); + + it('does not activate countries outside the list', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ countryCode: 'IE', hasConsented: true, contentType: 'Article' }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('AuxiaAPI'); + }); + + it('an unknown country is never treated as a Gandalf country', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + countryCode: '', + mvtId: 450_000, + hasConsented: true, + contentType: 'Article', + }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GuDismissible'); + }); + + it('lowercase nz in the payload is not matched (country codes are uppercase)', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + countryCode: 'nz', + mvtId: 450_000, + hasConsented: true, + contentType: 'Article', + }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GuDismissible'); + }); + }); + + describe('free pageviews (0-based counter)', () => { + it.each([0, 1, 2])('returns GandalfFreeView for count %i (consented)', (count) => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: true, gandalfPageViewCount: count }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GandalfFreeView'); + }); + + it.each([0, 1, 2])('returns GandalfFreeView for count %i (un-consented)', (count) => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: false, gandalfPageViewCount: count }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GandalfFreeView'); + }); + + it('treats a missing counter from an old client as 0', () => { + const payload = buildPayload({ hasConsented: true }); + delete payload.gandalfPageViewCount; + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, ['NZ']); + expect(gateType).toBe('GandalfFreeView'); + }); + + it(`uses the free allowance constant of ${GANDALF_FREE_PAGE_VIEW_COUNT}`, () => { + expect(GANDALF_FREE_PAGE_VIEW_COUNT).toBe(3); + }); + }); + + describe('hard gate from the fourth eligible pageview', () => { + it.each([3, 4, 10])('returns GandalfMandatoryPopup for count %i (consented)', (count) => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: true, gandalfPageViewCount: count }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GandalfMandatoryPopup'); + }); + + it.each([3, 4, 10])( + 'returns GandalfMandatoryPopup for count %i (un-consented)', + (count) => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: false, gandalfPageViewCount: count }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GandalfMandatoryPopup'); + }, + ); + + it('ignores legacy dismissal and display counters', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + hasConsented: true, + gandalfPageViewCount: 0, + gateDismissCount: 9, + gateDisplayCount: 9, + }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GandalfFreeView'); + }); + + it('ignores the supporter/hide-support-messaging state', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + hasConsented: true, + gandalfPageViewCount: 3, + hideSupportMessagingTimestamp: now - 1000, + }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GandalfMandatoryPopup'); + }); + }); + + describe('eligible Guardian content metadata', () => { + it.each([ + 'Network Front', + 'Section', + 'Tag', + 'Audio', + 'Crossword', + 'Gallery', + 'Interactive', + 'LiveBlog', + 'ImageContent', + 'Video', + ])('accepts %s', (contentType) => { + expect(gandalfIsValidContentType(contentType)).toBe(true); + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ contentType }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GandalfFreeView'); + }); + + it('matches content types case-insensitively', () => { + expect(gandalfIsValidContentType('liveblog')).toBe(true); + expect(gandalfIsValidContentType('network front')).toBe(true); + }); + + it.each(['Article', 'Picture', 'Survey', 'Signup', ''])( + 'does not accept %s', + (contentType) => { + expect(gandalfIsValidContentType(contentType)).toBe(false); + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ contentType }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('None'); + }, + ); + }); + + describe('exclusions', () => { + it.each([ + 'about', + 'info', + 'membership', + 'help', + 'guardian-live-australia', + 'gnm-archive', + 'thefilter', + 'thefilter-us', + ])('excludes section %s', (sectionId) => { + expect(gandalfIsValidSection(sectionId)).toBe(false); + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ sectionId }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('None'); + }); + + it('excludes the newsletter sign-up tag', () => { + expect(gandalfIsValidTagIds(['info/newsletter-sign-up'])).toBe(false); + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ tagIds: ['info/newsletter-sign-up'] }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('None'); + }); + + it.each([ + 'www.theguardian.com/tips', + 'www.theguardian.com/help/ng-interactive/2017/mar/17/contact-the-guardian-securely', + 'www.theguardian.com/info/privacy', + 'www.theguardian.com/info/complaints-and-corrections', + 'www.theguardian.com/the-whole-picture', + ])('excludes page %s', (articleIdentifier) => { + expect(gandalfArticleIdentifierIsAllowed(articleIdentifier)).toBe(false); + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ articleIdentifier }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('None'); + }); + + it('allows ordinary articles and front pages', () => { + expect( + gandalfArticleIdentifierIsAllowed( + 'www.theguardian.com/world/2026/sep/01/sample-article', + ), + ).toBe(true); + expect(gandalfArticleIdentifierIsAllowed('www.theguardian.com/uk')).toBe(true); + }); + }); + + describe('deliberate overrides', () => { + it('newsshowcase still receives the dismissible gate', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ shouldServeDismissible: true }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GuDismissible'); + }); + + it('staff showgate=mandatory still receives the Gu mandatory gate', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ showDefaultGate: 'mandatory' }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GuMandatory'); + }); + + it('staff showgate=dismissible still receives the Gu dismissible gate', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ showDefaultGate: 'dismissible' }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GuDismissible'); + }); + }); + + describe('non-Gandalf traffic is unaffected while the list is active', () => { + it('consented GB readers in the Auxia share still go to Auxia', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + countryCode: 'GB', + mvtId: 100_000, + hasConsented: true, + contentType: 'Article', + }), + now, + true, + ['NZ', 'CA'], + ); + expect(gateType).toBe('AuxiaAPI'); + }); + + it('consented GB readers outside the Auxia share keep the Guardian fallback', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + countryCode: 'GB', + mvtId: 450_000, + hasConsented: true, + contentType: 'Article', + }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GuDismissible'); + }); + + it('Ireland keeps its mandatory rollout behaviour when not listed', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + countryCode: 'IE', + hasConsented: true, + contentType: 'Article', + }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('AuxiaAPI'); + }); + + it('a listed country other than NZ takes the Gandalf journey', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + countryCode: 'CA', + hasConsented: true, + contentType: 'LiveBlog', + gandalfPageViewCount: 3, + }), + now, + true, + ['NZ', 'CA'], + ); + expect(gateType).toBe('GandalfMandatoryPopup'); + }); + }); +}); diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/ireland.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/ireland.test.ts index 3f284fabb..52f9166d6 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/ireland.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/ireland.test.ts @@ -32,7 +32,7 @@ describe('getTreatmentsRequestPayloadToGateType (ireland)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('AuxiaAPI'); }); @@ -70,7 +70,7 @@ describe('getTreatmentsRequestPayloadToGateType (ireland)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('AuxiaAnalyticsThenNone'); }); @@ -108,7 +108,7 @@ describe('getTreatmentsRequestPayloadToGateType (ireland)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('AuxiaAnalyticsThenGuDismissible'); }); @@ -146,7 +146,7 @@ describe('getTreatmentsRequestPayloadToGateType (ireland)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('AuxiaAnalyticsThenGuMandatory'); }); }); diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/special-cases.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/special-cases.test.ts index a714bb8a9..22a32a62c 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/special-cases.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/special-cases.test.ts @@ -23,7 +23,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('None'); }); @@ -48,7 +48,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('None'); }); @@ -73,7 +73,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('None'); }); @@ -98,7 +98,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('None'); }); @@ -123,7 +123,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('GuDismissible'); }); @@ -148,7 +148,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('GuDismissible'); }); @@ -173,7 +173,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('GuMandatory'); }); @@ -198,7 +198,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('GuDismissible'); }); }); diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/world-without-ireland.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/world-without-ireland.test.ts index 03a46c7f5..c98ea8c19 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/world-without-ireland.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/world-without-ireland.test.ts @@ -37,7 +37,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('None'); }); it('logic.md [03], first dismissible gates', () => { @@ -75,7 +75,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('GuDismissible'); }); it('logic.md [03], high gate dismiss count', () => { @@ -113,7 +113,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('None'); }); @@ -138,7 +138,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('AuxiaAnalyticsThenNone'); }); @@ -163,7 +163,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('AuxiaAnalyticsThenGuDismissible'); }); @@ -188,7 +188,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('AuxiaAnalyticsThenNone'); }); }); diff --git a/src/server/signin-gate/libPureTests/hideSupportMessagingHasOverride.test.ts b/src/server/signin-gate/libPureTests/hideSupportMessagingHasOverride.test.ts index a04c6e548..e7068848e 100644 --- a/src/server/signin-gate/libPureTests/hideSupportMessagingHasOverride.test.ts +++ b/src/server/signin-gate/libPureTests/hideSupportMessagingHasOverride.test.ts @@ -180,7 +180,7 @@ it('getTreatmentsRequestPayloadToGateType, without override', () => { hideSupportMessagingTimestamp: undefined, // <- no override }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('GuDismissible'); }); @@ -219,7 +219,7 @@ it('getTreatmentsRequestPayloadToGateType, with override', () => { hideSupportMessagingTimestamp: 1755644400000, // <- tested: 2025-08-20 00:00:00 +0100 }; const now = 1756568890120; // 2025-08-30 16:48:10 +0100 (less than 30 days) - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('None'); }); @@ -257,6 +257,6 @@ it('getTreatmentsRequestPayloadToGateType, with override, ireland with Auxia Ana hideSupportMessagingTimestamp: 1755644400000, // <- tested: 2025-08-20 00:00:00 +0100 }; const now = 1756568890120; // 2025-08-30 16:48:10 +0100 (less than 30 days) - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); expect(gateType).toStrictEqual('AuxiaAnalyticsThenNone'); // Instead of AuxiaAnalyticsThenGuDismissible }); diff --git a/src/server/signin-gate/logic.md b/src/server/signin-gate/logic.md index 4e690b773..1f549a9fa 100644 --- a/src/server/signin-gate/logic.md +++ b/src/server/signin-gate/logic.md @@ -120,3 +120,66 @@ except for the UK (countryCode 'GB') where it is reduced to the first 20% (1 to [01] use gu_hide_support_messaging cookie ``` + +### Gandalf (Guardian-managed sign-in gate journey) + +"Gandalf" is the marketing name for this journey: a 100% rollout run entirely by +Guardian rules with no Auxia involvement. + +Active for a reader when their country code (case-insensitively) appears in the +`gandalfSignInGateCountries` channel switch list. Unknown or unlisted countries +never enter this section and follow the rules above. Removing a country from +the list (or clearing the list, or the field being absent from +`channel-switches.json`) restores that country's previous behaviour — the +rollback path. Counters are per country, because campaigns differ by country +group. + +This journey is 100% rollout, not an A/B test: no MVT/audience-share allocation +is used, and Auxia is never consulted — no GetTreatments call and no +LogTreatmentInteraction call, for consented and un-consented readers alike. + +The eligible content types are the Guardian metadata values: Network Front, +Section, Tag (fronts), Audio, Crossword, Gallery, Interactive, LiveBlog, +ImageContent (CAPI Picture pages) and Video. + +Excluded pages (legal/customer-service pages, The Filter, newsletter sign-up +tags, tips, the secure-contact page, privacy, complaints-and-corrections and +the-whole-picture) neither show a gate nor advance the counter. + +The client sends `gandalfPageViewCount`: the 0-based number of eligible +pageviews it has already counted for the request's country (a dedicated +persistent per-country client counter, not `dailyArticleCount` or +`gateDisplayCount`). The response carries the `gandalfSignInGate` marker on +both outcomes below so the client can count the pageview and identify +Guardian-managed responses. + +``` + ---------------------------------------------- + | [G1] | + | | + | - No Auxia request | + 0 <= count | - No gate displayed | + < 3 | - Response carries the gandalfSignInGate | + | marker so the client counts the pageview | + | | + -----------|----------------------------------------------- + | [G2] | + | | + | - No Auxia request | + count >= 3 | - Guardian drives the gate: | + | - Non-dismissible sign-in popup | + | (NONDISMISSIBLE_SIGN_IN_GATE_POPUP) | + | - Persists until the reader signs in | + | | + -----------|----------------------------------------------- + +Special cases (evaluated with the Gandalf exclusion lists): +- denied URLs and ineligible pages: no gate, no marker, counter not advanced +- shouldServeDismissible (newsshowcase): GuDismissible, as today +- staff showDefaultGate override: Gu default gates, as today +``` + +Reporting: the client emits the standard Ophan SIGN_IN_GATE view/click events +under a stable Gandalf identity (`GandalfSignInGate`, variant +`gandalf-`). This is reporting metadata only — there is no A/B +test allocation. diff --git a/src/server/signin-gate/types.ts b/src/server/signin-gate/types.ts index 9618fe15b..626cdd659 100644 --- a/src/server/signin-gate/types.ts +++ b/src/server/signin-gate/types.ts @@ -30,6 +30,7 @@ export interface AuxiaAPISurface { export interface ProxyGetTreatmentsAnswerData { responseId: string; userTreatment?: UserTreatment; + gandalfSignInGate?: boolean; // [7] gandalfSignInGate } export interface AuxiaAPILogTreatmentInteractionRequestPayload { @@ -64,6 +65,7 @@ export interface AuxiaAPIGetTreatmentsRequestPayload { export interface UserTreatmentsEnvelop { responseId: string; userTreatments: UserTreatment[]; + gandalfSignInGate?: boolean; // [7] gandalfSignInGate } export type GateType = @@ -73,7 +75,9 @@ export type GateType = | 'AuxiaAPI' // [4] | 'AuxiaAnalyticsThenNone' // [5] | 'AuxiaAnalyticsThenGuDismissible' // [6] - | 'AuxiaAnalyticsThenGuMandatory'; // [7] + | 'AuxiaAnalyticsThenGuMandatory' // [7] + | 'GandalfFreeView' // [8] + | 'GandalfMandatoryPopup'; // [9] // [1] Signals no gate to display // [2] Signals the Gu Dismissible gate @@ -82,6 +86,11 @@ export type GateType = // [5] Here, we query Auxia for analytics, but then show no gate // [6] Here, we query Auxia for analytics but do not return the result and instead return the Gu Dismissible gate // [7] Same as [5] but we return the Gu Mandatory gate +// [8] GandalfFreeView: no gate on this pageview, but the response carries the +// gandalfSignInGate marker so the client can count the completed pageview +// [9] GandalfMandatoryPopup: return the Guardian-managed non-dismissible popup +// gate. No Auxia request is made for either Gandalf response (see +// [7] gandalfSignInGate below). type ShowGateValues = 'true' | 'mandatory' | 'dismissible' | undefined; @@ -103,6 +112,7 @@ export interface GetTreatmentsRequestPayload { showDefaultGate: ShowGateValues; // [4] gateDisplayCount: number; // [5] hideSupportMessagingTimestamp: number | undefined; // [6] + gandalfPageViewCount?: number; // [8] gandalfPageViewCount } // [1] articleIdentifier examples: @@ -161,3 +171,31 @@ export interface GetTreatmentsRequestPayload { // of not showing the gate if the reader has performed a single contribution in the past 30 days. // It is either undefined or return the timestamp carried by cookie `gu_hide_support_messaging` // See: https://github.com/guardian/support-frontend/blob/7a5c0f9209054c24934b876771392531c261f51c/support-frontend/assets/helpers/storage/contributionsCookies.ts#L11 + +// [7] gandalfSignInGate (comment group: gandalf) +// +// date: 2nd September 2026 +// +// "Gandalf" is the marketing name for the Guardian-managed sign-in gate +// journey: a 100% rollout, run entirely by Guardian rules with no Auxia +// involvement, currently live for New Zealand and extendable to further +// countries via the gandalfSignInGateCountries channel switch. +// +// `gandalfSignInGate` marks responses produced by the active Gandalf rules +// (the GandalfFreeView and GandalfMandatoryPopup gate types). It is present +// (true) on both so the client can: +// - count the completed eligible pageview even when no gate is displayed; +// - identify Guardian-managed treatments and skip every Auxia interaction call; +// - report to Ophan under a stable Gandalf identity instead of Auxia's. +// +// [8] gandalfPageViewCount +// +// date: 2nd September 2026 +// +// `gandalfPageViewCount` is the 0-based number of eligible pageviews the +// reader has already completed in the request's country under the active +// Gandalf rules. Counters are per country (campaigns differ by country +// group). The field is optional so that older clients (and traffic outside +// the Gandalf countries) remain compatible; a missing value is treated as 0. +// The client increments its persistent per-country counter only after +// receiving a response carrying the gandalfSignInGate marker. From ba4d4b56b7ce5f743691eb2b3ef03283ca9585d8 Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Tue, 15 Sep 2026 12:05:15 +0100 Subject: [PATCH 2/7] Revert Gandalf sign-in gate rules and Auxia bypass Remove the Gandalf country-based bypass logic that prevented Auxia consultation for readers in gandalfSignInGateCountries. The getBannerSuppressedChecker method no longer accepts countryCode parameter or checks the gandalfSignInGateCountries channel switch. Update gandalfMandatoryPopupUserTreatment to use Gandalf-specific treatment IDs ('gandalf-mandatory-popup') instead of 'default-treatment-id' to distinguish Gandalf popup events in Ophan analysis --- src/server/api/bannerRouter.ts | 6 +- src/server/lib/auxia.test.ts | 135 ------------------ src/server/lib/auxia.ts | 14 -- src/server/signin-gate/libPure.ts | 12 +- .../gandalf.test.ts | 10 ++ 5 files changed, 18 insertions(+), 159 deletions(-) diff --git a/src/server/api/bannerRouter.ts b/src/server/api/bannerRouter.ts index d1b984bb0..4ec4a281f 100644 --- a/src/server/api/bannerRouter.ts +++ b/src/server/api/bannerRouter.ts @@ -215,11 +215,7 @@ export const buildBannerRouter = ( checkAuxiaSuppression, forLogging: auxiaStatus, getTreatment, - } = auxia.getBannerSuppressedChecker( - channelSwitches.get(), - targeting.mvtId, - targeting.countryCode, - ); + } = auxia.getBannerSuppressedChecker(channelSwitches.get(), targeting.mvtId); const response = await buildBannerData( targeting, diff --git a/src/server/lib/auxia.test.ts b/src/server/lib/auxia.test.ts index 5d7bb03d9..af83ec5aa 100644 --- a/src/server/lib/auxia.test.ts +++ b/src/server/lib/auxia.test.ts @@ -499,138 +499,3 @@ describe('Auxia.getBannerSuppressedChecker – mvtId rollout', () => { expect((global.fetch as jest.Mock).mock.calls.length).toBe(1); }); }); - -describe('Auxia.getBannerSuppressedChecker – Gandalf bypass', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should not consult Auxia for a listed country', async () => { - const auxia = new Auxia(mockConfig); - const { checkAuxiaSuppression, forLogging, getTreatment } = - auxia.getBannerSuppressedChecker( - { ...mockChannelSwitches, gandalfSignInGateCountries: ['NZ'] }, - inRolloutMvtId, - 'NZ', - ); - - const result = await checkAuxiaSuppression('browser-id', mockAttributes); - - expect(result).toBe(false); - expect((global.fetch as jest.Mock).mock.calls.length).toBe(0); - expect(forLogging()).toBe('not-consulted'); - expect(getTreatment()).toBeUndefined(); - }); - - it('should match listed countries case-insensitively', async () => { - const auxia = new Auxia(mockConfig); - const { checkAuxiaSuppression, forLogging } = auxia.getBannerSuppressedChecker( - { ...mockChannelSwitches, gandalfSignInGateCountries: ['nz'] }, - inRolloutMvtId, - 'NZ', - ); - - const result = await checkAuxiaSuppression('browser-id', mockAttributes); - - expect(result).toBe(false); - expect((global.fetch as jest.Mock).mock.calls.length).toBe(0); - expect(forLogging()).toBe('not-consulted'); - }); - - it('should bypass Auxia for every listed country', async () => { - const auxia = new Auxia(mockConfig); - const { checkAuxiaSuppression, forLogging } = auxia.getBannerSuppressedChecker( - { ...mockChannelSwitches, gandalfSignInGateCountries: ['NZ', 'CA'] }, - inRolloutMvtId, - 'CA', - ); - - const result = await checkAuxiaSuppression('browser-id', mockAttributes); - - expect(result).toBe(false); - expect((global.fetch as jest.Mock).mock.calls.length).toBe(0); - expect(forLogging()).toBe('not-consulted'); - }); - - it('should keep consulting Auxia for listed countries when the list is empty', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce( - successResponse([makeUserTreatment(JSON.stringify({ show_banner: 'false' }))]), - ); - - const auxia = new Auxia(mockConfig); - const { checkAuxiaSuppression, forLogging } = auxia.getBannerSuppressedChecker( - { ...mockChannelSwitches, gandalfSignInGateCountries: [] }, - inRolloutMvtId, - 'NZ', - ); - - const result = await checkAuxiaSuppression('browser-id', mockAttributes); - - expect(result).toBe(true); - expect((global.fetch as jest.Mock).mock.calls.length).toBe(1); - expect(forLogging()).toBe('suppressed'); - }); - - it('should keep consulting Auxia for unlisted countries while the list is active', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce( - successResponse([makeUserTreatment(JSON.stringify({ show_banner: 'true' }))]), - ); - - const auxia = new Auxia(mockConfig); - const { checkAuxiaSuppression, forLogging, getTreatment } = - auxia.getBannerSuppressedChecker( - { ...mockChannelSwitches, gandalfSignInGateCountries: ['NZ'] }, - inRolloutMvtId, - 'GB', - ); - - const result = await checkAuxiaSuppression('browser-id', mockAttributes); - - expect(result).toBe(false); - expect((global.fetch as jest.Mock).mock.calls.length).toBe(1); - expect(forLogging()).toBe('not-suppressed'); - expect(getTreatment()).toEqual({ - treatmentId: 'tid-1', - treatmentTrackingId: 'ttid-1', - }); - }); - - it('should keep the current behaviour for a missing country code while the list is active', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce( - successResponse([makeUserTreatment(JSON.stringify({ show_banner: 'true' }))]), - ); - - const auxia = new Auxia(mockConfig); - const { checkAuxiaSuppression, forLogging } = auxia.getBannerSuppressedChecker( - { ...mockChannelSwitches, gandalfSignInGateCountries: ['NZ'] }, - inRolloutMvtId, - undefined, - ); - - const result = await checkAuxiaSuppression('browser-id', mockAttributes); - - // A missing country is never treated as listed: current behaviour applies. - expect((global.fetch as jest.Mock).mock.calls.length).toBe(1); - expect(forLogging()).toBe('not-suppressed'); - expect(result).toBe(false); - }); - - it('should tolerate old switch documents without the country list', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce( - successResponse([makeUserTreatment(JSON.stringify({ show_banner: 'true' }))]), - ); - - const auxia = new Auxia(mockConfig); - const { checkAuxiaSuppression, forLogging } = auxia.getBannerSuppressedChecker( - { ...mockChannelSwitches, gandalfSignInGateCountries: undefined }, - inRolloutMvtId, - 'NZ', - ); - - const result = await checkAuxiaSuppression('browser-id', mockAttributes); - - expect((global.fetch as jest.Mock).mock.calls.length).toBe(1); - expect(forLogging()).toBe('not-suppressed'); - expect(result).toBe(false); - }); -}); diff --git a/src/server/lib/auxia.ts b/src/server/lib/auxia.ts index 72f850668..0cf1217f1 100644 --- a/src/server/lib/auxia.ts +++ b/src/server/lib/auxia.ts @@ -248,18 +248,10 @@ export class Auxia { * - checkAuxiaSuppression: calls checkBannerSuppression and captures the result for logging * - forLogging: returns the cached status without making a request * - getTreatment: returns the cached auxia treatment data (if banner was not suppressed) - * - * countryCode is the reader's geolocation from the banner targeting payload. - * When the reader's country is in the gandalfSignInGateCountries channel - * switch (the Guardian-managed sign-in gate journey), Auxia is never - * consulted: the banner is not suppressed, the logged status stays - * 'not-consulted' and no treatment is exposed, so the client cannot send - * Auxia interaction events for the banner either. */ getBannerSuppressedChecker( channelSwitches: ChannelSwitches, mvtId: number, - countryCode?: string, ): { checkAuxiaSuppression: ( browserId: string, @@ -278,12 +270,6 @@ export class Auxia { if (!channelSwitches.enableAuxiaForBanners) { return false; } - const gandalfCountries = (channelSwitches.gandalfSignInGateCountries ?? []).map( - (country) => country.toUpperCase(), - ); - if (countryCode !== undefined && gandalfCountries.includes(countryCode.toUpperCase())) { - return false; - } if (!inAuxiaAudience(mvtId)) { return false; } diff --git a/src/server/signin-gate/libPure.ts b/src/server/signin-gate/libPure.ts index cf0da9b4e..bc1eba58f 100644 --- a/src/server/signin-gate/libPure.ts +++ b/src/server/signin-gate/libPure.ts @@ -136,9 +136,11 @@ export const gandalfMandatoryPopupUserTreatment = (): UserTreatment => { // but the treatmentType uses the POPUP variant so the client renders the v2 // modal (mounted on document.body) instead of the inline article gate. // - // The treatmentId stays 'default-treatment-id' so the client's existing - // "do not call Auxia for default treatments" guard also applies here as - // defence in depth on top of the gandalfSignInGate response marker. + // The treatmentId is Gandalf-specific (not the shared + // 'default-treatment-id') so Ophan component events for the Gandalf popup + // are distinguishable in analysis. Auxia interaction suppression does not + // depend on the treatmentId: the client skips Auxia calls for responses + // carrying the gandalfSignInGate marker. const title = 'Sorry for the interruption'; const subtitle = "Once you are signed in, we'll bring you back here shortly"; @@ -154,8 +156,8 @@ export const gandalfMandatoryPopupUserTreatment = (): UserTreatment => { }; const treatmentContentEncoded = JSON.stringify(treatmentContent); return { - treatmentId: 'default-treatment-id', - treatmentTrackingId: 'default-treatment-tracking-id', + treatmentId: 'gandalf-mandatory-popup', + treatmentTrackingId: 'gandalf-mandatory-popup-tracking-id', rank: '1', contentLanguageCode: 'en-GB', treatmentContent: treatmentContentEncoded, diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts index ef804ea9c..33ce55337 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts @@ -4,6 +4,7 @@ import { gandalfIsValidContentType, gandalfIsValidSection, gandalfIsValidTagIds, + gandalfMandatoryPopupUserTreatment, getTreatmentsRequestPayloadToGateType, } from '../../libPure'; import type { GetTreatmentsRequestPayload } from '../../types'; @@ -418,4 +419,13 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { expect(gateType).toBe('GandalfMandatoryPopup'); }); }); + + describe('mandatory popup treatment identity', () => { + it('uses a Gandalf-specific treatmentId so analysis can distinguish it from the standard gate', () => { + const treatment = gandalfMandatoryPopupUserTreatment(); + expect(treatment.treatmentId).toBe('gandalf-mandatory-popup'); + expect(treatment.treatmentTrackingId).toBe('gandalf-mandatory-popup-tracking-id'); + expect(treatment.treatmentType).toBe('NONDISMISSIBLE_SIGN_IN_GATE_POPUP'); + }); + }); }); From b95090f7b9bd297922c5dbee0307546829aaf025 Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Tue, 15 Sep 2026 12:40:22 +0100 Subject: [PATCH 3/7] Drive the Gandalf gate from dailyArticleCount gandalfPageViewCount duplicated dailyArticleCount, which the client already sends on every request. The free-allowance rule now reads the existing 1-based field: pageviews 1-3 are free, the fourth and later return the mandatory popup. --- src/server/signin-gate/libPure.ts | 22 +++--- .../gandalf.test.ts | 77 ++++++++++--------- src/server/signin-gate/logic.md | 27 +++---- src/server/signin-gate/types.ts | 19 ++--- 4 files changed, 68 insertions(+), 77 deletions(-) diff --git a/src/server/signin-gate/libPure.ts b/src/server/signin-gate/libPure.ts index bc1eba58f..503809fd6 100644 --- a/src/server/signin-gate/libPure.ts +++ b/src/server/signin-gate/libPure.ts @@ -273,10 +273,10 @@ export const gandalfPageMetadataIsEligibleForGateDisplay = ( ); }; -// The free allowance: the first three eligible pageviews do not show a gate. -// The counter sent by the client is 0-based (number of previously completed -// eligible pageviews in the request's country), so 0, 1 and 2 are free and 3+ -// shows the hard popup. +// The free allowance: the first three pageviews of the day do not show a gate. +// The client sends dailyArticleCount, the number of pageviews the reader has +// already made today including the current one (1-based), so counts 1-3 are +// free and 4+ shows the hard popup. export const GANDALF_FREE_PAGE_VIEW_COUNT = 3; export const userTreatmentsEnvelopToProxyGetTreatmentsAnswerData = ( @@ -509,17 +509,16 @@ export const getTreatmentsRequestPayloadToGateType = ( // Effects: // - Guardian drives the gate, Auxia is never consulted (no GetTreatments // and no LogTreatmentInteraction for either consent state) - // - the first three eligible pageviews are free (the response carries the - // gandalfSignInGate marker with no treatment so the client counts the - // pageview but shows no gate) - // - from the fourth eligible pageview onwards the Guardian-managed + // - the first three pageviews of the day are free (the response carries + // the gandalfSignInGate marker with no treatment, so the client knows + // this is a Guardian-managed decision and shows no gate) + // - from the fourth daily pageview onwards the Guardian-managed // non-dismissible popup is returned // // The special cases below (URL denials, page eligibility, newsshowcase // override and the staff testing feature) are deliberately evaluated with // the Gandalf lists. Pages excluded here return 'None' without the - // marker, so excluded pageviews neither show a gate nor consume the - // allowance. + // marker. const gandalfCountries = (gandalfSignInGateCountries ?? []).map((country) => country.toUpperCase(), @@ -543,8 +542,7 @@ export const getTreatmentsRequestPayloadToGateType = ( if (isStaffTestConditionShowDefaultGate(payload)) { return staffTestConditionToDefaultGate(payload); } - const gandalfPageViewCount = payload.gandalfPageViewCount ?? 0; - return gandalfPageViewCount < GANDALF_FREE_PAGE_VIEW_COUNT + return payload.dailyArticleCount <= GANDALF_FREE_PAGE_VIEW_COUNT ? 'GandalfFreeView' : 'GandalfMandatoryPopup'; } diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts index 33ce55337..60201c8fe 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts @@ -16,7 +16,7 @@ const buildPayload = ( ): GetTreatmentsRequestPayload => ({ browserId: 'sample', isSupporter: false, - dailyArticleCount: 5, + dailyArticleCount: 1, articleIdentifier: 'www.theguardian.com/world/2026/sep/01/sample-article', editionId: 'AU', contentType: 'LiveBlog', @@ -31,7 +31,6 @@ const buildPayload = ( showDefaultGate: undefined, gateDisplayCount: 0, hideSupportMessagingTimestamp: undefined, - gandalfPageViewCount: 0, ...overrides, }); @@ -78,7 +77,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { const countries = ['NZ', 'CA']; for (const countryCode of countries) { const gateType = getTreatmentsRequestPayloadToGateType( - buildPayload({ countryCode, gandalfPageViewCount: 0 }), + buildPayload({ countryCode }), now, true, ['nz', 'ca'], @@ -114,6 +113,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { mvtId: 450_000, hasConsented: true, contentType: 'Article', + dailyArticleCount: 5, }), now, true, @@ -129,6 +129,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { mvtId: 450_000, hasConsented: true, contentType: 'Article', + dailyArticleCount: 5, }), now, true, @@ -138,20 +139,10 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }); }); - describe('free pageviews (0-based counter)', () => { - it.each([0, 1, 2])('returns GandalfFreeView for count %i (consented)', (count) => { - const gateType = getTreatmentsRequestPayloadToGateType( - buildPayload({ hasConsented: true, gandalfPageViewCount: count }), - now, - true, - ['NZ'], - ); - expect(gateType).toBe('GandalfFreeView'); - }); - - it.each([0, 1, 2])('returns GandalfFreeView for count %i (un-consented)', (count) => { + describe('free pageviews (daily article count, includes the current view)', () => { + it.each([0, 1, 2, 3])('returns GandalfFreeView for daily count %i (consented)', (count) => { const gateType = getTreatmentsRequestPayloadToGateType( - buildPayload({ hasConsented: false, gandalfPageViewCount: count }), + buildPayload({ hasConsented: true, dailyArticleCount: count }), now, true, ['NZ'], @@ -159,34 +150,43 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { expect(gateType).toBe('GandalfFreeView'); }); - it('treats a missing counter from an old client as 0', () => { - const payload = buildPayload({ hasConsented: true }); - delete payload.gandalfPageViewCount; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, ['NZ']); - expect(gateType).toBe('GandalfFreeView'); - }); + it.each([0, 1, 2, 3])( + 'returns GandalfFreeView for daily count %i (un-consented)', + (count) => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: false, dailyArticleCount: count }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GandalfFreeView'); + }, + ); it(`uses the free allowance constant of ${GANDALF_FREE_PAGE_VIEW_COUNT}`, () => { expect(GANDALF_FREE_PAGE_VIEW_COUNT).toBe(3); }); }); - describe('hard gate from the fourth eligible pageview', () => { - it.each([3, 4, 10])('returns GandalfMandatoryPopup for count %i (consented)', (count) => { - const gateType = getTreatmentsRequestPayloadToGateType( - buildPayload({ hasConsented: true, gandalfPageViewCount: count }), - now, - true, - ['NZ'], - ); - expect(gateType).toBe('GandalfMandatoryPopup'); - }); + describe('hard gate from the fourth daily pageview', () => { + it.each([4, 5, 10])( + 'returns GandalfMandatoryPopup for daily count %i (consented)', + (count) => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: true, dailyArticleCount: count }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GandalfMandatoryPopup'); + }, + ); - it.each([3, 4, 10])( - 'returns GandalfMandatoryPopup for count %i (un-consented)', + it.each([4, 5, 10])( + 'returns GandalfMandatoryPopup for daily count %i (un-consented)', (count) => { const gateType = getTreatmentsRequestPayloadToGateType( - buildPayload({ hasConsented: false, gandalfPageViewCount: count }), + buildPayload({ hasConsented: false, dailyArticleCount: count }), now, true, ['NZ'], @@ -199,7 +199,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ hasConsented: true, - gandalfPageViewCount: 0, + dailyArticleCount: 1, gateDismissCount: 9, gateDisplayCount: 9, }), @@ -214,7 +214,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ hasConsented: true, - gandalfPageViewCount: 3, + dailyArticleCount: 4, hideSupportMessagingTimestamp: now - 1000, }), now, @@ -382,6 +382,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { mvtId: 450_000, hasConsented: true, contentType: 'Article', + dailyArticleCount: 5, }), now, true, @@ -410,7 +411,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { countryCode: 'CA', hasConsented: true, contentType: 'LiveBlog', - gandalfPageViewCount: 3, + dailyArticleCount: 4, }), now, true, diff --git a/src/server/signin-gate/logic.md b/src/server/signin-gate/logic.md index 1f549a9fa..c37f47fe3 100644 --- a/src/server/signin-gate/logic.md +++ b/src/server/signin-gate/logic.md @@ -144,37 +144,38 @@ ImageContent (CAPI Picture pages) and Video. Excluded pages (legal/customer-service pages, The Filter, newsletter sign-up tags, tips, the secure-contact page, privacy, complaints-and-corrections and -the-whole-picture) neither show a gate nor advance the counter. +the-whole-picture) never display the gate. -The client sends `gandalfPageViewCount`: the 0-based number of eligible -pageviews it has already counted for the request's country (a dedicated -persistent per-country client counter, not `dailyArticleCount` or -`gateDisplayCount`). The response carries the `gandalfSignInGate` marker on -both outcomes below so the client can count the pageview and identify -Guardian-managed responses. +The trigger is the standard `dailyArticleCount` payload field: the number of +pageviews the reader has already made today, including the current one +(`gu.history.dailyArticleCount` on the client). It is a generic daily count +maintained by the client regardless of the Gandalf exclusion lists. The +response carries the `gandalfSignInGate` marker on both outcomes below so +the client can identify Guardian-managed responses (for Ophan reporting and +to skip Auxia interaction calls). ``` ---------------------------------------------- | [G1] | | | | - No Auxia request | - 0 <= count | - No gate displayed | - < 3 | - Response carries the gandalfSignInGate | - | marker so the client counts the pageview | + daily count | - No gate displayed | + <= 3 | - Response carries the gandalfSignInGate | + | marker | | | -----------|----------------------------------------------- | [G2] | | | | - No Auxia request | - count >= 3 | - Guardian drives the gate: | - | - Non-dismissible sign-in popup | + daily count | - Guardian drives the gate: | + >= 4 | - Non-dismissible sign-in popup | | (NONDISMISSIBLE_SIGN_IN_GATE_POPUP) | | - Persists until the reader signs in | | | -----------|----------------------------------------------- Special cases (evaluated with the Gandalf exclusion lists): -- denied URLs and ineligible pages: no gate, no marker, counter not advanced +- denied URLs and ineligible pages: no gate, no marker - shouldServeDismissible (newsshowcase): GuDismissible, as today - staff showDefaultGate override: Gu default gates, as today ``` diff --git a/src/server/signin-gate/types.ts b/src/server/signin-gate/types.ts index 626cdd659..722d5504f 100644 --- a/src/server/signin-gate/types.ts +++ b/src/server/signin-gate/types.ts @@ -87,7 +87,8 @@ export type GateType = // [6] Here, we query Auxia for analytics but do not return the result and instead return the Gu Dismissible gate // [7] Same as [5] but we return the Gu Mandatory gate // [8] GandalfFreeView: no gate on this pageview, but the response carries the -// gandalfSignInGate marker so the client can count the completed pageview +// gandalfSignInGate marker so the client recognises it as a +// Guardian-managed decision // [9] GandalfMandatoryPopup: return the Guardian-managed non-dismissible popup // gate. No Auxia request is made for either Gandalf response (see // [7] gandalfSignInGate below). @@ -112,7 +113,6 @@ export interface GetTreatmentsRequestPayload { showDefaultGate: ShowGateValues; // [4] gateDisplayCount: number; // [5] hideSupportMessagingTimestamp: number | undefined; // [6] - gandalfPageViewCount?: number; // [8] gandalfPageViewCount } // [1] articleIdentifier examples: @@ -184,18 +184,9 @@ export interface GetTreatmentsRequestPayload { // `gandalfSignInGate` marks responses produced by the active Gandalf rules // (the GandalfFreeView and GandalfMandatoryPopup gate types). It is present // (true) on both so the client can: -// - count the completed eligible pageview even when no gate is displayed; // - identify Guardian-managed treatments and skip every Auxia interaction call; // - report to Ophan under a stable Gandalf identity instead of Auxia's. // -// [8] gandalfPageViewCount -// -// date: 2nd September 2026 -// -// `gandalfPageViewCount` is the 0-based number of eligible pageviews the -// reader has already completed in the request's country under the active -// Gandalf rules. Counters are per country (campaigns differ by country -// group). The field is optional so that older clients (and traffic outside -// the Gandalf countries) remain compatible; a missing value is treated as 0. -// The client increments its persistent per-country counter only after -// receiving a response carrying the gandalfSignInGate marker. +// The gate decision is driven by the standard `dailyArticleCount` payload +// field: the reader's pageview count for the current day, including this +// pageview. From e8e66645aad64ffadbb153557ec7ecfd5d56cff6 Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Wed, 16 Sep 2026 12:34:44 +0100 Subject: [PATCH 4/7] Reduce Gandalf free pageview allowance from 3 to 2 The Gandalf sign-in gate now triggers on the third pageview instead of the fourth. Update documentation, tests, and constant to reflect the new free allowance of two pageviews per day. Add Article to the list of eligible content types for Gandalf gate display. --- docs/signinGate.md | 21 +++---- src/server/signin-gate/libPure.ts | 13 ++-- .../gandalf.test.ts | 59 +++++++++---------- src/server/signin-gate/logic.md | 10 ++-- 4 files changed, 50 insertions(+), 53 deletions(-) diff --git a/docs/signinGate.md b/docs/signinGate.md index bde8b1af3..29b19e6de 100644 --- a/docs/signinGate.md +++ b/docs/signinGate.md @@ -30,19 +30,20 @@ active for any reader whose country code (case-insensitively) appears in the `gandalfSignInGateCountries` channel switch list (currently New Zealand). For listed countries SDC owns the rules entirely and Auxia is bypassed: -- the first three eligible pageviews are free (the response carries the - `gandalfSignInGate` marker with no treatment, so DCR counts the pageview - but shows no gate); -- from the fourth eligible pageview onwards SDC returns a hardcoded +- the first two pageviews of the day are free (the response carries the + `gandalfSignInGate` marker with no treatment, so DCR recognises the + Guardian-managed decision but shows no gate); +- from the third pageview of the day onwards SDC returns a hardcoded Guardian-managed non-dismissible popup treatment - (`NONDISMISSIBLE_SIGN_IN_GATE_POPUP`); + (`NONDISMISSIBLE_SIGN_IN_GATE_POPUP`). The trigger is the standard + `dailyArticleCount` payload field (`gu.history.dailyArticleCount` on the + client), which already includes the current pageview; - no Auxia GetTreatments or LogTreatmentInteraction request is made for either consent state; -- the eligible surfaces are the Guardian metadata values Network Front, - Section, Tag, Audio, Crossword, Gallery, Interactive, LiveBlog, ImageContent - and Video, minus the exclusions listed in [logic.md](/src/server/signin-gate/logic.md); -- the client keeps one pageview counter per country (campaigns differ by - country group); +- the eligible surfaces are the Guardian metadata values Article, Network + Front, Section, Tag, Audio, Crossword, Gallery, Interactive, LiveBlog, + ImageContent and Video, minus the exclusions listed in + [logic.md](/src/server/signin-gate/logic.md); - Ophan events use a stable Gandalf identity (`GandalfSignInGate`, variant `gandalf-`) instead of the Auxia test metadata. This is not an A/B test. diff --git a/src/server/signin-gate/libPure.ts b/src/server/signin-gate/libPure.ts index 503809fd6..cad30105b 100644 --- a/src/server/signin-gate/libPure.ts +++ b/src/server/signin-gate/libPure.ts @@ -207,6 +207,7 @@ export const isValidTagIds = (tagIds: string[]): boolean => { // guardian/frontend). Note that CAPI "Picture" pages are sent as ImageContent, // and fronts are sent as Network Front / Section / Tag. const gandalfContentTypes = [ + 'Article', 'Network Front', 'Section', 'Tag', @@ -273,11 +274,11 @@ export const gandalfPageMetadataIsEligibleForGateDisplay = ( ); }; -// The free allowance: the first three pageviews of the day do not show a gate. +// The free allowance: the first two pageviews of the day do not show a gate. // The client sends dailyArticleCount, the number of pageviews the reader has -// already made today including the current one (1-based), so counts 1-3 are -// free and 4+ shows the hard popup. -export const GANDALF_FREE_PAGE_VIEW_COUNT = 3; +// already made today including the current one (1-based), so counts 1-2 are +// free and 3+ shows the hard popup. +export const GANDALF_FREE_PAGE_VIEW_COUNT = 2; export const userTreatmentsEnvelopToProxyGetTreatmentsAnswerData = ( envelop: UserTreatmentsEnvelop, @@ -509,10 +510,10 @@ export const getTreatmentsRequestPayloadToGateType = ( // Effects: // - Guardian drives the gate, Auxia is never consulted (no GetTreatments // and no LogTreatmentInteraction for either consent state) - // - the first three pageviews of the day are free (the response carries + // - the first two pageviews of the day are free (the response carries // the gandalfSignInGate marker with no treatment, so the client knows // this is a Guardian-managed decision and shows no gate) - // - from the fourth daily pageview onwards the Guardian-managed + // - from the third daily pageview onwards the Guardian-managed // non-dismissible popup is returned // // The special cases below (URL denials, page eligibility, newsshowcase diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts index 60201c8fe..938e4a42a 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts @@ -140,7 +140,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }); describe('free pageviews (daily article count, includes the current view)', () => { - it.each([0, 1, 2, 3])('returns GandalfFreeView for daily count %i (consented)', (count) => { + it.each([0, 1, 2])('returns GandalfFreeView for daily count %i (consented)', (count) => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ hasConsented: true, dailyArticleCount: count }), now, @@ -150,26 +150,23 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { expect(gateType).toBe('GandalfFreeView'); }); - it.each([0, 1, 2, 3])( - 'returns GandalfFreeView for daily count %i (un-consented)', - (count) => { - const gateType = getTreatmentsRequestPayloadToGateType( - buildPayload({ hasConsented: false, dailyArticleCount: count }), - now, - true, - ['NZ'], - ); - expect(gateType).toBe('GandalfFreeView'); - }, - ); + it.each([0, 1, 2])('returns GandalfFreeView for daily count %i (un-consented)', (count) => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: false, dailyArticleCount: count }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('GandalfFreeView'); + }); it(`uses the free allowance constant of ${GANDALF_FREE_PAGE_VIEW_COUNT}`, () => { - expect(GANDALF_FREE_PAGE_VIEW_COUNT).toBe(3); + expect(GANDALF_FREE_PAGE_VIEW_COUNT).toBe(2); }); }); - describe('hard gate from the fourth daily pageview', () => { - it.each([4, 5, 10])( + describe('hard gate from the third daily pageview', () => { + it.each([3, 4, 10])( 'returns GandalfMandatoryPopup for daily count %i (consented)', (count) => { const gateType = getTreatmentsRequestPayloadToGateType( @@ -182,7 +179,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }, ); - it.each([4, 5, 10])( + it.each([3, 4, 10])( 'returns GandalfMandatoryPopup for daily count %i (un-consented)', (count) => { const gateType = getTreatmentsRequestPayloadToGateType( @@ -214,7 +211,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ hasConsented: true, - dailyArticleCount: 4, + dailyArticleCount: 3, hideSupportMessagingTimestamp: now - 1000, }), now, @@ -227,6 +224,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { describe('eligible Guardian content metadata', () => { it.each([ + 'Article', 'Network Front', 'Section', 'Tag', @@ -253,19 +251,16 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { expect(gandalfIsValidContentType('network front')).toBe(true); }); - it.each(['Article', 'Picture', 'Survey', 'Signup', ''])( - 'does not accept %s', - (contentType) => { - expect(gandalfIsValidContentType(contentType)).toBe(false); - const gateType = getTreatmentsRequestPayloadToGateType( - buildPayload({ contentType }), - now, - true, - ['NZ'], - ); - expect(gateType).toBe('None'); - }, - ); + it.each(['Picture', 'Survey', 'Signup', ''])('does not accept %s', (contentType) => { + expect(gandalfIsValidContentType(contentType)).toBe(false); + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ contentType }), + now, + true, + ['NZ'], + ); + expect(gateType).toBe('None'); + }); }); describe('exclusions', () => { @@ -411,7 +406,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { countryCode: 'CA', hasConsented: true, contentType: 'LiveBlog', - dailyArticleCount: 4, + dailyArticleCount: 3, }), now, true, diff --git a/src/server/signin-gate/logic.md b/src/server/signin-gate/logic.md index c37f47fe3..7c17827cf 100644 --- a/src/server/signin-gate/logic.md +++ b/src/server/signin-gate/logic.md @@ -138,9 +138,9 @@ This journey is 100% rollout, not an A/B test: no MVT/audience-share allocation is used, and Auxia is never consulted — no GetTreatments call and no LogTreatmentInteraction call, for consented and un-consented readers alike. -The eligible content types are the Guardian metadata values: Network Front, -Section, Tag (fronts), Audio, Crossword, Gallery, Interactive, LiveBlog, -ImageContent (CAPI Picture pages) and Video. +The eligible content types are the Guardian metadata values: Article, +Network Front, Section, Tag (fronts), Audio, Crossword, Gallery, Interactive, +LiveBlog, ImageContent (CAPI Picture pages) and Video. Excluded pages (legal/customer-service pages, The Filter, newsletter sign-up tags, tips, the secure-contact page, privacy, complaints-and-corrections and @@ -160,7 +160,7 @@ to skip Auxia interaction calls). | | | - No Auxia request | daily count | - No gate displayed | - <= 3 | - Response carries the gandalfSignInGate | + <= 2 | - Response carries the gandalfSignInGate | | marker | | | -----------|----------------------------------------------- @@ -168,7 +168,7 @@ to skip Auxia interaction calls). | | | - No Auxia request | daily count | - Guardian drives the gate: | - >= 4 | - Non-dismissible sign-in popup | + >= 3 | - Non-dismissible sign-in popup | | (NONDISMISSIBLE_SIGN_IN_GATE_POPUP) | | - Persists until the reader signs in | | | From c58db210ada41dfe6b520f689f770e403e39f8b4 Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Wed, 16 Sep 2026 14:57:30 +0100 Subject: [PATCH 5/7] Replace gandalfSignInGateCountries list with enableGandalfSignInGate boolean switch The Gandalf sign-in gate configuration now uses a single boolean switch (`enableGandalfSignInGate`) instead of a country list (`gandalfSignInGateCountries`). When enabled, the journey applies only to New Zealand readers. Update documentation, tests, and implementation to reflect the simplified on/off toggle. Remove case-insensitive country matching logic and multi-country support. --- docs/auxia.md | 13 ++- docs/signinGate.md | 13 ++- src/server/api/auxiaProxyRouter.ts | 6 +- src/server/channelSwitches.ts | 8 +- src/server/signin-gate/libPure.ts | 26 +++--- .../enableAuxia.test.ts | 14 ++-- .../gandalf.test.ts | 83 ++++++++----------- .../ireland.test.ts | 8 +- .../special-cases.test.ts | 16 ++-- .../world-without-ireland.test.ts | 12 +-- .../hideSupportMessagingHasOverride.test.ts | 6 +- src/server/signin-gate/logic.md | 13 ++- src/server/signin-gate/types.ts | 4 +- 13 files changed, 101 insertions(+), 121 deletions(-) diff --git a/docs/auxia.md b/docs/auxia.md index 209d113ee..ce4f14ec8 100644 --- a/docs/auxia.md +++ b/docs/auxia.md @@ -33,11 +33,8 @@ See [auxia.ts](../src/server/lib/auxia.ts) for implementation. ### Gandalf bypass -While the reader's country appears in the `gandalfSignInGateCountries` channel -switch list, they are never sent to Auxia on either channel: the sign-in gate -is fully Guardian-managed (see [signinGate.md](signinGate.md)) and the banner -suppression checker short-circuits before contacting Auxia, so banners are not -suppressed, the logged status stays `not-consulted`, and no Auxia treatment is -attached to the banner response (which also prevents client-side Auxia -interaction events). Removing a country from the list restores the previous -behaviour for that country. +While the `enableGandalfSignInGate` channel switch is on, New Zealand readers +are never sent to Auxia for sign-in gates: the journey is fully +Guardian-managed (see [signinGate.md](signinGate.md)). Banner behaviour is +unchanged — the banner suppression checker consults Auxia exactly as before, +for every country. diff --git a/docs/signinGate.md b/docs/signinGate.md index 29b19e6de..9299d62a4 100644 --- a/docs/signinGate.md +++ b/docs/signinGate.md @@ -26,9 +26,9 @@ SDC also has an endpoint for tracking interactions (view/click) with the gate: ` "Gandalf" is the marketing name for the Guardian-managed sign-in gate journey: a 100% rollout run entirely by Guardian rules, with no Auxia involvement. It is -active for any reader whose country code (case-insensitively) appears in the -`gandalfSignInGateCountries` channel switch list (currently New Zealand). For -listed countries SDC owns the rules entirely and Auxia is bypassed: +active for any reader whose country code is `NZ` when the +`enableGandalfSignInGate` channel switch is on. For that surface SDC owns the +rules entirely and Auxia is bypassed: - the first two pageviews of the day are free (the response carries the `gandalfSignInGate` marker with no treatment, so DCR recognises the @@ -48,10 +48,9 @@ listed countries SDC owns the rules entirely and Auxia is bypassed: `gandalf-`) instead of the Auxia test metadata. This is not an A/B test. -Adding a country is a configuration change (add its ISO code to the list in -the Channel Switches UI). Removing a country — or the field being absent from -`channel-switches.json` — restores that country's previous behaviour, which is -the rollback path. +Toggling the switch is a configuration change (the Channel Switches UI). +Switching it off — or the field being absent from `channel-switches.json` — +restores the previous behaviour, which is the rollback path. ### Data diff --git a/src/server/api/auxiaProxyRouter.ts b/src/server/api/auxiaProxyRouter.ts index 32b227d78..26beaa042 100644 --- a/src/server/api/auxiaProxyRouter.ts +++ b/src/server/api/auxiaProxyRouter.ts @@ -79,14 +79,14 @@ export const buildAuxiaProxyRouter = ( try { const now = Date.now(); // current time in milliseconds since epoch const payload = req.body as GetTreatmentsRequestPayload; - const { enableAuxia, gandalfSignInGateCountries } = channelSwitches.get(); + const { enableAuxia, enableGandalfSignInGate } = channelSwitches.get(); const gateType = getTreatmentsRequestPayloadToGateType( payload, now, enableAuxia, // Tolerate old switch documents without the field: an - // absent list means the Gandalf journey is off everywhere. - gandalfSignInGateCountries ?? [], + // absent switch means the Gandalf journey is off. + enableGandalfSignInGate ?? false, ); const envelop = await gateTypeToUserTreatmentsEnvelop(config, gateType, payload); if (envelop !== undefined) { diff --git a/src/server/channelSwitches.ts b/src/server/channelSwitches.ts index 664b36ea1..3de943512 100644 --- a/src/server/channelSwitches.ts +++ b/src/server/channelSwitches.ts @@ -16,10 +16,10 @@ export interface ChannelSwitches { enableAuxia: boolean; // for sign-in gates enableAuxiaForBanners: boolean; // Gandalf: marketing name for the Guardian-managed sign-in gate journey - // (100% Guardian-owned rules, no Auxia). Countries listed here (ISO codes, - // case-insensitive) run the Gandalf journey; an absent or empty list means - // it is off everywhere, which is the rollback path. - gandalfSignInGateCountries?: string[]; + // (100% Guardian-owned rules, no Auxia). When switched on, New Zealand + // readers run the Gandalf journey; off means it is disabled, which is the + // rollback path. + enableGandalfSignInGate?: boolean; } const getSwitches = (): Promise => diff --git a/src/server/signin-gate/libPure.ts b/src/server/signin-gate/libPure.ts index cad30105b..cd11e3c1f 100644 --- a/src/server/signin-gate/libPure.ts +++ b/src/server/signin-gate/libPure.ts @@ -129,8 +129,8 @@ export const gandalfMandatoryPopupUserTreatment = (): UserTreatment => { // // "Gandalf" is the marketing name for the Guardian-managed sign-in gate // journey: a 100% rollout run entirely by Guardian rules with no Auxia - // involvement (currently New Zealand, extendable to further countries via - // the gandalfSignInGateCountries channel switch). + // involvement (currently New Zealand, gated by the enableGandalfSignInGate + // channel switch). // // The Guardian-managed hard gate. The copy matches guMandatoryUserTreatment, // but the treatmentType uses the POPUP variant so the client renders the v2 @@ -195,8 +195,8 @@ export const isValidTagIds = (tagIds: string[]): boolean => { // // "Gandalf" is the marketing name for the Guardian-managed sign-in gate // journey: a 100% rollout, run entirely by Guardian rules with no Auxia -// involvement, currently live for New Zealand and extendable to further -// countries via the gandalfSignInGateCountries channel switch. +// involvement, currently live for New Zealand and switched on via the +// enableGandalfSignInGate channel switch. // // Gandalf widens both the eligible content types and the exclusion list. // These helpers are only consulted by the active Gandalf branch, so the @@ -483,13 +483,13 @@ export const getTreatmentsRequestPayloadToGateType = ( payload: GetTreatmentsRequestPayload, now: number, enableAuxia: boolean, - gandalfSignInGateCountries: string[] | undefined, + enableGandalfSignInGate: boolean | undefined, ): GateType => { // now: current time in milliseconds since epoch // enableAuxia: channel switch to enable/disable Auxia integration - // gandalfSignInGateCountries: channel switch listing the countries in the - // Gandalf sign-in gate journey (see channelSwitches.ts); undefined or - // empty disables the journey everywhere + // enableGandalfSignInGate: channel switch turning on the Gandalf + // sign-in gate journey (see channelSwitches.ts); undefined or false + // disables the journey // This function is a pure function (without any side effects) which gets the body // of a '/auxia/get-treatments' request and returns the correct GateType @@ -503,9 +503,8 @@ export const getTreatmentsRequestPayloadToGateType = ( // Guardian-owned, Auxia-free 100% rollout) // // Prerequisites: - // - the reader's country is listed in the gandalfSignInGateCountries - // channel switch (case-insensitive match on the config side; unknown or - // other countries are never treated as Gandalf countries) + // - the enableGandalfSignInGate channel switch is on (the journey is + // currently New Zealand only) // // Effects: // - Guardian drives the gate, Auxia is never consulted (no GetTreatments @@ -521,10 +520,7 @@ export const getTreatmentsRequestPayloadToGateType = ( // the Gandalf lists. Pages excluded here return 'None' without the // marker. - const gandalfCountries = (gandalfSignInGateCountries ?? []).map((country) => - country.toUpperCase(), - ); - if (gandalfCountries.includes(payload.countryCode)) { + if (enableGandalfSignInGate === true && payload.countryCode === 'NZ') { if (!gandalfArticleIdentifierIsAllowed(payload.articleIdentifier)) { return 'None'; } diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/enableAuxia.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/enableAuxia.test.ts index 0e7842208..bf0497bcb 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/enableAuxia.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/enableAuxia.test.ts @@ -25,7 +25,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, false); // When Auxia is disabled, should use Guardian dismissible gate expect(gateType).toBe('GuDismissible'); @@ -52,7 +52,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); // When Auxia is enabled and user qualifies, should use Auxia expect(gateType).toBe('AuxiaAPI'); @@ -79,7 +79,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, false); expect(gateType).toBe('None'); }); @@ -104,7 +104,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, false); expect(gateType).toBe('None'); }); @@ -129,7 +129,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: now - 1000, // Less than 30 days }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, false); expect(gateType).toBe('None'); }); @@ -154,7 +154,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toBe('AuxiaAPI'); }); @@ -179,7 +179,7 @@ describe('getTreatmentsRequestPayloadToGateType (enableAuxia switch)', () => { hideSupportMessagingTimestamp: undefined, }; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, false, false); // Falls back to Guardian logic even for Ireland expect(gateType).toBe('GuDismissible'); }); diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts index 938e4a42a..ce529c8de 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts @@ -35,18 +35,18 @@ const buildPayload = ( }); describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { - describe('country not in the list preserves the current behaviour', () => { - it('consented readers still go to Auxia when the country is not listed', () => { + describe('switch off preserves the current behaviour', () => { + it('consented readers still go to Auxia when the switch is off', () => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ hasConsented: true, contentType: 'Article' }), now, true, - [], + false, ); expect(gateType).toBe('AuxiaAPI'); }); - it('un-consented readers still get Auxia analytics then Guardian rules when the country is not listed', () => { + it('un-consented readers still get Auxia analytics then Guardian rules when the switch is off', () => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ hasConsented: false, @@ -55,12 +55,12 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }), now, true, - [], + false, ); expect(gateType).toBe('AuxiaAnalyticsThenGuDismissible'); }); - it('a missing country list is treated as empty', () => { + it('a missing switch is treated as off', () => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ hasConsented: true, contentType: 'Article' }), now, @@ -72,36 +72,23 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }); }); - describe('country list membership', () => { - it('activates every country in the list', () => { - const countries = ['NZ', 'CA']; - for (const countryCode of countries) { - const gateType = getTreatmentsRequestPayloadToGateType( - buildPayload({ countryCode }), - now, - true, - ['nz', 'ca'], - ); - expect(gateType).toBe('GandalfFreeView'); - } - }); - - it('matches list entries case-insensitively', () => { + describe('switch state and country', () => { + it('runs the Gandalf journey for New Zealand readers when switched on', () => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ countryCode: 'NZ' }), now, true, - ['nz'], + true, ); expect(gateType).toBe('GandalfFreeView'); }); - it('does not activate countries outside the list', () => { + it('does not run the Gandalf journey for other countries even when switched on', () => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ countryCode: 'IE', hasConsented: true, contentType: 'Article' }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('AuxiaAPI'); }); @@ -117,7 +104,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GuDismissible'); }); @@ -133,7 +120,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GuDismissible'); }); @@ -145,7 +132,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ hasConsented: true, dailyArticleCount: count }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GandalfFreeView'); }); @@ -155,7 +142,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ hasConsented: false, dailyArticleCount: count }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GandalfFreeView'); }); @@ -173,7 +160,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ hasConsented: true, dailyArticleCount: count }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GandalfMandatoryPopup'); }, @@ -186,7 +173,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ hasConsented: false, dailyArticleCount: count }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GandalfMandatoryPopup'); }, @@ -202,7 +189,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GandalfFreeView'); }); @@ -216,7 +203,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GandalfMandatoryPopup'); }); @@ -241,7 +228,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ contentType }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GandalfFreeView'); }); @@ -257,7 +244,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ contentType }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('None'); }); @@ -279,7 +266,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ sectionId }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('None'); }); @@ -290,7 +277,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ tagIds: ['info/newsletter-sign-up'] }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('None'); }); @@ -307,7 +294,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ articleIdentifier }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('None'); }); @@ -328,7 +315,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ shouldServeDismissible: true }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GuDismissible'); }); @@ -338,7 +325,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ showDefaultGate: 'mandatory' }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GuMandatory'); }); @@ -348,7 +335,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { buildPayload({ showDefaultGate: 'dismissible' }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GuDismissible'); }); @@ -365,7 +352,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }), now, true, - ['NZ', 'CA'], + true, ); expect(gateType).toBe('AuxiaAPI'); }); @@ -381,12 +368,12 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('GuDismissible'); }); - it('Ireland keeps its mandatory rollout behaviour when not listed', () => { + it('Ireland keeps its mandatory rollout behaviour when the switch is on', () => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ countryCode: 'IE', @@ -395,12 +382,12 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }), now, true, - ['NZ'], + true, ); expect(gateType).toBe('AuxiaAPI'); }); - it('a listed country other than NZ takes the Gandalf journey', () => { + it('a non-NZ country never takes the Gandalf journey, even when the switch is on', () => { const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ countryCode: 'CA', @@ -410,9 +397,11 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }), now, true, - ['NZ', 'CA'], + true, ); - expect(gateType).toBe('GandalfMandatoryPopup'); + // Canada is not a Gandalf country and LiveBlog is not globally + // eligible, so no gate is served. + expect(gateType).toBe('None'); }); }); diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/ireland.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/ireland.test.ts index 52f9166d6..5378a0689 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/ireland.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/ireland.test.ts @@ -32,7 +32,7 @@ describe('getTreatmentsRequestPayloadToGateType (ireland)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('AuxiaAPI'); }); @@ -70,7 +70,7 @@ describe('getTreatmentsRequestPayloadToGateType (ireland)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('AuxiaAnalyticsThenNone'); }); @@ -108,7 +108,7 @@ describe('getTreatmentsRequestPayloadToGateType (ireland)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('AuxiaAnalyticsThenGuDismissible'); }); @@ -146,7 +146,7 @@ describe('getTreatmentsRequestPayloadToGateType (ireland)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('AuxiaAnalyticsThenGuMandatory'); }); }); diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/special-cases.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/special-cases.test.ts index 22a32a62c..98a9faf5e 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/special-cases.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/special-cases.test.ts @@ -23,7 +23,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('None'); }); @@ -48,7 +48,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('None'); }); @@ -73,7 +73,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('None'); }); @@ -98,7 +98,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('None'); }); @@ -123,7 +123,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('GuDismissible'); }); @@ -148,7 +148,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('GuDismissible'); }); @@ -173,7 +173,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('GuMandatory'); }); @@ -198,7 +198,7 @@ describe('getTreatmentsRequestPayloadToGateType (special cases)', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('GuDismissible'); }); }); diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/world-without-ireland.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/world-without-ireland.test.ts index c98ea8c19..63602872f 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/world-without-ireland.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/world-without-ireland.test.ts @@ -37,7 +37,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('None'); }); it('logic.md [03], first dismissible gates', () => { @@ -75,7 +75,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('GuDismissible'); }); it('logic.md [03], high gate dismiss count', () => { @@ -113,7 +113,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('None'); }); @@ -138,7 +138,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('AuxiaAnalyticsThenNone'); }); @@ -163,7 +163,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('AuxiaAnalyticsThenGuDismissible'); }); @@ -188,7 +188,7 @@ describe('getTreatmentsRequestPayloadToGateType', () => { hideSupportMessagingTimestamp: undefined, }; const now = 1756568322187; - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('AuxiaAnalyticsThenNone'); }); }); diff --git a/src/server/signin-gate/libPureTests/hideSupportMessagingHasOverride.test.ts b/src/server/signin-gate/libPureTests/hideSupportMessagingHasOverride.test.ts index e7068848e..7c19f1ab5 100644 --- a/src/server/signin-gate/libPureTests/hideSupportMessagingHasOverride.test.ts +++ b/src/server/signin-gate/libPureTests/hideSupportMessagingHasOverride.test.ts @@ -180,7 +180,7 @@ it('getTreatmentsRequestPayloadToGateType, without override', () => { hideSupportMessagingTimestamp: undefined, // <- no override }; const now = 1756568322187; // current time in milliseconds since epoch - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('GuDismissible'); }); @@ -219,7 +219,7 @@ it('getTreatmentsRequestPayloadToGateType, with override', () => { hideSupportMessagingTimestamp: 1755644400000, // <- tested: 2025-08-20 00:00:00 +0100 }; const now = 1756568890120; // 2025-08-30 16:48:10 +0100 (less than 30 days) - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('None'); }); @@ -257,6 +257,6 @@ it('getTreatmentsRequestPayloadToGateType, with override, ireland with Auxia Ana hideSupportMessagingTimestamp: 1755644400000, // <- tested: 2025-08-20 00:00:00 +0100 }; const now = 1756568890120; // 2025-08-30 16:48:10 +0100 (less than 30 days) - const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, []); + const gateType = getTreatmentsRequestPayloadToGateType(payload, now, true, false); expect(gateType).toStrictEqual('AuxiaAnalyticsThenNone'); // Instead of AuxiaAnalyticsThenGuDismissible }); diff --git a/src/server/signin-gate/logic.md b/src/server/signin-gate/logic.md index 7c17827cf..f2ea7717c 100644 --- a/src/server/signin-gate/logic.md +++ b/src/server/signin-gate/logic.md @@ -126,13 +126,12 @@ except for the UK (countryCode 'GB') where it is reduced to the first 20% (1 to "Gandalf" is the marketing name for this journey: a 100% rollout run entirely by Guardian rules with no Auxia involvement. -Active for a reader when their country code (case-insensitively) appears in the -`gandalfSignInGateCountries` channel switch list. Unknown or unlisted countries -never enter this section and follow the rules above. Removing a country from -the list (or clearing the list, or the field being absent from -`channel-switches.json`) restores that country's previous behaviour — the -rollback path. Counters are per country, because campaigns differ by country -group. +Active for a reader when the `enableGandalfSignInGate` channel switch is on and +their country code is `NZ`. Unknown or other countries never enter this section +and follow the rules above. Switching the switch off (or the field being absent +from `channel-switches.json`) disables the journey — the rollback path. The +trigger is the standard `dailyArticleCount` payload field: the reader's +pageview count for the current day, including this pageview. This journey is 100% rollout, not an A/B test: no MVT/audience-share allocation is used, and Auxia is never consulted — no GetTreatments call and no diff --git a/src/server/signin-gate/types.ts b/src/server/signin-gate/types.ts index 722d5504f..0ecc68cf6 100644 --- a/src/server/signin-gate/types.ts +++ b/src/server/signin-gate/types.ts @@ -178,8 +178,8 @@ export interface GetTreatmentsRequestPayload { // // "Gandalf" is the marketing name for the Guardian-managed sign-in gate // journey: a 100% rollout, run entirely by Guardian rules with no Auxia -// involvement, currently live for New Zealand and extendable to further -// countries via the gandalfSignInGateCountries channel switch. +// involvement, currently live for New Zealand and switched on via the +// enableGandalfSignInGate channel switch. // // `gandalfSignInGate` marks responses produced by the active Gandalf rules // (the GandalfFreeView and GandalfMandatoryPopup gate types). It is present From bd207dec78adbb39c31f6064e1ecf8f21c8c3e31 Mon Sep 17 00:00:00 2001 From: Juarez Mota Date: Thu, 17 Sep 2026 11:02:42 +0100 Subject: [PATCH 6/7] Unify Gandalf and global sign-in gate exclusion lists Remove duplicate Gandalf-specific exclusion helpers (gandalfIsValidSection, gandalfIsValidTagIds, gandalfArticleIdentifierIsAllowed) and use the shared isValidSection, isValidTagIds, and articleIdentifierIsAllowed functions instead. Add thefilter-us, privacy, complaints-and-corrections, and the-whole-picture to the global exclusion lists. Update documentation to reflect that Gandalf uses generic display exclusions rather than separate rules. --- docs/signinGate.md | 3 +- src/server/signin-gate/libPure.ts | 56 ++++--------------- .../articleIdentifierIsAllowed.test.ts | 5 ++ .../gandalf.test.ts | 18 +++--- .../libPureTests/isValidSection.test.ts | 4 ++ src/server/signin-gate/logic.md | 8 +-- 6 files changed, 32 insertions(+), 62 deletions(-) diff --git a/docs/signinGate.md b/docs/signinGate.md index 9299d62a4..a240afdc0 100644 --- a/docs/signinGate.md +++ b/docs/signinGate.md @@ -45,8 +45,7 @@ rules entirely and Auxia is bypassed: ImageContent and Video, minus the exclusions listed in [logic.md](/src/server/signin-gate/logic.md); - Ophan events use a stable Gandalf identity (`GandalfSignInGate`, variant - `gandalf-`) instead of the Auxia test metadata. This is not an A/B - test. + `gandalf-nz`) instead of the Auxia test metadata. This is not an A/B test. Toggling the switch is a configuration change (the Channel Switches UI). Switching it off — or the field being absent from `channel-switches.json` — diff --git a/src/server/signin-gate/libPure.ts b/src/server/signin-gate/libPure.ts index cd11e3c1f..ad495ffaa 100644 --- a/src/server/signin-gate/libPure.ts +++ b/src/server/signin-gate/libPure.ts @@ -180,6 +180,7 @@ export const isValidSection = (sectionId: string): boolean => { 'guardian-live-australia', 'gnm-archive', 'thefilter', + 'thefilter-us', ]; return !invalidSections.includes(sectionId); }; @@ -198,10 +199,8 @@ export const isValidTagIds = (tagIds: string[]): boolean => { // involvement, currently live for New Zealand and switched on via the // enableGandalfSignInGate channel switch. // -// Gandalf widens both the eligible content types and the exclusion list. -// These helpers are only consulted by the active Gandalf branch, so the -// existing global (Article-only) eligibility used by every other country is -// unchanged. +// Gandalf widens the eligible content types while sharing the generic +// sign-in-gate display exclusions used by every country. // The exact Guardian content metadata values (see DotcomContentType in // guardian/frontend). Note that CAPI "Picture" pages are sent as ImageContent, @@ -226,51 +225,13 @@ export const gandalfIsValidContentType = (contentType: string): boolean => { return validTypes.includes(contentType.toLowerCase()); }; -export const gandalfIsValidSection = (sectionId: string): boolean => { - // Union of the global sign-in gate exclusions, The Filter US, and the - // legal/customer-service sections excluded across the reader revenue - // channels. - const invalidSections = [ - 'about', - 'info', - 'membership', - 'help', - 'guardian-live-australia', - 'gnm-archive', - 'thefilter', - 'thefilter-us', - ]; - return !invalidSections.includes(sectionId); -}; - -export const gandalfIsValidTagIds = (tagIds: string[]): boolean => { - const invalidTagIds = ['info/newsletter-sign-up']; - return !tagIds.some((tagId: string): boolean => invalidTagIds.includes(tagId)); -}; - -export const gandalfArticleIdentifierIsAllowed = (articleIdentifier: string): boolean => { - // Union of the global URL denials and the legal/customer-service page - // exclusions used by the wider reader revenue channels. - const denyPrefixes = [ - 'www.theguardian.com/tips', - 'www.theguardian.com/help/ng-interactive/2017/mar/17/contact-the-guardian-securely', - 'www.theguardian.com/info/privacy', - 'www.theguardian.com/info/complaints-and-corrections', - 'www.theguardian.com/the-whole-picture', - ]; - - return !denyPrefixes.some((denyIdentifer) => articleIdentifier.startsWith(denyIdentifer)); -}; - export const gandalfPageMetadataIsEligibleForGateDisplay = ( contentType: string, sectionId: string, tagIds: string[], ): boolean => { return ( - gandalfIsValidContentType(contentType) && - gandalfIsValidSection(sectionId) && - gandalfIsValidTagIds(tagIds) + gandalfIsValidContentType(contentType) && isValidSection(sectionId) && isValidTagIds(tagIds) ); }; @@ -341,6 +302,9 @@ export const articleIdentifierIsAllowed = (articleIdentifier: string): boolean = const denyPrefixes = [ 'www.theguardian.com/tips', 'www.theguardian.com/help/ng-interactive/2017/mar/17/contact-the-guardian-securely', + 'www.theguardian.com/info/privacy', + 'www.theguardian.com/info/complaints-and-corrections', + 'www.theguardian.com/the-whole-picture', ]; return !denyPrefixes.some((denyIdentifer) => articleIdentifier.startsWith(denyIdentifer)); @@ -517,11 +481,11 @@ export const getTreatmentsRequestPayloadToGateType = ( // // The special cases below (URL denials, page eligibility, newsshowcase // override and the staff testing feature) are deliberately evaluated with - // the Gandalf lists. Pages excluded here return 'None' without the - // marker. + // the generic display exclusions. Pages excluded here return 'None' + // without the marker. if (enableGandalfSignInGate === true && payload.countryCode === 'NZ') { - if (!gandalfArticleIdentifierIsAllowed(payload.articleIdentifier)) { + if (!articleIdentifierIsAllowed(payload.articleIdentifier)) { return 'None'; } if ( diff --git a/src/server/signin-gate/libPureTests/articleIdentifierIsAllowed.test.ts b/src/server/signin-gate/libPureTests/articleIdentifierIsAllowed.test.ts index 12b751dd3..cc822b0ed 100644 --- a/src/server/signin-gate/libPureTests/articleIdentifierIsAllowed.test.ts +++ b/src/server/signin-gate/libPureTests/articleIdentifierIsAllowed.test.ts @@ -9,4 +9,9 @@ it('articleIdentifierIsAllowed', () => { expect(articleIdentifierIsAllowed('www.theguardian.com/tips')).toBe(false); expect(articleIdentifierIsAllowed('www.theguardian.com/tips#test')).toBe(false); expect(articleIdentifierIsAllowed('www.theguardian.com/tips/test')).toBe(false); + expect(articleIdentifierIsAllowed('www.theguardian.com/info/privacy')).toBe(false); + expect(articleIdentifierIsAllowed('www.theguardian.com/info/complaints-and-corrections')).toBe( + false, + ); + expect(articleIdentifierIsAllowed('www.theguardian.com/the-whole-picture')).toBe(false); }); diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts index ce529c8de..5ce575a9f 100644 --- a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts @@ -1,11 +1,11 @@ import { + articleIdentifierIsAllowed, GANDALF_FREE_PAGE_VIEW_COUNT, - gandalfArticleIdentifierIsAllowed, gandalfIsValidContentType, - gandalfIsValidSection, - gandalfIsValidTagIds, gandalfMandatoryPopupUserTreatment, getTreatmentsRequestPayloadToGateType, + isValidSection, + isValidTagIds, } from '../../libPure'; import type { GetTreatmentsRequestPayload } from '../../types'; @@ -261,7 +261,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { 'thefilter', 'thefilter-us', ])('excludes section %s', (sectionId) => { - expect(gandalfIsValidSection(sectionId)).toBe(false); + expect(isValidSection(sectionId)).toBe(false); const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ sectionId }), now, @@ -272,7 +272,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { }); it('excludes the newsletter sign-up tag', () => { - expect(gandalfIsValidTagIds(['info/newsletter-sign-up'])).toBe(false); + expect(isValidTagIds(['info/newsletter-sign-up'])).toBe(false); const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ tagIds: ['info/newsletter-sign-up'] }), now, @@ -289,7 +289,7 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { 'www.theguardian.com/info/complaints-and-corrections', 'www.theguardian.com/the-whole-picture', ])('excludes page %s', (articleIdentifier) => { - expect(gandalfArticleIdentifierIsAllowed(articleIdentifier)).toBe(false); + expect(articleIdentifierIsAllowed(articleIdentifier)).toBe(false); const gateType = getTreatmentsRequestPayloadToGateType( buildPayload({ articleIdentifier }), now, @@ -301,11 +301,9 @@ describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { it('allows ordinary articles and front pages', () => { expect( - gandalfArticleIdentifierIsAllowed( - 'www.theguardian.com/world/2026/sep/01/sample-article', - ), + articleIdentifierIsAllowed('www.theguardian.com/world/2026/sep/01/sample-article'), ).toBe(true); - expect(gandalfArticleIdentifierIsAllowed('www.theguardian.com/uk')).toBe(true); + expect(articleIdentifierIsAllowed('www.theguardian.com/uk')).toBe(true); }); }); diff --git a/src/server/signin-gate/libPureTests/isValidSection.test.ts b/src/server/signin-gate/libPureTests/isValidSection.test.ts index fd101c8cd..908f45eaa 100644 --- a/src/server/signin-gate/libPureTests/isValidSection.test.ts +++ b/src/server/signin-gate/libPureTests/isValidSection.test.ts @@ -9,4 +9,8 @@ describe('isValidSection', () => { // `about` is taken from the list of hard coded invalid sections expect(isValidSection('about')).toBe(false); }); + + it('does not accept `thefilter-us`', () => { + expect(isValidSection('thefilter-us')).toBe(false); + }); }); diff --git a/src/server/signin-gate/logic.md b/src/server/signin-gate/logic.md index f2ea7717c..6af73efba 100644 --- a/src/server/signin-gate/logic.md +++ b/src/server/signin-gate/logic.md @@ -148,7 +148,7 @@ the-whole-picture) never display the gate. The trigger is the standard `dailyArticleCount` payload field: the number of pageviews the reader has already made today, including the current one (`gu.history.dailyArticleCount` on the client). It is a generic daily count -maintained by the client regardless of the Gandalf exclusion lists. The +maintained by the client regardless of the display exclusions. The response carries the `gandalfSignInGate` marker on both outcomes below so the client can identify Guardian-managed responses (for Ophan reporting and to skip Auxia interaction calls). @@ -173,7 +173,7 @@ to skip Auxia interaction calls). | | -----------|----------------------------------------------- -Special cases (evaluated with the Gandalf exclusion lists): +Special cases (evaluated with the generic display exclusions): - denied URLs and ineligible pages: no gate, no marker - shouldServeDismissible (newsshowcase): GuDismissible, as today - staff showDefaultGate override: Gu default gates, as today @@ -181,5 +181,5 @@ Special cases (evaluated with the Gandalf exclusion lists): Reporting: the client emits the standard Ophan SIGN_IN_GATE view/click events under a stable Gandalf identity (`GandalfSignInGate`, variant -`gandalf-`). This is reporting metadata only — there is no A/B -test allocation. +`gandalf-nz`). This is reporting metadata only — there is no A/B test +allocation. From 4a52f25d5a5464d3510404bcdf9c5673bf46a350 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Thu, 17 Sep 2026 12:21:01 +0100 Subject: [PATCH 7/7] update copy --- src/server/signin-gate/libPure.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/signin-gate/libPure.ts b/src/server/signin-gate/libPure.ts index ad495ffaa..4628b9186 100644 --- a/src/server/signin-gate/libPure.ts +++ b/src/server/signin-gate/libPure.ts @@ -142,8 +142,8 @@ export const gandalfMandatoryPopupUserTreatment = (): UserTreatment => { // depend on the treatmentId: the client skips Auxia calls for responses // carrying the gandalfSignInGate marker. - const title = 'Sorry for the interruption'; - const subtitle = "Once you are signed in, we'll bring you back here shortly"; + const title = "No, you don't need to pay to keep reading"; + const subtitle = "Simply sign in - it's free, and much quicker than you think"; const body = 'We’re committed to keeping our quality reporting open. By registering and providing us with insight into your preferences, you’re helping us to engage with you more deeply, and that allows us to keep our journalism free for all.'; const treatmentContent = {