diff --git a/README.md b/README.md index 51deeeb..32525a2 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,12 @@ as local scratch space. Each processed publication version is written to a new object prefix before its database rows are updated, so a failed replacement cannot overwrite the previously published files. +Replicas also share protocol state in the application database: authentication +sessions and one-time nonces use the published toolbox `KnexSessionManager`, +and both page purchases and admin funding share durable payment transaction +claims. Startup migrations create the additive tables before requests are served. +See [the protocol-state rollout requirements](docs/devops.md#shared-authentication-and-payment-state). + ## Project Layout ```text diff --git a/docs/devops.md b/docs/devops.md index 00218da..fbd498d 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -143,13 +143,15 @@ The production deploy remains a separate, explicitly dispatched workflow. Production builds must pass the runtime policy on their exact immutable image digest. `deploy-local.sh` requires `IMAGE_TAG` and `IMAGE_DIGEST`; the deployment workflow supplies both from its build output and retains the scan and rollout -evidence, including on failure. The SDK upgrade includes no database migration. -Any future schema change needs compatibility review before using this procedure. +evidence, including on failure. The shared protocol-state upgrade has the +additive schema migration and compatibility requirements described below. `promote-guarded.py` creates two candidate replicas on distinct nodes behind a private Service, with a PDB and a copy of the existing production egress boundary. Both replicas must serve health, status, catalog and a real stored free-page PNG; -an anonymous paid-page request must still be denied. The candidate's ten-second +an anonymous paid-page request must still be denied, and a synthetic authenticated +client must receive the configured signed 402 challenge with spending disabled. +The candidate's ten-second preStop hook is exercised by withdrawing one candidate while another node serves 100 consecutive requests. Two exact-image Ready replicas must return before promotion. Public root, health, catalog and stored-page probes run throughout. @@ -169,3 +171,37 @@ delete that pool while the public Service selects it. A failure before cutover removes only the isolated candidate resources and preserves the old public pool. Network-ops fleet gates and independent public probes remain required around the workflow. + +## Shared authentication and payment state + +Migration `202609240001_shared_protocol_state.cjs` creates `auth_sessions`, +`auth_message_nonces` and `payment_replays` before serving requests. Existing +wallet, content, purchase and payout rows are unchanged. MySQL protocol tables +use ASCII binary collation so distinct case-sensitive base64 nonces cannot +collapse onto the same primary key. Every replica must use the same application +database and server wallet identity. Session/nonce handling uses the published +`KnexSessionManager`; initial-request claims are capped at 256 per identity. +Expired sessions and orphaned nonces are pruned hourly with no overlapping prune +in one process. Cleanup failure is logged without granting authentication. + +Both page and admin-funding payment middleware use the same atomic transaction-ID +claim table. Claims have no expiration: pruning accepted transaction IDs could +reopen replay of old payments. A duplicate returns false; database failures +propagate so the middleware fails closed. Keep these rows through restarts, +upgrades, restores and application rollback. The migration deliberately refuses +`down`; rolling back code must not delete protocol state. + +Before promotion, take and verify an encrypted application-database backup. +Rehearse the additive migration and check the MySQL table collations/primary keys. +Old code ignores the extra tables, but old process-local sessions do not become +shared: finish the guarded cutover before accepting replicated authentication. +Do not remove the tables or roll back replay state while serving payments. + +Candidate acceptance must include a non-spending authenticated paid-page request +that obtains a signed 402 challenge with the configured amount, with handshake +and requests deliberately sent to different replicas. The synthetic client's +`createAction` must throw before spending. Anonymous paid-page denial and free +rendered routes remain required. A real bounded paid test is separate operator +authorization and must verify the purchase ledger and entitlement, not merely an +HTTP status. On any failure, preserve the evidence and halt the release wave; +green free routes do not establish payment acceptance. diff --git a/migrations/202609240001_shared_protocol_state.cjs b/migrations/202609240001_shared_protocol_state.cjs new file mode 100644 index 0000000..e73ca64 --- /dev/null +++ b/migrations/202609240001_shared_protocol_state.cjs @@ -0,0 +1,48 @@ +// Additive application tables; no wallet/content rows are changed. +exports.up = async function (knex) { + const caseSensitive = table => { + if (['mysql', 'mysql2'].includes(knex.client.config.client)) { + table.charset('ascii') + table.collate('ascii_bin') + } + } + if (!await knex.schema.hasTable('auth_sessions')) { + await knex.schema.createTable('auth_sessions', table => { + caseSensitive(table) + table.string('sessionNonce', 64).primary() + table.string('peerNonce', 64).nullable() + table.string('peerIdentityKey', 130).nullable() + table.boolean('isAuthenticated').notNullable() + table.bigInteger('lastUpdate').notNullable() + table.boolean('certificatesRequired').nullable() + table.boolean('certificatesValidated').nullable() + table.bigInteger('expiresAt').notNullable() + table.index(['peerIdentityKey', 'lastUpdate']) + table.index('expiresAt') + }) + } + if (!await knex.schema.hasTable('auth_message_nonces')) { + await knex.schema.createTable('auth_message_nonces', table => { + caseSensitive(table) + // Also holds initial: scopes, which are not session-table keys. + table.string('sessionNonce', 130).notNullable() + table.string('messageNonce', 64).notNullable() + table.bigInteger('expiresAt').notNullable() + table.primary(['sessionNonce', 'messageNonce']) + table.index('expiresAt') + }) + } + if (!await knex.schema.hasTable('payment_replays')) { + await knex.schema.createTable('payment_replays', table => { + caseSensitive(table) + table.string('transactionId', 64).primary() + table.timestamp('createdAt').notNullable() + }) + } +} + +exports.down = async function () { + // Reverting application code is safe with these additive tables retained. + // Forgetting payment claims would reopen replay of already accepted payments. + throw new Error('Retain shared protocol state when rolling back application code') +} diff --git a/package-lock.json b/package-lock.json index 2c72e8d..bdc89ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Open BSV License", "dependencies": { "@aws-sdk/client-s3": "^3.1130.0", - "@bsv/auth-express-middleware": "2.2.6", + "@bsv/auth-express-middleware": "2.2.7", "@bsv/identity-react": "^1.1.14", "@bsv/payment-express-middleware": "2.1.7", "@bsv/sdk": "2.8.4", @@ -663,9 +663,9 @@ } }, "node_modules/@bsv/auth-express-middleware": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@bsv/auth-express-middleware/-/auth-express-middleware-2.2.6.tgz", - "integrity": "sha512-yujQW5/b45b+CADf9MPxcYofHA6Y7yMV5TCXKYzkVH00ZipG5DhfRAwFOKVVmeH5cXh0zaMYsvlOtZrrTxdT9g==", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@bsv/auth-express-middleware/-/auth-express-middleware-2.2.7.tgz", + "integrity": "sha512-JjtIeiS5H5PcuAwlnXrScY6LjyvY3TbO14Olrvf6Z6Jq8ip2AN6FP+7BPCLqciB/WPzGVhlB4tvG/QwsRH48Qg==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "mime-types": "^3.0.2" diff --git a/package.json b/package.json index 8995869..c77ce4e 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1130.0", - "@bsv/auth-express-middleware": "2.2.6", + "@bsv/auth-express-middleware": "2.2.7", "@bsv/identity-react": "^1.1.14", "@bsv/payment-express-middleware": "2.1.7", "@bsv/sdk": "2.8.4", diff --git a/scripts/k8s/promote-guarded.py b/scripts/k8s/promote-guarded.py index 7b1a18d..f1d601a 100644 --- a/scripts/k8s/promote-guarded.py +++ b/scripts/k8s/promote-guarded.py @@ -121,6 +121,7 @@ def endpoints(service, expected_pods=None, timeout=60): SMOKE = r""" const base=process.argv[1]||'http://127.0.0.1:8080'; +const peer=process.argv[2]; async function get(path) { const r=await fetch(base+path,{signal:AbortSignal.timeout(10000)}); if(r.status!==200)throw Error(path+': '+r.status);return r; } const health=await (await get('/healthz')).json();if(!health.ok)throw Error('health not OK'); await get('/'); const status=await (await get('/api/status')).json();if(status.status!=='success')throw Error('status not success'); @@ -132,15 +133,46 @@ def endpoints(service, expected_pods=None, timeout=60): const rendered=Buffer.from(await (await get(view.imageUrl)).arrayBuffer());if(rendered.subarray(0,8).toString('hex')!=='89504e470d0a1a0a')throw Error('rendered PNG failed'); const paid=await fetch(base+`/api/publications/${id}/pages/2`,{signal:AbortSignal.timeout(10000)}); if(paid.status!==401)throw Error('anonymous paid page did not reject'); -console.log(JSON.stringify({health:true,catalog:true,freePNG:true,freeJSON:true,renderedPNG:true,paidAccessDenied:true})); +const {AuthFetch,PrivateKey,ProtoWallet}=await import('@bsv/sdk'); +const wallet=new ProtoWallet(PrivateKey.fromRandom());let challenge=false,spendBlocked=false; +wallet.createAction=async args=>{ + if(args.outputs?.length!==1||args.outputs[0].satoshis!==status.pricePerPageSats)throw Error('Unexpected payment amount'); + spendBlocked=true;throw Error('TEST_PAYMENT_DISABLED'); +}; +let sequence=0;const destinations=new Set(); +const observe=async (url,init)=>{ + const parsed=new URL(String(url)); + const destination=peer&&sequence++%2===1?peer:base; + destinations.add(destination); + const response=await fetch(destination+parsed.pathname+parsed.search,{...init,signal:AbortSignal.timeout(10000)}); + if(String(url).includes('/pages/2')){ + if(response.status!==402||Number(response.headers.get('x-bsv-payment-satoshis-required'))!==status.pricePerPageSats||!response.headers.get('x-bsv-auth-signature'))throw Error('Authenticated payment challenge failed'); + challenge=true; + } + return response; +}; +const auth=new AuthFetch(wallet,undefined,undefined,undefined,{},observe); +try{await auth.fetch(base+`/api/publications/${id}/pages/2?format=json`);throw Error('Expected blocked synthetic payment');} +catch(error){if(error.message!=='TEST_PAYMENT_DISABLED')throw error;} +if(!challenge||!spendBlocked)throw Error('Authenticated payment probe incomplete'); +if(peer&&destinations.size!==2)throw Error('Cross-replica authentication was not exercised'); +console.log(JSON.stringify({health:true,catalog:true,freePNG:true,freeJSON:true,renderedPNG:true,paidAccessDenied:true,authenticatedPaymentChallenge:true,spendingDisabled:true,crossReplica:destinations.size===2})); """ def smoke(pods): - for pod in pods: + if len(pods) != 2: + raise RuntimeError('Smoke acceptance requires two replicas') + for index, pod in enumerate(pods): guard() - command('exec', pod['metadata']['name'], '--', 'node', '--input-type=module', '-e', SMOKE) - record('pod-smoke-passed', pod=pod['metadata']['name']) + peer = pods[1-index] + address = peer['status']['podIP'] + if ':' in address: + address = '['+address+']' + command('exec', pod['metadata']['name'], '--', 'node', '--input-type=module', '-e', SMOKE, + 'http://127.0.0.1:8080', 'http://'+address+':8080') + record('pod-smoke-passed', pod=pod['metadata']['name'], + peer=peer['metadata']['name'], crossReplica=True, spendingDisabled=True) def main(manifest, image): diff --git a/src/server/protocolState.test.ts b/src/server/protocolState.test.ts new file mode 100644 index 0000000..805ffd8 --- /dev/null +++ b/src/server/protocolState.test.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { Server } from 'node:http' +import express from 'express' +import knex, { type Knex } from 'knex' +import { AuthFetch, PrivateKey, ProtoWallet, type WalletInterface } from '@bsv/sdk' +import { createAuthMiddleware } from '@bsv/auth-express-middleware' +import { createPaymentMiddleware } from '@bsv/payment-express-middleware' +import { afterEach, beforeEach, it } from 'vitest' +import { createProtocolState, KnexPaymentReplayStore } from './protocolState.js' + +const migration = createRequire(import.meta.url)('../../migrations/202609240001_shared_protocol_state.cjs') as { up: (db: Knex) => Promise, down: () => Promise } +let folder: string +let database: Knex +let replica: Knex +const servers: Server[] = [] + +beforeEach(async () => { + folder = await mkdtemp(path.join(tmpdir(), 'papertrade-protocol-')) + let config: Knex.Config = { client: 'better-sqlite3', connection: { filename: path.join(folder, 'state.db') }, useNullAsDefault: true, pool: { min: 1, max: 1 } } + const mysqlConfigFile = process.env.PAPERTRADE_PROTOCOL_TEST_MYSQL_CONFIG + if (mysqlConfigFile !== undefined) { + const connection = JSON.parse(await readFile(mysqlConfigFile, 'utf8')) as Knex.MySqlConnectionConfig + assert.equal(connection.host, '127.0.0.1') + assert.equal(connection.database, 'papertrade_protocol_test', 'Only the disposable local test database may be used') + config = { client: 'mysql2', connection, pool: { min: 1, max: 1 } } + } + database = knex(config) + replica = knex(config) + if (config.client === 'better-sqlite3') { + await database.raw('PRAGMA journal_mode = WAL') + await database.raw('PRAGMA busy_timeout = 5000') + await replica.raw('PRAGMA busy_timeout = 5000') + } + await migration.up(database) +}) + +afterEach(async () => { + for (const server of servers.splice(0)) { + server.closeAllConnections() + await new Promise((resolve, reject) => { server.close(error => error == null ? resolve() : reject(error)) }) + } + if (database.client.config.client === 'mysql2') { + for (const table of ['payment_replays', 'auth_message_nonces', 'auth_sessions']) await database.schema.dropTableIfExists(table) + } + await Promise.all([database.destroy(), replica.destroy()]) + await rm(folder, { recursive: true, force: true }) +}) + +it('authenticates requests distributed across independent replicas and rejects a replay on the other replica', async () => { + const wallet = new ProtoWallet(PrivateKey.fromRandom()) as unknown as WalletInterface + const origins: string[] = [] + for (const db of [database, replica]) { + const app = express() + app.use(express.json()) + app.use(createAuthMiddleware({ wallet, sessionManager: createProtocolState(db).sessionManager })) + const state = createProtocolState(db) + wallet.internalizeAction = async () => { throw new Error('TEST_PAYMENT_DISABLED') } + app.get('/paid', createPaymentMiddleware({ wallet, replayStore: state.replayStore, calculateRequestPrice: () => 25 }), (_req, res) => res.json({ paid: true })) + app.get('/reader', (req, res) => res.json({ identity: (req as typeof req & { auth?: { identityKey: string } }).auth?.identityKey })) + const server = app.listen(0, '127.0.0.1') + servers.push(server) + await once(server, 'listening') + const address = server.address() + assert.ok(address !== null && typeof address === 'object') + origins.push(`http://127.0.0.1:${address.port}`) + } + const clientWallet = new ProtoWallet(PrivateKey.fromRandom()) as unknown as WalletInterface + let sequence = 0 + let lastRequest: { url: string, init?: RequestInit, replica: number } | undefined + const alternate: typeof fetch = async (url, init) => { + const destination = sequence++ % 2 + const parsed = new URL(String(url)) + const actual = origins[destination] + parsed.pathname + parsed.search + if (parsed.pathname === '/reader') lastRequest = { url: actual, init, replica: destination } + return await fetch(actual, init) + } + const auth = new AuthFetch(clientWallet, undefined, undefined, undefined, {}, alternate) + const identity = await clientWallet.getPublicKey({ identityKey: true }) + for (let i = 0; i < 4; i++) { + const response = await auth.fetch(origins[0] + '/reader') + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), { identity: identity.publicKey }) + } + assert.ok(lastRequest !== undefined) + const replay = await fetch(origins[1 - lastRequest.replica] + '/reader', lastRequest.init) + assert.equal(replay.status, 401) + assert.equal((await replay.json() as { code: string }).code, 'ERR_AUTH_FAILED') + let paymentAttempted = false + clientWallet.createAction = async () => { paymentAttempted = true; throw new Error('TEST_PAYMENT_DISABLED') } + await assert.rejects(auth.fetch(origins[0] + '/paid'), /TEST_PAYMENT_DISABLED/) + assert.equal(paymentAttempted, true) +}, 15000) + +it('retains one atomic payment claim across replicas, new instances and migration reruns', async () => { + const txid = 'ab'.repeat(32) + const results = await Promise.all([new KnexPaymentReplayStore(database).claim(txid), new KnexPaymentReplayStore(replica).claim(txid)]) + assert.deepEqual(results.sort(), [false, true]) + await migration.up(database) + assert.equal(await new KnexPaymentReplayStore(replica).claim(txid), false) + assert.equal(await new KnexPaymentReplayStore(database).claim('cd'.repeat(32)), true) + await assert.rejects(migration.down(), /Retain shared protocol state/) + assert.equal(await new KnexPaymentReplayStore(database).claim(txid), false) +}) + +it('fails closed on unavailable replay storage and invalid transaction IDs', async () => { + const store = new KnexPaymentReplayStore(database) + await assert.rejects(store.claim('not-a-txid'), /Invalid payment transaction ID/) + await database.schema.dropTable('payment_replays') + await assert.rejects(store.claim('ef'.repeat(32)), /no such table|doesn't exist/) +}) + +it('keeps base64 nonce case distinct and does not prune a live session replay claim', async () => { + const { sessionManager } = createProtocolState(database) + const now = Date.now() + for (const sessionNonce of ['Ab'.repeat(32), 'ab'.repeat(32)]) { + await sessionManager.addSession({ sessionNonce, isAuthenticated: true, lastUpdate: now }) + assert.equal(await sessionManager.claimMessageNonce(sessionNonce, 'Cd'.repeat(32)), true) + } + await database('auth_message_nonces').update({ expiresAt: now - 1000 }) + await sessionManager.pruneExpiredSessions() + assert.equal(await createProtocolState(replica).sessionManager.claimMessageNonce('Ab'.repeat(32), 'Cd'.repeat(32)), false) + await database('auth_sessions').update({ expiresAt: now - 1000 }) + assert.equal(await sessionManager.pruneExpiredSessions(), 2) + assert.equal((await database('auth_message_nonces')).length, 0) +}) + +it('runs the actual promotion smoke including its signed non-spending payment challenge', async () => { + const wallet = new ProtoWallet(PrivateKey.fromRandom()) as unknown as WalletInterface + wallet.internalizeAction = async () => { throw new Error('TEST_PAYMENT_DISABLED') } + const origins: string[] = [] + for (const db of [database, replica]) { + const app = express() + const state = createProtocolState(db) + const png = Buffer.from('89504e470d0a1a0a', 'hex') + app.use(express.json()) + app.get('/healthz', (_req, res) => res.json({ ok: true })) + app.get('/', (_req, res) => res.send('fixture')) + app.get('/api/status', (_req, res) => res.json({ status: 'success', pricePerPageSats: 25 })) + app.get('/api/publications', (_req, res) => res.json({ publications: [{ id: 'fixture' }] })) + app.get('/api/publications/fixture/pages/1', (req, res) => { + if (req.query.format === 'json') res.json({ status: 'success', pageAccessMode: 'free', imageUrl: '/api/publications/fixture/pages/1/rendered?fixture=1' }) + else res.set('x-papertrade-page-access', 'free').send(png) + }) + app.get('/api/publications/fixture/pages/1/rendered', (_req, res) => res.send(png)) + app.use(createAuthMiddleware({ wallet, sessionManager: state.sessionManager })) + app.get('/api/publications/fixture/pages/2', createPaymentMiddleware({ wallet, replayStore: state.replayStore, calculateRequestPrice: () => 25 }), (_req, res) => res.json({ paid: true })) + const server = app.listen(0, '127.0.0.1') + servers.push(server) + await once(server, 'listening') + const address = server.address() + assert.ok(address !== null && typeof address === 'object') + origins.push(`http://127.0.0.1:${address.port}`) + } + const source = await readFile('scripts/k8s/promote-guarded.py', 'utf8') + const script = /SMOKE = r"""([\s\S]*?)"""/.exec(source)?.[1] + assert.ok(script !== undefined) + const { stdout } = await promisify(execFile)(process.execPath, ['--input-type=module', '-e', script, ...origins], { timeout: 15000 }) + const result = JSON.parse(stdout) as { authenticatedPaymentChallenge: boolean, spendingDisabled: boolean, crossReplica: boolean } + assert.equal(result.authenticatedPaymentChallenge, true) + assert.equal(result.spendingDisabled, true) + assert.equal(result.crossReplica, true) + assert.equal((await database('payment_replays')).length, 0) +}, 20000) diff --git a/src/server/protocolState.ts b/src/server/protocolState.ts new file mode 100644 index 0000000..f342636 --- /dev/null +++ b/src/server/protocolState.ts @@ -0,0 +1,35 @@ +import type { PaymentReplayStore } from '@bsv/payment-express-middleware' +import { KnexSessionManager } from '@bsv/wallet-toolbox' +import type { Knex } from 'knex' + +function isDuplicate (error: unknown): boolean { + if (error == null || typeof error !== 'object') return false + const { code } = error as { code?: unknown } + return code === 'ER_DUP_ENTRY' || code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || code === 'SQLITE_CONSTRAINT_UNIQUE' +} + +/** One transaction can fund only one accepted request, across routes and replicas. */ +export class KnexPaymentReplayStore implements PaymentReplayStore { + constructor (private readonly db: Knex) {} + + async claim (transactionId: string): Promise { + if (!/^[0-9a-f]{64}$/.test(transactionId)) throw new TypeError('Invalid payment transaction ID') + try { + await this.db('payment_replays').insert({ transactionId, createdAt: new Date() }) + return true + } catch (error) { + if (isDuplicate(error)) return false + throw error + } + } +} + +export function createProtocolState (db: Knex): { + sessionManager: KnexSessionManager + replayStore: KnexPaymentReplayStore +} { + return { + sessionManager: new KnexSessionManager(db, { maxInitialRequestNoncesPerIdentity: 256 }), + replayStore: new KnexPaymentReplayStore(db) + } +} diff --git a/src/server/server.ts b/src/server/server.ts index ccfd313..4b04385 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -18,8 +18,11 @@ import { ensurePageText, getPublicationDir, processPublicationFile } from './con import { STARTER_AUTHOR_NAME, STARTER_WORKS, starterCoverPath, starterWorkById, type StarterWork, writeStarterPdf } from './starterWorks.js' import { appManifest, metaForPath, renderHtmlShell, robotsTxt, sitemapXml, walletManifest, type PublicPublicationMeta } from './web.js' import { paymentForPaidPagesOnly } from './paymentRouting.js' +import { createProtocolState } from './protocolState.js' import { deleteStoredDirectory, readStoredFile, storeBuffer, storedFileExists } from './objectStorage.js' +const protocolState = createProtocolState(db) + const serverDirname = path.dirname(fileURLToPath(import.meta.url)) const HTTP_PORT = Number(process.env.HTTP_PORT ?? process.env.PORT ?? '3001') const ROUTING_PREFIX = process.env.ROUTING_PREFIX ?? '/api' @@ -1101,11 +1104,13 @@ async function createApp (): Promise { app.use(createAuthMiddleware({ wallet: walletBootstrap.wallet, - allowUnauthenticated: true + allowUnauthenticated: true, + sessionManager: protocolState.sessionManager })) const pagePaymentMiddleware = createPaymentMiddleware({ wallet: walletBootstrap.wallet, + replayStore: protocolState.replayStore, calculateRequestPrice: calculatePagePrice as any }) const paidPagePaymentMiddleware = paymentForPaidPagesOnly(pagePaymentMiddleware) @@ -1114,6 +1119,7 @@ async function createApp (): Promise { const adminFundingPaymentMiddleware = createPaymentMiddleware({ wallet: walletBootstrap.wallet, + replayStore: protocolState.replayStore, calculateRequestPrice: calculateAdminFundingPrice as any }) @@ -2078,10 +2084,22 @@ createApp() const server = app.listen(HTTP_PORT, () => { console.log(`PaperTrade listening on ${HTTP_PORT}`) }) + let pruning = false + const pruneSessions = (): void => { + if (pruning) return + pruning = true + void protocolState.sessionManager.pruneExpiredSessions() + .catch(() => { console.warn('Expired authentication session cleanup failed') }) + .finally(() => { pruning = false }) + } + const sessionCleanup = setInterval(pruneSessions, 60 * 60 * 1000) + sessionCleanup.unref() + pruneSessions() let stopping = false const stop = (): void => { if (stopping) return stopping = true + clearInterval(sessionCleanup) void closeHttpServer(server) .then(async () => { await db.destroy() }) .then(() => { process.exit(0) })