Skip to content

Commit 69fa23d

Browse files
fix(runtime): GET /i18n/translations/:locale carries an ETag and Cache-Control: private, no-cache, and a matching If-None-Match is answered 304 (#25785) (#25956)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DEVkZEEx5UmhSJgP3kcEfLX)_ Part of #25785 (the first step the triage comment 6397341079 names: the validator and cache directive on the bundle). #25785 remains open for its second half, keeping `metadataForms` out of the boot bundle, which is a wire-shape change for `domain:spec`. Clause-②: no Metadata-protocol: none The claim is 6407675488 (seat `domain:server#2`, card thread `session_01HFAuCZPty5SsvseoPRgukQ`). Declared surface addition: amendment 6408228005 (one `scripts/check-route-envelope.mjs` row, below). ## What changes `GET /i18n/translations/:locale` (and its `?locale=` spelling) answered every console open with the whole locale bundle and no `ETag` or `Cache-Control`, so the browser could not revalidate what it already held. On a hosted tenant that is 80–88 KB compressed per open (the card's cloud#2914 reading). 1. **`packages/runtime/src/domains/i18n.ts`**, the one handler body every transport converges on. A new `translationsAnswer` builds the translations answer on both of the route's success exits (the direct read and the `zh` → `zh-CN` fallback): - the `200` is `deps.success`'s envelope, unchanged, plus `ETag` and `Cache-Control: private, no-cache`; - the `ETag` is weak, `W/"BASE64URL-SHA256"` over `JSON.stringify` of the exact envelope served (`locale`, `requestedLocale` and `translations`), **computed on every read**. Nothing cheaper (a counter, the bundle object's identity) stands in for the bytes, so a `304` can only ever pin a caller to the bundle it really holds. It is weak because it vouches for the JSON, not for the encoding a compressing layer puts on the wire. This is the invariant `getMetaItemCached` states for `/meta` (`packages/metadata-protocol/src/protocol.ts`), with SHA-256 in place of its 32-bit hash; - a request whose `If-None-Match` names that representation (RFC 9110 §13.1.2: a comma list, weak comparison, or `*`) is answered `{ status: 304, headers }` with no `body`. The `304` carries the same two headers (§15.4.5). A malformed or stale tag is served the full bundle, the safe direction; - `private` because the bundle is served only to a signed-in caller and names the application's own objects; `no-cache` because the bundle changes without notice (an authored translation, a package install), so freshness is the validator's job and never a TTL. - The anonymous floor (ADR-0056 D2) is still the handler's first statement: an anonymous caller holding the current validator is refused `401`, and no refusal carries either header. 2. **No transport change.** `dispatcher-plugin.ts`'s `sendResultBase` and `@objectstack/hono`'s `toResponse` already write `body: undefined` as a body-less response (`JSON.stringify(undefined)` is `undefined`, so Hono builds `Response(undefined, { status: 304 })`). Both are now pinned on that. 3. **Declared surface addition (amendment 6408228005).** `scripts/check-route-envelope.mjs` `DISPATCHER_DOMAINS['i18n.ts']` goes 0 → 2 with a note: the two answers are kind 1, the same classification `meta.ts` took for `Vary: Accept-Language` (`deps.success` takes no headers, and a `304` has no envelope a helper could build). No other row and no gate logic moves, and `DomainHandlerDeps` gains no member. 4. **Changeset**: `@objectstack/runtime` patch. Not touched: `packages/spec`, every published response schema (the `200` body is byte-identical and `GetTranslationsResponseSchema` is unchanged), `/i18n/locales`, `/i18n/labels/*`. The console needs no change: `ui/apps/console/src/loadLanguage.ts` reads the bundle through `createAuthenticatedFetch`, a plain `fetch(input, { ...init, headers })` with the default cache mode, so the browser sends `If-None-Match` itself and hands the console the cached body on a `304`. ## Measured on a real composition `examples/app-crm` booted with `pnpm dev:crm -- --fresh` from this branch (full `pnpm build`, `VERDICT command-exit 0`), signed in as the seeded admin, read over loopback with `curl`: | locale | cold read | read again with the issued `ETag` in `If-None-Match` | |:--|:--|:--| | `zh-CN` | `200`, 220,391 B raw (67,303 B at `gzip -9`; the dev server does not compress) | `304`, 0 B | | `en` | `200`, 213,264 B raw (58,351 B at `gzip -9`) | `304`, 0 B | - Both answers carry `Cache-Control: private, no-cache` and the same `ETag`; the `200` keeps `Vary: Origin` from the CORS layer. - 25 cold reads of `zh-CN` answered one `ETag`, and 25 conditional reads all answered `304`. - The `ETag` recomputed offline from the served `zh-CN` body equals the header byte for byte (`W/"cOAyyUQ7yb5bulH5g7ybiAf8sbJebhwAZur_peIct_E"`): the invariant, read on the real bundle. - **Cost.** The extra `JSON.stringify` plus SHA-256 of the 220 KB envelope is 1.8 ms per read on this 4-core shared box (stringify alone 0.9 ms; mean of 50 after a warm-up). Loopback wall time: `200` median 16.1 ms, `304` median 11.6 ms over 25 reads each. The saving the card targets is the bytes: on the ops seat's measured link (326–464 KB/s) the 80–88 KB compressed hosted bundle is about 0.2–0.27 s per warm open. - **Before.** `origin/main` `cb0a2fcf`'s handler sets no header on this route (the card's measurement on pin `888d6865` shows none, and the ablation below reds every header assertion on that blob). ## Verification Commits: `a373155e` (the fix), `e8c4b7a7` (runtime tests), `0b2e6592` (adapter test, changeset), `7b2a1a2b` (test typing), `773c3f3b` (the gate row). Each reading names its head. - **New tests.** - `packages/runtime/src/domains/i18n-translations-etag.test.ts`, 21 tests on the handler body: the `200` headers on both spellings and the fallback arm; the validator recomputed from the served body; stable while unchanged; moves on a one-string change and on an in-place mutation of the same bundle object; `304` for the weak tag, its strong spelling, a list with and without spaces, `*`, a Fetch `Headers` object and a repeated header in the record shape, on both spellings; full bundle for a stale, unquoted or empty tag; a validator issued before a change gets the new bundle; one locale's validator does not answer another locale; the anonymous floor answers `401` with no headers and the provider unread. - `packages/runtime/src/i18n-translations-etag.hono.integration.test.ts`, 4 tests over a real socket (`LiteKernel` + `HonoServerPlugin` + `createDispatcherPlugin`): `200` with both headers and the same envelope; `304` with a 0-byte body and both headers; a validator from before a bundle change is served the new bundle; anonymous with the validator is `401`, no `ETag`. - `packages/adapters/hono/src/hono-not-modified-response.test.ts`, 3 tests: a dispatcher `{ status: 304, headers }` leaves `toResponse` as a 0-byte `304` with both headers; the domain is handed the raw `Request` whose `if-none-match` it reads; the `200` control keeps its JSON body. Characterization pins: `toResponse` is unchanged, and they were green before the domain change. - **Ablation** (fix committed first, `i18n.ts` replaced on disk by its `origin/main` `cb0a2fcf` blob under a trap, restored with `git checkout HEAD --`): the two runtime files went 23 failed of 25. The 2 that stay green are controls that hold either way (the missing-locale `400` carries no headers; anonymous gets `401`). Restored blob `bb41d094` equals `HEAD`'s, `git diff HEAD` empty, `git status` clean. - **Package suites.** At `0b2e6592` (the later commits touch only the integration test's typing, re-run green at `7b2a1a2b`, and the gate row): `pnpm --filter @objectstack/runtime test` (vitest `local` project, under the verify lock, `VERDICT command-exit 0`): 446 files, 7,125 passed, 19 skipped. `pnpm --filter @objectstack/hono test`: 13 files, 228 passed. `typecheck` for both packages exits 0 (runtime's includes `check:test-typecheck`, which first caught two untyped `res.json()` reads in the integration test, fixed in 7b2a1a2). - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands` at head `773c3f3b` derives 87 families on the 6 changed paths, and all 87 exit 0 (each exit code captured before any pipe). `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran`: 87 derived, 87 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. The first run, at `7b2a1a2b` (67 families), had three that were not green: `check-plugin-teardown-shape.mjs --self-test` and `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (exit 3: the shallow clone lacked the fixture commit `621a4876`, and `dist/` was unbuilt), and both exit 0 after fetching that commit and a full `pnpm build`; `check:route-envelope` was red on `i18n.ts handBuilt: found 2, declared 0`, which the declared row above classifies. `pnpm exec eslint --no-inline-config` on the five changed code files exits 0. `node scripts/pm/check-governed-merges.mjs --branch HEAD`: 0 of 6 paths hit the register, NOT governed, 561 changed lines. ## Acceptance notes - **Out of scope by the claim, carrier `domain:spec`:** the card's second half, keeping `metadataForms` (46.8 KB raw on the measured tenant) out of the boot bundle and loading it with Studio, plus the optional scoping of `objects` to the current app. Both change what `GET /i18n/translations/:locale` returns, which is a wire-shape change (triage 6397341079). #25785 stays open for it. - `/i18n/locales` and `/i18n/labels/:object/:locale` still carry no validator. Neither was measured on the open path and the card does not ask for them; noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01HFAuCZPty5SsvseoPRgukQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3761710 commit 69fa23d

6 files changed

Lines changed: 558 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): `GET /i18n/translations/:locale` carries an `ETag` and `Cache-Control: private, no-cache`, and a matching `If-None-Match` is answered `304`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** The translation bundle (both spellings, `/i18n/translations/:locale` and `/i18n/translations?locale=`) answered every read with the whole locale bundle and neither header, so a browser re-downloaded it on every console open: 80–88 KB compressed on a hosted tenant.
11+
- **Now.** The `200` carries a weak `ETag`, a SHA-256 of the exact envelope served (`locale`, `requestedLocale` and `translations`), computed on every read, and `Cache-Control: private, no-cache`. A request whose `If-None-Match` names that representation (a strong or weak tag, a comma list, or `*`) is answered `304` with no body and the same two headers. A bundle that changed since the validator was issued is served in full under a new validator.
12+
- **Unchanged.** The `200` body is byte-for-byte the envelope it was. An anonymous caller is still refused `401` before any validator is read, and refusals carry neither header. `/i18n/locales` and `/i18n/labels/*` are untouched.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25785] A dispatcher `304` reaches the caller as a body-less `304` with
5+
* the route's own headers, through `toResponse`'s `response` arm.
6+
*
7+
* The `/i18n/translations` domain answers a matching `If-None-Match` with
8+
* `{ status: 304, headers }` and no `body` (`runtime/src/domains/i18n.ts`,
9+
* `translationsAnswer`). `toResponse` writes a `response` with `c.json(body,
10+
* status)`; a `304` must reach the wire with NO body — a `Response` with a
11+
* null-body status and a body throws in the Fetch constructor, and a body of
12+
* `"null"` or `"undefined"` is not a `304` at all. This file pins that the
13+
* body-less answer survives this adapter, and that the domain is handed the
14+
* request headers it reads the validator from.
15+
*
16+
* The standalone server's half, over a real socket and the real domain, is
17+
* `packages/runtime/src/i18n-translations-etag.hono.integration.test.ts`. This
18+
* package's vitest config aliases `@objectstack/runtime` to a stub, so the
19+
* dispatcher here is a fixture.
20+
*/
21+
22+
import { describe, it, expect, vi, beforeEach } from 'vitest';
23+
import type { Hono } from 'hono';
24+
25+
const mockDispatcher = {
26+
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
27+
handleAuth: vi.fn(),
28+
dispatch: vi.fn(),
29+
};
30+
31+
vi.mock('@objectstack/runtime', () => ({
32+
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
33+
}));
34+
35+
import { createHonoApp } from './index';
36+
37+
const PREFIX = '/api/v1';
38+
const PATH = `${PREFIX}/i18n/translations/fr`;
39+
const ETAG = 'W/"validator-of-the-served-bundle"';
40+
const HEADERS = { ETag: ETAG, 'Cache-Control': 'private, no-cache' };
41+
42+
const kernel = { name: 'test-kernel' } as any;
43+
const bootApp = (): Hono => createHonoApp({ kernel, prefix: PREFIX });
44+
45+
describe('#25785: a body-less dispatcher 304 stays a body-less 304', () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks();
48+
mockDispatcher.handleAuth.mockResolvedValue({ handled: false });
49+
});
50+
51+
it('is written as 304 with an empty body and the route\'s ETag and Cache-Control', async () => {
52+
mockDispatcher.dispatch.mockResolvedValue({ handled: true, response: { status: 304, headers: HEADERS } });
53+
54+
const res = await bootApp().request(`http://localhost${PATH}`, { headers: { 'If-None-Match': ETAG } });
55+
56+
expect(res.status).toBe(304);
57+
expect((await res.arrayBuffer()).byteLength).toBe(0);
58+
expect(res.headers.get('etag')).toBe(ETAG);
59+
expect(res.headers.get('cache-control')).toBe('private, no-cache');
60+
});
61+
62+
it('hands the domain the request it reads If-None-Match from', async () => {
63+
mockDispatcher.dispatch.mockResolvedValue({ handled: true, response: { status: 304, headers: HEADERS } });
64+
65+
await bootApp().request(`http://localhost${PATH}`, { headers: { 'If-None-Match': ETAG } });
66+
67+
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(1);
68+
const context = mockDispatcher.dispatch.mock.calls[0][4];
69+
expect(context.request.headers.get('if-none-match')).toBe(ETAG);
70+
});
71+
72+
it('the 200 beside it still carries its JSON body and the same headers (control)', async () => {
73+
const body = { success: true, data: { locale: 'fr', translations: { objects: {} } } };
74+
mockDispatcher.dispatch.mockResolvedValue({ handled: true, response: { status: 200, body, headers: HEADERS } });
75+
76+
const res = await bootApp().request(`http://localhost${PATH}`);
77+
78+
expect(res.status).toBe(200);
79+
await expect(res.json()).resolves.toEqual(body);
80+
expect(res.headers.get('etag')).toBe(ETAG);
81+
expect(res.headers.get('cache-control')).toBe('private, no-cache');
82+
});
83+
});
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #25785 — the translation bundle carries a validator, and a browser that
5+
* holds the current bundle is answered `304` instead of the whole bundle.
6+
*
7+
* ## The defect
8+
*
9+
* `GET /i18n/translations/:locale` answered every console open with the whole
10+
* locale bundle (288–361 KB raw, 80–88 KB compressed, measured on a hosted
11+
* tenant) and no `ETag` or `Cache-Control`, so nothing let the browser
12+
* revalidate what it already held.
13+
*
14+
* ## What is pinned
15+
*
16+
* - A: the `200` carries `ETag` and `Cache-Control: private, no-cache`, and its
17+
* body is the envelope it always was.
18+
* - B: the validator is a hash of the bytes served — read here by recomputing
19+
* it from the body, so a validator derived from anything cheaper (a counter,
20+
* the bundle's identity) fails this file, not production.
21+
* - C: `If-None-Match` naming that representation, in every spelling RFC 9110
22+
* §13.1.2 allows (strong or weak, a list, `*`) and through both header shapes
23+
* a transport hands in, is answered `304` with the same two headers and no
24+
* body; a stale or malformed one is served the full bundle.
25+
* - D: the anonymous floor still decides first — a matching validator does not
26+
* turn the `401` into a `304`, and no refusal carries a validator.
27+
*
28+
* The wire half (both headers and the empty `304` over a real socket) is
29+
* `i18n-translations-etag.hono.integration.test.ts`.
30+
*/
31+
32+
import { createHash } from 'node:crypto';
33+
import { describe, it, expect, vi } from 'vitest';
34+
35+
import { handleI18nRequest } from './i18n.js';
36+
import { apiErrorResponse } from '../error-envelope.js';
37+
import type { DomainHandlerDeps } from '../domain-handler-registry.js';
38+
import type { HttpProtocolContext } from '../http-dispatcher.js';
39+
40+
const MEMBER_EC = { userId: 'usr_member', isSystem: false, positions: [], permissions: [], systemPermissions: [] };
41+
42+
function member(headers: unknown = {}): HttpProtocolContext {
43+
return { request: { headers }, executionContext: { ...MEMBER_EC } } as unknown as HttpProtocolContext;
44+
}
45+
46+
function anonymous(headers: unknown = {}): HttpProtocolContext {
47+
return { request: { headers } } as unknown as HttpProtocolContext;
48+
}
49+
50+
function makeService(bundles: Record<string, Record<string, unknown>>) {
51+
return {
52+
getLocales: vi.fn(() => Object.keys(bundles)),
53+
getDefaultLocale: vi.fn(() => 'en'),
54+
getTranslations: vi.fn((locale: string) => bundles[locale] ?? {}),
55+
};
56+
}
57+
58+
function makeDeps(service: ReturnType<typeof makeService>): DomainHandlerDeps {
59+
return {
60+
getService: vi.fn(async () => service),
61+
resolveService: vi.fn(async () => service),
62+
// The dispatcher's own envelope builder, `meta` slot included.
63+
success: (data: any, meta?: any) => ({ status: 200, body: { success: true, data, meta } }),
64+
error: (message: string, httpStatus = 500, details?: any) =>
65+
apiErrorResponse({ message, httpStatus, details }),
66+
} as unknown as DomainHandlerDeps;
67+
}
68+
69+
const FR = { objects: { lead: { label: 'Prospect', fields: { company: { label: 'Société' } } } } };
70+
const ZH_CN = { objects: { lead: { label: '潜在客户' } } };
71+
72+
/** The validator of a served body, recomputed from the bytes the transport writes. */
73+
function validatorOf(body: unknown): string {
74+
return `W/"${createHash('sha256').update(JSON.stringify(body)).digest('base64url')}"`;
75+
}
76+
77+
async function read(
78+
service: ReturnType<typeof makeService>,
79+
context: HttpProtocolContext,
80+
input: { subPath: string; query?: Record<string, unknown> } = { subPath: '/translations/fr' },
81+
): Promise<any> {
82+
return handleI18nRequest(makeDeps(service), input.subPath, 'GET', input.query ?? {}, context);
83+
}
84+
85+
// ── A + B: the 200 and its validator ────────────────────────────────────────
86+
87+
describe('#25785 A — a translations read carries its validator and cache directive', () => {
88+
it.each([
89+
['the path spelling', { subPath: '/translations/fr' }],
90+
['the query spelling', { subPath: '/translations', query: { locale: 'fr' } }],
91+
])('%s: 200, the same body as before, plus ETag and Cache-Control: private, no-cache', async (_label, input) => {
92+
const result = await read(makeService({ en: {}, fr: FR }), member(), input);
93+
expect(result.handled).toBe(true);
94+
expect(result.response.status).toBe(200);
95+
expect(result.response.body).toEqual({ success: true, data: { locale: 'fr', translations: FR } });
96+
expect(result.response.headers['Cache-Control']).toBe('private, no-cache');
97+
expect(result.response.headers.ETag).toMatch(/^W\/"[A-Za-z0-9_-]{43}"$/);
98+
});
99+
100+
it('the locale fallback arm (zh → zh-CN) carries them too, over its own body', async () => {
101+
const result = await read(makeService({ en: {}, 'zh-CN': ZH_CN }), member(), { subPath: '/translations/zh' });
102+
expect(result.response.status).toBe(200);
103+
expect(result.response.body.data).toEqual({ locale: 'zh-CN', requestedLocale: 'zh', translations: ZH_CN });
104+
expect(result.response.headers['Cache-Control']).toBe('private, no-cache');
105+
expect(result.response.headers.ETag).toBe(validatorOf(result.response.body));
106+
});
107+
108+
it('a refusal carries no validator: the missing-locale 400', async () => {
109+
const result = await read(makeService({ fr: FR }), member(), { subPath: '/translations', query: {} });
110+
expect(result.response.status).toBe(400);
111+
expect(result.response.headers).toBeUndefined();
112+
});
113+
});
114+
115+
describe('#25785 B — the validator is a hash of the bytes served, recomputed on every read', () => {
116+
it('equals the hash of the served envelope', async () => {
117+
const result = await read(makeService({ fr: FR }), member());
118+
expect(result.response.headers.ETag).toBe(validatorOf(result.response.body));
119+
});
120+
121+
it('is stable while the bundle is unchanged, and moves when one string changes', async () => {
122+
const bundles: Record<string, Record<string, unknown>> = { fr: FR };
123+
const service = makeService(bundles);
124+
const first = (await read(service, member())).response.headers.ETag;
125+
expect((await read(service, member())).response.headers.ETag).toBe(first);
126+
127+
bundles.fr = { objects: { lead: { label: 'Prospect', fields: { company: { label: 'Entreprise' } } } } };
128+
const changed = await read(service, member());
129+
expect(changed.response.headers.ETag).not.toBe(first);
130+
expect(changed.response.headers.ETag).toBe(validatorOf(changed.response.body));
131+
});
132+
133+
it('moves when the bundle object is MUTATED in place — identity is not the validator', async () => {
134+
const mutable = { objects: { lead: { label: 'Prospect' } } } as Record<string, any>;
135+
const service = makeService({ fr: mutable });
136+
const before = (await read(service, member())).response.headers.ETag;
137+
mutable.objects.lead.label = 'Piste';
138+
expect((await read(service, member())).response.headers.ETag).not.toBe(before);
139+
});
140+
});
141+
142+
// ── C: the conditional read ─────────────────────────────────────────────────
143+
144+
describe('#25785 C — If-None-Match naming the current bundle is answered 304, with no body', () => {
145+
async function currentValidator(): Promise<string> {
146+
return (await read(makeService({ fr: FR }), member())).response.headers.ETag;
147+
}
148+
149+
function expectNotModified(result: any, etag: string) {
150+
expect(result.handled).toBe(true);
151+
expect(result.response.status).toBe(304);
152+
expect('body' in result.response).toBe(false);
153+
// RFC 9110 §15.4.5: the 304 carries the ETag and Cache-Control the 200 would.
154+
expect(result.response.headers).toEqual({ ETag: etag, 'Cache-Control': 'private, no-cache' });
155+
}
156+
157+
it.each([
158+
['the weak tag it was served', (etag: string) => etag],
159+
['the strong spelling of it (weak comparison)', (etag: string) => etag.replace(/^W\//, '')],
160+
['a list naming it after another tag', (etag: string) => `W/"stale", ${etag}`],
161+
['a list without spaces', (etag: string) => `"stale",${etag}`],
162+
['*', () => '*'],
163+
])('%s → 304', async (_label, spell) => {
164+
const etag = await currentValidator();
165+
expectNotModified(await read(makeService({ fr: FR }), member({ 'if-none-match': spell(etag) })), etag);
166+
});
167+
168+
it('through a Fetch Headers object (the @objectstack/hono catch-all hands its raw Request)', async () => {
169+
const etag = await currentValidator();
170+
expectNotModified(await read(makeService({ fr: FR }), member(new Headers({ 'If-None-Match': etag }))), etag);
171+
});
172+
173+
it('through a repeated header in the record shape, read as one list', async () => {
174+
const etag = await currentValidator();
175+
expectNotModified(await read(makeService({ fr: FR }), member({ 'if-none-match': ['W/"stale"', etag] })), etag);
176+
});
177+
178+
it('on the query spelling too', async () => {
179+
const etag = await currentValidator();
180+
const result = await read(
181+
makeService({ fr: FR }), member({ 'if-none-match': etag }), { subPath: '/translations', query: { locale: 'fr' } },
182+
);
183+
expectNotModified(result, etag);
184+
});
185+
186+
it.each([
187+
['a stale validator', 'W/"stale"'],
188+
['an unquoted value (malformed: matches nothing)', 'stale'],
189+
['an empty header', ''],
190+
])('%s is served the full bundle with the current validator', async (_label, header) => {
191+
const etag = await currentValidator();
192+
const result = await read(makeService({ fr: FR }), member({ 'if-none-match': header }));
193+
expect(result.response.status).toBe(200);
194+
expect(result.response.body).toEqual({ success: true, data: { locale: 'fr', translations: FR } });
195+
expect(result.response.headers.ETag).toBe(etag);
196+
});
197+
198+
it('a validator issued before the bundle changed is served the new bundle, never a 304', async () => {
199+
const bundles: Record<string, Record<string, unknown>> = { fr: FR };
200+
const service = makeService(bundles);
201+
const issued = (await read(service, member())).response.headers.ETag;
202+
bundles.fr = { objects: { lead: { label: 'Piste' } } };
203+
const result = await read(service, member({ 'if-none-match': issued }));
204+
expect(result.response.status).toBe(200);
205+
expect(result.response.body.data.translations).toEqual({ objects: { lead: { label: 'Piste' } } });
206+
expect(result.response.headers.ETag).not.toBe(issued);
207+
});
208+
209+
it('a validator for one locale does not answer another locale 304', async () => {
210+
const service = makeService({ fr: FR, 'zh-CN': ZH_CN });
211+
const frEtag = (await read(service, member())).response.headers.ETag;
212+
const result = await read(service, member({ 'if-none-match': frEtag }), { subPath: '/translations/zh-CN' });
213+
expect(result.response.status).toBe(200);
214+
expect(result.response.body.data.translations).toEqual(ZH_CN);
215+
});
216+
});
217+
218+
// ── D: the floor decides first ──────────────────────────────────────────────
219+
220+
describe('#25785 D — the anonymous floor still answers before any validator is read', () => {
221+
it('an anonymous caller holding the current validator gets 401, no 304 and no headers', async () => {
222+
const etag = (await read(makeService({ fr: FR }), member())).response.headers.ETag;
223+
const service = makeService({ fr: FR });
224+
const result = await read(service, anonymous({ 'if-none-match': etag }));
225+
expect(result.response.status).toBe(401);
226+
expect(result.response.body.error.code).toBe('UNAUTHENTICATED');
227+
expect(result.response.headers).toBeUndefined();
228+
expect(service.getTranslations).not.toHaveBeenCalled();
229+
});
230+
});

0 commit comments

Comments
 (0)