diff --git a/src/server/pageResponseCache.test.ts b/src/server/pageResponseCache.test.ts new file mode 100644 index 0000000..c051581 --- /dev/null +++ b/src/server/pageResponseCache.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import express from 'express' +import { AuthFetch, PrivateKey, ProtoWallet, type WalletInterface } from '@bsv/sdk' +import { createAuthMiddleware } from '@bsv/auth-express-middleware' +import { it } from 'vitest' +import { setPageResponseCachePolicy } from './pageResponseCache.js' + +it('rereads a page across fresh auth sessions without replaying a cached signed response', async () => { + const serverWallet = new ProtoWallet(PrivateKey.fromRandom()) as unknown as WalletInterface + const clientWallet = new ProtoWallet(PrivateKey.fromRandom()) as unknown as WalletInterface + const app = express() + app.use(express.json()) + app.use(createAuthMiddleware({ wallet: serverWallet, allowUnauthenticated: true })) + let reads = 0 + app.get('/page', (_req, res) => { + reads += 1 + setPageResponseCachePolicy(res) + res.json({ entitled: true, reads }) + }) + const server = app.listen(0, '127.0.0.1') + await once(server, 'listening') + const cache = new Map() + const pagePolicies: string[] = [] + const browserFetch: typeof fetch = async (input, init) => { + const key = String(input) + const cached = cache.get(key) + if (cached != null) return cached.clone() + const response = await fetch(input, init) + const policy = response.headers.get('cache-control') ?? '' + if (key.endsWith('/page')) pagePolicies.push(policy) + if (/max-age=\d+/.test(policy) && !policy.includes('no-store')) cache.set(key, response.clone()) + return response + } + try { + const address = server.address() + assert.ok(address !== null && typeof address === 'object') + for (let read = 1; read <= 3; read += 1) { + // Each instance models a reload/cold launch with a new auth session. + const client = new AuthFetch(clientWallet, undefined, undefined, undefined, {}, browserFetch) + const response = await client.fetch(`http://127.0.0.1:${address.port}/page`) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), { entitled: true, reads: read }) + } + assert.equal(cache.size, 0) + assert.deepEqual(pagePolicies, new Array(3).fill('private, no-store')) + } finally { + server.closeAllConnections() + await new Promise((resolve, reject) => { server.close(error => error == null ? resolve() : reject(error)) }) + } +}, 10000) diff --git a/src/server/pageResponseCache.ts b/src/server/pageResponseCache.ts new file mode 100644 index 0000000..4ebda68 --- /dev/null +++ b/src/server/pageResponseCache.ts @@ -0,0 +1,7 @@ +import type { Response } from 'express' + +export function setPageResponseCachePolicy (res: Response): void { + // BRC-103 response signatures/nonces belong to one request and session. + // A private browser cache can otherwise replay them after wallet restart. + res.setHeader('Cache-Control', 'private, no-store') +} diff --git a/src/server/server.ts b/src/server/server.ts index 4b04385..2ca7d4f 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -19,6 +19,7 @@ import { STARTER_AUTHOR_NAME, STARTER_WORKS, starterCoverPath, starterWorkById, import { appManifest, metaForPath, renderHtmlShell, robotsTxt, sitemapXml, walletManifest, type PublicPublicationMeta } from './web.js' import { paymentForPaidPagesOnly } from './paymentRouting.js' import { createProtocolState } from './protocolState.js' +import { setPageResponseCachePolicy } from './pageResponseCache.js' import { deleteStoredDirectory, readStoredFile, storeBuffer, storedFileExists } from './objectStorage.js' const protocolState = createProtocolState(db) @@ -618,7 +619,7 @@ async function sendPublicationPageImage ( if (pageAccessMode != null) { res.setHeader('X-PaperTrade-Page-Access', pageAccessMode) } - res.setHeader('Cache-Control', 'private, max-age=60') + setPageResponseCachePolicy(res) if (req.query.format === 'json' && pageTokenSecret != null) { const extracted = await ensurePageText( publication.canonical_pdf_path,