755ef3f89667

add the issues and thread tables to kepr.db with per-repo numbers, hashed follow-up tokens and the data layer the public pages read (v3.0.27)

time
2026-09-26 19:05 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
3835b4d58a83
4 files package.json+1 −1src/issues/store.ts+137 −0src/server/db.ts+32 −1tests/issues.test.ts+74 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.26",3+	"version": "3.0.27",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/issues/store.ts+137 −0
@@ -0,0 +1,137 @@1+import type { Database } from "bun:sqlite";2+import { createHash, randomBytes } from "node:crypto";3+4+export const ISSUE_TYPES = ["bug", "idea", "question"] as const;5+export type IssueType = (typeof ISSUE_TYPES)[number];6+7+export type Issue = {8+	id: number;9+	number: number;10+	title: string;11+	body: string;12+	type: IssueType;13+	version: string;14+	reporter_name: string;15+	filed_at: number;16+	status: "open" | "closed";17+	fixed_in: string;18+	token_hash: string | null;19+};20+21+export type Entry = {22+	id: number;23+	author: "reporter" | "operator";24+	body: string;25+	created_at: number;26+};27+28+export type NewIssue = {29+	repo: string;30+	title: string;31+	body: string;32+	type: IssueType;33+	version: string;34+	reporterName: string;35+	contactEmail: string;36+	ipHash: string;37+	filedAt: number;38+};39+40+// Everything the public may see; the contact email and IP hash stay out of every public read.41+const PUBLIC_COLUMNS =42+	"id, number, title, body, type, version, reporter_name, filed_at, status, fixed_in, token_hash";43+44+export function hashToken(token: string): string {45+	return createHash("sha256").update(token).digest("hex");46+}47+48+/** Files an issue under the repo's next number and returns it with its follow-up token (§23). */49+export function fileIssue(db: Database, issue: NewIssue): { number: number; token: string } {50+	const token = randomBytes(32).toString("hex");51+	const number = db.transaction(() => {52+		const row = db53+			.query<{ next: number }, [string]>(54+				"SELECT COALESCE(MAX(number), 0) + 1 AS next FROM issues WHERE repo = ?",55+			)56+			.get(issue.repo) as { next: number };57+		db.query(58+			`INSERT INTO issues (repo, number, title, body, type, version, reporter_name, contact_email,59+				filed_at, ip_hash, token_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,60+		).run(61+			issue.repo,62+			row.next,63+			issue.title,64+			issue.body,65+			issue.type,66+			issue.version,67+			issue.reporterName,68+			issue.contactEmail,69+			issue.filedAt,70+			issue.ipHash,71+			hashToken(token),72+		);73+		return row.next;74+	})();75+	return { number, token };76+}77+78+/** Issues filed from one hashed IP since a time, hidden ones included: spam stays counted. */79+export function filingsSince(db: Database, ipHash: string, since: number): number {80+	const row = db81+		.query<{ n: number }, [string, number]>(82+			"SELECT COUNT(*) AS n FROM issues WHERE ip_hash = ? AND filed_at > ?",83+		)84+		.get(ipHash, since);85+	return row?.n ?? 0;86+}87+88+/** A repo's visible issues with one status, newest first. */89+export function listIssues(db: Database, repo: string, status: "open" | "closed"): Issue[] {90+	return db91+		.query<Issue, [string, string]>(92+			`SELECT ${PUBLIC_COLUMNS} FROM issues WHERE repo = ? AND status = ? AND hidden = 093+			ORDER BY number DESC`,94+		)95+		.all(repo, status);96+}97+98+export function issueCounts(db: Database, repo: string): { open: number; closed: number } {99+	const rows = db100+		.query<{ status: "open" | "closed"; n: number }, [string]>(101+			"SELECT status, COUNT(*) AS n FROM issues WHERE repo = ? AND hidden = 0 GROUP BY status",102+		)103+		.all(repo);104+	const count = (status: string) => rows.find((row) => row.status === status)?.n ?? 0;105+	return { open: count("open"), closed: count("closed") };106+}107+108+/** A visible issue by its number; a hidden one is as missing as an unknown one. */109+export function findIssue(db: Database, repo: string, number: number): Issue | null {110+	return db111+		.query<Issue, [string, number]>(112+			`SELECT ${PUBLIC_COLUMNS} FROM issues WHERE repo = ? AND number = ? AND hidden = 0`,113+		)114+		.get(repo, number);115+}116+117+export function threadEntries(db: Database, issueId: number): Entry[] {118+	return db119+		.query<Entry, [number]>(120+			"SELECT id, author, body, created_at FROM issue_entries WHERE issue_id = ? ORDER BY id",121+		)122+		.all(issueId);123+}124+125+export function addEntry(126+	db: Database,127+	issueId: number,128+	author: Entry["author"],129+	body: string,130+	at: number,131+): number {132+	return Number(133+		db134+			.query("INSERT INTO issue_entries (issue_id, author, body, created_at) VALUES (?, ?, ?, ?)")135+			.run(issueId, author, body, at).lastInsertRowid,136+	);137+}

view file

