From 2f3b342122c4233c0f9433350f9e06c37d0feb6a Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 26 Aug 2026 17:49:17 -0300 Subject: [PATCH 1/7] Evaluate as the visitor identified by the response Requests issued while a page is rendered forward the cookies of the request being rendered, which on a first visit carry no identity yet, so the middleware issued a second client ID and user token for them. The page was evaluated as a visitor the browser never receives, as only the identity of the page response reaches it. The issued cookies are now reflected on the request being handled, so every request derived from it evaluates as the same visitor. --- e2e/app/pages/identity.vue | 18 +++++++++++ e2e/mock-server.ts | 28 ++++++++++++++--- e2e/specs/ssr/identity.spec.ts | 26 ++++++++++++++++ src/runtime/server/middleware/croct.ts | 26 ++++++++++++++++ test/runtime/server/middleware/croct.test.ts | 32 ++++++++++++++++++++ 5 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 e2e/app/pages/identity.vue create mode 100644 e2e/specs/ssr/identity.spec.ts diff --git a/e2e/app/pages/identity.vue b/e2e/app/pages/identity.vue new file mode 100644 index 0000000..ace2c23 --- /dev/null +++ b/e2e/app/pages/identity.vue @@ -0,0 +1,18 @@ + + + diff --git a/e2e/mock-server.ts b/e2e/mock-server.ts index 7a7c2fa..67a0206 100644 --- a/e2e/mock-server.ts +++ b/e2e/mock-server.ts @@ -33,6 +33,8 @@ const LOCALIZED_SLOT_CONTENT: Record> = { }, }; +const MOCK_CLIENT_ID = '11111111111111111111111111111111'; + const EVALUATION_RESULTS: Record = { now: '2026-01-01T00:00:00.000000', }; @@ -43,10 +45,13 @@ const CREDENTIALS_BY_TENANT: Record = { [TENANT_NAME]: TENANT_CREDENTIALS, }; -type Route = (body: Record, url: URL) => {status: number, data: unknown}; +type Headers = Record; + +type Route = (body: Record, url: URL, headers: Headers) => {status: number, data: unknown}; const routes: Record = { track: () => ({status: 200, data: {}}), + cid: () => ({status: 200, data: MOCK_CLIENT_ID}), credentials: (_, url) => { const tenant = url.searchParams.get('tenant') ?? ''; const credentials = CREDENTIALS_BY_TENANT[tenant]; @@ -57,9 +62,20 @@ const routes: Record = { return {status: 404, data: {error: `Unknown tenant "${tenant}".`}}; }, - evaluate: body => { + evaluate: (body, _, headers) => { const query = String(body.query ?? ''); + // Echoes the identity of the caller, to compare both sides of the SDK + if (query === 'identity') { + return { + status: 200, + data: { + token: headers['x-token'] ?? null, + clientId: headers['x-client-id'] ?? null, + }, + }; + } + // Echoes the received context so the specs can assert which page // the evaluation was based on. if (query === CONTEXT_ECHO_QUERY) { @@ -128,9 +144,11 @@ function handleRequest(request: IncomingMessage, response: ServerResponse): void request.on('end', () => { response.setHeader('Content-Type', 'application/json'); - response.setHeader('Access-Control-Allow-Origin', '*'); + // The SDK sends credentials, so the origin cannot be a wildcard + response.setHeader('Access-Control-Allow-Origin', request.headers.origin ?? '*'); + response.setHeader('Access-Control-Allow-Credentials', 'true'); response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - response.setHeader('Access-Control-Allow-Headers', '*'); + response.setHeader('Access-Control-Allow-Headers', request.headers['access-control-request-headers'] ?? '*'); if (request.method === 'OPTIONS') { response.writeHead(204); @@ -151,7 +169,7 @@ function handleRequest(request: IncomingMessage, response: ServerResponse): void return; } - const {status, data} = route(requestBody, url); + const {status, data} = route(requestBody, url, request.headers); response.writeHead(status); response.end(JSON.stringify(data)); diff --git a/e2e/specs/ssr/identity.spec.ts b/e2e/specs/ssr/identity.spec.ts new file mode 100644 index 0000000..d283182 --- /dev/null +++ b/e2e/specs/ssr/identity.spec.ts @@ -0,0 +1,26 @@ +import {test, expect} from '@playwright/test'; + +test.describe('identity', () => { + test('should evaluate as the visitor the response identifies, on a first visit', async ({page}) => { + // No cookies yet, so the middleware issues the identity while rendering + await page.goto('/identity'); + + const cookies = await page.context().cookies(); + const clientId = cookies.find(cookie => cookie.name === 'ct.client_id'); + const userToken = cookies.find(cookie => cookie.name === 'ct.user_token'); + + await expect(page.getByTestId('client-id')).toHaveText(clientId!.value); + await expect(page.getByTestId('token')).toHaveText(userToken!.value); + }); + + test('should evaluate as the visitor the cookies identify, on a later visit', async ({page}) => { + await page.goto('/identity'); + + const [{value: clientId}] = (await page.context().cookies()) + .filter(cookie => cookie.name === 'ct.client_id'); + + await page.goto('/identity'); + + await expect(page.getByTestId('client-id')).toHaveText(clientId); + }); +}); diff --git a/src/runtime/server/middleware/croct.ts b/src/runtime/server/middleware/croct.ts index bc7c212..6d8776b 100644 --- a/src/runtime/server/middleware/croct.ts +++ b/src/runtime/server/middleware/croct.ts @@ -31,6 +31,9 @@ function shouldSkip(pathname: string): boolean { } export default defineEventHandler(async event => { + // Requests issued while rendering a page share the context of the request + // being rendered, so the identity is resolved once and reused, rather than + // issuing a new client ID and user token for every internal request. const url = getRequestURL(event); if (shouldSkip(url.pathname)) { @@ -94,8 +97,31 @@ export default defineEventHandler(async event => { ...productionDefaults, httpOnly: true, }); + + // Requests issued while rendering forward the cookies of the request being + // rendered, which still carry the previous identity, if any. Reflecting the + // issued cookies keeps the identity the same across all of them. + updateRequestCookies(event, { + [cookie.clientId.name]: clientId, + [cookie.userToken.name]: userToken.toString(), + ...(previewToken !== null && previewToken !== 'exit' ? {[cookie.previewToken.name]: previewToken} : {}), + }); }); +function updateRequestCookies(event: H3Event, values: Record): void { + const header = event.node.req.headers.cookie ?? ''; + + const cookies = header.split(';') + .map(entry => entry.trim()) + .filter(entry => entry !== '' && !(entry.split('=')[0] in values)); + + for (const [name, value] of Object.entries(values)) { + cookies.push(`${name}=${value}`); + } + + event.node.req.headers.cookie = cookies.join('; '); +} + function resolveClientId(cookieValue: string | undefined): string { if (cookieValue !== undefined && CLIENT_ID_PATTERN.test(cookieValue)) { return cookieValue; diff --git a/test/runtime/server/middleware/croct.test.ts b/test/runtime/server/middleware/croct.test.ts index 25c7497..fdce860 100644 --- a/test/runtime/server/middleware/croct.test.ts +++ b/test/runtime/server/middleware/croct.test.ts @@ -164,6 +164,29 @@ describe('middleware', () => { expect(event.context.croct!.clientId).not.toBe('not-a-uuid'); }); + it('should reflect the issued client ID on the request being handled', async () => { + const event = createMockEvent(); + + await handleRequest(event); + + // Requests issued while rendering forward the cookies of this request + expect(event.node.req.headers.cookie) + .toContain(`ct.client_id=${event.context.croct!.clientId}`); + }); + + it('should replace an invalid client ID on the request being handled', async () => { + const event = createMockEvent(); + + event.node.req.headers.cookie = 'ct.client_id=not-a-uuid; foo=bar'; + + await handleRequest(event); + + expect(event.node.req.headers.cookie).not.toContain('not-a-uuid'); + expect(event.node.req.headers.cookie).toContain('foo=bar'); + expect(event.node.req.headers.cookie) + .toContain(`ct.client_id=${event.context.croct!.clientId}`); + }); + it('should set the client ID cookie in the response', async () => { const event = createMockEvent(); @@ -184,6 +207,15 @@ describe('middleware', () => { expect(event.context.croct!.userToken).toBeTruthy(); }); + it('should reflect the issued token on the request being handled', async () => { + const event = createMockEvent(); + + await handleRequest(event); + + expect(event.node.req.headers.cookie) + .toContain(`ct.user_token=${event.context.croct!.userToken}`); + }); + it('should issue a new token when cookie is invalid', async () => { const event = createMockEvent(); From 8e70fc6afc7d1c9f817bf0b09f1a33371fb4e837 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 26 Aug 2026 17:56:25 -0300 Subject: [PATCH 2/7] Share the identity with the SDK The cookies carrying the client ID, the user token and the preview token were restricted to HTTP, so the SDK running in the browser could neither read nor write them, and evaluated as a different visitor than the server. They are now readable by the SDK, as in the other frameworks, which also hands it the preview token back. --- e2e/app/pages/identity.vue | 22 +++++++++++++++---- e2e/specs/middleware.spec.ts | 9 ++++---- e2e/specs/ssr/identity.spec.ts | 23 +++++++++++++++++--- src/runtime/server/middleware/croct.ts | 2 -- src/runtime/server/utils/cookie.ts | 1 - test/runtime/server/middleware/croct.test.ts | 10 +++++++++ test/runtime/server/utils/cookie.test.ts | 4 ++-- 7 files changed, 55 insertions(+), 16 deletions(-) diff --git a/e2e/app/pages/identity.vue b/e2e/app/pages/identity.vue index ace2c23..318781e 100644 --- a/e2e/app/pages/identity.vue +++ b/e2e/app/pages/identity.vue @@ -1,18 +1,32 @@ diff --git a/e2e/specs/middleware.spec.ts b/e2e/specs/middleware.spec.ts index 4572924..302a924 100644 --- a/e2e/specs/middleware.spec.ts +++ b/e2e/specs/middleware.spec.ts @@ -1,5 +1,6 @@ import {test, expect} from '@playwright/test'; import {Token} from '@croct/sdk/token'; +import {DEFAULT_CREDENTIALS} from '../constants'; test.describe('server middleware', () => { test.describe('cookies', () => { @@ -52,10 +53,10 @@ test.describe('server middleware', () => { test.describe('preview tokens', () => { function createValidPreviewToken(): string { - const header = Buffer.from('{"alg":"none"}').toString('base64url'); - const payload = Buffer.from('{"exp":9999999999}').toString('base64url'); - - return `${header}.${payload}.`; + // A token the SDK also accepts, as it manages the cookie in the browser + return Token.issue(DEFAULT_CREDENTIALS.appId) + .withDuration(3600) + .toString(); } test('should set preview cookie from a valid query parameter', async ({page}) => { diff --git a/e2e/specs/ssr/identity.spec.ts b/e2e/specs/ssr/identity.spec.ts index d283182..7732159 100644 --- a/e2e/specs/ssr/identity.spec.ts +++ b/e2e/specs/ssr/identity.spec.ts @@ -9,8 +9,8 @@ test.describe('identity', () => { const clientId = cookies.find(cookie => cookie.name === 'ct.client_id'); const userToken = cookies.find(cookie => cookie.name === 'ct.user_token'); - await expect(page.getByTestId('client-id')).toHaveText(clientId!.value); - await expect(page.getByTestId('token')).toHaveText(userToken!.value); + await expect(page.getByTestId('server-client-id')).toHaveText(clientId!.value); + await expect(page.getByTestId('server-token')).toHaveText(userToken!.value); }); test('should evaluate as the visitor the cookies identify, on a later visit', async ({page}) => { @@ -21,6 +21,23 @@ test.describe('identity', () => { await page.goto('/identity'); - await expect(page.getByTestId('client-id')).toHaveText(clientId); + await expect(page.getByTestId('server-client-id')).toHaveText(clientId); + }); + + test('should evaluate as the same visitor in the browser', async ({page}) => { + await page.goto('/identity'); + + const serverToken = await page.getByTestId('server-token').textContent(); + + await expect(page.getByTestId('browser-token')).toHaveText(serverToken!, {timeout: 10000}); + }); + + test('should expose the identity to the SDK', async ({page}) => { + await page.goto('/identity'); + + const cookies = await page.evaluate(() => document.cookie); + + expect(cookies).toContain('ct.client_id='); + expect(cookies).toContain('ct.user_token='); }); }); diff --git a/src/runtime/server/middleware/croct.ts b/src/runtime/server/middleware/croct.ts index 6d8776b..c6f0ff4 100644 --- a/src/runtime/server/middleware/croct.ts +++ b/src/runtime/server/middleware/croct.ts @@ -84,7 +84,6 @@ export default defineEventHandler(async event => { path: '/', ...(cookie.previewToken.domain !== '' ? {domain: cookie.previewToken.domain} : {}), ...productionDefaults, - httpOnly: true, }); } @@ -95,7 +94,6 @@ export default defineEventHandler(async event => { path: '/', ...(cookie.clientId.domain !== '' ? {domain: cookie.clientId.domain} : {}), ...productionDefaults, - httpOnly: true, }); // Requests issued while rendering forward the cookies of the request being diff --git a/src/runtime/server/utils/cookie.ts b/src/runtime/server/utils/cookie.ts index f076eba..ef849f5 100644 --- a/src/runtime/server/utils/cookie.ts +++ b/src/runtime/server/utils/cookie.ts @@ -19,7 +19,6 @@ export function setUserTokenCookie(event: H3Event, token: Token): void { path: '/', ...(config.domain !== '' ? {domain: config.domain} : {}), ...getProductionDefaults(), - httpOnly: true, }); } diff --git a/test/runtime/server/middleware/croct.test.ts b/test/runtime/server/middleware/croct.test.ts index fdce860..d1cdad0 100644 --- a/test/runtime/server/middleware/croct.test.ts +++ b/test/runtime/server/middleware/croct.test.ts @@ -187,6 +187,16 @@ describe('middleware', () => { .toContain(`ct.client_id=${event.context.croct!.clientId}`); }); + it('should let the SDK read the client ID cookie', async () => { + const event = createMockEvent(); + + await handleRequest(event); + + const cookie = getSetCookies(event).find(entry => entry.startsWith('ct.client_id=')); + + expect(cookie).not.toContain('HttpOnly'); + }); + it('should set the client ID cookie in the response', async () => { const event = createMockEvent(); diff --git a/test/runtime/server/utils/cookie.test.ts b/test/runtime/server/utils/cookie.test.ts index be44417..dd4edc9 100644 --- a/test/runtime/server/utils/cookie.test.ts +++ b/test/runtime/server/utils/cookie.test.ts @@ -64,7 +64,7 @@ describe('cookie', () => { expect(cookies.some(cookie => cookie.startsWith(`ct.user_token=${token.toString()}`))).toBe(true); }); - it('should set the HttpOnly flag', () => { + it('should let the SDK read the cookie', () => { const event = createMockEvent(); const token = Token.issue(appId).withDuration(3600); @@ -72,7 +72,7 @@ describe('cookie', () => { const cookies = getSetCookies(event); - expect(cookies.some(cookie => cookie.includes('HttpOnly'))).toBe(true); + expect(cookies.some(cookie => cookie.includes('HttpOnly'))).toBe(false); }); it('should set the cookie max-age from config', () => { From 5da79507198c5466a52f1bd0966235c6d60d0ef6 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 26 Aug 2026 18:07:23 -0300 Subject: [PATCH 3/7] Align the preview token validation with the SDK --- e2e/app/pages/identity.vue | 2 + e2e/app/pages/index.vue | 3 + e2e/app/server/api/identity.get.ts | 6 ++ e2e/mock-server.ts | 9 +- e2e/specs/middleware.spec.ts | 11 +++ e2e/specs/ssr/identity.spec.ts | 87 ++++++++++++++++---- src/runtime/server/middleware/croct.ts | 9 +- test/runtime/server/middleware/croct.test.ts | 20 ++++- 8 files changed, 120 insertions(+), 27 deletions(-) create mode 100644 e2e/app/server/api/identity.get.ts diff --git a/e2e/app/pages/identity.vue b/e2e/app/pages/identity.vue index 318781e..3b7b6af 100644 --- a/e2e/app/pages/identity.vue +++ b/e2e/app/pages/identity.vue @@ -8,8 +8,10 @@ type Identity = { clientId: string | null, }; +// Evaluated through the internal route, which the server resolves const {data: server} = await useEvaluation('identity'); +// Evaluated by the SDK running in the browser const browser = ref(null); onMounted(async () => { diff --git a/e2e/app/pages/index.vue b/e2e/app/pages/index.vue index 1c2a800..e566ab4 100644 --- a/e2e/app/pages/index.vue +++ b/e2e/app/pages/index.vue @@ -27,6 +27,9 @@
  • page context
  • +
  • + identity +
  • CSR

    diff --git a/e2e/app/server/api/identity.get.ts b/e2e/app/server/api/identity.get.ts new file mode 100644 index 0000000..e84a088 --- /dev/null +++ b/e2e/app/server/api/identity.get.ts @@ -0,0 +1,6 @@ +export default defineEventHandler(async () => { + // Evaluated through the server composable, outside a page render + const result = await evaluate('identity'); + + return {result: result}; +}) diff --git a/e2e/mock-server.ts b/e2e/mock-server.ts index 67a0206..4dd51c1 100644 --- a/e2e/mock-server.ts +++ b/e2e/mock-server.ts @@ -50,7 +50,14 @@ type Headers = Record; type Route = (body: Record, url: URL, headers: Headers) => {status: number, data: unknown}; const routes: Record = { - track: () => ({status: 200, data: {}}), + track: (body, _, headers) => ({ + status: 200, + // Echoes the identity of the caller, to compare it with the server's + data: { + token: headers['x-token'] ?? body.token ?? null, + clientId: headers['x-client-id'] ?? body.clientId ?? null, + }, + }), cid: () => ({status: 200, data: MOCK_CLIENT_ID}), credentials: (_, url) => { const tenant = url.searchParams.get('tenant') ?? ''; diff --git a/e2e/specs/middleware.spec.ts b/e2e/specs/middleware.spec.ts index 302a924..ef09df1 100644 --- a/e2e/specs/middleware.spec.ts +++ b/e2e/specs/middleware.spec.ts @@ -71,6 +71,17 @@ test.describe('server middleware', () => { expect(previewCookie!.value).toBe(previewToken); }); + test('should expose the preview token to the SDK', async ({page}) => { + const previewToken = createValidPreviewToken(); + + await page.goto(`/?croct-preview=${previewToken}`); + + // The SDK renders the preview widget and manages the token in the browser + const cookies = await page.evaluate(() => document.cookie); + + expect(cookies).toContain(`ct.preview_token=${previewToken}`); + }); + test('should persist preview cookie across navigations', async ({page}) => { const previewToken = createValidPreviewToken(); diff --git a/e2e/specs/ssr/identity.spec.ts b/e2e/specs/ssr/identity.spec.ts index 7732159..782d0da 100644 --- a/e2e/specs/ssr/identity.spec.ts +++ b/e2e/specs/ssr/identity.spec.ts @@ -1,43 +1,98 @@ import {test, expect} from '@playwright/test'; +import type {Page} from '@playwright/test'; + +type Identity = { + clientId: string, + userToken: string, +}; test.describe('identity', () => { - test('should evaluate as the visitor the response identifies, on a first visit', async ({page}) => { + async function getIdentity(page: Page): Promise { + const cookies = await page.context().cookies(); + + return { + clientId: cookies.find(cookie => cookie.name === 'ct.client_id')!.value, + userToken: cookies.find(cookie => cookie.name === 'ct.user_token')!.value, + }; + } + + test('should expose the identity to the SDK', async ({page}) => { + await page.goto('/identity'); + + const cookies = await page.evaluate(() => document.cookie); + + expect(cookies).toContain('ct.client_id='); + expect(cookies).toContain('ct.user_token='); + }); + + test('should evaluate as the identified visitor while rendering a first visit', async ({page}) => { // No cookies yet, so the middleware issues the identity while rendering await page.goto('/identity'); - const cookies = await page.context().cookies(); - const clientId = cookies.find(cookie => cookie.name === 'ct.client_id'); - const userToken = cookies.find(cookie => cookie.name === 'ct.user_token'); + const {clientId, userToken} = await getIdentity(page); - await expect(page.getByTestId('server-client-id')).toHaveText(clientId!.value); - await expect(page.getByTestId('server-token')).toHaveText(userToken!.value); + await expect(page.getByTestId('server-client-id')).toHaveText(clientId); + await expect(page.getByTestId('server-token')).toHaveText(userToken); }); - test('should evaluate as the visitor the cookies identify, on a later visit', async ({page}) => { + test('should evaluate as the identified visitor while rendering a later visit', async ({page}) => { await page.goto('/identity'); - const [{value: clientId}] = (await page.context().cookies()) - .filter(cookie => cookie.name === 'ct.client_id'); + const {clientId, userToken} = await getIdentity(page); await page.goto('/identity'); await expect(page.getByTestId('server-client-id')).toHaveText(clientId); + await expect(page.getByTestId('server-token')).toHaveText(userToken); + }); + + test('should evaluate as the identified visitor when navigating on the client', async ({page}) => { + await page.goto('/'); + + const {clientId, userToken} = await getIdentity(page); + + await page.getByRole('link', {name: 'identity'}).click(); + + await expect(page.getByTestId('server-client-id')).toHaveText(clientId); + await expect(page.getByTestId('server-token')).toHaveText(userToken); }); - test('should evaluate as the same visitor in the browser', async ({page}) => { + test('should evaluate as the identified visitor in the browser', async ({page}) => { await page.goto('/identity'); - const serverToken = await page.getByTestId('server-token').textContent(); + const {userToken} = await getIdentity(page); - await expect(page.getByTestId('browser-token')).toHaveText(serverToken!, {timeout: 10000}); + await expect(page.getByTestId('browser-token')).toHaveText(userToken, {timeout: 10000}); }); - test('should expose the identity to the SDK', async ({page}) => { + test('should evaluate as the identified visitor in an application route', async ({page}) => { await page.goto('/identity'); - const cookies = await page.evaluate(() => document.cookie); + const {userToken} = await getIdentity(page); - expect(cookies).toContain('ct.client_id='); - expect(cookies).toContain('ct.user_token='); + // The request shares the cookies of the page + const response = await page.request.get('/api/identity'); + + expect(response.ok()).toBe(true); + + const {result} = await response.json(); + + expect(result.token).toBe(userToken); + }); + + test('should keep the identity of an identified user', async ({page}) => { + // The application resolver identifies the user from a session cookie + await page.context().addCookies([{ + name: 'app.session_user', + value: 'user-42', + url: 'http://localhost:3200', + }]); + + await page.goto('/identity'); + + const {userToken} = await getIdentity(page); + + await expect(page.getByTestId('server-token')).toHaveText(userToken); + await expect(page.getByTestId('browser-token')).toHaveText(userToken, {timeout: 10000}); }); }); diff --git a/src/runtime/server/middleware/croct.ts b/src/runtime/server/middleware/croct.ts index c6f0ff4..116ae72 100644 --- a/src/runtime/server/middleware/croct.ts +++ b/src/runtime/server/middleware/croct.ts @@ -9,7 +9,6 @@ import { } from 'h3'; import type {H3Event} from 'h3'; import {Token} from '@croct/sdk/token'; -import {base64UrlDecode} from '@croct/sdk/base64Url'; import {useRuntimeConfig} from '#imports'; import {credentialsResolver, localeResolver, userIdResolver} from '#croct/resolvers'; import {setUserTokenCookie, getProductionDefaults} from '../utils/cookie'; @@ -225,12 +224,10 @@ function isPreviewTokenValid(token: unknown): token is string { return false; } - const now = Math.floor(Date.now() / 1000); - + // The SDK manages the token in the browser, so both sides must agree + // on what a valid token is. try { - const payload = JSON.parse(base64UrlDecode(token.split('.')[1]).toString()); - - return Number.isInteger(payload.exp) && payload.exp > now; + return Token.parse(token).isValidNow(); } catch { return false; } diff --git a/test/runtime/server/middleware/croct.test.ts b/test/runtime/server/middleware/croct.test.ts index d1cdad0..4759c02 100644 --- a/test/runtime/server/middleware/croct.test.ts +++ b/test/runtime/server/middleware/croct.test.ts @@ -85,10 +85,10 @@ describe('middleware', () => { } function createValidPreviewToken(): string { - const header = Buffer.from('{"alg":"none"}').toString('base64url'); - const payload = Buffer.from('{"exp":9999999999}').toString('base64url'); - - return `${header}.${payload}.`; + // The SDK parses the token in the browser, so both sides must accept it + return Token.issue(appId) + .withDuration(3600) + .toString(); } describe('route skipping', () => { @@ -646,6 +646,18 @@ describe('middleware', () => { expect(event.context.croct!.previewToken).toBe(previewToken); }); + it('should not set preview token for an expired token', async () => { + const expiredToken = Token.issue(appId) + .withDuration(-3600) + .toString(); + + const event = createMockEvent(`http://localhost:3000/?croct-preview=${expiredToken}`); + + await handleRequest(event); + + expect(event.context.croct!.previewToken).toBeUndefined(); + }); + it('should not set preview token for an invalid token', async () => { const event = createMockEvent('http://localhost:3000/?croct-preview=garbage'); From c2a6e2eeb4b4134c4a07be295b97a802f9f6849b Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 26 Aug 2026 18:20:57 -0300 Subject: [PATCH 4/7] Cover the identity of fetches and settle the preview exit The identity was only asserted for evaluations, so fetches are now covered on both sides, in application routes as well. The preview cookie is managed by the SDK too, so exiting preview settles asynchronously, which the assertion now waits for. --- e2e/app/pages/identity.vue | 35 +++++++++++++------- e2e/app/server/api/identity.get.ts | 12 +++++-- e2e/constants.ts | 5 +++ e2e/mock-server.ts | 51 +++++++++++++++++++----------- e2e/specs/middleware.spec.ts | 10 ++++-- e2e/specs/ssr/identity.spec.ts | 40 +++++++++++++---------- 6 files changed, 102 insertions(+), 51 deletions(-) diff --git a/e2e/app/pages/identity.vue b/e2e/app/pages/identity.vue index 3b7b6af..2e0158b 100644 --- a/e2e/app/pages/identity.vue +++ b/e2e/app/pages/identity.vue @@ -8,27 +8,40 @@ type Identity = { clientId: string | null, }; -// Evaluated through the internal route, which the server resolves -const {data: server} = await useEvaluation('identity'); +// Evaluated and fetched through the internal routes, which the server resolves +const {data: evaluated} = await useEvaluation('identity'); +const {data: fetched} = await useContent('identity-echo'); -// Evaluated by the SDK running in the browser -const browser = ref(null); +// Evaluated and fetched by the SDK running in the browser +const browserEvaluated = ref(null); +const browserFetched = ref(null); onMounted(async () => { - browser.value = await useCroct().evaluate('identity'); + const croct = useCroct(); + + browserEvaluated.value = await croct.evaluate('identity'); + browserFetched.value = (await croct.fetch('identity-echo')).content; }); diff --git a/e2e/app/server/api/identity.get.ts b/e2e/app/server/api/identity.get.ts index e84a088..8faaea6 100644 --- a/e2e/app/server/api/identity.get.ts +++ b/e2e/app/server/api/identity.get.ts @@ -1,6 +1,12 @@ export default defineEventHandler(async () => { - // Evaluated through the server composable, outside a page render - const result = await evaluate('identity'); + // Evaluated and fetched through the server composables, outside a page render + const [evaluated, fetched] = await Promise.all([ + evaluate('identity'), + fetchContent('identity-echo'), + ]); - return {result: result}; + return { + evaluated: evaluated, + fetched: fetched.content, + }; }) diff --git a/e2e/constants.ts b/e2e/constants.ts index aa928f5..2227d7a 100644 --- a/e2e/constants.ts +++ b/e2e/constants.ts @@ -33,3 +33,8 @@ export const TENANT_CREDENTIALS = { // specs can assert which page an evaluation or fetch was based on. export const CONTEXT_ECHO_QUERY = 'context'; export const CONTEXT_ECHO_SLOT = 'context-echo'; + +// Query and slot the mock server answers with the identity of the caller, so the +// specs can assert that both sides of the SDK evaluate as the same visitor. +export const IDENTITY_ECHO_QUERY = 'identity'; +export const IDENTITY_ECHO_SLOT = 'identity-echo'; diff --git a/e2e/mock-server.ts b/e2e/mock-server.ts index 4dd51c1..29baf96 100644 --- a/e2e/mock-server.ts +++ b/e2e/mock-server.ts @@ -1,5 +1,13 @@ import {createServer, type IncomingMessage, type ServerResponse} from 'http'; -import {MOCK_SERVER_PORT, TENANT_NAME, TENANT_CREDENTIALS, CONTEXT_ECHO_QUERY, CONTEXT_ECHO_SLOT} from './constants'; +import { + MOCK_SERVER_PORT, + TENANT_NAME, + TENANT_CREDENTIALS, + CONTEXT_ECHO_QUERY, + CONTEXT_ECHO_SLOT, + IDENTITY_ECHO_QUERY, + IDENTITY_ECHO_SLOT, +} from './constants'; const PORT = MOCK_SERVER_PORT; @@ -49,15 +57,15 @@ type Headers = Record; type Route = (body: Record, url: URL, headers: Headers) => {status: number, data: unknown}; +function identityOf(headers: Headers): {token: string | null, clientId: string | null} { + return { + token: (headers['x-token'] as string | undefined) ?? null, + clientId: (headers['x-client-id'] as string | undefined) ?? null, + }; +} + const routes: Record = { - track: (body, _, headers) => ({ - status: 200, - // Echoes the identity of the caller, to compare it with the server's - data: { - token: headers['x-token'] ?? body.token ?? null, - clientId: headers['x-client-id'] ?? body.clientId ?? null, - }, - }), + track: () => ({status: 200, data: {}}), cid: () => ({status: 200, data: MOCK_CLIENT_ID}), credentials: (_, url) => { const tenant = url.searchParams.get('tenant') ?? ''; @@ -73,14 +81,8 @@ const routes: Record = { const query = String(body.query ?? ''); // Echoes the identity of the caller, to compare both sides of the SDK - if (query === 'identity') { - return { - status: 200, - data: { - token: headers['x-token'] ?? null, - clientId: headers['x-client-id'] ?? null, - }, - }; + if (query === IDENTITY_ECHO_QUERY) { + return {status: 200, data: identityOf(headers)}; } // Echoes the received context so the specs can assert which page @@ -105,10 +107,23 @@ const routes: Record = { }, }; }, - content: (body, url) => { + content: (body, url, headers) => { const slotId = String(body.slotId ?? url.searchParams.get('slotId') ?? ''); const baseSlotId = slotId.split('@')[0]; + // Echoes the identity of the caller, to compare both sides of the SDK + if (baseSlotId === IDENTITY_ECHO_SLOT) { + return { + status: 200, + data: { + content: { + _component: null, + ...identityOf(headers), + }, + }, + }; + } + // Echoes the received context so the specs can assert which page // the fetch was based on. if (baseSlotId === CONTEXT_ECHO_SLOT) { diff --git a/e2e/specs/middleware.spec.ts b/e2e/specs/middleware.spec.ts index ef09df1..5ba54ee 100644 --- a/e2e/specs/middleware.spec.ts +++ b/e2e/specs/middleware.spec.ts @@ -123,9 +123,13 @@ test.describe('server middleware', () => { await page.goto('/?croct-preview=exit'); - const cookiesAfterExit = await page.context().cookies(); - - expect(cookiesAfterExit.find(cookie => cookie.name === 'ct.preview_token')).toBeUndefined(); + // The SDK also manages the cookie, so the exit settles asynchronously + await expect + .poll( + async () => (await page.context().cookies()) + .find(cookie => cookie.name === 'ct.preview_token'), + ) + .toBeUndefined(); }); }); diff --git a/e2e/specs/ssr/identity.spec.ts b/e2e/specs/ssr/identity.spec.ts index 782d0da..e794953 100644 --- a/e2e/specs/ssr/identity.spec.ts +++ b/e2e/specs/ssr/identity.spec.ts @@ -25,47 +25,52 @@ test.describe('identity', () => { expect(cookies).toContain('ct.user_token='); }); - test('should evaluate as the identified visitor while rendering a first visit', async ({page}) => { + test('should evaluate and fetch as the identified visitor while rendering a first visit', async ({page}) => { // No cookies yet, so the middleware issues the identity while rendering await page.goto('/identity'); const {clientId, userToken} = await getIdentity(page); - await expect(page.getByTestId('server-client-id')).toHaveText(clientId); - await expect(page.getByTestId('server-token')).toHaveText(userToken); + await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken); + await expect(page.getByTestId('server-evaluation-client-id')).toHaveText(clientId); + await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken); + await expect(page.getByTestId('server-fetch-client-id')).toHaveText(clientId); }); - test('should evaluate as the identified visitor while rendering a later visit', async ({page}) => { + test('should evaluate and fetch as the identified visitor while rendering a later visit', async ({page}) => { await page.goto('/identity'); const {clientId, userToken} = await getIdentity(page); await page.goto('/identity'); - await expect(page.getByTestId('server-client-id')).toHaveText(clientId); - await expect(page.getByTestId('server-token')).toHaveText(userToken); + await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken); + await expect(page.getByTestId('server-evaluation-client-id')).toHaveText(clientId); + await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken); }); - test('should evaluate as the identified visitor when navigating on the client', async ({page}) => { + test('should evaluate and fetch as the identified visitor when navigating on the client', async ({page}) => { await page.goto('/'); const {clientId, userToken} = await getIdentity(page); await page.getByRole('link', {name: 'identity'}).click(); - await expect(page.getByTestId('server-client-id')).toHaveText(clientId); - await expect(page.getByTestId('server-token')).toHaveText(userToken); + await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken); + await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken); + await expect(page.getByTestId('server-fetch-client-id')).toHaveText(clientId); }); - test('should evaluate as the identified visitor in the browser', async ({page}) => { + test('should evaluate and fetch as the identified visitor in the browser', async ({page}) => { await page.goto('/identity'); const {userToken} = await getIdentity(page); - await expect(page.getByTestId('browser-token')).toHaveText(userToken, {timeout: 10000}); + await expect(page.getByTestId('browser-evaluation-token')).toHaveText(userToken, {timeout: 10000}); + await expect(page.getByTestId('browser-fetch-token')).toHaveText(userToken, {timeout: 10000}); }); - test('should evaluate as the identified visitor in an application route', async ({page}) => { + test('should evaluate and fetch as the identified visitor in an application route', async ({page}) => { await page.goto('/identity'); const {userToken} = await getIdentity(page); @@ -75,9 +80,10 @@ test.describe('identity', () => { expect(response.ok()).toBe(true); - const {result} = await response.json(); + const {evaluated, fetched} = await response.json(); - expect(result.token).toBe(userToken); + expect(evaluated.token).toBe(userToken); + expect(fetched.token).toBe(userToken); }); test('should keep the identity of an identified user', async ({page}) => { @@ -92,7 +98,9 @@ test.describe('identity', () => { const {userToken} = await getIdentity(page); - await expect(page.getByTestId('server-token')).toHaveText(userToken); - await expect(page.getByTestId('browser-token')).toHaveText(userToken, {timeout: 10000}); + await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken); + await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken); + await expect(page.getByTestId('browser-evaluation-token')).toHaveText(userToken, {timeout: 10000}); + await expect(page.getByTestId('browser-fetch-token')).toHaveText(userToken, {timeout: 10000}); }); }); From 28cd175d1fd36cc06aa7fd58b2fde146d65e7977 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 26 Aug 2026 18:27:56 -0300 Subject: [PATCH 5/7] Cover the exit from preview mode --- e2e/mock-server.ts | 4 +++- e2e/specs/ssr/identity.spec.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/e2e/mock-server.ts b/e2e/mock-server.ts index 29baf96..59ffac6 100644 --- a/e2e/mock-server.ts +++ b/e2e/mock-server.ts @@ -57,10 +57,11 @@ type Headers = Record; type Route = (body: Record, url: URL, headers: Headers) => {status: number, data: unknown}; -function identityOf(headers: Headers): {token: string | null, clientId: string | null} { +function identityOf(headers: Headers): {token: string | null, clientId: string | null, preview: boolean} { return { token: (headers['x-token'] as string | undefined) ?? null, clientId: (headers['x-client-id'] as string | undefined) ?? null, + preview: headers['x-preview-token'] !== undefined, }; } @@ -119,6 +120,7 @@ const routes: Record = { content: { _component: null, ...identityOf(headers), + preview: body.previewToken !== undefined, }, }, }; diff --git a/e2e/specs/ssr/identity.spec.ts b/e2e/specs/ssr/identity.spec.ts index e794953..1ddc35d 100644 --- a/e2e/specs/ssr/identity.spec.ts +++ b/e2e/specs/ssr/identity.spec.ts @@ -1,5 +1,7 @@ import {test, expect} from '@playwright/test'; import type {Page} from '@playwright/test'; +import {Token} from '@croct/sdk/token'; +import {DEFAULT_CREDENTIALS} from '../../constants'; type Identity = { clientId: string, @@ -16,6 +18,12 @@ test.describe('identity', () => { }; } + async function fetchIdentity(page: Page): Promise<{token: string, preview: boolean}> { + const {fetched} = await (await page.request.get('/api/identity')).json(); + + return fetched; + } + test('should expose the identity to the SDK', async ({page}) => { await page.goto('/identity'); @@ -86,6 +94,23 @@ test.describe('identity', () => { expect(fetched.token).toBe(userToken); }); + test('should stop previewing once the preview is exited', async ({page}) => { + const previewToken = Token.issue(DEFAULT_CREDENTIALS.appId) + .withDuration(3600) + .toString(); + + await page.goto(`/identity?croct-preview=${previewToken}`); + + expect((await fetchIdentity(page)).preview).toBe(true); + + await page.goto('/identity?croct-preview=exit'); + + // The SDK also manages the preview cookie, so exiting settles asynchronously + await expect + .poll(async () => (await fetchIdentity(page)).preview) + .toBe(false); + }); + test('should keep the identity of an identified user', async ({page}) => { // The application resolver identifies the user from a session cookie await page.context().addCookies([{ From 50d53a53d089e324fc7d7c3c4fd786fc5e0d3c6f Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 26 Aug 2026 18:33:18 -0300 Subject: [PATCH 6/7] State that the cookies are readable by the SDK --- src/runtime/server/middleware/croct.ts | 4 ++++ src/runtime/server/utils/cookie.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/runtime/server/middleware/croct.ts b/src/runtime/server/middleware/croct.ts index 116ae72..17c0963 100644 --- a/src/runtime/server/middleware/croct.ts +++ b/src/runtime/server/middleware/croct.ts @@ -83,6 +83,8 @@ export default defineEventHandler(async event => { path: '/', ...(cookie.previewToken.domain !== '' ? {domain: cookie.previewToken.domain} : {}), ...productionDefaults, + // The SDK manages the preview mode in the browser + httpOnly: false, }); } @@ -93,6 +95,8 @@ export default defineEventHandler(async event => { path: '/', ...(cookie.clientId.domain !== '' ? {domain: cookie.clientId.domain} : {}), ...productionDefaults, + // The SDK reads the client ID in the browser + httpOnly: false, }); // Requests issued while rendering forward the cookies of the request being diff --git a/src/runtime/server/utils/cookie.ts b/src/runtime/server/utils/cookie.ts index ef849f5..6c893f1 100644 --- a/src/runtime/server/utils/cookie.ts +++ b/src/runtime/server/utils/cookie.ts @@ -19,6 +19,8 @@ export function setUserTokenCookie(event: H3Event, token: Token): void { path: '/', ...(config.domain !== '' ? {domain: config.domain} : {}), ...getProductionDefaults(), + // The SDK reads the token in the browser + httpOnly: false, }); } From 396a4ef50a4c212e732c669ae8fa0ab2200fc781 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 26 Aug 2026 18:36:31 -0300 Subject: [PATCH 7/7] Address the review of the identity changes Cookies named after object properties are no longer dropped from the request, and the mock API only allows credentials for an explicit origin. --- e2e/mock-server.ts | 13 ++++++++++--- src/runtime/server/middleware/croct.ts | 3 ++- test/runtime/server/middleware/croct.test.ts | 11 +++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/e2e/mock-server.ts b/e2e/mock-server.ts index 59ffac6..96d1905 100644 --- a/e2e/mock-server.ts +++ b/e2e/mock-server.ts @@ -167,13 +167,20 @@ function handleRequest(request: IncomingMessage, response: ServerResponse): void request.on('data', (chunk: Buffer) => body.push(chunk)); request.on('end', () => { + const {origin} = request.headers; + response.setHeader('Content-Type', 'application/json'); - // The SDK sends credentials, so the origin cannot be a wildcard - response.setHeader('Access-Control-Allow-Origin', request.headers.origin ?? '*'); - response.setHeader('Access-Control-Allow-Credentials', 'true'); response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); response.setHeader('Access-Control-Allow-Headers', request.headers['access-control-request-headers'] ?? '*'); + if (origin === undefined) { + response.setHeader('Access-Control-Allow-Origin', '*'); + } else { + // The SDK sends credentials, which forbids a wildcard origin + response.setHeader('Access-Control-Allow-Origin', origin); + response.setHeader('Access-Control-Allow-Credentials', 'true'); + } + if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); diff --git a/src/runtime/server/middleware/croct.ts b/src/runtime/server/middleware/croct.ts index 17c0963..31cb5b8 100644 --- a/src/runtime/server/middleware/croct.ts +++ b/src/runtime/server/middleware/croct.ts @@ -110,11 +110,12 @@ export default defineEventHandler(async event => { }); function updateRequestCookies(event: H3Event, values: Record): void { + const names = new Set(Object.keys(values)); const header = event.node.req.headers.cookie ?? ''; const cookies = header.split(';') .map(entry => entry.trim()) - .filter(entry => entry !== '' && !(entry.split('=')[0] in values)); + .filter(entry => entry !== '' && !names.has(entry.split('=')[0])); for (const [name, value] of Object.entries(values)) { cookies.push(`${name}=${value}`); diff --git a/test/runtime/server/middleware/croct.test.ts b/test/runtime/server/middleware/croct.test.ts index 4759c02..616d1be 100644 --- a/test/runtime/server/middleware/croct.test.ts +++ b/test/runtime/server/middleware/croct.test.ts @@ -174,6 +174,17 @@ describe('middleware', () => { .toContain(`ct.client_id=${event.context.croct!.clientId}`); }); + it('should keep cookies named after object properties on the request', async () => { + const event = createMockEvent(); + + event.node.req.headers.cookie = 'toString=value; constructor=value'; + + await handleRequest(event); + + expect(event.node.req.headers.cookie).toContain('toString=value'); + expect(event.node.req.headers.cookie).toContain('constructor=value'); + }); + it('should replace an invalid client ID on the request being handled', async () => { const event = createMockEvent();