diff --git a/src/routes/auth.js b/src/routes/auth.js index 88a5f19..ba50e24 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -10,6 +10,16 @@ const { verifyNip98Event } = require('../utils/nip98'); const AUTH_WINDOW_MS = 60 * 1000; const AUTH_RATE_MAX_KEYS = 10000; +// Shared with the real PostgreSQL lifecycle check so parameter inference is exercised +// against the migrated schema, rather than accepted only by query mocks. +const challengeInsertQuery = ` + INSERT INTO smirk_challenges (challenge, socket_id, user_id) + SELECT $1::varchar(64), $2::varchar(255), id + FROM users + WHERE id = $3 AND socket_id = $2 AND anon_token = $4 + RETURNING id +`; + /** * The host a NIP-98 auth event must be signed for. * @@ -260,13 +270,7 @@ router.post('/api/auth/smirk/challenge', challengeRateLimit, asyncHandler(async // Store a stable user binding as well as the current socket. The user binding is what // prevents a public Tavern/Match socket id from being used to attach an attacker's key. - const inserted = await db.query(` - INSERT INTO smirk_challenges (challenge, socket_id, user_id) - SELECT $1, $2, id - FROM users - WHERE id = $3 AND socket_id = $2 AND anon_token = $4 - RETURNING id - `, [challenge, socketId, session.userId, session.token]); + const inserted = await db.query(challengeInsertQuery, [challenge, socketId, session.userId, session.token]); if (inserted.rows.length !== 1) throw unauthorized(); // Opportunistic cleanup of expired and long-used challenges. @@ -470,3 +474,5 @@ router.get('/api/auth/smirk/status', asyncHandler(async (req, res) => { return router; }; + +module.exports.challengeInsertQuery = challengeInsertQuery; diff --git a/src/scripts/showcase-lifecycle-smoke.js b/src/scripts/showcase-lifecycle-smoke.js index aacbde6..00bdf3e 100644 --- a/src/scripts/showcase-lifecycle-smoke.js +++ b/src/scripts/showcase-lifecycle-smoke.js @@ -12,6 +12,7 @@ const path = require('node:path'); const { promisify } = require('node:util'); const { Client } = require('pg'); const { chromium } = require('playwright-core'); +const { challengeInsertQuery } = require('../routes/auth'); const execFile = promisify(childProcess.execFile); const ROOT = path.resolve(__dirname, '../..'); @@ -143,6 +144,7 @@ function classifyStartupFailure(output) { function classifyHarnessFailure(error, phase) { const sqlStateCodes = { + '42P08': 'DATABASE_PARAMETER_TYPE_CONFLICT', '42P01': 'DATABASE_TABLE_UNDEFINED', '42703': 'DATABASE_COLUMN_UNDEFINED', '42883': 'DATABASE_FUNCTION_UNDEFINED', @@ -370,6 +372,19 @@ async function main() { throw error; }); + // PREPARE checks the route's actual parameter inference without generating a + // challenge, activating Smirk, or passing any authentication values to PostgreSQL. + phase = 'auth-query-compile'; + const authDb = new Client({ host: socketDir, port: dbPort, database: dbName, user: dbUser, + connectionTimeoutMillis: 2000, statement_timeout: 5000, lock_timeout: 2000 }); + try { + await authDb.connect(); + await authDb.query(`PREPARE showcase_smirk_challenge AS ${challengeInsertQuery}`); + await authDb.query('DEALLOCATE showcase_smirk_challenge'); + } finally { + await authDb.end(); + } + phase = 'browser-setup'; browser = await chromium.launch({ headless: true }); for (const role of ROLES) {