src/server/db.ts+32 −1
@@ -2,7 +2,7 @@22 import { mkdirSync } from "node:fs";33 import { join } from "node:path";44 5-// §19: only what the pages and the sync loop read so far; later phases add their tables.5+// §19: only what the pages, the sync loop and issues use so far; later phases add their tables.66 const SCHEMA = `77 CREATE TABLE IF NOT EXISTS settings (88 	key   TEXT PRIMARY KEY,@@ -40,6 +40,37 @@4040 	repo  TEXT PRIMARY KEY,4141 	count INTEGER NOT NULL DEFAULT 04242 );43+-- §23. Numbers are per repo and set explicitly, so Kepr 2's carry over on import. The contact44+-- email is the operator's alone; token_hash is the follow-up link's SHA-256 (NULL for imports).45+CREATE TABLE IF NOT EXISTS issues (46+	id            INTEGER PRIMARY KEY,47+	repo          TEXT NOT NULL,48+	number        INTEGER NOT NULL,49+	title         TEXT NOT NULL,50+	body          TEXT NOT NULL,51+	type          TEXT NOT NULL CHECK (type IN ('bug', 'idea', 'question')),52+	version       TEXT NOT NULL DEFAULT '',53+	reporter_name TEXT NOT NULL DEFAULT '',54+	contact_email TEXT NOT NULL DEFAULT '',55+	filed_at      INTEGER NOT NULL,56+	status        TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),57+	closed_at     INTEGER,58+	fixed_in      TEXT NOT NULL DEFAULT '',59+	hidden        INTEGER NOT NULL DEFAULT 0,60+	ip_hash       TEXT NOT NULL DEFAULT '',61+	token_hash    TEXT,62+	UNIQUE (repo, number)63+);64+CREATE INDEX IF NOT EXISTS issues_ip ON issues (ip_hash, filed_at);65+-- The thread after the first post: the reporter's follow-ups and the operator's replies.66+CREATE TABLE IF NOT EXISTS issue_entries (67+	id         INTEGER PRIMARY KEY,68+	issue_id   INTEGER NOT NULL REFERENCES issues (id),69+	author     TEXT NOT NULL CHECK (author IN ('reporter', 'operator')),70+	body       TEXT NOT NULL,71+	created_at INTEGER NOT NULL72+);73+CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);4374 `;4475 4576 export type RepoSettings = {

view file

tests/issues.test.ts+74 −0
@@ -0,0 +1,74 @@1+import { describe, expect, test } from "bun:test";2+import { mkdtemp } from "node:fs/promises";3+import { tmpdir } from "node:os";4+import { join } from "node:path";5+import {6+	addEntry,7+	fileIssue,8+	filingsSince,9+	findIssue,10+	hashToken,11+	issueCounts,12+	listIssues,13+	type NewIssue,14+	threadEntries,15+} from "../src/issues/store.ts";16+import { openKeprDb } from "../src/server/db.ts";17+18+const newIssue = (repo: string, title: string, filedAt = 1000): NewIssue => ({19+	repo,20+	title,21+	body: "It broke.",22+	type: "bug",23+	version: "1.0.0",24+	reporterName: "",25+	contactEmail: "[email protected]",26+	ipHash: "ip-a",27+	filedAt,28+});29+30+describe("issue data layer", async () => {31+	const db = openKeprDb(await mkdtemp(join(tmpdir(), "kepr-issues-")));32+33+	test("numbers are per repo and continue after an imported number", () => {34+		expect(fileIssue(db, newIssue("app", "one")).number).toBe(1);35+		expect(fileIssue(db, newIssue("app", "two")).number).toBe(2);36+		expect(fileIssue(db, newIssue("lib", "first")).number).toBe(1);37+		db.run(38+			"INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('lib', 41, 'from kepr 2', 'x', 'idea', 5)",39+		);40+		expect(fileIssue(db, newIssue("lib", "next")).number).toBe(42);41+	});42+43+	test("only the token's hash is stored, and public reads leave out contact and IP", () => {44+		const { number, token } = fileIssue(db, newIssue("app", "secret"));45+		expect(token).toMatch(/^[0-9a-f]{64}$/);46+		const issue = findIssue(db, "app", number);47+		expect(issue?.token_hash).toBe(hashToken(token));48+		expect(JSON.stringify(issue)).not.toContain(token);49+		expect(Object.keys(issue ?? {})).not.toContain("contact_email");50+		expect(Object.keys(issue ?? {})).not.toContain("ip_hash");51+	});52+53+	test("hidden issues vanish from lists, counts and lookups but still count against the IP", () => {54+		const before = filingsSince(db, "ip-a", 0);55+		const { number } = fileIssue(db, newIssue("app", "spam"));56+		db.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = ?", [number]);57+		expect(findIssue(db, "app", number)).toBeNull();58+		expect(listIssues(db, "app", "open").map((i) => i.title)).not.toContain("spam");59+		expect(issueCounts(db, "app")).toEqual({ open: 3, closed: 0 });60+		expect(filingsSince(db, "ip-a", 0)).toBe(before + 1);61+		expect(filingsSince(db, "ip-a", 1000)).toBe(0);62+	});63+64+	test("the thread keeps entries in order with their author kind", () => {65+		const issue = findIssue(db, "app", 1);66+		if (!issue) throw new Error("issue 1 missing");67+		addEntry(db, issue.id, "reporter", "More detail.", 2000);68+		addEntry(db, issue.id, "operator", "Thanks.", 3000);69+		expect(threadEntries(db, issue.id).map((e) => [e.author, e.body])).toEqual([70+			["reporter", "More detail."],71+			["operator", "Thanks."],72+		]);73+	});74+});

view file