Skip to content

Commit d5ff801

Browse files
Add confirmed uid query param alongside guardian-puzzle-context
Adopts a separately-confirmed mechanism from the native (Android/iOS) apps' real AmuseLabs integration: append uid=<userId> as a plain query parameter when the reader is signed in, omitted entirely (not uid=null or empty) when signed out. This is additive, not a replacement for guardian-puzzle-context, which still carries dark mode and puzzle date, for which there is no separately-confirmed mechanism yet. Applies uniformly to all 6 registry entries via the shared buildPuzzleIframeSrc function (renamed from buildPuzzleIframeSrcWithContext, since it now does more than just the context), not per-puzzle-type, since the mechanism is provider-level. uid's value is sourced identically to the existing guardian-puzzle-context.userId (idToken.claims.legacy_identity_id). The native apps call their equivalent value a "puzzleId", there is no independent confirmation the identifier format matches ours, only that this exact query parameter name/pattern is what they use. Adds test coverage for uid present/correct when signed in, uid entirely absent when signed out, and guardian-puzzle-context coexisting correctly alongside uid in both cases. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 15f5eb8 commit d5ff801

2 files changed

Lines changed: 143 additions & 28 deletions

File tree

dotcom-rendering/src/components/PuzzleIframe.island.test.tsx

Lines changed: 102 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { act, render, screen, waitFor } from '@testing-library/react';
22
import { getAuthStatus, subscribeToAuthStateChange } from '../lib/identity';
33
import { useMatchMedia } from '../lib/useMatchMedia';
44
import {
5-
buildPuzzleIframeSrcWithContext,
5+
buildPuzzleIframeSrc,
66
type PuzzleContext,
77
PuzzleIframe,
88
} from './PuzzleIframe.island';
@@ -35,18 +35,15 @@ const signedOut = () => ({ kind: 'SignedOut' as const });
3535
const contextParam = (context: PuzzleContext) =>
3636
`guardian-puzzle-context=${encodeURIComponent(JSON.stringify(context))}`;
3737

