Data format

Comments live under COMMENTS_PATH (default comments/).

comments/
  index.json
  hello-world.json
  posts/another-post.json

One aggregate file per post, not one file per comment: the proxy read would otherwise cost one request per comment.

Post file

{
  "version": 1,
  "post": "hello-world",
  "updatedAt": "2026-10-30T05:00:00.000Z",
  "comments": [
    {
      "id": "8c8f3690-7bb7-4add-901d-c829b337417e",
      "parentId": null,
      "name": "Alice",
      "content": "Nice.",
      "createdAt": "2026-10-30T04:12:00.000Z",
      "emailEncrypted": "<base64>",
      "emailKeyFingerprint": "a1b2c3d4"
    }
  ]
}
FieldIn APINotes
idyesUUID v4, created at submit time
parentIdyesMust exist in the same file; two levels max in V1
nameyesPlain text
contentyesPlain text, escaped on render
createdAtyesSubmit time, not approval time
emailEncryptednoAES-256-GCM, key derived from COMMENTS_SECRET
emailKeyFingerprintnoIdentifies old ciphertext after rotation

When no email is provided, all three email fields are omitted.

Index file

{
  "version": 1,
  "updatedAt": "2026-10-30T05:00:00.000Z",
  "posts": { "hello-world": { "count": 12, "updatedAt": "2026-10-30T05:00:00.000Z" } }
}

The index is a convenience counter. Reads never depend on it, and it may briefly lag.

Slug rules

  • Lowercase letters, digits, -, _, /
  • Starts with a letter or digit
  • At most 179 characters
  • No .., no absolute path
  • A trailing .json is stripped

Concurrency

Writes use the file sha as an optimistic lock. A conflicting approval re-reads and retries a bounded number of times. The index write is independent and never fails the approval.

History is permanent

Deleting a comment removes the current version only. Git history keeps it.