diff --git a/package.json b/package.json
index 87fb661..d25a217 100644
--- a/package.json
+++ b/package.json
@@ -40,9 +40,10 @@
"quality:duplication": "node scripts/check-code-health.mjs duplication",
"quality:cycles": "node scripts/check-code-health.mjs cycles",
"quality:dependencies": "node scripts/check-code-health.mjs dependencies",
+ "test:dependency-security": "node --test scripts/http-cache-security.test.mjs",
"quality:suppressions": "node scripts/check-code-health.mjs suppressions",
"quality:hygiene": "node scripts/check-code-health.mjs hygiene",
- "quality": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm --filter web-annotator-extension type-check && pnpm --filter web-annotator-extension test && pnpm quality:unused && pnpm quality:complexity && pnpm quality:duplication && pnpm quality:cycles && pnpm quality:dependencies && pnpm quality:suppressions && pnpm docs:check && pnpm cf:build && pnpm --filter web-annotator-extension build && pnpm quality:hygiene",
+ "quality": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm --filter web-annotator-extension type-check && pnpm --filter web-annotator-extension test && pnpm quality:unused && pnpm quality:complexity && pnpm quality:duplication && pnpm quality:cycles && pnpm test:dependency-security && pnpm quality:dependencies && pnpm quality:suppressions && pnpm docs:check && pnpm cf:build && pnpm --filter web-annotator-extension build && pnpm quality:hygiene",
"test:guest-pdf": "node --test scripts/qualify-guest-pdf.mjs scripts/qualify-import-signin.mjs",
"test:account-pdf": "node --test scripts/qualify-account-pdf.mjs",
"test:account-notes": "vitest run --config vitest.account-notes.config.ts"
@@ -137,7 +138,11 @@
"brace-expansion@>=2.0.0 <2.1.7": "2.1.7",
"brace-expansion@>=5.0.0 <5.0.12": "5.0.12",
"undici@>=7.0.0 <7.29.1": "7.29.1",
- "devalue@<=5.9.2": "5.9.3"
+ "devalue@<=5.9.2": "5.9.3",
+ "http-cache-semantics": "4.3.0"
+ },
+ "patchedDependencies": {
+ "http-cache-semantics@4.3.0": "patches/http-cache-semantics@4.3.0.patch"
}
}
}
diff --git a/patches/README.md b/patches/README.md
new file mode 100644
index 0000000..199aa3d
--- /dev/null
+++ b/patches/README.md
@@ -0,0 +1,20 @@
+# HTTP cache semantics security patch
+
+GitHub's reviewed advisory for GHSA-ch52-4w7c-c8xp currently has no upstream
+patched version. The published `http-cache-semantics@4.3.0` source also needs a
+behavioral fix: client `max-stale` must not bypass non-storable or revalidation
+rules, or shared-cache restrictions for `proxy-revalidate` and `Set-Cookie`.
+
+This workspace pins the existing Astro build dependency to 4.3.0 and applies a
+small local patch to those checks. The Reader landing's only affected path is
+`landing-astro > astro > http-cache-semantics`; this does not add a runtime
+dependency or a vulnerability allowlist.
+
+The lockfile integrity, patch hash, and patched snapshot were synchronized
+from the reviewed PostTrainLLM #191 resolution and checked by a subsequent
+Fleet Workspace frozen install; no unrelated lock nodes were changed.
+
+`pnpm test:dependency-security` exercises 27 cache behavior cases against the
+package resolved through the `landing-astro` Astro install, including policy
+serialization and ordinary `max-stale` age and URL bounds. Keep the patch until
+the upstream source itself passes the same behavior tests.
diff --git a/patches/http-cache-semantics@4.3.0.patch b/patches/http-cache-semantics@4.3.0.patch
new file mode 100644
index 0000000..5b83088
--- /dev/null
+++ b/patches/http-cache-semantics@4.3.0.patch
@@ -0,0 +1,15 @@
+diff --git a/index.js b/index.js
+--- a/index.js
++++ b/index.js
+@@ -399,1 +399,10 @@
+- if (this._rescc['must-revalidate']) {
++ if (
++ this._rescc['must-revalidate'] ||
++ !this.storable() ||
++ this._rescc['no-cache'] ||
++ (this._isShared && (
++ this._rescc['proxy-revalidate'] ||
++ (this._resHeaders['set-cookie'] &&
++ !this._rescc.public && !this._rescc.immutable)
++ ))
++ ) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d4aa3b0..dc17e16 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,12 @@ overrides:
brace-expansion@>=5.0.0 <5.0.12: 5.0.12
undici@>=7.0.0 <7.29.1: 7.29.1
devalue@<=5.9.2: 5.9.3
+ http-cache-semantics: 4.3.0
+
+patchedDependencies:
+ http-cache-semantics@4.3.0:
+ hash: 1bde1fe699a4c0f618ade5961734d1d414333292a27feacc6780a501009c0c74
+ path: patches/http-cache-semantics@4.3.0.patch
importers:
@@ -3742,8 +3748,8 @@ packages:
resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==}
engines: {node: '>=20.19.0'}
- http-cache-semantics@4.2.0:
- resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
+ http-cache-semantics@4.3.0:
+ resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==}
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
@@ -7472,7 +7478,7 @@ snapshots:
get-tsconfig: 5.0.0-beta.4
github-slugger: 2.0.0
html-escaper: 3.0.3
- http-cache-semantics: 4.2.0
+ http-cache-semantics: 4.3.0(patch_hash=1bde1fe699a4c0f618ade5961734d1d414333292a27feacc6780a501009c0c74)
js-yaml: 4.3.2
jsonc-parser: 3.3.1
magic-string: 1.4.2
@@ -8199,7 +8205,7 @@ snapshots:
domutils: 4.0.2
entities: 8.0.0
- http-cache-semantics@4.2.0: {}
+ http-cache-semantics@4.3.0(patch_hash=1bde1fe699a4c0f618ade5961734d1d414333292a27feacc6780a501009c0c74): {}
husky@9.1.7: {}
diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs
index 564e9a8..3597fa8 100755
--- a/scripts/check-docs.mjs
+++ b/scripts/check-docs.mjs
@@ -20,8 +20,9 @@
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
-const ROOT = resolve(new URL('..', import.meta.url).pathname);
+const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
const DOCS = join(ROOT, 'docs');
const CANONICAL_TOP_LEVEL_DIRS = new Set([
diff --git a/scripts/http-cache-security.test.mjs b/scripts/http-cache-security.test.mjs
new file mode 100644
index 0000000..f687ee2
--- /dev/null
+++ b/scripts/http-cache-security.test.mjs
@@ -0,0 +1,91 @@
+import assert from 'node:assert/strict';
+import { createRequire } from 'node:module';
+import path from 'node:path';
+import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const consumer = process.env.READER_CACHE_CONSUMER || 'landing-astro';
+const consumerRequire = createRequire(path.join(root, consumer, 'package.json'));
+const astroRequire = createRequire(consumerRequire.resolve('astro/package.json'));
+const CachePolicy = astroRequire('http-cache-semantics');
+const request = {
+ url: 'https://cache-fixture.invalid/image',
+ method: 'GET',
+ headers: { host: 'cache-fixture.invalid' },
+};
+const cookie = { 'set-cookie': 'fictional-fixture=synthetic' };
+const scenarios = [
+ ['shared cookie without explicit opt-in', 'max-age=0', cookie, true, 1, false],
+ [
+ 'shared cookie with stale-while-revalidate',
+ 'max-age=0, stale-while-revalidate=600',
+ cookie,
+ true,
+ 1,
+ false,
+ ],
+ ['shared proxy revalidation', 'max-age=60, proxy-revalidate', {}, true, 61, false],
+ ['response requires revalidation', 'no-cache', {}, true, 1, false],
+ ['response cannot be stored', 'no-store', {}, true, 1, false],
+ ['private response in shared cache', 'private, max-age=60', {}, true, 61, false],
+ ['ordinary expired response', 'max-age=0', {}, true, 1, true],
+ ['ordinary fresh response', 'max-age=60', {}, true, 1, true],
+ ['explicitly public cookie', 'public, max-age=0', cookie, true, 1, true],
+ ['explicitly immutable cookie', 'immutable, max-age=0', cookie, true, 1, true],
+ ['cookie in private cache', 'max-age=0', cookie, false, 1, true],
+ ['proxy directive in private cache', 'max-age=0, proxy-revalidate', {}, false, 1, true],
+ ['must-revalidate remains enforced', 'max-age=0, must-revalidate', {}, true, 1, false],
+];
+
+for (const [name, cacheControl, extraHeaders, shared, age, expected] of scenarios) {
+ for (const serialized of [false, true]) {
+ test(`${consumer}: ${name}${serialized ? ' after serialization' : ''}`, () => {
+ let policy = new CachePolicy(
+ request,
+ {
+ status: 200,
+ headers: { 'cache-control': cacheControl, ...extraHeaders },
+ },
+ { shared }
+ );
+ if (serialized) policy = CachePolicy.fromObject(policy.toObject());
+ policy.now = () => policy._responseTime + age * 1000;
+ const next = {
+ ...request,
+ headers: { ...request.headers, 'cache-control': 'max-stale=99999' },
+ };
+ assert.equal(policy.satisfiesWithoutRevalidation(next), expected);
+ assert.equal(Boolean(policy.evaluateRequest(next).response), expected);
+ });
+ }
+}
+
+test(`${consumer}: max-stale still respects its age bound and request identity`, () => {
+ const policy = new CachePolicy(request, {
+ headers: { 'cache-control': 'max-age=10' },
+ });
+ policy.now = () => policy._responseTime + 15_000;
+ assert.equal(
+ policy.satisfiesWithoutRevalidation({
+ ...request,
+ headers: { ...request.headers, 'cache-control': 'max-stale=4' },
+ }),
+ false
+ );
+ assert.equal(
+ policy.satisfiesWithoutRevalidation({
+ ...request,
+ headers: { ...request.headers, 'cache-control': 'max-stale=6' },
+ }),
+ true
+ );
+ assert.equal(
+ policy.satisfiesWithoutRevalidation({
+ ...request,
+ url: 'https://cache-fixture.invalid/other',
+ headers: { ...request.headers, 'cache-control': 'max-stale=99999' },
+ }),
+ false
+ );
+});
diff --git a/src/components/HomeClient.tsx b/src/components/HomeClient.tsx
index 50fe48e..321c6dc 100644
--- a/src/components/HomeClient.tsx
+++ b/src/components/HomeClient.tsx
@@ -30,9 +30,6 @@ import { useAuth } from './AuthProvider';
import { LibraryEmptyOnboarding } from './LibraryEmptyOnboarding';
const Navbar = lazy(() => import('./Navbar').then((m) => ({ default: m.Navbar })));
-const ReviewPackBanner = lazy(() =>
- import('./ReviewPackBanner').then((m) => ({ default: m.ReviewPackBanner }))
-);
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { SegmentedControl } from './ui/segmented-control';
@@ -872,12 +869,6 @@ export default function HomeClient() {
- {articles.length > 0 && (
-
- Turn your highlights into memory — automatically. -
-- Reader builds a study pack from everything you've annotated. Read once. Remember - forever. -
- -