Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions scripts/migrate.js
Original file line number Diff line number Diff line change
@@ -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);
});
19 changes: 18 additions & 1 deletion scripts/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
124 changes: 124 additions & 0 deletions src/middleware/idempotency.js
Original file line number Diff line number Diff line change
@@ -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 };
5 changes: 3 additions & 2 deletions src/routes/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down