Skip to content
nviaOTP

Schema

Background Schema

What data lives where: local progress keys on the device versus the shared comments table, every field, who can see what, and the moderation states.

nviaOTP keeps as little data as it can. Course content ships in the bundle. Learner progress and preferences stay on the learner’s device. The only data stored on a server is the shared discussion, and even there the server never learns who a commenter is beyond the display name they typed. This document lists every stored field and its visibility.

On the device (local storage)

  • otp:progress — map of seriesSlug/chapterSlug to completion timestamp. Written when a chapter passes 80% scroll; removed when the learner unmarks it. Read by rings, checklists, path pages and the continue strip.
  • otp:theme — light or dim. Read before first paint to avoid a theme flash.
  • otp:comment-session — random 72-character token generated on first comment or report. Sent with those requests; never displayed.
  • otp:comment-name — the last display name used, to prefill the form. Editable at every post.

None of these leave the device except the session token, which is sent only when posting or reporting and is immediately hashed on the server. Clearing site data removes all of them. Earlier builds stored comments in a local otp:comments key; that key is no longer read or written.

On the server: `chapter_comments`

id            uuid        primary key, generated
chapter_key   text        "series-slug/chapter-slug", validated pattern
parent_id     uuid null   top-level when null; replies point to a top-level comment
display_name  text        2–40 characters, as typed
body          text        15–2000 characters, at most two links
session_hash  text        SHA-256 of the browser's session token
report_count  integer     distinct reports received
status        text        'visible' | 'hidden'
created_at    timestamptz server time of insert
Comment rows.

On the server: `comment_reports`

comment_id    uuid        the reported comment
session_hash  text        SHA-256 of the reporting browser's token
created_at    timestamptz
primary key (comment_id, session_hash)   -- one report per browser per comment
Report rows.

Who can see what

Row-level security is enabled on both tables with no table-level policies, so neither table can be queried directly by the public interface. Access goes through three functions. list_comments returns id, parent_id, display_name, body and created_at for visible comments of one chapter, and drops replies whose parent is hidden. It never returns session_hash, report_count or status. post_comment and report_comment accept writes after validation. Readers therefore see display names, bodies and timestamps — exactly what appears on the page — and nothing else.

The project’s operators can see all columns through administrative access. The session hash lets them group comments from one browser when investigating spam, but it cannot be reversed to the token and is not linked to any account, address or device identifier. IP addresses are not stored.

Moderation states

  • visible — default after a successful post.
  • hidden — set automatically when report_count reaches 3. Hidden comments and their replies disappear from list_comments. Operators can restore a comment by setting it back to visible after review, or delete it, which cascades to its replies and reports.

Reporting is idempotent per browser: a second report from the same browser returns “already reported” and does not increment the count. Hiding at three reports is deliberately low; a false hide is cheap to reverse, while abusive content left visible is not.

Validation and rate limits

  • Display name 2–40 characters after trimming; body 15–2000 characters after trimming.
  • At most two links per body.
  • One comment per browser per 30 seconds; eight per hour.
  • The same body cannot be posted twice from one browser within a day.
  • Replies must target a visible top-level comment in the same chapter; deeper nesting is rejected.

The same rules are checked in the browser for immediate feedback, but the database functions are the authority. These are spam-light controls: a determined actor can mint new tokens. They raise the cost of casual abuse without demanding accounts, consistent with the product’s no-account principle.

Lifecycle of a comment

A visitor types a name and body. The browser checks lengths and enables the button. On submit it calls post_comment with the chapter key, an optional parent id, the name, the body and the session token. The function hashes the token, applies the rate limits and duplicate check, validates the parent, and inserts a row with status visible. The browser then calls list_comments again and renders the new list, so what the poster sees is exactly what every other reader sees. If any check fails, the function raises a named error such as rate_limited_short or body_length, and the browser maps it to a plain sentence; nothing is inserted.

A report follows the same shape. The browser calls report_comment with the comment id and the session token. The function inserts a report row keyed by the comment and the token hash; if that pair already exists nothing changes and the caller is told so. Otherwise it increments the count and, at three, flips the status to hidden in the same statement. The next list_comments call omits the comment and its replies.

Retention and deletion

Visible comments are kept for as long as the chapter exists. Hidden comments are reviewed and either restored or deleted; deletion removes the row, its replies and its report rows together. Because no identity is collected there is nothing to export for a commenter beyond their own visible text. A reader who wants a comment removed can report it like anyone else or contact the site, quoting the chapter and timestamp shown on the page.

What is not stored

No accounts, email addresses, passwords, IP addresses, analytics events, quiz answers, playground code or lab inputs. The TOTP secret in the hero and workbench exists only in page memory. The P-256 key in the WebAuthn mock is generated as non-extractable and disappears with the tab. Seeded or sample comments are prohibited: the table shipped empty and every row in it was written by a reader.