Index: src/routes/comments.js
===================================================================
--- src/routes/comments.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/comments.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,115 @@
+/**
+ * Comments — phase G v1.
+ *
+ * POST /comments              -> create a comment on a post (auth required)
+ * POST /comments/:id/delete   -> delete (own, or god/site-admin)
+ *
+ * Threading: 1 level deep (top-level + replies). Replies-of-replies fold up
+ * into the same parent (UI keeps it shallow).
+ *
+ * Status: auto-approved for logged-in users (trust mode). The schema's
+ * `status` column stays so we can switch to moderation later without changing
+ * shape. Anonymous comments (require_login_to_comment = 0 + no user) come
+ * later — for now we always require login.
+ */
+
+import express from 'express';
+import { v4 as uuid } from 'uuid';
+import db from '../config/database.js';
+import { requireAuth } from '../middleware/auth.js';
+import PermissionsService from '../services/PermissionsService.js';
+
+const router = express.Router();
+
+// Limits
+const MAX_LEN = 4000;
+const MIN_LEN = 1;
+
+router.post('/', requireAuth, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site required');
+
+  const postSlug = (req.body.post_slug || '').trim();
+  const rawContent = (req.body.content || '').trim();
+  const parentId = (req.body.parent_comment_id || '').trim() || null;
+
+  if (!postSlug) return res.status(400).send('post_slug required');
+  if (rawContent.length < MIN_LEN) return res.status(400).send('Comment cannot be empty');
+  if (rawContent.length > MAX_LEN) return res.status(413).send(`Comment too long (max ${MAX_LEN} chars)`);
+
+  const post = db.prepare(
+    'SELECT id, slug FROM posts WHERE site_id = ? AND slug = ? AND status = ?'
+  ).get(site.id, postSlug, 'published');
+  if (!post) return res.status(404).send('Post not found');
+
+  if (!PermissionsService.canComment(req.session.user, site, post)) {
+    return res.status(403).send('Comments not allowed');
+  }
+
+  // Validate parent (must belong to this post; collapses replies-of-replies
+  // up to the top-level parent so we never go deeper than 1)
+  let resolvedParent = null;
+  if (parentId) {
+    const parent = db.prepare(
+      'SELECT id, parent_comment_id FROM comments WHERE id = ? AND post_id = ?'
+    ).get(parentId, post.id);
+    if (!parent) return res.status(400).send('Invalid parent comment');
+    resolvedParent = parent.parent_comment_id || parent.id;
+  }
+
+  // Status depends on the site's moderation mode.
+  // 'trust'    = auto-approve immediately (default).
+  // 'moderate' = pending until an admin reviews in /admin/comments.
+  // Author is the post author or god → always trusted (no point gatekeeping yourself).
+  const isTrustedAuthor = req.session.user.role === 'god'
+    || req.session.user.id === post.author_id;
+  const status = (site.comments_moderation_mode === 'moderate' && !isTrustedAuthor)
+    ? 'pending'
+    : 'approved';
+
+  const commentId = uuid();
+  db.prepare(`
+    INSERT INTO comments (id, post_id, author_id, parent_comment_id, content, status)
+    VALUES (?, ?, ?, ?, ?, ?)
+  `).run(commentId, post.id, req.session.user.id, resolvedParent, rawContent, status);
+
+  // Where to land after submit:
+  //   approved → scroll to the new comment
+  //   pending  → comments anchor + ?pending=1 query so post page can flash a notice
+  const target = status === 'approved'
+    ? `${res.locals.siteUrlBase || ''}/${post.slug}#comment-${commentId}`
+    : `${res.locals.siteUrlBase || ''}/${post.slug}?pending=1#comments`;
+  if (req.headers['hx-request']) {
+    res.setHeader('HX-Redirect', target);
+    return res.send('OK');
+  }
+  res.redirect(target);
+});
+
+router.post('/:id/delete', requireAuth, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site required');
+
+  const comment = db.prepare(`
+    SELECT c.id, c.author_id, c.post_id, p.slug AS post_slug
+    FROM comments c JOIN posts p ON p.id = c.post_id
+    WHERE c.id = ? AND p.site_id = ?
+  `).get(req.params.id, site.id);
+
+  if (!comment) return res.status(404).send('Not found');
+  if (!PermissionsService.canDeleteComment(req.session.user, comment, site)) {
+    return res.status(403).send('No permission');
+  }
+
+  // Delete the comment plus any replies that hung off it
+  db.prepare('DELETE FROM comments WHERE id = ? OR parent_comment_id = ?')
+    .run(req.params.id, req.params.id);
+
+  if (req.headers['hx-request']) {
+    res.setHeader('HX-Redirect', `${res.locals.siteUrlBase || ''}/${comment.post_slug}#comments`);
+    return res.send('OK');
+  }
+  res.redirect(`${res.locals.siteUrlBase || ''}/${comment.post_slug}#comments`);
+});
+
+export default router;