38-
describe('buildPuzzleIframeSrcWithContext', () => {
38+
describe('buildPuzzleIframeSrc', () => {
3939
it('appends the context as a JSON query param when the src has none', () => {
4040
const context: PuzzleContext = {
4141
userId: null,
4242
darkMode: false,
4343
puzzleDate: null,
4444
};
4545
expect(
46-
buildPuzzleIframeSrcWithContext(
47-
'https://example.com/puzzle',
48-
context,
49-
),
46+
buildPuzzleIframeSrc('https://example.com/puzzle', context),
5047
).toBe(`https://example.com/puzzle?${contextParam(context)}`);
5148
});
5249

@@ -57,12 +54,12 @@ describe('buildPuzzleIframeSrcWithContext', () => {
5754
puzzleDate: '2026-09-15',
5855
};
5956
expect(
60-
buildPuzzleIframeSrcWithContext(
57+
buildPuzzleIframeSrc(
6158
'https://example.com/puzzle?set=guardian-sudoku-easy&embed=1',
6259
context,
6360
),
6461
).toBe(
65-
`https://example.com/puzzle?set=guardian-sudoku-easy&embed=1&${contextParam(context)}`,
62+
`https://example.com/puzzle?set=guardian-sudoku-easy&embed=1&${contextParam(context)}&uid=abc123`,
6663
);
6764
});
6865

@@ -73,22 +70,45 @@ describe('buildPuzzleIframeSrcWithContext', () => {
7370
puzzleDate: null,
7471
};
7572
expect(
76-
buildPuzzleIframeSrcWithContext(
77-
'https://example.com/puzzle',
78-
context,
79-
),
73+
buildPuzzleIframeSrc('https://example.com/puzzle', context),
8074
).toContain('guardian-puzzle-context=');
8175
});
8276

8377
it('returns the src unchanged if it cannot be parsed as an absolute URL', () => {
8478
expect(
85-
buildPuzzleIframeSrcWithContext('not-a-url', {
79+
buildPuzzleIframeSrc('not-a-url', {
8680
userId: null,
8781
darkMode: false,
8882
puzzleDate: null,
8983
}),
9084
).toBe('not-a-url');
9185
});
86+
87+
it('appends uid alongside guardian-puzzle-context when the reader is signed in', () => {
88+
const context: PuzzleContext = {
89+
userId: 'user-123',
90+
darkMode: false,
91+
puzzleDate: null,
92+
};
93+
const src = buildPuzzleIframeSrc('https://example.com/puzzle', context);
94+
const url = new URL(src);
95+
96+
expect(url.searchParams.get('uid')).toBe('user-123');
97+
expect(url.searchParams.has('guardian-puzzle-context')).toBe(true);
98+
});
99+
100+
it('omits uid entirely when the reader is signed out (not uid=null or empty)', () => {
101+
const context: PuzzleContext = {
102+
userId: null,
103+
darkMode: false,
104+
puzzleDate: null,
105+
};
106+
const src = buildPuzzleIframeSrc('https://example.com/puzzle', context);
107+
const url = new URL(src);
108+
109+
expect(url.searchParams.has('uid')).toBe(false);
110+
expect(url.searchParams.has('guardian-puzzle-context')).toBe(true);
111+
});
92112
});
93113

94114
describe('PuzzleIframe', () => {
@@ -108,6 +128,9 @@ describe('PuzzleIframe', () => {
108128
) as PuzzleContext;
109129
};
110130

131+
const getUidFromSrc = (src: string): string | null =>
132+
new URL(src).searchParams.get('uid');
133+
111134
it('renders userId: null and darkMode: false while signed out with dark mode unavailable', async () => {
112135
mockedGetAuthStatus.mockResolvedValue(signedOut());
113136

@@ -349,4 +372,70 @@ describe('PuzzleIframe', () => {
349372
expect(getContextFromSrc(iframe.src).puzzleDate).toBeNull(),
350373
);
351374
});
375+
376+
it('includes uid alongside guardian-puzzle-context once signed in', async () => {
377+
mockedGetAuthStatus.mockResolvedValue(signedIn('user-123'));
378+
379+
render(
380+
<PuzzleIframe
381+
src="https://example.com/puzzle"
382+
title="Puzzle"
383+
darkModeAvailable={false}
384+
puzzleDate={null}
385+
/>,
386+
);
387+
388+
const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle');
389+
await waitFor(() => expect(getUidFromSrc(iframe.src)).toBe('user-123'));
390+
expect(
391+
new URL(iframe.src).searchParams.has('guardian-puzzle-context'),
392+
).toBe(true);
393+
});
394+
395+
it('omits uid entirely while signed out (not uid=null or empty)', async () => {
396+
mockedGetAuthStatus.mockResolvedValue(signedOut());
397+
398+
render(
399+
<PuzzleIframe
400+
src="https://example.com/puzzle"
401+
title="Puzzle"
402+
darkModeAvailable={false}
403+
puzzleDate={null}
404+
/>,
405+
);
406+
407+
const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle');
408+
await waitFor(() =>
409+
expect(getContextFromSrc(iframe.src).userId).toBeNull(),
410+
);
411+
expect(new URL(iframe.src).searchParams.has('uid')).toBe(false);
412+
expect(
413+
new URL(iframe.src).searchParams.has('guardian-puzzle-context'),
414+
).toBe(true);
415+
});
416+
417+
it('removes uid again if the reader signs back out', async () => {
418+
mockedGetAuthStatus.mockResolvedValueOnce(signedIn('user-123'));
419+
420+
render(
421+
<PuzzleIframe
422+
src="https://example.com/puzzle"
423+
title="Puzzle"
424+
darkModeAvailable={false}
425+
puzzleDate={null}
426+
/>,
427+
);
428+
429+
const iframe = await screen.findByTitle<HTMLIFrameElement>('Puzzle');
430+
await waitFor(() => expect(getUidFromSrc(iframe.src)).toBe('user-123'));
431+
432+
const onAuthStateChange =
433+
mockedSubscribeToAuthStateChange.mock.calls[0]![0];
434+
mockedGetAuthStatus.mockResolvedValueOnce(signedOut());
435+
await act(async () => {
436+
onAuthStateChange();
437+
});
438+
439+
await waitFor(() => expect(getUidFromSrc(iframe.src)).toBeNull());
440+
});
352441
});

dotcom-rendering/src/components/PuzzleIframe.island.tsx

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getAuthStatus, subscribeToAuthStateChange } from '../lib/identity';
44
import { useMatchMedia } from '../lib/useMatchMedia';
55

66
interface Props {
7-
/** The already-resolved iframe src URL (with `{slug}` substituted). */
7+
/** The already-resolved iframe src URL for this puzzle. */
88
src: string;
99
title: string;
1010
/**
@@ -137,14 +137,33 @@ const buildPuzzleContext = (
137137

138138
/**
139139
* Encodes `context` as JSON into a `guardian-puzzle-context` query
140-
* parameter on `src`, preserving any existing query parameters (e.g.
141-
* AmuseLabs' `?set=...&embed=1&idx=1`). Always includes the parameter -
142-
* unlike the previous `userId`-only mechanism, the context shape itself
143-
* always carries both fields, so there's no "nothing to add" case to omit
144-
* it for. Returns `src` unchanged if it cannot be parsed as an absolute
145-
* URL.
140+
* parameter on `src` (preserving any existing query parameters, e.g.
141+
* AmuseLabs' `?set=...&embed=1&idx=1`), and, separately, appends a plain
142+
* `uid=<userId>` query parameter when the reader is signed in.
143+
*
144+
* `uid` is a genuinely different, independently-confirmed mechanism from
145+
* `guardian-puzzle-context`: the native (Android/iOS) apps' own real,
146+
* working AmuseLabs integration appends `&uid=<value>` as a plain query
147+
* parameter when the user is authenticated, and omits it entirely when
148+
* signed out (their own words: "If the user is authenticated, we add
149+
* &uid=<puzzleId>"). It is added here *alongside*, not instead of,
150+
* `guardian-puzzle-context` (which still carries dark mode and puzzle
151+
* date, for which there is no separately-confirmed mechanism yet). `uid`'s
152+
* value is sourced identically to `context.userId`
153+
* (`idToken.claims.legacy_identity_id`), the native apps call their
154+
* equivalent value a "puzzleId", but there is no independent confirmation
155+
* that identifier format matches ours, only that this exact query
156+
* parameter name/pattern is what they use for their own equivalent value,
157+
* see the "Open questions" section of docs/puzzle-page.md.
158+
*
159+
* Always includes `guardian-puzzle-context` - unlike the previous
160+
* `userId`-only mechanism, the context shape itself always carries all
161+
* three fields, so there's no "nothing to add" case to omit it for. `uid`
162+
* is the only conditional part, present only when `context.userId` is
163+
* non-null, and never sent as `uid=null` or empty. Returns `src` unchanged
164+
* if it cannot be parsed as an absolute URL.
146165
*/
147-
export const buildPuzzleIframeSrcWithContext = (
166+
export const buildPuzzleIframeSrc = (
148167
src: string,
149168
context: PuzzleContext,
150169
): string => {
@@ -154,6 +173,9 @@ export const buildPuzzleIframeSrcWithContext = (
154173
'guardian-puzzle-context',
155174
JSON.stringify(context),
156175
);
176+
if (context.userId !== null) {
177+
url.searchParams.set('uid', context.userId);
178+
}
157179
return url.toString();
158180
} catch {
159181
return src;
@@ -183,12 +205,16 @@ const postContextMessage = (
183205
* iframe `src` (so it is present from the very first request the iframe
184206
* makes), and via `postMessage` once the iframe has loaded (`{ type:
185207
* 'guardian-puzzle-context', context }` - see `PuzzleContextMessage`).
186-
* Because `src` is derived from the reactive `usePuzzleUserId()`/
187-
* `usePuzzleDarkMode()` results, the iframe is automatically reloaded by
188-
* the browser (a fresh `src` triggers a new navigation) whenever the
189-
* reader's sign-in state or OS colour-scheme preference changes while on
190-
* the page - no manual reload fallback is needed for that case, though the
191-
* `onLoad` `postMessage` still fires again after each such reload too.
208+
* When the reader is signed in, also appends a plain `uid=<userId>` query
209+
* parameter alongside `guardian-puzzle-context` (see
210+
* `buildPuzzleIframeSrc`'s doc comment), a separately-confirmed mechanism
211+
* from the native apps' real AmuseLabs integration. Because `src` is
212+
* derived from the reactive `usePuzzleUserId()`/`usePuzzleDarkMode()`
213+
* results, the iframe is automatically reloaded by the browser (a fresh
214+
* `src` triggers a new navigation) whenever the reader's sign-in state or
215+
* OS colour-scheme preference changes while on the page - no manual
216+
* reload fallback is needed for that case, though the `onLoad`
217+
* `postMessage` still fires again after each such reload too.
192218
*/
193219
export const PuzzleIframe = ({
194220
src,
@@ -199,7 +225,7 @@ export const PuzzleIframe = ({
199225
const userId = usePuzzleUserId();
200226
const darkMode = usePuzzleDarkMode(darkModeAvailable);
201227
const context = buildPuzzleContext(userId, darkMode, puzzleDate);
202-
const iframeSrc = buildPuzzleIframeSrcWithContext(src, context);
228+
const iframeSrc = buildPuzzleIframeSrc(src, context);
203229

204230
return (
205231
<iframe

0 commit comments

Comments
 (0)