diff --git a/docs/ab-testing.md b/docs/ab-testing.md new file mode 100644 index 000000000..e69de29bb diff --git a/ilc/client/ClientRouter.js b/ilc/client/ClientRouter.js index 6f3295365..eab339376 100644 --- a/ilc/client/ClientRouter.js +++ b/ilc/client/ClientRouter.js @@ -20,6 +20,7 @@ export default class ClientRouter extends EventEmitter { #logger; #registryConf; #ilcConfigRoot; + #ilcState; /** @type Object */ #router; #prevRoute; @@ -54,6 +55,7 @@ export default class ClientRouter extends EventEmitter { this.#location = location; this.#logger = logger; this.#i18n = i18n; + this.#ilcState = state ?? {}; this.#ilcConfigRoot = ilcConfigRoot; this.#registryConf = this.#ilcConfigRoot.getConfig(); this.#router = new Router(this.#registryConf); @@ -142,12 +144,27 @@ export default class ClientRouter extends EventEmitter { if (routeConfig === undefined) { throw new this.errors.RouterError({ message: 'Can not find info about the slot.', data: { slotName } }); } - const appProps = appConfig.props || {}; + // ILC-level props for the app/slot. `appConfig.props` is the app's registry + // config; the user-app props live under a nested `appProps` key (below). + const appConfigProps = appConfig.props || {}; const routeProps = routeConfig.props || {}; - const finalRouteProps = deepmerge(appProps, routeProps); - - return finalRouteProps; + // Carry the experiment assignments (resolved once at SSR and inlined into + // ilcState) into every app's props on client-side navigation. They go under the + // nested `appProps` key — the same place the server uses (server-router.js) and + // the field a client consumer reads via getCurrentPathProps().appProps — + // so a freshly client-mounted app still receives the already-resolved assignment. + // This reuses the existing value (never re-buckets), keeping the assignment stable + // across all apps in a long-lived SPA session. + // + // Note: SSR-rendered (primary) apps already get experiments via the server SSR + // appProps, so this client-side merge is the forward-looking half of that same + // contract — it is what delivers the assignment to apps mounted purely on the + // client (no SSR). It is a no-op when ilcState carries no experiments. + const { experiments } = this.#ilcState; + const experimentsProps = experiments ? { appProps: { experiments } } : {}; + + return deepmerge.all([appConfigProps, routeProps, experimentsProps]); } #setInitialRoutes = (state) => { diff --git a/ilc/client/ClientRouter.spec.js b/ilc/client/ClientRouter.spec.js index 10a2452fa..51a376954 100644 --- a/ilc/client/ClientRouter.spec.js +++ b/ilc/client/ClientRouter.spec.js @@ -605,6 +605,53 @@ describe('client router', () => { 'Can not find info about the slot', ); }); + + it('does not add an appProps.experiments field when ilcState carries no experiments', () => { + // router from beforeEach is built with empty ilcState ({}), so props are untouched + chai.expect(router.getCurrentRouteProps('@portal/hero', 'hero').appProps).to.equal(undefined); + }); + + it('merges ilcState.experiments into appProps so client-side navigation carries the assignment', () => { + const experiments = { 'homepage-hero': 'variant-b', 'example-experiment': 'variant-a' }; + // Rebuild the beforeEach router with experiments in ilcState. Reassign the + // module-level `router` (after cleaning the empty one) so afterEach tears down + // its window listeners — a local instance would leak handlers into later + // navigation specs and trigger a full-page reload. + router.removeEventListeners(); + router = new ClientRouter(configRoot, { experiments }, undefined, singleSpa, handlePageTransaction); + + // nested under `appProps` to match the server (server-router.js), so a client + // consumer can read it via getCurrentPathProps().appProps on mount + chai.expect(router.getCurrentRouteProps('@portal/hero', 'hero').appProps).to.be.eql({ experiments }); + chai.expect(router.getPrevRouteProps('@portal/hero', 'hero').appProps).to.be.eql({ experiments }); + }); + + it('preserves the app/route own props alongside the injected experiments', () => { + const experiments = { 'homepage-hero': 'variant-b' }; + router.removeEventListeners(); + router = new ClientRouter(configRoot, { experiments }, undefined, singleSpa, handlePageTransaction); + + const props = router.getCurrentRouteProps('@portal/hero', 'hero'); + // the app's own props must survive the merge — experiments are purely additive + chai.expect(props.heroSecondProp).to.equal('heroSecondProp'); + chai.expect(props.heroFirstProp).to.be.an('object'); + chai.expect(props.appProps).to.be.eql({ experiments }); + }); + + it('reuses the resolved assignment without re-bucketing (stable across current/prev and repeated calls)', () => { + const experiments = { 'homepage-hero': 'variant-b' }; + router.removeEventListeners(); + router = new ClientRouter(configRoot, { experiments }, undefined, singleSpa, handlePageTransaction); + + // same resolved value every time — the client never re-evaluates the assignment + chai.expect(router.getCurrentRouteProps('@portal/hero', 'hero').appProps.experiments).to.be.eql( + experiments, + ); + chai.expect(router.getCurrentRouteProps('@portal/hero', 'hero').appProps.experiments).to.be.eql( + experiments, + ); + chai.expect(router.getPrevRouteProps('@portal/hero', 'hero').appProps.experiments).to.be.eql(experiments); + }); }); describe('when i18n was provided', () => { diff --git a/ilc/config/custom-environment-variables.json5 b/ilc/config/custom-environment-variables.json5 index 993b31360..cb1c11a7f 100644 --- a/ilc/config/custom-environment-variables.json5 +++ b/ilc/config/custom-environment-variables.json5 @@ -28,5 +28,8 @@ }, client: { protocol: 'ILC_CLIENT_PROTOCOL', + }, + experiments: { + enabled: 'EXPERIMENTS_ENABLED', } } diff --git a/ilc/config/default.json5 b/ilc/config/default.json5 index d5941c933..190ce688e 100644 --- a/ilc/config/default.json5 +++ b/ilc/config/default.json5 @@ -32,5 +32,22 @@ }, client: { protocol: 'https', - } + }, + experiments: { + // Global kill-switch. Set to false (e.g. via env in an incident) to send every + // visitor to control with no assignment. Toggling this is a config change, not a deploy. + enabled: true, + // Experiment definitions, keyed by id. Empty in the OSS baseline: a deployment + // declares its own experiments in an environment/local config layer (see + // config/local.json5 for the local demo; the integration test injects its own + // ruleset from tests/fixtures/experiments.json5), so a fresh install assigns no + // one and writes no x-ab-* cookies until it opts in. + // + // This static-JSON ruleset is the current source, read by + // server/experiments/StaticConfigRulesetProvider. See docs/ab-testing.md for a + // later direction where a remote experiment-management service (management UI + + // SSE/polling delivery) becomes the source of truth; until then, experiments are + // configured here. + ruleset: {}, + }, } diff --git a/ilc/server/app.js b/ilc/server/app.js index 1bf77d882..713a07077 100644 --- a/ilc/server/app.js +++ b/ilc/server/app.js @@ -13,14 +13,20 @@ import { TransitionHooksExecutor } from './TransitionHooksExecutor'; const { Test500Error } = require('./errorHandler/ErrorHandler'); const i18n = require('./i18n'); +const { applyExperiments } = require('./experiments'); const reportingPluginManager = require('./plugins/reportingPlugin'); const AccessLogger = require('./logger/accessLogger'); const { isStaticFile, isHealthCheck, isDataUri } = require('./utils/utils'); /** * @param {Registry} registryService + * @param {*} pluginManager + * @param {import('./experiments').Ruleset} [experimentsRuleset] Optional ruleset override + * for the experiment layer. Left undefined in production so `applyExperiments` reads the + * config-loaded ruleset; the integration test passes a fixture here to drive the real + * onRequest path deterministically, without a node-config test layer. */ -module.exports = async function createApplication(registryService, pluginManager) { +module.exports = async function createApplication(registryService, pluginManager, experimentsRuleset) { const reportingPlugin = reportingPluginManager.getInstance(); const errorHandler = errorHandlerFactory(); const appConfig = Application.getConfig(reportingPlugin); @@ -62,6 +68,12 @@ module.exports = async function createApplication(registryService, pluginManager ); await i18nOnRequest(req, reply); + + try { + applyExperiments(req, reply, experimentsRuleset); + } catch (error) { + logger.warn({ error }, 'Failed to apply experiment assignments; continuing with control'); + } }); app.addHook('onResponse', (req, reply, done) => { diff --git a/ilc/server/app.spec.js b/ilc/server/app.spec.js index 7e342f1ed..8c17d3b56 100644 --- a/ilc/server/app.spec.js +++ b/ilc/server/app.spec.js @@ -1,13 +1,24 @@ +const fs = require('fs'); +const path = require('path'); const chai = require('chai'); +const JSON5 = require('json5'); const nock = require('nock'); const supertest = require('supertest'); const helpers = require('../tests/helpers'); const createApp = require('./app'); -async function createTestServer(mockRegistryOptions = {}, mockPluginOptions = {}) { +// Experiment ruleset for the integration test. Loaded explicitly here and injected via +// createApp's seam, rather than through a node-config test layer, so it stays scoped to +// this spec instead of leaking into every NODE_ENV=test file's config. +const experimentsRuleset = JSON5.parse( + fs.readFileSync(path.join(__dirname, '../tests/fixtures/experiments.json5'), 'utf8'), +).ruleset; + +async function createTestServer(mockRegistryOptions = {}, mockPluginOptions = {}, rulesetOverride) { const app = await createApp( helpers.getRegistryMock(mockRegistryOptions), helpers.getPluginManagerMock(mockPluginOptions), + rulesetOverride, ); await app.ready(); @@ -26,7 +37,7 @@ describe('App', () => { before(async () => { helpers.setupMockServersForApps(); - const serverInstance = await createTestServer(); + const serverInstance = await createTestServer({}, {}, experimentsRuleset); app = serverInstance.app; server = serverInstance.server; }); @@ -341,4 +352,38 @@ describe('App', () => { chai.expect(routerProps.reqUrl).to.include('?foo=bar&test=123'); }); + + // Integration coverage for the experiment onRequest hook through the real Fastify + // stack (the ungated `example-experiment` ruleset is injected from + // tests/fixtures/experiments.json5 via createApp — see the top of this file). + describe('experiment assignment', () => { + const setCookies = (response) => response.headers['set-cookie'] ?? []; + + it('mints a session cookie and assigns the ungated experiment over a real request', async () => { + const response = await server.get('/').expect(200); + const cookies = setCookies(response); + + chai.expect(cookies.some((c) => c.startsWith('ilc-sid='))).to.equal(true); + const ab = cookies.map((c) => c.split(';')[0]).find((c) => c.startsWith('x-ab-example-experiment=')); + chai.expect(ab, 'x-ab-example-experiment cookie set by the onRequest hook').to.exist; + // the resolved value is a real declared variant of the experiment + const variant = ab.split('=')[1]; + chai.expect(['variant-a', 'variant-b']).to.include(variant); + // a personalized (variant-bearing) response must not be shared-cached + chai.expect(response.headers['cache-control']).to.equal('private, no-store'); + }); + + it('reuses an existing assignment and does not re-issue its cookie', async () => { + const first = await server.get('/').expect(200); + const firstNames = setCookies(first).map((c) => c.split(';')[0]); + const sid = firstNames.find((c) => c.startsWith('ilc-sid=')); + const abCookie = firstNames.find((c) => c.startsWith('x-ab-example-experiment=')); + chai.expect(sid, 'session cookie issued on first visit').to.exist; + chai.expect(abCookie, 'assignment cookie issued on first visit').to.exist; + + const second = await server.get('/').set('Cookie', `${sid}; ${abCookie}`).expect(200); + const reissued = setCookies(second).some((c) => c.startsWith('x-ab-example-experiment=')); + chai.expect(reissued).to.equal(false); + }); + }); }); diff --git a/ilc/server/experiments/StaticConfigRulesetProvider.ts b/ilc/server/experiments/StaticConfigRulesetProvider.ts new file mode 100644 index 000000000..14e55d07a --- /dev/null +++ b/ilc/server/experiments/StaticConfigRulesetProvider.ts @@ -0,0 +1,54 @@ +import config from 'config'; +import type { Ruleset, RulesetProvider } from './interfaces'; +import { validateRuleset } from './validate'; + +/** + * Current implementation of the experiment ruleset source: reads the ruleset from the + * static JSON config layer — node-config `experiments.ruleset` (empty in the OSS baseline; + * populated in a local/registry config layer, or injected in tests). + * + * This is the single place that knows where the ruleset comes from. The assignment layer + * (assign / bucket / consent / cookies / propagation) only ever receives a resolved + * `Ruleset` and never reads configuration, so the source can evolve on its own. See + * `docs/ab-testing.md` for a later direction where a remote experiment-management service + * — a management UI plus SSE/polling delivery — becomes the source of truth, letting + * stakeholders manage experiments without an ILC deployment. That would arrive as another + * {@link RulesetProvider} implementation + * swapped in at `./ruleset`, with no change to the assignment layer — which is the point + * of keeping the source behind this seam. + * + * The read is defensive: a malformed ruleset degrades to "no experiments" rather than + * crashing ILC bootstrap, and validation problems are surfaced. It runs at construction + * time — before the app's structured (pino) logger exists — so problems go to stdout. + */ +export class StaticConfigRulesetProvider implements RulesetProvider { + private readonly ruleset: Ruleset; + + constructor() { + this.ruleset = StaticConfigRulesetProvider.read(); + } + + public getRuleset(): Ruleset { + return this.ruleset; + } + + private static read(): Ruleset { + if (!config.has('experiments.ruleset')) { + return {}; + } + + try { + const raw = config.get('experiments.ruleset'); + const problems = validateRuleset(raw); + if (problems.length > 0) { + // eslint-disable-next-line no-console + console.warn(`[experiments] ruleset has issues:\n ${problems.join('\n ')}`); + } + return raw; + } catch (error) { + // eslint-disable-next-line no-console + console.warn('[experiments] failed to load ruleset; running with no experiments', error); + return {}; + } + } +} diff --git a/ilc/server/experiments/assign.spec.ts b/ilc/server/experiments/assign.spec.ts new file mode 100644 index 000000000..7de0c6722 --- /dev/null +++ b/ilc/server/experiments/assign.spec.ts @@ -0,0 +1,247 @@ +import { expect } from 'chai'; +import { SESSION_COOKIE, abCookieName, assignExperiments } from './assign'; +import type { Ruleset } from './interfaces'; + +const ruleset: Ruleset = { + 'homepage-hero': { + status: 'active', + variants: [ + { name: 'variant-a', weight: 50 }, + { name: 'variant-b', weight: 50 }, + ], + }, +}; + +const pausedRuleset: Ruleset = { + 'homepage-hero': { status: 'paused', variants: ruleset['homepage-hero'].variants }, +}; + +const AB_COOKIE = abCookieName('homepage-hero'); + +// Build a request from a cookie name→value map (no cookies → empty headers). +const request = (cookies: Record = {}): { headers: { cookie?: string } } => { + const entries = Object.entries(cookies); + return entries.length + ? { headers: { cookie: entries.map(([name, value]) => `${name}=${value}`).join('; ') } } + : { headers: {} }; +}; + +const findDirective = (result: ReturnType, name: string) => + result.cookieDirectives.find((directive) => directive.name === name); + +describe('experiments/assign', () => { + describe('first visit (no cookies)', () => { + it('generates a session id and assigns every active experiment', () => { + const result = assignExperiments(request(), ruleset); + + expect(result.sessionId).to.be.a('string').with.length.greaterThan(0); + expect(result.assignments).to.have.property('homepage-hero'); + expect(['variant-a', 'variant-b']).to.include(result.assignments['homepage-hero']); + }); + + it('emits Set-Cookie directives for the session and a per-experiment x-ab-* cookie', () => { + const result = assignExperiments(request(), ruleset); + + const sessionDirective = findDirective(result, SESSION_COOKIE); + const abDirective = findDirective(result, AB_COOKIE); + + expect(sessionDirective).to.not.equal(undefined); + expect(sessionDirective?.options.httpOnly).to.equal(true); + expect(abDirective).to.not.equal(undefined); + expect(abDirective?.value).to.equal(result.assignments['homepage-hero']); + }); + }); + + describe('returning visit (cookies present)', () => { + it('reuses the existing session id and variant, emitting no new cookies', () => { + const first = assignExperiments(request(), ruleset); + const sessionId = first.sessionId; + const variant = first.assignments['homepage-hero']; + + const second = assignExperiments(request({ [SESSION_COOKIE]: sessionId, [AB_COOKIE]: variant }), ruleset); + + expect(second.sessionId).to.equal(sessionId); + expect(second.assignments).to.deep.equal(first.assignments); + expect(second.cookieDirectives).to.have.length(0); + }); + + it('is stable across many re-evaluations of the same session', () => { + const seed = assignExperiments(request(), ruleset); + const variants = new Set(); + for (let i = 0; i < 50; i++) { + variants.add( + assignExperiments(request({ [SESSION_COOKIE]: seed.sessionId }), ruleset).assignments[ + 'homepage-hero' + ], + ); + } + expect(variants.size).to.equal(1); + }); + }); + + describe('paused experiments', () => { + it('does not assign a variant for a paused experiment', () => { + const result = assignExperiments(request(), pausedRuleset); + expect(result.assignments).to.not.have.property('homepage-hero'); + }); + }); + + describe('resilience', () => { + it('does not throw on an empty ruleset', () => { + const result = assignExperiments(request(), {}); + expect(result.assignments).to.deep.equal({}); + }); + + it('skips a malformed experiment (missing variants) instead of throwing', () => { + // Ruleset comes from untyped config; a missing variants array must not crash. + const malformed = { broken: { status: 'active' } } as unknown as Ruleset; + const result = assignExperiments(request(), malformed); + expect(result.assignments).to.deep.equal({}); + }); + }); + + describe('inert when nothing is assigned (non-breaking caching guarantee)', () => { + // The OSS default ships an empty ruleset. A deployment not using experiments must + // get NO ilc-sid cookie and NO personalization — otherwise applyExperiments would + // force Cache-Control: private, no-store on every response and break CDN caching. + it('mints no session cookie for a fresh visitor when the ruleset is empty', () => { + const result = assignExperiments(request(), {}); + expect(result.assignments).to.deep.equal({}); + expect(result.cookieDirectives).to.have.length(0); + }); + + it('mints no session cookie when every experiment is paused', () => { + const result = assignExperiments(request(), pausedRuleset); + expect(result.cookieDirectives).to.have.length(0); + }); + + it('still mints the session cookie once an active experiment assigns', () => { + const result = assignExperiments(request(), ruleset); + expect(findDirective(result, SESSION_COOKIE)).to.not.equal(undefined); + }); + }); + + describe('consent gating (vendor-neutral seam)', () => { + const gated: Ruleset = { + 'homepage-hero': { + status: 'active', + consentCategory: 'performance', + variants: ruleset['homepage-hero'].variants, + }, + }; + const granted = () => 'granted' as const; + const denied = () => 'denied' as const; + + it('assigns when the resolver grants the category', () => { + const result = assignExperiments(request(), gated, { resolveConsent: granted }); + expect(['variant-a', 'variant-b']).to.include(result.assignments['homepage-hero']); + }); + + it('does not assign when the resolver denies the category', () => { + const result = assignExperiments(request(), gated, { resolveConsent: denied }); + expect(result.assignments).to.not.have.property('homepage-hero'); + }); + + it('is fail-closed when no resolver is provided (categorised experiment skipped)', () => { + const result = assignExperiments(request(), gated); + expect(result.assignments).to.not.have.property('homepage-hero'); + }); + + it('expires a previously-stored assignment when consent is no longer granted', () => { + const result = assignExperiments(request({ [SESSION_COOKIE]: 'sid-1', [AB_COOKIE]: 'variant-b' }), gated, { + resolveConsent: denied, + }); + const expire = findDirective(result, AB_COOKIE); + expect(expire?.options.maxAge).to.equal(0); + }); + + it('runs unconditionally when an experiment declares no consent category', () => { + const result = assignExperiments(request(), ruleset, { resolveConsent: denied }); + expect(['variant-a', 'variant-b']).to.include(result.assignments['homepage-hero']); + }); + }); + + describe('cookie reconciliation against the ruleset (security)', () => { + it('never propagates a tampered variant value — re-resolves to a declared variant', () => { + const tampered = encodeURIComponent(''); + const result = assignExperiments(request({ [SESSION_COOKIE]: 'sid-1', [AB_COOKIE]: tampered }), ruleset); + // The injected string is discarded; only a ruleset-declared variant survives. + expect(['variant-a', 'variant-b']).to.include(result.assignments['homepage-hero']); + }); + + it('only ever yields variant values declared by the ruleset', () => { + const result = assignExperiments(request(), ruleset); + for (const [experimentId, variant] of Object.entries(result.assignments)) { + const declared = ruleset[experimentId].variants.map((v) => v.name); + expect(declared).to.include(variant); + } + }); + + it('ignores an x-ab-* cookie for an experiment not in the ruleset', () => { + const result = assignExperiments( + request({ [SESSION_COOKIE]: 'sid-1', [abCookieName('removed-experiment')]: 'whatever' }), + ruleset, + ); + expect(result.assignments).to.not.have.property('removed-experiment'); + }); + }); + + describe('secure cookies', () => { + it('omits the Secure attribute by default (plain http)', () => { + const result = assignExperiments(request(), ruleset); + for (const directive of result.cookieDirectives) { + expect(directive.options.secure).to.not.equal(true); + } + }); + + it('marks every emitted cookie Secure when secure:true (https)', () => { + const result = assignExperiments(request(), ruleset, { secure: true }); + expect(result.cookieDirectives).to.have.length.greaterThan(0); + for (const directive of result.cookieDirectives) { + expect(directive.options.secure).to.equal(true); + } + }); + + it('marks an expiry cookie Secure too, so https browsers accept the deletion', () => { + const result = assignExperiments( + request({ [SESSION_COOKIE]: 'sid-1', [abCookieName('gone')]: 'stale' }), + ruleset, + { secure: true }, + ); + const expire = findDirective(result, abCookieName('gone')); + expect(expire?.options.secure).to.equal(true); + }); + }); + + describe('orphaned cookie cleanup', () => { + it('expires an x-ab-* cookie for an experiment removed from the ruleset', () => { + const result = assignExperiments( + request({ [SESSION_COOKIE]: 'sid-1', [abCookieName('removed')]: 'variant-b' }), + ruleset, + ); + const expire = findDirective(result, abCookieName('removed')); + expect(expire).to.not.equal(undefined); + expect(expire?.value).to.equal(''); + expect(expire?.options.maxAge).to.equal(0); + }); + + it('expires an x-ab-* cookie for a paused experiment', () => { + const result = assignExperiments( + request({ [SESSION_COOKIE]: 'sid-1', [AB_COOKIE]: 'variant-b' }), + pausedRuleset, + ); + const expire = findDirective(result, AB_COOKIE); + expect(expire?.options.maxAge).to.equal(0); + }); + + it('leaves the cookie of a live assignment untouched (no spurious expiry)', () => { + const seed = assignExperiments(request(), ruleset); + const variant = seed.assignments['homepage-hero']; + const result = assignExperiments( + request({ [SESSION_COOKIE]: seed.sessionId, [AB_COOKIE]: variant }), + ruleset, + ); + expect(findDirective(result, AB_COOKIE)).to.equal(undefined); + }); + }); +}); diff --git a/ilc/server/experiments/assign.ts b/ilc/server/experiments/assign.ts new file mode 100644 index 000000000..ff5d1b2a7 --- /dev/null +++ b/ilc/server/experiments/assign.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto'; +import { parse as parseCookieHeader } from 'cookie'; +import { bucketVariant } from './bucket'; +import { + AB_COOKIE_PREFIX, + SESSION_COOKIE, + abCookieName, + abCookieOptions, + expireCookieOptions, + sessionCookieOptions, +} from './cookies'; +import type { AssignmentResult, AssignOptions, CookieDirective, ExperimentAssignments, Ruleset } from './interfaces'; + +// Cookie names/options live in ./cookies; re-exported here as the layer's public surface. +export { SESSION_COOKIE, AB_COOKIE_PREFIX, abCookieName } from './cookies'; + +interface MinimalRequest { + readonly headers: { readonly cookie?: string }; +} + +type ParsedCookies = Record; + +function readCookies(request: MinimalRequest): ParsedCookies { + const header = request.headers.cookie; + return header ? parseCookieHeader(header) : {}; +} + +/** + * Resolve experiment variants for a single request. + * + * Pure and side-effect-free: it reads the per-experiment `x-ab-*` cookies off the + * request, resolves a variant for every `active` experiment (reusing the stored + * variant when it is still valid so returning visitors are stable), and returns + * the assignments plus the cookie directives the caller must write. Assignment + * never throws on bad input and never depends on a network call, satisfying the + * "site functions normally when the experiment layer misbehaves" requirement. + * + * A stored variant is reused only when it is still a declared variant of that + * experiment, so every value that leaves this function is ruleset-controlled — a + * tampered cookie can never inject an arbitrary string into the assignments + * (which are later inlined into the page), and a variant removed from the ruleset + * is re-resolved rather than trusted. + * + * Any `x-ab-*` cookie that no longer maps to a live assignment (experiment paused + * or removed from the ruleset) is expired, so stale assignments don't linger for + * up to 90 days or resurrect if an id is reused. + * + * @param options.secure emit cookies with `Secure` (set when the site is https). + */ +export function assignExperiments( + request: MinimalRequest, + ruleset: Ruleset, + { secure = false, resolveConsent }: AssignOptions = {}, +): AssignmentResult { + const cookies = readCookies(request); + + const incomingSessionId = cookies[SESSION_COOKIE]; + const sessionId = incomingSessionId || randomUUID(); + + // Null-prototype map: experiment ids come from the ruleset and cookie names, so a + // key like `toString`/`constructor` must not collide with Object.prototype — + // otherwise the `in` check in the cleanup sweep below would wrongly treat it as + // an existing assignment. + const assignments: ExperimentAssignments = Object.create(null); + const cookieDirectives: CookieDirective[] = []; + + for (const [experimentId, experiment] of Object.entries(ruleset)) { + // Defensive: the ruleset comes from an untyped source, so tolerate a malformed + // entry (missing/empty variants, wrong status) by skipping it rather than + // throwing — assignment must never break the request (the docstring contract). + if ( + !experiment || + experiment.status !== 'active' || + !Array.isArray(experiment.variants) || + experiment.variants.length === 0 + ) { + continue; + } + + // Vendor-neutral consent gate. With a declared category, the experiment runs + // only when the deployment's resolver returns `granted`; `denied`/`unknown` + // (incl. no resolver) skip assignment, and the orphan sweep below expires any + // previously-stored cookie so a withdrawn-consent visitor reverts to baseline. + if (experiment.consentCategory) { + const state = resolveConsent ? resolveConsent(experiment.consentCategory) : 'unknown'; + if (state !== 'granted') { + continue; + } + } + + const stored = cookies[abCookieName(experimentId)]; + const isStoredVariantValid = stored !== undefined && experiment.variants.some((v) => v.name === stored); + + if (isStoredVariantValid) { + assignments[experimentId] = stored as string; + continue; + } + + const variant = bucketVariant(sessionId, experimentId, experiment.variants); + if (variant !== undefined) { + assignments[experimentId] = variant; + cookieDirectives.push({ + name: abCookieName(experimentId), + value: variant, + options: abCookieOptions(secure), + }); + } + } + + // Expire any `x-ab-*` cookie that didn't resolve to a live assignment this + // request: experiment paused or removed from the ruleset. + // Cookies backing a current assignment are in `assignments` and left untouched. + for (const cookieName of Object.keys(cookies)) { + if (!cookieName.startsWith(AB_COOKIE_PREFIX)) { + continue; + } + const experimentId = cookieName.slice(AB_COOKIE_PREFIX.length); + if (experimentId in assignments) { + continue; + } + cookieDirectives.push({ name: cookieName, value: '', options: expireCookieOptions(secure) }); + } + + // Only persist a freshly-minted session id when it actually seeded an assignment. + // A deployment with no active experiments (the OSS default ships an empty ruleset) + // must stay fully inert — minting `ilc-sid` here would otherwise add a cookie and + // force `Cache-Control: private, no-store` on every response, breaking shared/CDN + // caching for installs that don't use experiments at all. + if (!incomingSessionId && Object.keys(assignments).length > 0) { + cookieDirectives.push({ name: SESSION_COOKIE, value: sessionId, options: sessionCookieOptions(secure) }); + } + + return { sessionId, assignments, cookieDirectives }; +} diff --git a/ilc/server/experiments/bucket.spec.ts b/ilc/server/experiments/bucket.spec.ts new file mode 100644 index 000000000..cb2897479 --- /dev/null +++ b/ilc/server/experiments/bucket.spec.ts @@ -0,0 +1,122 @@ +import { expect } from 'chai'; +import { bucketVariant, hashSeed } from './bucket'; +import type { ExperimentVariant } from './interfaces'; + +const fiftyFifty: ExperimentVariant[] = [ + { name: 'variant-a', weight: 50 }, + { name: 'variant-b', weight: 50 }, +]; + +describe('experiments/bucket', () => { + describe('hashSeed', () => { + it('is deterministic for the same input', () => { + expect(hashSeed('abc:test')).to.equal(hashSeed('abc:test')); + }); + + it('matches frozen golden values (guards cross-pod / cross-Node stickiness)', () => { + // Pinning the exact output protects the documented stability guarantee: any + // change to the hash (algorithm, byte offset) would silently re-bucket every + // cookie-less visitor. Values are the first 4 bytes of SHA-256, big-endian. + expect(hashSeed('golden-session:golden-exp')).to.equal(265145338); + expect(hashSeed('abc:test')).to.equal(2803300838); + }); + + it('returns an unsigned 32-bit integer', () => { + const hash = hashSeed('some-session:some-experiment'); + expect(hash).to.be.a('number'); + expect(hash).to.be.within(0, 0xffffffff); + expect(Number.isInteger(hash)).to.equal(true); + }); + + it('produces different hashes for different inputs', () => { + expect(hashSeed('a:x')).to.not.equal(hashSeed('b:x')); + }); + }); + + describe('bucketVariant', () => { + it('always returns the same variant for the same session and experiment', () => { + const first = bucketVariant('session-1', 'exp', fiftyFifty); + const second = bucketVariant('session-1', 'exp', fiftyFifty); + expect(first).to.equal(second); + }); + + it('returns undefined when there are no variants', () => { + expect(bucketVariant('session-1', 'exp', [])).to.equal(undefined); + }); + + it('only ever returns a declared variant name', () => { + for (let i = 0; i < 1000; i++) { + const variant = bucketVariant(`session-${i}`, 'exp', fiftyFifty); + expect(['variant-a', 'variant-b']).to.include(variant); + } + }); + + it('distributes a 50/50 split within a small tolerance', () => { + const counts: Record = {}; + const total = 10000; + for (let i = 0; i < total; i++) { + const variant = bucketVariant(`visitor-${i}`, 'homepage-hero', fiftyFifty) as string; + counts[variant] = (counts[variant] ?? 0) + 1; + } + const skew = Math.abs((counts['variant-a'] ?? 0) - (counts['variant-b'] ?? 0)) / total; + expect(skew).to.be.lessThan(0.05); + }); + + it('keeps lower buckets in the baseline when a variant weight grows (contiguous slices)', () => { + // A session that lands in the baseline at 50/50 must remain baseline when + // the variant-b weight grows from the top of the range. + const baselineHeavy: ExperimentVariant[] = [ + { name: 'variant-a', weight: 70 }, + { name: 'variant-b', weight: 30 }, + ]; + for (let i = 0; i < 2000; i++) { + const session = `visitor-${i}`; + if (bucketVariant(session, 'exp', fiftyFifty) === 'variant-a') { + // baseline slice [0,50) is a subset of [0,70) => still baseline. + expect(bucketVariant(session, 'exp', baselineHeavy)).to.equal('variant-a'); + } + } + }); + + it('respects a 100% single-variant allocation', () => { + const allVariantB: ExperimentVariant[] = [{ name: 'variant-b', weight: 100 }]; + for (let i = 0; i < 200; i++) { + expect(bucketVariant(`v-${i}`, 'exp', allVariantB)).to.equal('variant-b'); + } + }); + + it('maps the frozen golden session to its expected variant', () => { + // hashSeed('golden-session:golden-exp') % 100 === 38, which falls in the baseline's [0,50). + expect(bucketVariant('golden-session', 'golden-exp', fiftyFifty)).to.equal('variant-a'); + }); + + it('only returns declared names for an N-variant (3-way) experiment', () => { + const threeWay: ExperimentVariant[] = [ + { name: 'variant-a', weight: 34 }, + { name: 'variant-b', weight: 33 }, + { name: 'variant-c', weight: 33 }, + ]; + const seen = new Set(); + for (let i = 0; i < 3000; i++) { + const v = bucketVariant(`n-${i}`, 'cta', threeWay); + expect(['variant-a', 'variant-b', 'variant-c']).to.include(v); + seen.add(v as string); + } + // all three slices are reachable + expect(seen.size).to.equal(3); + }); + + it('falls back to the first variant (never undefined) when weights sum to < 100', () => { + // variant-a + variant-b = 80; buckets [80,100) have no owner and must fall back to variant-a. + const under: ExperimentVariant[] = [ + { name: 'variant-a', weight: 40 }, + { name: 'variant-b', weight: 40 }, + ]; + for (let i = 0; i < 1000; i++) { + const v = bucketVariant(`u-${i}`, 'exp', under); + expect(v).to.not.equal(undefined); + expect(['variant-a', 'variant-b']).to.include(v); + } + }); + }); +}); diff --git a/ilc/server/experiments/bucket.ts b/ilc/server/experiments/bucket.ts new file mode 100644 index 000000000..e20baf5f7 --- /dev/null +++ b/ilc/server/experiments/bucket.ts @@ -0,0 +1,53 @@ +import { createHash } from 'node:crypto'; +import type { ExperimentId, ExperimentVariant, VariantName } from './interfaces'; + +const BUCKET_COUNT = 100; + +/** + * Deterministic hash of the bucketing seed → unsigned 32-bit integer. + * + * Determinism is the whole correctness story: the same `(sessionId, experimentId)` + * pair must always resolve to the same bucket on every ILC pod and across Node + * versions, otherwise a visitor could flip variants between requests. We use Node's + * built-in `crypto` (SHA-256) rather than a hand-rolled or third-party hash because + * the digest is standardised and stable across Node versions and platforms — exactly + * the cross-pod / cross-version stickiness guarantee we need — with no extra + * dependency. The first 4 bytes of the digest are read as a big-endian uint32. + */ +export function hashSeed(seed: string): number { + return createHash('sha256').update(seed).digest().readUInt32BE(0); +} + +/** + * Resolve a variant for a session using weighted, contiguous buckets. + * + * The visitor is placed in a stable bucket `[0, 100)`; variants own contiguous + * weight slices in declaration order. Because slices are contiguous and order + * is stable, growing one variant's weight only ever pulls visitors *into* it + * from the slice boundary — it never re-shuffles already-assigned visitors + * across unrelated variants. + * + * @returns the resolved variant name, or `undefined` when no variants exist. + */ +export function bucketVariant( + sessionId: string, + experimentId: ExperimentId, + variants: readonly ExperimentVariant[], +): VariantName | undefined { + if (variants.length === 0) { + return undefined; + } + + const bucket = hashSeed(`${sessionId}:${experimentId}`) % BUCKET_COUNT; + + let cumulative = 0; + for (const variant of variants) { + cumulative += variant.weight; + if (bucket < cumulative) { + return variant.name; + } + } + + // Weights summed to < 100: fall back to the first (baseline) variant. + return variants[0].name; +} diff --git a/ilc/server/experiments/consent.spec.ts b/ilc/server/experiments/consent.spec.ts new file mode 100644 index 000000000..e4c3a8f4c --- /dev/null +++ b/ilc/server/experiments/consent.spec.ts @@ -0,0 +1,37 @@ +import { expect } from 'chai'; +import { resolveConsent, setConsentResolver } from './consent'; + +const req = (cookie?: string) => ({ headers: cookie ? { cookie } : {} }); + +describe('experiments/consent', () => { + afterEach(() => setConsentResolver(undefined)); // reset module-level state between tests + + it('returns unknown when no resolver is registered (fail-closed)', () => { + expect(resolveConsent(req(), 'performance')).to.equal('unknown'); + }); + + it('delegates the decision to the registered resolver, per category', () => { + setConsentResolver((_r, category) => (category === 'performance' ? 'granted' : 'denied')); + expect(resolveConsent(req(), 'performance')).to.equal('granted'); + expect(resolveConsent(req(), 'targeting')).to.equal('denied'); + }); + + it('passes the request through so a resolver can read its own consent signal', () => { + setConsentResolver((r) => (r.headers.cookie?.includes('consent=ok') ? 'granted' : 'denied')); + expect(resolveConsent(req('consent=ok'), 'x')).to.equal('granted'); + expect(resolveConsent(req('consent=no'), 'x')).to.equal('denied'); + }); + + it('is fail-closed (unknown) when the resolver throws', () => { + setConsentResolver(() => { + throw new Error('consent backend down'); + }); + expect(resolveConsent(req(), 'x')).to.equal('unknown'); + }); + + it('can be cleared back to fail-closed', () => { + setConsentResolver(() => 'granted'); + setConsentResolver(undefined); + expect(resolveConsent(req(), 'x')).to.equal('unknown'); + }); +}); diff --git a/ilc/server/experiments/consent.ts b/ilc/server/experiments/consent.ts new file mode 100644 index 000000000..bdae6a52d --- /dev/null +++ b/ilc/server/experiments/consent.ts @@ -0,0 +1,38 @@ +import type { ConsentState } from './interfaces'; + +/** + * Vendor-neutral consent seam. + * + * ILC ships NO consent-vendor logic. A deployment that must gate experiments behind + * consent registers a resolver that maps its own consent system (a cookie, header, + * session lookup, geo rule, …) to a {@link ConsentState} for a given experiment + * `consentCategory`. The category string is opaque to ILC. + * + * Fail-closed by default: with no resolver registered, {@link resolveConsent} returns + * `unknown`, and assignment treats anything other than `granted` as "do not assign" — + * so a categorised experiment never runs until a deployment wires up consent. + */ +export type ConsentRequest = { readonly headers: { readonly cookie?: string } }; +export type ConsentResolver = (request: ConsentRequest, category: string) => ConsentState; + +let registeredResolver: ConsentResolver | undefined; + +/** Register the deployment's consent resolver (pass `undefined` to clear it). */ +export function setConsentResolver(resolver: ConsentResolver | undefined): void { + registeredResolver = resolver; +} + +/** + * Resolve consent for a category. Returns `unknown` when no resolver is registered or + * the resolver throws — both treated as "not granted" (fail-closed) by assignment. + */ +export function resolveConsent(request: ConsentRequest, category: string): ConsentState { + if (!registeredResolver) { + return 'unknown'; + } + try { + return registeredResolver(request, category) ?? 'unknown'; + } catch { + return 'unknown'; + } +} diff --git a/ilc/server/experiments/cookies.ts b/ilc/server/experiments/cookies.ts new file mode 100644 index 000000000..f49690c98 --- /dev/null +++ b/ilc/server/experiments/cookies.ts @@ -0,0 +1,42 @@ +import type { CookieOptions } from './interfaces'; + +// ILC mints its own session id (gateway-agnostic, ILC-public like `ilc-i18n`), so the +// feature works in OSS ILC without depending on any deployment's identity headers. +/** Stable per-visitor session id minted by ILC; seeds deterministic bucketing. */ +export const SESSION_COOKIE = 'ilc-sid'; + +// Per-experiment sticky cookie `x-ab-`. One cookie per experiment +// (rather than a single combined blob) so an experiment's assignment can be expired +// independently when it is paused or removed from the ruleset. +export const AB_COOKIE_PREFIX = 'x-ab-'; + +/** Cookie name carrying the resolved variant for a single experiment. */ +export function abCookieName(experimentId: string): string { + return `${AB_COOKIE_PREFIX}${experimentId}`; +} + +const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60; +const NINETY_DAYS_SECONDS = 90 * 24 * 60 * 60; + +// The session seed is HttpOnly: the client never needs to read the bucketing seed (it +// reads the resolved variant from the inlined ilcState / the x-ab-* cookie), so keeping +// this stable visitor id out of JS shrinks the XSS-exfiltration and variant-reroll +// surface. `Secure` is applied per request (see `withSecure`). +const SESSION_COOKIE_OPTIONS: CookieOptions = { httpOnly: true, sameSite: 'lax', path: '/', maxAge: ONE_YEAR_SECONDS }; +// Variant cookies are non-HttpOnly by design: the client may read them synchronously at +// bootstrap to stay consistent with the server-resolved variant (no hydration mismatch). +// Only a cache — the server still owns assignment via the ruleset, and a stored value is +// honoured only when it is a declared variant, so a tampered cookie can at worst force +// one harmless re-evaluation. +const AB_COOKIE_OPTIONS: CookieOptions = { httpOnly: false, sameSite: 'lax', path: '/', maxAge: NINETY_DAYS_SECONDS }; +// maxAge 0 expires an existing cookie — used to remove a stored assignment. +const EXPIRE_COOKIE_OPTIONS: CookieOptions = { httpOnly: false, sameSite: 'lax', path: '/', maxAge: 0 }; + +/** Apply the deployment-wide `secure` decision (true on https) to a base option set. */ +function withSecure(base: CookieOptions, secure: boolean): CookieOptions { + return { ...base, secure }; +} + +export const sessionCookieOptions = (secure: boolean): CookieOptions => withSecure(SESSION_COOKIE_OPTIONS, secure); +export const abCookieOptions = (secure: boolean): CookieOptions => withSecure(AB_COOKIE_OPTIONS, secure); +export const expireCookieOptions = (secure: boolean): CookieOptions => withSecure(EXPIRE_COOKIE_OPTIONS, secure); diff --git a/ilc/server/experiments/index.spec.ts b/ilc/server/experiments/index.spec.ts new file mode 100644 index 000000000..d1605fd2d --- /dev/null +++ b/ilc/server/experiments/index.spec.ts @@ -0,0 +1,127 @@ +import { expect } from 'chai'; +import { applyExperiments } from './index'; +import { SESSION_COOKIE, abCookieName } from './assign'; +import type { Ruleset } from './interfaces'; + +// Minimal fakes for the Fastify request/reply pair the onRequest hook passes in. +// We only exercise the bits `applyExperiments` touches: `request.raw` (cookies + +// ilcState) and `reply.raw` (the Set-Cookie header). +function makeRequest(cookie?: string) { + return { raw: { headers: cookie ? { cookie } : {}, ilcState: undefined as any } } as any; +} + +function makeReply(preset?: string | string[], sent = false) { + const headers: Record = {}; + if (preset !== undefined) { + headers['Set-Cookie'] = preset; + } + return { + sent, + raw: { + getHeader: (name: string) => headers[name], + setHeader: (name: string, value: string | string[]) => { + headers[name] = value; + }, + }, + _headers: headers, + } as any; +} + +const setCookies = (reply: any): string[] => { + const v = reply._headers['Set-Cookie']; + return v === undefined ? [] : Array.isArray(v) ? v : [v]; +}; + +const ruleset: Ruleset = { + 'homepage-hero': { + status: 'active', + variants: [ + { name: 'variant-a', weight: 50 }, + { name: 'variant-b', weight: 50 }, + ], + }, +}; + +describe('experiments/index applyExperiments', () => { + it('writes resolved assignments onto request.raw.ilcState.experiments', () => { + const request = makeRequest(); + applyExperiments(request, makeReply(), ruleset); + + expect(request.raw.ilcState).to.be.an('object'); + expect(request.raw.ilcState.experiments).to.have.property('homepage-hero'); + expect(['variant-a', 'variant-b']).to.include(request.raw.ilcState.experiments['homepage-hero']); + }); + + it('emits the session and per-experiment x-ab-* Set-Cookie headers', () => { + const reply = makeReply(); + applyExperiments(makeRequest(), reply, ruleset); + + const cookies = setCookies(reply); + expect(cookies.some((c) => c.startsWith(`${SESSION_COOKIE}=`))).to.equal(true); + expect(cookies.some((c) => c.startsWith(`${abCookieName('homepage-hero')}=`))).to.equal(true); + }); + + it('appends to, rather than clobbers, a Set-Cookie already on the reply (e.g. i18n)', () => { + const reply = makeReply('ilc-i18n=en-US%3AUSD; Path=/'); + applyExperiments(makeRequest(), reply, ruleset); + + const cookies = setCookies(reply); + expect(cookies.some((c) => c.startsWith('ilc-i18n='))).to.equal(true); + expect(cookies.some((c) => c.startsWith(`${SESSION_COOKIE}=`))).to.equal(true); + expect(cookies.length).to.be.greaterThan(1); + }); + + it('marks cookies Secure (test config serves https via client.protocol)', () => { + const reply = makeReply(); + applyExperiments(makeRequest(), reply, ruleset); + // default/test config has client.protocol: 'https', so the flag must be present + expect(setCookies(reply).every((c) => /;\s*Secure/i.test(c))).to.equal(true); + }); + + it('empty ruleset is fully inert: experiments left unset, no cookies', () => { + const request = makeRequest(); + const reply = makeReply(); + applyExperiments(request, reply, {}); + + // Unset (not an empty object) so nothing is merged into fragments or inlined. + expect(request.raw.ilcState.experiments).to.equal(undefined); + expect(setCookies(reply)).to.have.length(0); + }); + + it('does nothing when the reply was already sent (e.g. an i18n redirect)', () => { + const request = makeRequest(); + const reply = makeReply(undefined, true); // reply.sent === true + applyExperiments(request, reply, ruleset); + + expect(setCookies(reply)).to.have.length(0); + expect(request.raw.ilcState?.experiments).to.equal(undefined); + }); + + describe('cache safety (no shared-cached variants)', () => { + const cacheControl = (reply: any): string | undefined => reply._headers['Cache-Control'] as string | undefined; + + it('marks a personalized response private, no-store when a variant is assigned', () => { + const reply = makeReply(); + applyExperiments(makeRequest(), reply, ruleset); + expect(cacheControl(reply)).to.equal('private, no-store'); + }); + + it('marks the response uncacheable even for a returning visitor with no new cookies', () => { + // sticky cookie already present -> no Set-Cookie, but the page still varies by variant + const reply = makeReply(); + applyExperiments( + makeRequest(`${SESSION_COOKIE}=sid-1; ${abCookieName('homepage-hero')}=variant-b`), + reply, + ruleset, + ); + expect(setCookies(reply)).to.have.length(0); + expect(cacheControl(reply)).to.equal('private, no-store'); + }); + + it('does NOT touch Cache-Control when nothing was assigned or set (page stays cacheable)', () => { + const reply = makeReply(); + applyExperiments(makeRequest(`${SESSION_COOKIE}=sid-1`), reply, {}); + expect(cacheControl(reply)).to.equal(undefined); + }); + }); +}); diff --git a/ilc/server/experiments/index.ts b/ilc/server/experiments/index.ts new file mode 100644 index 000000000..60edb990d --- /dev/null +++ b/ilc/server/experiments/index.ts @@ -0,0 +1,107 @@ +import config from 'config'; +import { serialize as serializeCookie } from 'cookie'; +import type { ServerResponseFastifyReply } from '../types/FastifyReply'; +import type { PatchedFastifyRequest } from '../types/PatchedHttpRequest'; +import { assignExperiments } from './assign'; +import { resolveConsent } from './consent'; +import { experimentsEnabled, defaultRulesetProvider } from './ruleset'; +import type { Ruleset } from './interfaces'; + +// Re-export the consent seam so a deployment can register its resolver at bootstrap. +export { setConsentResolver } from './consent'; +export type { ConsentResolver, ConsentRequest } from './consent'; + +// Public surface: the onRequest hook calls `applyExperiments`. The ruleset comes from a +// swappable source ({@link RulesetProvider}) — currently a static-JSON config layer, with +// a remote experiment-management service as a later direction; the kill-switch stays config-driven +// (see `ruleset.ts`). Lower-level helpers (assign/bucket) stay module-private, imported by their specs. +export { ruleset, experimentsEnabled, defaultRulesetProvider } from './ruleset'; +export type { Experiment, ExperimentAssignments, Ruleset, RulesetProvider } from './interfaces'; + +/** True when the edge reports https via `x-forwarded-proto` (first value wins). */ +function forwardedProtoIsHttps(request: PatchedFastifyRequest): boolean { + const header = request.raw.headers['x-forwarded-proto']; + const value = Array.isArray(header) ? header[0] : header; + return typeof value === 'string' && value.split(',')[0].trim().toLowerCase() === 'https'; +} + +/** Append one `Set-Cookie` header without clobbering cookies set earlier in the request. */ +function appendSetCookie(reply: ServerResponseFastifyReply, serialized: string): void { + const existing = reply.raw.getHeader('Set-Cookie'); + if (existing === undefined) { + reply.raw.setHeader('Set-Cookie', serialized); + } else if (Array.isArray(existing)) { + reply.raw.setHeader('Set-Cookie', [...existing, serialized]); + } else { + reply.raw.setHeader('Set-Cookie', [String(existing), serialized]); + } +} + +/** + * Resolve experiment assignments for the request and propagate them: + * - into `ilcState.experiments` (inlined into the page and forwarded to fragments via appProps); + * - as `Set-Cookie` headers so the assignment is sticky across the session. + * + * Designed to be called from the ILC `onRequest` hook. It must run *after* i18n + * so it appends to, rather than overwrites, any cookie i18n already set. + * + * Honours the global kill-switch: when experiments are disabled the layer is fully + * inert — no assignment, no session mint, no cookies, and `ilcState.experiments` is + * left unset so no empty `experiments` object is merged into fragments or inlined. + * + * Cookies get the `Secure` attribute when the site is served over https — per + * `client.protocol`, OR when the edge reports https via `x-forwarded-proto` (a + * TLS-terminating proxy in front of an http origin). This only ever *adds* `Secure`, + * so a plain-http deployment still sets cookies, while an https edge never leaks the + * session id over a non-secure attribute. + * + * When the response is personalized (a variant is assigned or a cookie is minted) + * it is marked `Cache-Control: private, no-store` so a shared cache/CDN can't serve + * one visitor's variant — or a single minted session id — to everyone. Without this + * an upstream cache keyed on the URL would collapse the whole experiment. + * + * @param rulesetOverride test seam — defaults to the active {@link defaultRulesetProvider}. + * Read per call so a future provider with a live (background-synced) cache is picked up. + */ +export function applyExperiments( + request: PatchedFastifyRequest, + reply: ServerResponseFastifyReply, + rulesetOverride: Ruleset = defaultRulesetProvider.getRuleset(), +): void { + // i18n (which runs first) may already have redirected and flushed the response; + // appending a Set-Cookie then would throw ERR_HTTP_HEADERS_SENT. + if (reply.sent) { + return; + } + + request.raw.ilcState = request.raw.ilcState ?? {}; + + // Fully inert when disabled: leave `experiments` unset (rather than an empty object) + // so neither server-router nor ClientRouter merges an empty `experiments` into every + // app's appProps, and nothing extra is inlined into the page. + if (!experimentsEnabled()) { + return; + } + + const secure = config.get('client.protocol') === 'https' || forwardedProtoIsHttps(request); + const { assignments, cookieDirectives } = assignExperiments(request.raw, rulesetOverride, { + secure, + resolveConsent: (category) => resolveConsent(request.raw, category), + }); + + // Only attach `experiments` when something was actually assigned — keeps the + // no-active-experiments case inert end-to-end (no empty object downstream). + if (Object.keys(assignments).length > 0) { + request.raw.ilcState.experiments = assignments; + } + + for (const directive of cookieDirectives) { + appendSetCookie(reply, serializeCookie(directive.name, directive.value, directive.options)); + } + + // The response now varies per visitor (a resolved variant) or carries a freshly + // minted session id — either way it must not be shared-cached. + if (Object.keys(assignments).length > 0 || cookieDirectives.length > 0) { + reply.raw.setHeader('Cache-Control', 'private, no-store'); + } +} diff --git a/ilc/server/experiments/interfaces.ts b/ilc/server/experiments/interfaces.ts new file mode 100644 index 000000000..bff2cd474 --- /dev/null +++ b/ilc/server/experiments/interfaces.ts @@ -0,0 +1,110 @@ +/** + * Type definitions for the ILC experiment-assignment layer. + * + * An "experiment" is an N-variant test. ILC resolves a single variant per + * experiment, per visitor session, deterministically (see {@link ./bucket}), + * and propagates the resolved assignments to every fragment via `appProps` and + * to the browser via the inlined ILC state. See `docs/ab-testing.md`. + */ + +/** Stable variant identifier surfaced to apps, e.g. `variant-a` | `variant-b`. */ +export type VariantName = string; + +/** Stable experiment identifier, e.g. `homepage-hero`. */ +export type ExperimentId = string; + +export interface ExperimentVariant { + /** Variant identifier the app branches on. */ + readonly name: VariantName; + /** Allocation weight. Weights within one experiment are expected to sum to 100. */ + readonly weight: number; +} + +/** + * `active` — variants are bucketed and assigned. + * `paused` — experiment is ignored; visitors fall back to control (no assignment). + */ +export type ExperimentStatus = 'active' | 'paused'; + +/** + * Consent decision for a category, resolved by a deployment-provided resolver. + * `unknown` (no resolver, or resolver can't decide) is treated as "not granted". + */ +export type ConsentState = 'granted' | 'denied' | 'unknown'; + +export interface Experiment { + readonly status: ExperimentStatus; + readonly variants: readonly ExperimentVariant[]; + /** + * Optional, vendor-neutral consent gate. When set, the experiment is assigned only + * if a deployment-registered consent resolver returns `granted` for this category + * (see `./consent`). The category string is opaque to ILC — the deployment maps its + * own consent system to it. With no resolver registered, a categorised experiment is + * fail-closed (not assigned). Experiments without a category run unconditionally. + */ + readonly consentCategory?: string; +} + +/** The complete set of experiments ILC knows about, keyed by experiment id. */ +export type Ruleset = Readonly>; + +/** + * Source of the experiment {@link Ruleset} — the single, deliberate seam between *where + * the ruleset comes from* and *how it is evaluated*. The assignment layer + * ({@link ./assign}, {@link ./bucket}) only ever receives a resolved `Ruleset`; it never + * reads configuration, so the source can be swapped without touching bucketing, consent, + * or propagation. + * + * `getRuleset()` is synchronous on purpose: the ruleset lives in memory and is evaluated + * locally per request with no network call. The current implementation reads a static + * JSON layer ({@link ./StaticConfigRulesetProvider}); a provider backed by a remote + * experiment-management service would keep its in-memory copy fresh out-of-band (SSE or polling) and + * still answer `getRuleset()` synchronously from that cache. + */ +export interface RulesetProvider { + getRuleset(): Ruleset; +} + +/** Resolved variant per experiment for a single visitor session. */ +export type ExperimentAssignments = Record; + +export interface CookieOptions { + readonly httpOnly: boolean; + readonly sameSite: 'lax' | 'strict' | 'none'; + readonly path: string; + readonly maxAge: number; + /** + * Sets the `Secure` attribute. Driven by the external protocol (`client.protocol`): + * on an https site the browser only sends these cookies over TLS. Left off in plain + * http (e.g. local dev), where a `Secure` cookie would otherwise be dropped. + */ + readonly secure?: boolean; +} + +/** Options controlling how {@link assignExperiments} emits cookies. */ +export interface AssignOptions { + /** Emit cookies with the `Secure` attribute (true when the site is served over https). */ + readonly secure?: boolean; + /** + * Resolve consent for an experiment's declared `consentCategory`. When omitted, a + * categorised experiment is treated as `unknown` (fail-closed, not assigned). + * Experiments without a `consentCategory` ignore this entirely. + */ + readonly resolveConsent?: (category: string) => ConsentState; +} + +/** A cookie the caller must write to the response to persist assignment state. */ +export interface CookieDirective { + readonly name: string; + readonly value: string; + readonly options: CookieOptions; +} + +export interface AssignmentResult { + /** Stable per-visitor session id used as the bucketing seed. */ + readonly sessionId: string; + /** Resolved variant for every active experiment. */ + readonly assignments: ExperimentAssignments; + /** Cookies the caller must set on the response (empty when nothing changed). */ + readonly cookieDirectives: readonly CookieDirective[]; +} diff --git a/ilc/server/experiments/ruleset.spec.ts b/ilc/server/experiments/ruleset.spec.ts new file mode 100644 index 000000000..a7af2631c --- /dev/null +++ b/ilc/server/experiments/ruleset.spec.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai'; +import { experimentsEnabled, isExperimentsEnabledValue, ruleset } from './ruleset'; +import { validateRuleset } from './validate'; + +describe('experiments/ruleset', () => { + it('loads a ruleset object from configuration that passes validation', () => { + // Asserts the loader returns a well-formed, valid ruleset — not that any + // particular experiment is configured (the ruleset is environment-specific, + // so coupling the test to its contents would assert config, not code). + expect(ruleset).to.be.an('object'); + expect(validateRuleset(ruleset)).to.deep.equal([]); + }); + + describe('kill-switch', () => { + it('is enabled by default (config value true / absent)', () => { + expect(experimentsEnabled()).to.equal(true); + }); + + it('treats only boolean false and string "false" as disabled', () => { + expect(isExperimentsEnabledValue(false)).to.equal(false); + expect(isExperimentsEnabledValue('false')).to.equal(false); // env vars arrive as strings + expect(isExperimentsEnabledValue(true)).to.equal(true); + expect(isExperimentsEnabledValue(undefined)).to.equal(true); + expect(isExperimentsEnabledValue('true')).to.equal(true); + }); + }); +}); diff --git a/ilc/server/experiments/ruleset.ts b/ilc/server/experiments/ruleset.ts new file mode 100644 index 000000000..0a30e49fc --- /dev/null +++ b/ilc/server/experiments/ruleset.ts @@ -0,0 +1,29 @@ +import config from 'config'; +import type { Ruleset, RulesetProvider } from './interfaces'; +import { StaticConfigRulesetProvider } from './StaticConfigRulesetProvider'; + +/** + * The active ruleset source. Current default: the static-JSON-backed provider + * ({@link StaticConfigRulesetProvider}); see that file for how the source can later move + * to an Experiment-Service-backed provider. The assignment layer reads the ruleset + * through this provider, never from config directly. + */ +export const defaultRulesetProvider: RulesetProvider = new StaticConfigRulesetProvider(); + +/** Convenience snapshot of the current ruleset from the default provider. */ +export const ruleset: Ruleset = defaultRulesetProvider.getRuleset(); + +/** + * Global kill-switch. Experiments run unless `experiments.enabled` is explicitly + * `false` (boolean or the string `"false"`, so an env-var override works). Lets ops + * disable all experiments at once without a deploy. This is intentionally separate from + * the ruleset source above: it stays an ops-level config toggle even once experiment + * definitions move to the Experiment Service. + */ +export function isExperimentsEnabledValue(value: unknown): boolean { + return value !== false && value !== 'false'; +} + +export function experimentsEnabled(): boolean { + return isExperimentsEnabledValue(config.has('experiments.enabled') ? config.get('experiments.enabled') : undefined); +} diff --git a/ilc/server/experiments/validate.spec.ts b/ilc/server/experiments/validate.spec.ts new file mode 100644 index 000000000..96f9769fd --- /dev/null +++ b/ilc/server/experiments/validate.spec.ts @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import { validateRuleset } from './validate'; +import type { Ruleset } from './interfaces'; + +describe('experiments/validate', () => { + it('returns no problems for a well-formed ruleset', () => { + const ruleset: Ruleset = { + exp: { + status: 'active', + variants: [ + { name: 'variant-a', weight: 50 }, + { name: 'variant-b', weight: 50 }, + ], + }, + }; + expect(validateRuleset(ruleset)).to.deep.equal([]); + }); + + it('flags weights that do not sum to 100', () => { + const ruleset: Ruleset = { + exp: { + status: 'active', + variants: [ + { name: 'variant-a', weight: 30 }, + { name: 'variant-b', weight: 30 }, + ], + }, + }; + const problems = validateRuleset(ruleset); + expect(problems).to.have.length(1); + expect(problems[0]).to.contain('sum to 60'); + }); + + it('flags duplicate variant names', () => { + const ruleset: Ruleset = { + exp: { + status: 'active', + variants: [ + { name: 'a', weight: 50 }, + { name: 'a', weight: 50 }, + ], + }, + }; + expect(validateRuleset(ruleset).some((p) => p.includes('duplicate variant name'))).to.equal(true); + }); + + it('flags an experiment id that is not cookie-safe', () => { + const ruleset: Ruleset = { + 'bad id;': { status: 'active', variants: [{ name: 'variant-a', weight: 100 }] }, + }; + expect(validateRuleset(ruleset).some((p) => p.includes('cookie name'))).to.equal(true); + }); + + it('accepts realistic cookie-safe ids', () => { + const ruleset: Ruleset = { + 'search-recs-exp-2026q1': { status: 'active', variants: [{ name: 'variant-a', weight: 100 }] }, + }; + expect(validateRuleset(ruleset)).to.deep.equal([]); + }); + + it('flags negative weights', () => { + const ruleset: Ruleset = { + exp: { + status: 'active', + variants: [ + { name: 'a', weight: 130 }, + { name: 'b', weight: -30 }, + ], + }, + }; + expect(validateRuleset(ruleset).some((p) => p.includes('negative weight'))).to.equal(true); + }); + + it('flags an experiment with no variants', () => { + const ruleset: Ruleset = { exp: { status: 'active', variants: [] } }; + expect(validateRuleset(ruleset).some((p) => p.includes('no variants'))).to.equal(true); + }); +}); diff --git a/ilc/server/experiments/validate.ts b/ilc/server/experiments/validate.ts new file mode 100644 index 000000000..203ca852e --- /dev/null +++ b/ilc/server/experiments/validate.ts @@ -0,0 +1,100 @@ +import type { Experiment, ExperimentVariant, Ruleset } from './interfaces'; + +const TOTAL_WEIGHT = 100; +const VALID_STATUSES = ['active', 'paused']; + +// The experiment id becomes part of the cookie name `x-ab-`. Restrict it to +// characters that are valid in a cookie name (RFC 6265 token), so a typo such as a +// space or `;` can't make `Set-Cookie` throw at request time and silently disable +// the experiment for every visitor. +const COOKIE_SAFE_ID = /^[A-Za-z0-9._-]+$/; + +function checkId(experimentId: string): string[] { + if (COOKIE_SAFE_ID.test(experimentId)) { + return []; + } + return [ + `"${experimentId}": id may only contain letters, digits, dot, underscore or hyphen (it is used in the x-ab- cookie name)`, + ]; +} + +function checkStatus(experimentId: string, experiment: Experiment): string[] { + if (VALID_STATUSES.includes(experiment.status)) { + return []; + } + return [ + `"${experimentId}": status "${experiment.status}" is not one of ${VALID_STATUSES.join(', ')} — the experiment will never run`, + ]; +} + +function checkVariant(experimentId: string, variant: ExperimentVariant): string[] { + if (typeof variant.weight !== 'number' || !Number.isFinite(variant.weight)) { + return [`"${experimentId}": variant "${variant.name}" has a non-numeric weight`]; + } + if (variant.weight < 0) { + return [`"${experimentId}": variant "${variant.name}" has a negative weight`]; + } + if (variant.weight === 0) { + // A zero-weight variant owns an empty bucket slice and is never assigned; + // flag it because authors treat the first variant as the baseline fallback. + return [`"${experimentId}": variant "${variant.name}" has weight 0 and will never be assigned`]; + } + return []; +} + +function checkVariants(experimentId: string, variants: readonly ExperimentVariant[]): string[] { + const problems: string[] = []; + const names = new Set(); + let total = 0; + + for (const variant of variants) { + if (names.has(variant.name)) { + problems.push(`"${experimentId}": duplicate variant name "${variant.name}"`); + } + names.add(variant.name); + + problems.push(...checkVariant(experimentId, variant)); + total += Number.isFinite(variant.weight) ? variant.weight : 0; + } + + if (total !== TOTAL_WEIGHT) { + problems.push(`"${experimentId}": variant weights sum to ${total}, expected ${TOTAL_WEIGHT}`); + } + return problems; +} + +/** + * Check a ruleset for authoring mistakes that would otherwise fail *silently* at + * runtime — a cookie-unsafe id, a typo'd `status` (which would make the experiment + * never run with no error), non-numeric or negative weights, weights that don't sum + * to 100, a zero-weight (unreachable) variant, or duplicate variant names. Returns a + * list of human-readable problems (empty when valid). + * + * This is advisory and defensive: it never throws — even on a structurally broken + * ruleset (e.g. a missing `variants` array from an untyped source) — so a bad + * ruleset can be reported in CI or at load without ever blocking ILC bootstrap. + */ +export function validateRuleset(ruleset: Ruleset): string[] { + const problems: string[] = []; + + for (const [experimentId, experiment] of Object.entries(ruleset)) { + problems.push(...checkId(experimentId)); + + if (!experiment || typeof experiment !== 'object') { + problems.push(`"${experimentId}": is not an experiment object`); + continue; + } + + problems.push(...checkStatus(experimentId, experiment)); + + const { variants } = experiment; + if (!Array.isArray(variants) || variants.length === 0) { + problems.push(`"${experimentId}": has no variants`); + continue; + } + + problems.push(...checkVariants(experimentId, variants)); + } + + return problems; +} diff --git a/ilc/server/tailor/configs-injector.ts b/ilc/server/tailor/configs-injector.ts index 0320be065..444ed4cae 100644 --- a/ilc/server/tailor/configs-injector.ts +++ b/ilc/server/tailor/configs-injector.ts @@ -7,6 +7,7 @@ import { CanonicalTagService } from '../services/CanonicalTagService'; import type { PatchedHttpRequest } from '../types/PatchedHttpRequest'; import type { Template, TransformedRegistryConfig, TransformedSpecialRoute } from '../types/Registry'; import type { App as RegistryApp } from '../types/RegistryConfig'; +import { escapeJsonForScriptTag } from '../utils/helpers'; type ConfigsInjectorRequest = PatchedHttpRequest & { registryConfig: TransformedRegistryConfig; @@ -270,7 +271,7 @@ export class ConfigsInjector { return ''; } - return ``; + return ``; } private wrapWithAsyncScriptTag(url: string): string { diff --git a/ilc/server/tailor/server-router.js b/ilc/server/tailor/server-router.js index a613844bc..d8e9115e8 100644 --- a/ilc/server/tailor/server-router.js +++ b/ilc/server/tailor/server-router.js @@ -84,7 +84,19 @@ module.exports = class ServerRouter { } } - ssrOpts.appProps = deepmerge.all([appInfo.props || {}, appInfo.ssrProps || {}, row.props || {}]); + const ilcState = this.#getIlcState(); + // Nest experiments inside an `appProps` sub-field — that's where a client + // consumer reads user-app props from `requestData.getCurrentPathProps().appProps`. + // The outer object also carries `appConfig` (registry-defined infra config) as a sibling. + const experimentsProps = ilcState.experiments + ? { appProps: { experiments: ilcState.experiments } } + : {}; + ssrOpts.appProps = deepmerge.all([ + appInfo.props || {}, + appInfo.ssrProps || {}, + row.props || {}, + experimentsProps, + ]); ssrOpts.wrapperConf = row.wrapperConf; ssrOpts.spaBundleUrl = appInfo.spaBundle; diff --git a/ilc/server/types/PatchedHttpRequest.ts b/ilc/server/types/PatchedHttpRequest.ts index d3605a85f..fe254d091 100644 --- a/ilc/server/types/PatchedHttpRequest.ts +++ b/ilc/server/types/PatchedHttpRequest.ts @@ -6,6 +6,8 @@ import { FastifyRequest, RouteGenericInterface } from 'fastify'; export interface IlcState { locale?: string; forceSpecialRoute?: string; + /** Resolved experiment variants for this session, keyed by experiment id. */ + experiments?: Record; } export interface PatchedHttpRequest extends IncomingMessage { diff --git a/ilc/server/utils/helpers.spec.ts b/ilc/server/utils/helpers.spec.ts new file mode 100644 index 000000000..3db1153cb --- /dev/null +++ b/ilc/server/utils/helpers.spec.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai'; +import { escapeJsonForScriptTag } from './helpers'; + +describe('helpers', () => { + describe('escapeJsonForScriptTag', () => { + it('escapes the characters that could break out of a sequence embedded in a value', () => { + const escaped = escapeJsonForScriptTag(JSON.stringify({ x: '' })); + expect(escaped).to.not.contain(''); + expect(escaped).to.not.contain('', sep: 'a\u2028b', amp: 'Tom & Jerry' }; + const parsed = JSON.parse(escapeJsonForScriptTag(JSON.stringify(value))); + expect(parsed).to.deep.equal(value); + }); + + it('leaves a plain string untouched', () => { + expect(escapeJsonForScriptTag('{"a":1}')).to.equal('{"a":1}'); + }); + }); +}); diff --git a/ilc/server/utils/helpers.ts b/ilc/server/utils/helpers.ts index a65bf8ad9..65d8c78d5 100644 --- a/ilc/server/utils/helpers.ts +++ b/ilc/server/utils/helpers.ts @@ -1,3 +1,19 @@ +/** + * Make a JSON string safe to inline inside a `` block. + * `JSON.stringify` does not escape `<`, `>`, `&`, or the JS line terminators + * U+2028/U+2029, so a value containing `` could break out of the tag. + * Escaping them as `\uXXXX` keeps the JSON valid (it parses back identically) while + * making tag breakout impossible — defense-in-depth for any user-influenced value. + */ +export function escapeJsonForScriptTag(json: string): string { + return json + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + export function buildForwardedHeaders( proxyHeaderNames: string[] | null | undefined, requestHeaders: Record, diff --git a/ilc/tests/fixtures/experiments.json5 b/ilc/tests/fixtures/experiments.json5 new file mode 100644 index 000000000..ca4239054 --- /dev/null +++ b/ilc/tests/fixtures/experiments.json5 @@ -0,0 +1,17 @@ +{ + // Deterministic experiment ruleset for the integration test in server/app.spec.js. + // + // It is loaded and injected explicitly by that spec (through createApp's ruleset + // seam) instead of by node-config, so it no longer leaks into the config baseline of + // every NODE_ENV=test file. One ungated, evenly split experiment is enough to assert + // the real onRequest assignment path (cookie mint + sticky reuse) end-to-end. + ruleset: { + 'example-experiment': { + status: 'active', + variants: [ + { name: 'variant-a', weight: 50 }, + { name: 'variant-b', weight: 50 }, + ], + }, + }, +}