Skip to content

fix: judge "Shared across callers" per route, as the boot warning does - #259

Merged
derrickmehaffy merged 4 commits into
mainfrom
fix/shared-across-callers-flag
Oct 13, 2026
Merged

derrickmehaffy merged 4 commits into
mainfrom
fix/shared-across-callers-flag

Conversation

@derrickmehaffy

@derrickmehaffy derrickmehaffy commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What changed and why

The failure

The boot warning and the dashboard's Shared across callers badge are supposed to say the same thing: this content type caches authenticated responses under a key that does not identify the caller, so one user's response can be served to another. The docs say so in three places ("The admin dashboard shows the same thing as a Shared across callers badge, because nobody reads boot logs").

They did not agree.

  • Boot warning (resolveUserStrategy.ts). Computed per route since Per-content-type maxAge and hitpass are ignored for routes declared in routes #230, on the values each route resolved to: route.method === 'GET' && route.hitpass === false && route.keys?.useAuth !== true.
  • Badge. cacheStats.summary() exposed the content type's own hitpass and keys.useAuth, and ContentTypeTable flagged hitpass === false && !keysAuthIdentity.

A route's resolved values can differ from its content type's in both directions:

Configuration Boot warning Badge before
(A) Content type keeps the default hitpass. A listed route sets hitpass: false and keys without useAuth. warns, names /api/editors/:id not shown (false negative)
(B) Content type has hitpass: false, no useAuth, and only listed routes. Since #236 those keep the strategy's bypass via listedRouteHitpass, so authenticated requests are never cached. silent shown (false positive)

(A) is the one that matters. It is a real cross-caller exposure that the dashboard did not show.

The fix

  • New server/src/utils/config/unkeyedAuthenticatedRoutes.ts holds the predicate (isUnkeyedAuthenticatedRoute) and the path list (unkeyedAuthenticatedRoutes). The boot warning and the stats service both call it, so they cannot drift again.
  • ContentTypeStats gains unkeyedAuthenticatedRoutes: string[]: the routes the boot warning names for that content type.
  • ContentTypeTable flags a row when that list is non-empty, and falls back to the old hitpass === false && !keysAuthIdentity only when the field is absent. The field is typed optional for that reason: an admin bundle cached in a browser across an upgrade may meet an older server.
  • The predicate tests keys.useAuth for truthiness, as generateCacheKey does, rather than === true. The badge and warning exist to say a route's key does not identify the caller, so they ask the question the key generator answers. With === true, a route configured with useAuth: "true" (what env() returns in a JavaScript config) was keyed per caller and still warned about and flagged. This is fixed in the predicate, not by coercing useAuth when the config is resolved: the listed-route hitpass, the Cache-Control downgrade and the stats' keysAuthIdentity all compare === true, and coercing would change what those configurations bypass and emit. Cache keys are untouched.
  • The tooltip names the routes. It used to say "Hitpass is disabled but keys.useAuth is not set" in every case. In case (A) the content type's own hitpass is enabled, so that sent the admin to a setting that was already right. When the server sends the list, it now reads "Route /api/editors/:id caches authenticated requests without keys.useAuth, so one caller's response can be served to another." (pluralised, en and fr, new key contentTypes.authRisk.routesHint). The old string is kept for the fallback path, where the badge comes from exactly those content type settings.
  • resolveUserStrategy.ts change is minimal: one import, and the inline filter replaced by the helper call. The predicate, the warning text and when it fires are byte-for-byte the same logic. Open PR fix: treat options set to undefined as omitted #248 also edits this file (it adds definedOnly). git merge-tree against fix: treat options set to undefined as omitted #248's head reports a clean merge.
  • Docs: admin/index.md (badge description), caching/keys.md and recipes/authenticated.md (what the warning names), and reference/routes.md (the new field in the /stats example, plus a paragraph on how it differs from hitpass/keysAuthIdentity). The TypeDoc API pages regenerate on docs:build. I did not commit a regeneration because it rewrites every page's source links.

Why this is not breaking

  • Existing stats fields are unchanged. hitpass and keysAuthIdentity still describe the content type's own settings, with the same values as before. The jest spec asserts this for both cases above. Anything else reading them (the Keyed per caller badge, the widget, external consumers of /rest-cache/stats) sees no difference. The new field is purely additive.
  • The boot warning keeps its message and its condition for every boolean useAuth. The only difference is that a route whose useAuth is a truthy non-boolean ("true", 1) is no longer named. Those routes' keys already identified the caller, so that warning was false. Nothing about what is cached, keys, Cache-Control or bypass changes for them.
  • Nothing about what is cached, keys, Cache-Control or bypass is touched. No permission changes.
  • The badge changes only where it was wrong: it now appears for (A) and not for (B). For every configuration where content type and routes agree, which is all the string-form and default-route cases, the two expressions give the same answer.

