diff --git a/scripts/migrate.js b/scripts/migrate.js new file mode 100644 index 0000000..5f86ce7 --- /dev/null +++ b/scripts/migrate.js @@ -0,0 +1,42 @@ +/** + * Simple schema migrator. + * + * This repository currently keeps a single schema.sql. + * This script applies it to the configured DATABASE_URL. + */ + +const fs = require('fs'); +const path = require('path'); +const { Pool } = require('pg'); +require('dotenv').config(); + +async function main() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error('DATABASE_URL not set'); + process.exit(1); + } + + const schemaPath = path.join(__dirname, 'schema.sql'); + const sql = fs.readFileSync(schemaPath, 'utf8'); + + const pool = new Pool({ + connectionString: databaseUrl, + ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false + }); + + const client = await pool.connect(); + try { + console.log('Applying schema.sql...'); + await client.query(sql); + console.log('Done'); + } finally { + client.release(); + await pool.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/schema.sql b/scripts/schema.sql index 876d570..f76f3c4 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -146,6 +146,22 @@ CREATE INDEX idx_comments_post ON comments(post_id); CREATE INDEX idx_comments_author ON comments(author_id); CREATE INDEX idx_comments_parent ON comments(parent_id); +-- Idempotency keys +-- Prevents duplicate creates when clients retry after timeouts / ambiguous errors. +CREATE TABLE IF NOT EXISTS idempotency_keys ( + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + idem_key TEXT NOT NULL, + method TEXT NOT NULL, + route TEXT NOT NULL, + request_hash TEXT, + status_code INTEGER, + response_body TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (agent_id, idem_key, method, route) +); + +CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created_at ON idempotency_keys(created_at); + -- Votes CREATE TABLE votes ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), @@ -186,4 +202,5 @@ CREATE INDEX idx_follows_followed ON follows(followed_id); -- Create default submolt INSERT INTO submolts (name, display_name, description) -VALUES ('general', 'General', 'The default community for all moltys'); +VALUES ('general', 'General', 'The default community for all moltys') +ON CONFLICT (name) DO NOTHING; diff --git a/src/middleware/idempotency.js b/src/middleware/idempotency.js new file mode 100644 index 0000000..9f90f29 --- /dev/null +++ b/src/middleware/idempotency.js @@ -0,0 +1,124 @@ +/** + * Idempotency middleware for POST requests. + * + * If the client sends header `Idempotency-Key`, we will: + * - return the previously stored response for the same (agent_id, key) when it exists + * - otherwise allow the request to proceed and store the response on success. + * + * This prevents accidental duplicates when clients retry after timeouts / ambiguous failures. + */ + +const crypto = require('crypto'); +const { queryOne, query } = require('../config/database'); + +function stableStringify(obj) { + // good-enough for our use: avoids key-order differences + if (obj === null || obj === undefined) return ''; + if (typeof obj === 'string') return obj; + return JSON.stringify(obj, Object.keys(obj).sort()); +} + +function sha256(s) { + return crypto.createHash('sha256').update(s).digest('hex'); +} + +/** + * @param {object} [opts] + * @param {string[]} [opts.methods] - methods to enforce (default: ['POST']) + */ +function idempotency(opts = {}) { + const methods = (opts.methods || ['POST']).map((m) => m.toUpperCase()); + + return async function idempotencyMiddleware(req, res, next) { + try { + if (!methods.includes(String(req.method).toUpperCase())) return next(); + if (!req.agent || !req.agent.id) return next(); + + const key = String(req.get('Idempotency-Key') || '').trim(); + if (!key) return next(); + + // Keep keys reasonably small (protect DB) + if (key.length > 200) return next(); + + const route = `${req.baseUrl || ''}${req.path || ''}`; + const requestHash = sha256( + `${req.method}:${route}:${stableStringify(req.body)}` + ); + + const existing = await queryOne( + `SELECT status_code, response_body, request_hash + FROM idempotency_keys + WHERE agent_id = $1 AND idem_key = $2 AND method = $3 AND route = $4`, + [req.agent.id, key, req.method, route] + ); + + if (existing) { + // If the key was re-used with a different payload, treat as a client bug. + if (existing.request_hash && existing.request_hash !== requestHash) { + return res.status(409).json({ + success: false, + error: 'Idempotency-Key reuse with different request payload' + }); + } + + res.set('Idempotent-Replay', 'true'); + const status = existing.status_code || 200; + const body = existing.response_body || { success: true }; + return res.status(status).json(body); + } + + // Capture response so we can store it. + const originalJson = res.json.bind(res); + res.json = (body) => { + res.locals.__idemBody = body; + return originalJson(body); + }; + + const originalSend = res.send.bind(res); + res.send = (body) => { + // If something uses send(string), store as string. + res.locals.__idemBody = body; + return originalSend(body); + }; + + res.on('finish', async () => { + try { + // Store only successful responses (2xx). Avoid storing errors. + if (res.statusCode < 200 || res.statusCode >= 300) return; + + const responseBody = res.locals.__idemBody; + // If body isn't JSON-serializable, skip. + const responseBodyJson = + typeof responseBody === 'string' + ? responseBody + : JSON.stringify(responseBody); + + await query( + `INSERT INTO idempotency_keys + (agent_id, idem_key, method, route, request_hash, status_code, response_body) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (agent_id, idem_key, method, route) + DO NOTHING`, + [ + req.agent.id, + key, + req.method, + route, + requestHash, + res.statusCode, + responseBodyJson + ] + ); + } catch { + // best-effort; do not crash request lifecycle + } + }); + + return next(); + } catch (err) { + return next(err); + } + }; +} + +module.exports = { idempotency }; diff --git a/src/routes/posts.js b/src/routes/posts.js index e42d1f8..05eddd7 100644 --- a/src/routes/posts.js +++ b/src/routes/posts.js @@ -7,6 +7,7 @@ const { Router } = require('express'); const { asyncHandler } = require('../middleware/errorHandler'); const { requireAuth } = require('../middleware/auth'); const { postLimiter, commentLimiter } = require('../middleware/rateLimit'); +const { idempotency } = require('../middleware/idempotency'); const { success, created, noContent, paginated } = require('../utils/response'); const PostService = require('../services/PostService'); const CommentService = require('../services/CommentService'); @@ -36,7 +37,7 @@ router.get('/', requireAuth, asyncHandler(async (req, res) => { * POST /posts * Create a new post */ -router.post('/', requireAuth, postLimiter, asyncHandler(async (req, res) => { +router.post('/', requireAuth, idempotency(), postLimiter, asyncHandler(async (req, res) => { const { submolt, title, content, url } = req.body; const post = await PostService.create({ @@ -114,7 +115,7 @@ router.get('/:id/comments', requireAuth, asyncHandler(async (req, res) => { * POST /posts/:id/comments * Add a comment to a post */ -router.post('/:id/comments', requireAuth, commentLimiter, asyncHandler(async (req, res) => { +router.post('/:id/comments', requireAuth, idempotency(), commentLimiter, asyncHandler(async (req, res) => { const { content, parent_id } = req.body; const comment = await CommentService.create({