diff --git a/docs/auxia.md b/docs/auxia.md index 372918b01..ce4f14ec8 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,11 @@ 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 `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 216a684eb..a240afdc0 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,38 @@ 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 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 + 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`). 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 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-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` — +restores the 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..26beaa042 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, enableGandalfSignInGate } = channelSwitches.get(); + const gateType = getTreatmentsRequestPayloadToGateType( + payload, + now, + enableAuxia, + // Tolerate old switch documents without the field: an + // absent switch means the Gandalf journey is off. + enableGandalfSignInGate ?? false, + ); const envelop = await gateTypeToUserTreatmentsEnvelop(config, gateType, payload); if (envelop !== undefined) { const data = userTreatmentsEnvelopToProxyGetTreatmentsAnswerData(envelop); diff --git a/src/server/channelSwitches.ts b/src/server/channelSwitches.ts index 6bece8f7b..3de943512 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). 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/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..4628b9186 100644 --- a/src/server/signin-gate/libPure.ts +++ b/src/server/signin-gate/libPure.ts @@ -124,6 +124,48 @@ 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, 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 + // modal (mounted on document.body) instead of the inline article gate. + // + // 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 = "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 = { + 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: 'gandalf-mandatory-popup', + treatmentTrackingId: 'gandalf-mandatory-popup-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); @@ -138,6 +180,7 @@ export const isValidSection = (sectionId: string): boolean => { 'guardian-live-australia', 'gnm-archive', 'thefilter', + 'thefilter-us', ]; return !invalidSections.includes(sectionId); }; @@ -148,6 +191,56 @@ 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 switched on via the +// enableGandalfSignInGate channel switch. +// +// 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, +// and fronts are sent as Network Front / Section / Tag. +const gandalfContentTypes = [ + 'Article', + '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 gandalfPageMetadataIsEligibleForGateDisplay = ( + contentType: string, + sectionId: string, + tagIds: string[], +): boolean => { + return ( + gandalfIsValidContentType(contentType) && isValidSection(sectionId) && isValidTagIds(tagIds) + ); +}; + +// 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-2 are +// free and 3+ shows the hard popup. +export const GANDALF_FREE_PAGE_VIEW_COUNT = 2; + export const userTreatmentsEnvelopToProxyGetTreatmentsAnswerData = ( envelop: UserTreatmentsEnvelop, ): ProxyGetTreatmentsAnswerData | undefined => { @@ -165,6 +258,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, + }), }; }; @@ -203,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)); @@ -345,9 +447,13 @@ export const getTreatmentsRequestPayloadToGateType = ( payload: GetTreatmentsRequestPayload, now: number, enableAuxia: boolean, + enableGandalfSignInGate: boolean | undefined, ): GateType => { // now: current time in milliseconds since epoch // enableAuxia: channel switch to enable/disable Auxia integration + // 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 @@ -355,6 +461,53 @@ 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 enableGandalfSignInGate channel switch is on (the journey is + // currently New Zealand only) + // + // Effects: + // - Guardian drives the gate, Auxia is never consulted (no GetTreatments + // and no LogTreatmentInteraction for either consent state) + // - 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 third 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 generic display exclusions. Pages excluded here return 'None' + // without the marker. + + if (enableGandalfSignInGate === true && payload.countryCode === 'NZ') { + if (!articleIdentifierIsAllowed(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); + } + return payload.dailyArticleCount <= 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/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/enableAuxia.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/enableAuxia.test.ts index 8ac024ac7..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 new file mode 100644 index 000000000..5ce575a9f --- /dev/null +++ b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/gandalf.test.ts @@ -0,0 +1,414 @@ +import { + articleIdentifierIsAllowed, + GANDALF_FREE_PAGE_VIEW_COUNT, + gandalfIsValidContentType, + gandalfMandatoryPopupUserTreatment, + getTreatmentsRequestPayloadToGateType, + isValidSection, + isValidTagIds, +} 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: 1, + 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, + ...overrides, +}); + +describe('getTreatmentsRequestPayloadToGateType (Gandalf)', () => { + 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 switch is off', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + hasConsented: false, + dailyArticleCount: 5, + contentType: 'Article', + }), + now, + true, + false, + ); + expect(gateType).toBe('AuxiaAnalyticsThenGuDismissible'); + }); + + it('a missing switch is treated as off', () => { + 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('switch state and country', () => { + it('runs the Gandalf journey for New Zealand readers when switched on', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ countryCode: 'NZ' }), + now, + true, + true, + ); + expect(gateType).toBe('GandalfFreeView'); + }); + + 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, + true, + ); + 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', + dailyArticleCount: 5, + }), + now, + true, + true, + ); + 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', + dailyArticleCount: 5, + }), + now, + true, + true, + ); + expect(gateType).toBe('GuDismissible'); + }); + }); + + describe('free pageviews (daily article count, includes the current view)', () => { + it.each([0, 1, 2])('returns GandalfFreeView for daily count %i (consented)', (count) => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: true, dailyArticleCount: count }), + now, + true, + true, + ); + 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, + true, + ); + expect(gateType).toBe('GandalfFreeView'); + }); + + it(`uses the free allowance constant of ${GANDALF_FREE_PAGE_VIEW_COUNT}`, () => { + expect(GANDALF_FREE_PAGE_VIEW_COUNT).toBe(2); + }); + }); + + describe('hard gate from the third daily pageview', () => { + it.each([3, 4, 10])( + 'returns GandalfMandatoryPopup for daily count %i (consented)', + (count) => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: true, dailyArticleCount: count }), + now, + true, + true, + ); + expect(gateType).toBe('GandalfMandatoryPopup'); + }, + ); + + it.each([3, 4, 10])( + 'returns GandalfMandatoryPopup for daily count %i (un-consented)', + (count) => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ hasConsented: false, dailyArticleCount: count }), + now, + true, + true, + ); + expect(gateType).toBe('GandalfMandatoryPopup'); + }, + ); + + it('ignores legacy dismissal and display counters', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + hasConsented: true, + dailyArticleCount: 1, + gateDismissCount: 9, + gateDisplayCount: 9, + }), + now, + true, + true, + ); + expect(gateType).toBe('GandalfFreeView'); + }); + + it('ignores the supporter/hide-support-messaging state', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + hasConsented: true, + dailyArticleCount: 3, + hideSupportMessagingTimestamp: now - 1000, + }), + now, + true, + true, + ); + expect(gateType).toBe('GandalfMandatoryPopup'); + }); + }); + + describe('eligible Guardian content metadata', () => { + it.each([ + 'Article', + '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, + true, + ); + expect(gateType).toBe('GandalfFreeView'); + }); + + it('matches content types case-insensitively', () => { + expect(gandalfIsValidContentType('liveblog')).toBe(true); + expect(gandalfIsValidContentType('network front')).toBe(true); + }); + + it.each(['Picture', 'Survey', 'Signup', ''])('does not accept %s', (contentType) => { + expect(gandalfIsValidContentType(contentType)).toBe(false); + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ contentType }), + now, + true, + true, + ); + 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(isValidSection(sectionId)).toBe(false); + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ sectionId }), + now, + true, + true, + ); + expect(gateType).toBe('None'); + }); + + it('excludes the newsletter sign-up tag', () => { + expect(isValidTagIds(['info/newsletter-sign-up'])).toBe(false); + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ tagIds: ['info/newsletter-sign-up'] }), + now, + true, + true, + ); + 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(articleIdentifierIsAllowed(articleIdentifier)).toBe(false); + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ articleIdentifier }), + now, + true, + true, + ); + expect(gateType).toBe('None'); + }); + + it('allows ordinary articles and front pages', () => { + expect( + articleIdentifierIsAllowed('www.theguardian.com/world/2026/sep/01/sample-article'), + ).toBe(true); + expect(articleIdentifierIsAllowed('www.theguardian.com/uk')).toBe(true); + }); + }); + + describe('deliberate overrides', () => { + it('newsshowcase still receives the dismissible gate', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ shouldServeDismissible: true }), + now, + true, + true, + ); + expect(gateType).toBe('GuDismissible'); + }); + + it('staff showgate=mandatory still receives the Gu mandatory gate', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ showDefaultGate: 'mandatory' }), + now, + true, + true, + ); + expect(gateType).toBe('GuMandatory'); + }); + + it('staff showgate=dismissible still receives the Gu dismissible gate', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ showDefaultGate: 'dismissible' }), + now, + true, + true, + ); + 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, + true, + ); + 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', + dailyArticleCount: 5, + }), + now, + true, + true, + ); + expect(gateType).toBe('GuDismissible'); + }); + + it('Ireland keeps its mandatory rollout behaviour when the switch is on', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + countryCode: 'IE', + hasConsented: true, + contentType: 'Article', + }), + now, + true, + true, + ); + expect(gateType).toBe('AuxiaAPI'); + }); + + it('a non-NZ country never takes the Gandalf journey, even when the switch is on', () => { + const gateType = getTreatmentsRequestPayloadToGateType( + buildPayload({ + countryCode: 'CA', + hasConsented: true, + contentType: 'LiveBlog', + dailyArticleCount: 3, + }), + now, + true, + true, + ); + // Canada is not a Gandalf country and LiveBlog is not globally + // eligible, so no gate is served. + expect(gateType).toBe('None'); + }); + }); + + 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'); + }); + }); +}); diff --git a/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/ireland.test.ts b/src/server/signin-gate/libPureTests/getTreatmentsRequestPayloadToGateType/ireland.test.ts index 3f284fabb..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 a714bb8a9..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 03a46c7f5..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 a04c6e548..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/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 4e690b773..6af73efba 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 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 +LogTreatmentInteraction call, for consented and un-consented readers alike. + +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 +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 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). + +``` + ---------------------------------------------- + | [G1] | + | | + | - No Auxia request | + daily count | - No gate displayed | + <= 2 | - Response carries the gandalfSignInGate | + | marker | + | | + -----------|----------------------------------------------- + | [G2] | + | | + | - No Auxia request | + daily count | - Guardian drives the gate: | + >= 3 | - Non-dismissible sign-in popup | + | (NONDISMISSIBLE_SIGN_IN_GATE_POPUP) | + | - Persists until the reader signs in | + | | + -----------|----------------------------------------------- + +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 +``` + +Reporting: the client emits the standard Ophan SIGN_IN_GATE view/click events +under a stable Gandalf identity (`GandalfSignInGate`, variant +`gandalf-nz`). 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..0ecc68cf6 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,12 @@ 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 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). type ShowGateValues = 'true' | 'mandatory' | 'dismissible' | undefined; @@ -161,3 +171,22 @@ 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 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 +// (true) on both so the client can: +// - identify Guardian-managed treatments and skip every Auxia interaction call; +// - report to Ophan under a stable Gandalf identity instead of Auxia's. +// +// The gate decision is driven by the standard `dailyArticleCount` payload +// field: the reader's pageview count for the current day, including this +// pageview.