Tests

shared/tests/auth-risk-flag-consistency.test.js (jest). Uses jest.mock on the playground's config/cache-strategy to add cases (A) api::writer.editor and (B) api::page.page without touching the shared config. It captures the boot log and, for each case, asserts:

  1. as a precondition, whether the server warned about the content type;
  2. that the dashboard's flag agrees. The spec mirrors ContentTypeTable's expression, new field first and old fallback second;
  3. that unkeyedAuthenticatedRoutes is exactly ["/api/editors/:id"] for (A) and [] for (B).

A third case (C) puts hitpass: false and keys.useAuth: "true" (a string) on /api/articles. It first proves on the live cache that the key identifies the caller: Alice misses then hits, and Bob misses. Then it asserts there is no boot warning, unkeyedAuthenticatedRoutes is [], and there is no badge.

A fourth test pins hitpass and keysAuthIdentity to their old values.

e2e/tests/shared-across-callers.spec.ts (Playwright). The playground's strategy must not contain an unkeyed authenticated route, because every other spec on that server relies on it. So this intercepts GET /rest-cache/stats, fetches the real response, and rewrites rows to stage (A), (B) and an old-server response without the field. It then asserts the badge on the rendered dashboard. It also hovers the badge and asserts the tooltip. For (A) it must name /api/editors/:id and must not say "Hitpass is disabled". For the fallback it must keep the old sentence.

Before (unfixed main), jest: both directions of the disagreement:

    ✕ (A) flags a content type whose listed route shares authenticated entries (32 ms)
    ✕ (B) does not flag a content type whose routes all keep the strategy bypass (29 ms)
    ✓ keeps the content type's own fields as they were (31 ms)
    Expected: true
    Received: false
    > 110 |     expect(dashboardFlags(stats)).toBe(true);
    Expected: false
    Received: true
    > 119 |     expect(dashboardFlags(stats)).toBe(false);
Tests:       2 failed, 1 passed, 3 total

The preconditions on the line before each of those (bootWarns(...) true for A, false for B) passed. So the server's own judgement was as described, and the failure is the dashboard disagreeing with it.

Before (unfixed main), Playwright:

  ✘  1 … › "Shared across callers" badge › flags a content type for a route that shares authenticated entries (6.5s)
  ✘  2 … › "Shared across callers" badge › does not flag a content type whose routes all keep the bypass (8.7s)
  ✓  3 … › "Shared across callers" badge › falls back to the content type settings when the list is absent (2.5s)
  1) …
    Expected substring: "Shared across callers"
    Received string:    "api::writer.editor01h/api/editors/api/editors/:idAlso purges 1 related typePurge"
  2) …
    Expected substring: not "Shared across callers"
    Received string: "api::page.pageShared across callers01h/api/pages/api/pages/:idAlso purges 1 related typePurge"
  2 failed
  1 passed

Test 3 passes on main by design. It is the regression guard for the fallback, which is the old behaviour.

Follow-up commits, each seen red first:

Case (C) against the === true predicate (the MISS/HIT/MISS precondition passed, so the key did identify the caller):

    ✓ (A) flags a content type whose listed route shares authenticated entries (27 ms)
    ✓ (B) does not flag a content type whose routes all keep the strategy bypass (24 ms)
    ✕ (C) does not flag a route whose truthy non-boolean useAuth keys entries per caller (185 ms)
    ✓ keeps the content type's own fields as they were (34 ms)
    Expected: false
    Received: true
    > 178 |     expect(bootWarns("api::article.article")).toBe(false);
Tests:       1 failed, 3 passed, 4 total

Tooltip assertions against the old tooltip:

  ✘  1 … › "Shared across callers" badge › flags a content type for a route that shares authenticated entries (8.1s)
  ✓  2 … › "Shared across callers" badge › does not flag a content type whose routes all keep the bypass (2.5s)
  ✓  3 … › "Shared across callers" badge › falls back to the content type settings when the list is absent (3.2s)
    Error: expect(locator).toContainText(expected) failed
    Expected substring: "/api/editors/:id"
    Received string:    "Hitpass is disabled but keys.useAuth is not set, so one caller's response can be served to another."
  1 failed
  2 passed

After:

    ✓ (A) flags a content type whose listed route shares authenticated entries (52 ms)
    ✓ (B) does not flag a content type whose routes all keep the strategy bypass (28 ms)
    ✓ (C) does not flag a route whose truthy non-boolean useAuth keys entries per caller (214 ms)
    ✓ keeps the content type's own fields as they were (36 ms)
Tests:       4 passed, 4 total

  ✓  14 … › "Shared across callers" badge › flags a content type for a route that shares authenticated entries (1.9s)
  ✓  15 … › "Shared across callers" badge › does not flag a content type whose routes all keep the bypass (2.0s)
  ✓  16 … › "Shared across callers" badge › falls back to the content type settings when the list is absent (2.0s)

Closes #

Checklist

  • Tests added or updated. For a bug fix: a spec in shared/tests/ that fails before the change. Browser-visible behaviour goes in e2e/tests/.
  • pnpm run test:e2e:memory passes. (24 suites, 139 tests)
  • pnpm run test:lint passes (tsc --noEmit over server/ and admin/, plus eslint and prettier on the providers).
  • Touches admin/? pnpm run test:admin was run and passes. (16 passed)
  • Touches a provider or the published package shape? No provider change. One additive field on the stats response.
  • Any duration I touched is milliseconds. No duration touched.
  • User-visible change? docs/guide/ updated in this PR.
  • Conventional commit subjects, branched from main.

Notes for the reviewer

The boot warning and the dashboard badge exist to say the same thing:
this content type caches authenticated responses under a key that does
not identify the caller. Since #230 the warning has been computed per
route, on the hitpass and keys each route resolved to. The badge was
still computed from the content type's own hitpass and keys.useAuth, and
those disagree with the resolved routes in both directions:

- a content type that keeps the default hitpass, with a listed route
  that sets `hitpass: false` itself and no useAuth: the server warns, the
  dashboard showed nothing;
- a content type with `hitpass: false` and no useAuth whose routes are
  all listed: those keep the strategy's bypass (listedRouteHitpass), so
  nothing authenticated is cached and the server does not warn, but the
  dashboard flagged it.

The first is the one that matters. It is a real cross-caller exposure
that the badge, which exists because nobody reads boot logs, did not
show.

Move the predicate into one helper, used by both the warning and the
stats service, and expose its result as a new
`unkeyedAuthenticatedRoutes` field on each stats row. The table flags a
row when that list is non-empty, and falls back to the old expression
only when the field is absent. `hitpass` and `keysAuthIdentity` are
unchanged and still describe the content type, so anything else that
reads them sees the same values. The warning's text and conditions are
unchanged.
…r does

generateCacheKey adds the caller's identity whenever `keys.useAuth` is
truthy. The shared-across-callers predicate asked `useAuth !== true`, so a
route configured with `useAuth: "true"` - what env() returns in a JavaScript
config - had its entries keyed per caller and was still named in the boot
warning and flagged on the dashboard. The badge and warning exist to say a
key does not identify the caller; they now ask the same question the key
generator answers.

Fixed in the predicate rather than by coercing useAuth when the config is
resolved. The listed-route hitpass, the Cache-Control downgrade and the
stats' keysAuthIdentity all compare `=== true`, so coercing would change
what those configurations bypass and emit. Cache keys are untouched.

The new jest case first proves the premise on the live cache - Alice
misses then hits, Bob misses - and then asserts no warning and an empty
route list. Seen red before the change: the boot warning named the route.
The badge is now raised per route, but its tooltip still said "Hitpass is
disabled but keys.useAuth is not set". For a content type that keeps its
own hitpass and has a listed route that turns it off, that is false, and it
points the admin at a content type setting that is already right.

When the server sends the per-route list, the tooltip names those routes
and says they cache authenticated requests without keys.useAuth, with en
and fr strings under a new key. The old string stays for the fallback,
where the badge comes from the content type's own hitpass and useAuth and
the sentence is exactly true.

Seen red before the change: the (A) case's tooltip read "Hitpass is
disabled but keys.useAuth is not set, ..." with no route in it.
@derrickmehaffy
derrickmehaffy merged commit fdfb747 into main Oct 13, 2026
13 checks passed
@derrickmehaffy
derrickmehaffy deleted the fix/shared-across-callers-flag branch October 13, 2026 05:56
@github-actions github-actions Bot mentioned this pull request Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant