reviewzy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +30 -0
  2. package/LICENSE +661 -0
  3. package/README.md +170 -0
  4. package/bin/reviewzy +7 -0
  5. package/package.json +29 -0
  6. package/src/config.ts +76 -0
  7. package/src/daemon/app.ts +159 -0
  8. package/src/daemon/main.ts +103 -0
  9. package/src/daemon/origin.ts +60 -0
  10. package/src/dashboard/auth.ts +122 -0
  11. package/src/dashboard/mount.ts +373 -0
  12. package/src/dashboard/static/components.css +1116 -0
  13. package/src/dashboard/static/dashboard.css +182 -0
  14. package/src/dashboard/static/dashboard.js +169 -0
  15. package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
  16. package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
  17. package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  18. package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  19. package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
  20. package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
  21. package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
  22. package/src/dashboard/static/htmx.min.js +2 -0
  23. package/src/dashboard/static/tokens.css +213 -0
  24. package/src/dashboard/views.tsx +1388 -0
  25. package/src/db/human-save.ts +242 -0
  26. package/src/db/migrate.ts +71 -0
  27. package/src/db/migrations.ts +149 -0
  28. package/src/db/queries.ts +425 -0
  29. package/src/db/store.ts +83 -0
  30. package/src/db/style-guide.ts +236 -0
  31. package/src/db/sweep.ts +88 -0
  32. package/src/mcp/await-approved.ts +156 -0
  33. package/src/mcp/fetch-approved.ts +132 -0
  34. package/src/mcp/file-entries.ts +219 -0
  35. package/src/mcp/list-entries.ts +107 -0
  36. package/src/mcp/mark-applied.ts +103 -0
  37. package/src/mcp/route.ts +88 -0
  38. package/src/mcp/server.ts +59 -0
  39. package/src/mcp/style-guide-resource.ts +40 -0
  40. package/src/notify.ts +142 -0
  41. package/src/shim/daemon.ts +318 -0
  42. package/src/shim/lockfile.ts +153 -0
  43. package/src/shim/main.ts +172 -0
  44. package/src/shim/semver.ts +29 -0
  45. package/src/version.ts +4 -0
  46. package/tsconfig.json +26 -0
@@ -0,0 +1,122 @@
1
+ import { timingSafeEqual } from "hono/utils/buffer";
2
+
3
+ /** The one session cookie: a signed payload, so the daemon needs no server-side session store. */
4
+ export const SESSION_COOKIE = "reviewzy_session";
5
+
6
+ /**
7
+ * The signed payload. A session carries no data, only proof the password was entered: the signing
8
+ * key is derived from the password itself, so rotating the password invalidates every outstanding
9
+ * session, and the payload is fixed because there is nothing else to carry.
10
+ */
11
+ const SESSION_PAYLOAD = "reviewzy-session-v1";
12
+
13
+ /** The payload's base64url spelling, which is what the cookie actually carries. */
14
+ const ENCODED_PAYLOAD = Buffer.from(SESSION_PAYLOAD, "utf8").toString("base64url");
15
+
16
+ /**
17
+ * The cookie's pinned attributes: HttpOnly, SameSite=Strict, and browser-session (no Max-Age on the
18
+ * set). No Secure: the daemon serves plain loopback http only (the docs/design.md posture), where a
19
+ * Secure cookie would never be sent at all.
20
+ */
21
+ const SET_COOKIE_ATTRS = "HttpOnly; SameSite=Strict; Path=/";
22
+
23
+ /** The dashboard's session auth: sign and verify a cookie, and check the login password. */
24
+ export type SessionAuth = {
25
+ readonly enabled: boolean;
26
+ /** The cookie value for a fresh session: the payload and its HMAC, base64url halves joined by a dot. */
27
+ sign(): Promise<string>;
28
+ /** Whether the Cookie header holds a valid signed session. A forged or malformed cookie is absent, never an error. */
29
+ accept(cookieHeader: string | undefined): Promise<boolean>;
30
+ /** The timing-safe password check behind POST /login. */
31
+ acceptPassword(presented: string): Promise<boolean>;
32
+ /** The Set-Cookie header that establishes the session. */
33
+ setCookieHeader(value: string): string;
34
+ /** The Set-Cookie header that ends the session. */
35
+ clearCookieHeader(): string;
36
+ };
37
+
38
+ /**
39
+ * The dashboard's session auth. Disabled means no password was set: the dashboard stays open and
40
+ * mounts no auth surface at all, so the unset mode is byte-identical to today.
41
+ */
42
+ export function createSessionAuth(password: string | undefined): SessionAuth {
43
+ const enabled = password !== undefined;
44
+ const secret = password;
45
+
46
+ // The key is sha256(password), derived once and memoized. No boot salt: sessions must survive a
47
+ // daemon restart and the shim's upgrade drain, and the password itself is the only secret.
48
+ let keyPromise: Promise<CryptoKey> | undefined;
49
+ const key = (): Promise<CryptoKey> => {
50
+ keyPromise ??= (async () => {
51
+ // Unreachable: `accept` refuses before `key` when disabled, and `sign` only runs after a
52
+ // password check passed. Fail loudly if a future caller breaks that order.
53
+ if (secret === undefined) throw new Error("reviewzy: session key derived while auth is disabled");
54
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
55
+ return crypto.subtle.importKey("raw", digest, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
56
+ })();
57
+ return keyPromise;
58
+ };
59
+
60
+ const sign = async (): Promise<string> => {
61
+ const signature = await crypto.subtle.sign("HMAC", await key(), new TextEncoder().encode(SESSION_PAYLOAD));
62
+ return `${ENCODED_PAYLOAD}.${Buffer.from(signature).toString("base64url")}`;
63
+ };
64
+
65
+ const accept = async (cookieHeader: string | undefined): Promise<boolean> => {
66
+ if (!enabled) return false;
67
+ const value = cookieValue(cookieHeader, SESSION_COOKIE);
68
+ if (value === undefined) return false;
69
+ const dot = value.indexOf(".");
70
+ if (dot === -1) return false;
71
+ if (value.slice(0, dot) !== ENCODED_PAYLOAD) return false;
72
+ const presented = value.slice(dot + 1);
73
+ if (presented === "") return false;
74
+ const signature = await crypto.subtle.sign("HMAC", await key(), new TextEncoder().encode(SESSION_PAYLOAD));
75
+ return timingSafeEqual(Buffer.from(signature).toString("base64url"), presented);
76
+ };
77
+
78
+ const acceptPassword = async (presented: string): Promise<boolean> => {
79
+ if (secret === undefined || presented === "") return false;
80
+ return timingSafeEqual(secret, presented);
81
+ };
82
+
83
+ return {
84
+ enabled,
85
+ sign,
86
+ accept,
87
+ acceptPassword,
88
+ setCookieHeader: (value: string) => `${SESSION_COOKIE}=${value}; ${SET_COOKIE_ATTRS}`,
89
+ clearCookieHeader: () => `${SESSION_COOKIE}=; ${SET_COOKIE_ATTRS}; Max-Age=0`,
90
+ };
91
+ }
92
+
93
+ /** The named cookie's value from a Cookie header, or undefined. First match wins; surrounding quotes are stripped. */
94
+ function cookieValue(header: string | undefined, name: string): string | undefined {
95
+ if (header === undefined) return undefined;
96
+ for (const part of header.split(";")) {
97
+ const eq = part.indexOf("=");
98
+ if (eq === -1) continue;
99
+ if (part.slice(0, eq).trim() !== name) continue;
100
+ const value = part.slice(eq + 1).trim();
101
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1);
102
+ return value;
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /**
108
+ * A login's `next` destination: only a local path is honored (starts with `/`, not `//`, no control
109
+ * characters, no backslash, bounded length), so a crafted value can never redirect the browser off
110
+ * the dashboard or split the Location header. The backslash is rejected because the URL parser
111
+ * rewrites it to a slash, which would turn `/\evil.example` into the off-dashboard authority
112
+ * `evil.example`; the check runs on the decoded value, so every encoding arrives as the same
113
+ * backslash and is rejected the same way. Anything else means the list.
114
+ */
115
+ export function safeNext(raw: unknown): string | undefined {
116
+ if (typeof raw !== "string" || raw === "") return undefined;
117
+ if (raw.length > 2048) return undefined;
118
+ if (!raw.startsWith("/") || raw.startsWith("//")) return undefined;
119
+ if (raw.includes("\\")) return undefined;
120
+ if (/[\x00-\x1f\x7f]/.test(raw)) return undefined;
121
+ return raw;
122
+ }
@@ -0,0 +1,373 @@
1
+ import type { Context, Hono } from "hono";
2
+ import { serveStatic } from "hono/bun";
3
+ import { html } from "hono/html";
4
+ import type { Config } from "../config.ts";
5
+ import { approveEntry, batchApproveEntries, rejectEntry, saveHumanText } from "../db/human-save.ts";
6
+ import type { ApproveOutcome, BatchApproveResult, RejectOutcome, TransitionRefusal } from "../db/human-save.ts";
7
+ import { projectIdBySlug } from "../db/queries.ts";
8
+ import { parseStyleGuideForm, upsertStyleGuide } from "../db/style-guide.ts";
9
+ import type { StyleGuideForm } from "../db/style-guide.ts";
10
+ import type { Store } from "../db/store.ts";
11
+ import {
12
+ dashboardPage,
13
+ editorFragment,
14
+ editorPage,
15
+ editorViewFragment,
16
+ editorViewGoneFragment,
17
+ entriesFragment,
18
+ entryGoneFragment,
19
+ formatSaveRefusal,
20
+ formatStyleGuideRefusal,
21
+ formatTransitionRefusal,
22
+ getEntry,
23
+ loadEditor,
24
+ loadStyleGuide,
25
+ loginPage,
26
+ notFoundPage,
27
+ styleGuidePage,
28
+ styleGuideRegionFragment,
29
+ } from "./views.tsx";
30
+ import type { EditorState, ListNotice, ListParams, StyleGuideEditorState } from "./views.tsx";
31
+ import { createSessionAuth, safeNext } from "./auth.ts";
32
+
33
+ /** The directory the vendored assets live in, resolved from this module's own location, so the mount works from any cwd. */
34
+ const STATIC_ROOT = new URL("./static/", import.meta.url).pathname;
35
+
36
+ /**
37
+ * Mounts the dashboard: the entry list at `/`, the entry editor at `/entries/:id`, its save at
38
+ * `/entries/:id/save`, the approve and reject transitions, the batch approve, the style guide
39
+ * editor at `/style-guide` with its save at `/style-guide/save`, and the vendored assets under
40
+ * `/static/`. The Origin gate is app-wide in `createApp`, so no route here re-adds it. A set
41
+ * `DASHBOARD_PASSWORD` gates every dashboard route behind a signed session cookie and mounts the
42
+ * login surface; unset, the dashboard stays open and the auth surface does not exist.
43
+ */
44
+ export function mountDashboard(app: Hono, config: Config, store: Store): void {
45
+ const auth = createSessionAuth(config.DASHBOARD_PASSWORD);
46
+
47
+ if (auth.enabled) {
48
+ // The gate, registered before every dashboard route so nothing below it answers unsigned.
49
+ // Exempt: the auth surface itself (both spellings of each path), the vendored assets
50
+ // (css/js/fonts, never data), and the mcp/health/drain surface this app mounts ahead of the
51
+ // dashboard, which owns its own gates. Everything else fails closed. htmx requests answer 401
52
+ // with HX-Redirect so the browser follows the login instead of swapping it into the target
53
+ // region (a 303 would swap the login page into the list); a plain GET redirects with the path
54
+ // as `next` so the login lands back; a plain POST goes to the login without one.
55
+ app.use("*", async (c, next) => {
56
+ const path = c.req.path;
57
+ if (
58
+ path === "/login" ||
59
+ path === "/login/" ||
60
+ path === "/logout" ||
61
+ path === "/logout/" ||
62
+ path === "/mcp" ||
63
+ path === "/health" ||
64
+ path === "/drain" ||
65
+ path.startsWith("/static/")
66
+ ) {
67
+ return next();
68
+ }
69
+ if (await auth.accept(c.req.header("Cookie"))) return next();
70
+ if (c.req.header("HX-Request") !== undefined) {
71
+ return new Response(null, { status: 401, headers: { "HX-Redirect": "/login" } });
72
+ }
73
+ if (c.req.method === "GET") {
74
+ const url = new URL(c.req.url);
75
+ return c.redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`, 303);
76
+ }
77
+ return c.redirect("/login", 303);
78
+ });
79
+
80
+ // The login surface. GET /login renders the form (a signed-in visitor goes straight through,
81
+ // honoring a safe `next`); POST /login is the one credential check, a timing-safe compare,
82
+ // and sets the session cookie on success; the failure re-renders the form with one message
83
+ // and the field cleared. POST /logout clears the cookie; it is exempt from the gate so it is
84
+ // idempotent when already signed out. Each route also answers its trailing-slash spelling:
85
+ // hono matches paths exactly, so a visitor who typed the slash would otherwise be bounced into
86
+ // a dead end after authenticating, and a signed-in /logout/ would never clear the session.
87
+ const loginPageHandler = async (c: Context): Promise<Response> => {
88
+ const next = safeNext(c.req.query("next"));
89
+ if (await auth.accept(c.req.header("Cookie"))) {
90
+ // A signed-in visitor goes to their destination; a `next` that points back at the login
91
+ // page itself would redirect forever, so it means the list.
92
+ const destination =
93
+ next === undefined || next === "/login" || next.startsWith("/login?") || next.startsWith("/login/") ? "/" : next;
94
+ return c.redirect(destination, 303);
95
+ }
96
+ return c.html(html`<!doctype html>${loginPage(next)}`);
97
+ };
98
+ app.get("/login", loginPageHandler);
99
+ app.get("/login/", loginPageHandler);
100
+
101
+ app.post("/login", async (c) => {
102
+ // A uniform floor on every attempt, before anything else is parsed or compared: without it,
103
+ // a failure would answer faster than a success and the credential check would leak through
104
+ // timing to the same local attacker the delay is meant to slow. Constant and stateless, so
105
+ // the no-lockout, no-rate-limiting surface is unchanged.
106
+ await new Promise((resolve) => setTimeout(resolve, 250));
107
+ let form: FormData;
108
+ try {
109
+ form = await c.req.formData();
110
+ } catch {
111
+ // A malformed body (a JSON request, a broken boundary) is a failed attempt like any other,
112
+ // never a 500 at the credential boundary; the `next` it carried is unreadable.
113
+ return c.html(html`<!doctype html>${loginPage(undefined, "Wrong password. Try again.")}`);
114
+ }
115
+ const next = safeNext(form.get("next"));
116
+ const presented = form.get("password");
117
+ if (typeof presented === "string" && (await auth.acceptPassword(presented))) {
118
+ c.header("set-cookie", auth.setCookieHeader(await auth.sign()));
119
+ return c.redirect(next ?? "/", 303);
120
+ }
121
+ return c.html(html`<!doctype html>${loginPage(next, "Wrong password. Try again.")}`);
122
+ });
123
+
124
+ const logoutHandler = (c: Context): Response => {
125
+ c.header("set-cookie", auth.clearCookieHeader());
126
+ return c.redirect("/login", 303);
127
+ };
128
+ app.post("/logout", logoutHandler);
129
+ app.post("/logout/", logoutHandler);
130
+ }
131
+
132
+ app.get("/", (c) => {
133
+ const params: ListParams = {
134
+ q: c.req.query("q"),
135
+ status: c.req.query("status"),
136
+ project: c.req.query("project"),
137
+ };
138
+ // htmx marks its requests with HX-Request; those get the list region alone, swapped into
139
+ // #entries-list. Everything else is a navigation and gets the full page.
140
+ if (c.req.header("HX-Request") !== undefined) {
141
+ return c.html(entriesFragment(store, params));
142
+ }
143
+ return c.html(html`<!doctype html>${dashboardPage(store, params, undefined, auth.enabled)}`);
144
+ });
145
+
146
+ // serveStatic refuses traversal itself (the guard runs on the decoded request path, before the
147
+ // rewrite, so `..` and `//` never reach STATIC_ROOT), and the mime map covers the vendored set:
148
+ // css, js, woff2, txt. The rewrite drops the route's own prefix: serveStatic would otherwise join
149
+ // the full request path (`/static/tokens.css`) onto the root and find nothing.
150
+ app.get(
151
+ "/static/*",
152
+ serveStatic({ root: STATIC_ROOT, rewriteRequestPath: (path) => path.replace(/^\/static/, "") }),
153
+ );
154
+
155
+ // The editor page; an unknown id is a true 404 page with the way onward.
156
+ app.get("/entries/:id", (c) => {
157
+ const vm = loadEditor(store, c.req.param("id"));
158
+ if (vm === null) return c.html(html`<!doctype html>${notFoundPage(auth.enabled)}`, 404);
159
+ return c.html(html`<!doctype html>${editorPage(vm, undefined, auth.enabled)}`);
160
+ });
161
+
162
+ // The one save path. The store layer owns every decision (refusals, the single revision write,
163
+ // the draft→approved transition); this route only formats the outcome. htmx requests get the
164
+ // editor region swapped in; a plain navigation gets the Post/Redirect/Get redirect on success
165
+ // and the full page with the refusal callout otherwise.
166
+ app.post("/entries/:id/save", async (c) => {
167
+ const id = c.req.param("id");
168
+ const form = await c.req.formData();
169
+ // The store owns every semantic (empty refusal, no-op equality, max_len) on the authored
170
+ // bytes; this route only formats the outcome, so the text is passed raw. Trimming here would
171
+ // store bytes the author never saw and desync the client's char counter from the length the
172
+ // server refuses on. A non-string part (a crafted multipart file) is treated as missing.
173
+ const raw = form.get("text");
174
+ const text = typeof raw === "string" ? raw : "";
175
+ const outcome = saveHumanText(store, { id, text });
176
+
177
+ if (!outcome.ok && outcome.refusal.kind === "unknown") {
178
+ // htmx will not swap a 4xx, so the save response on a vanished id is a 200 fragment with the
179
+ // way onward inside the editor view itself; the GET above stays a true 404.
180
+ return c.html(editorViewGoneFragment());
181
+ }
182
+ const vm = loadEditor(store, id);
183
+ if (vm === null) return c.html(editorViewGoneFragment());
184
+ const state: EditorState = {
185
+ submittedText: text,
186
+ // The unknown case returned above, so this re-check is what narrows the refusal for the
187
+ // formatter: `outcome.ok` alone would leave `unknown` reachable in TS's view.
188
+ refusal:
189
+ !outcome.ok && outcome.refusal.kind !== "unknown"
190
+ ? { title: "Text not saved", body: formatSaveRefusal(outcome.refusal) }
191
+ : undefined,
192
+ };
193
+ if (c.req.header("HX-Request") === undefined) {
194
+ if (outcome.ok) return c.redirect(`/entries/${id}`, 303);
195
+ return c.html(html`<!doctype html>${editorPage(vm, state, auth.enabled)}`);
196
+ }
197
+ // The save swap targets the whole editor view, so the diff's after-side re-renders with the
198
+ // authored text; the transitions below answer with the region fragment only.
199
+ return c.html(editorViewFragment(vm, state));
200
+ });
201
+
202
+ // The two transitions. One route answers both the editor and the list: the buttons in the editor
203
+ // form carry a `view=editor` marker (htmx includes the closest form's inputs), the list's buttons
204
+ // carry the batch form's filters. Everything the store refuses is formatted here; the store's
205
+ // transition functions did the writing or not at all.
206
+ app.post("/entries/:id/approve", async (c) => {
207
+ const id = c.req.param("id");
208
+ const outcome = approveEntry(store, id);
209
+ const form = await c.req.formData();
210
+ if (form.get("view") === "editor") return transitionEditorReply(c, store, id, outcome, "approve", auth.enabled);
211
+ const notice = outcome.ok ? undefined : transitionNotice(store, "approve", id, outcome);
212
+ return listReply(c, store, form, notice, outcome.ok, auth.enabled);
213
+ });
214
+
215
+ app.post("/entries/:id/reject", async (c) => {
216
+ const id = c.req.param("id");
217
+ const outcome = rejectEntry(store, id);
218
+ const form = await c.req.formData();
219
+ if (form.get("view") === "editor") return transitionEditorReply(c, store, id, outcome, "reject", auth.enabled);
220
+ const notice = outcome.ok ? undefined : transitionNotice(store, "reject", id, outcome);
221
+ return listReply(c, store, form, notice, outcome.ok, auth.enabled);
222
+ });
223
+
224
+ // The batch approve: every selected id goes through the store's own approve independently, so one
225
+ // refusal never blocks another's approval. An empty selection is refused with a message, never a
226
+ // silent no-op.
227
+ app.post("/batch-approve", async (c) => {
228
+ const form = await c.req.formData();
229
+ const ids = form.getAll("id").filter((value): value is string => typeof value === "string");
230
+ const results = batchApproveEntries(store, ids);
231
+ const notice = batchNotice(store, results);
232
+ return listReply(c, store, form, notice, false, auth.enabled);
233
+ });
234
+
235
+ // The style guide editor: one section per page, the global guide at `/style-guide` and a
236
+ // project's at `?project=<slug>`. The selector is a list of links; a slug that resolves to
237
+ // nothing renders the editor with a notice, never a 500 and never a row (the store never mints a
238
+ // project). The guard above covers both routes like every other dashboard route.
239
+ app.get("/style-guide", (c) => {
240
+ const vm = loadStyleGuide(store, c.req.query("project"));
241
+ return c.html(html`<!doctype html>${styleGuidePage(vm, undefined, auth.enabled)}`);
242
+ });
243
+
244
+ app.post("/style-guide/save", async (c) => {
245
+ const form = await c.req.formData();
246
+ const projectValue = form.get("project");
247
+ // `project` empty or missing means the global section, exactly as the hidden input renders it.
248
+ const projectSlug = typeof projectValue === "string" && projectValue !== "" ? projectValue : null;
249
+ // The boundary parse types the three fields. A non-string part (a crafted multipart file) is
250
+ // treated as missing, and markdown stays raw: the merge trims at render, and trimming here
251
+ // would store bytes the author never saw.
252
+ const field = (key: string) => {
253
+ const value = form.get(key);
254
+ return typeof value === "string" ? value : "";
255
+ };
256
+ const raw: StyleGuideForm = {
257
+ markdown: field("markdown"),
258
+ bannedWordsCsv: field("banned_words"),
259
+ glossaryText: field("glossary"),
260
+ };
261
+
262
+ // The store owns the write; this route owns the two refusals the parse cannot name: the
263
+ // malformed glossary line (nothing written) and the vanished project (the store's upsert would
264
+ // throw, and the editor never mints one).
265
+ const parsed = parseStyleGuideForm(raw);
266
+ let notice: StyleGuideEditorState["notice"];
267
+ if (!parsed.ok) {
268
+ const formatted = formatStyleGuideRefusal(parsed.refusal);
269
+ notice = { kind: "refusal", title: formatted.title, body: formatted.body };
270
+ } else if (projectSlug !== null && projectIdBySlug(store, projectSlug) === null) {
271
+ notice = { kind: "unknown_project" };
272
+ } else {
273
+ upsertStyleGuide(store, projectSlug, parsed.input);
274
+ notice = { kind: "saved" };
275
+ }
276
+
277
+ const vm = loadStyleGuide(store, projectSlug ?? undefined);
278
+ const state: StyleGuideEditorState = { submitted: raw, notice };
279
+ if (c.req.header("HX-Request") === undefined) {
280
+ if (notice.kind === "saved") {
281
+ return c.redirect(projectSlug === null ? "/style-guide" : `/style-guide?project=${encodeURIComponent(projectSlug)}`, 303);
282
+ }
283
+ return c.html(html`<!doctype html>${styleGuidePage(vm, state, auth.enabled)}`);
284
+ }
285
+ return c.html(styleGuideRegionFragment(vm, state));
286
+ });
287
+ }
288
+
289
+ /** The three filters straight off an action's POST body, kept alive across a re-render. */
290
+ async function listParamsFrom(form: FormData): Promise<ListParams> {
291
+ const get = (key: string) => {
292
+ const value = form.get(key);
293
+ return typeof value === "string" && value !== "" ? value : undefined;
294
+ };
295
+ return { q: get("q"), status: get("status"), project: get("project") };
296
+ }
297
+
298
+ /** An action round's settled notice: the full page for a plain navigation, the list region for htmx. */
299
+ async function listReply(
300
+ c: Context,
301
+ store: Store,
302
+ form: FormData,
303
+ notice: ListNotice | undefined,
304
+ ok: boolean,
305
+ signedIn: boolean,
306
+ ) {
307
+ const params = await listParamsFrom(form);
308
+ if (c.req.header("HX-Request") === undefined) {
309
+ if (ok) return c.redirect("/", 303);
310
+ return c.html(html`<!doctype html>${dashboardPage(store, params, notice, signedIn)}`);
311
+ }
312
+ return c.html(entriesFragment(store, params, notice));
313
+ }
314
+
315
+ /** A transition's refusal as the list region's notice: unknown ids and vanished rows get their own. */
316
+ function transitionNotice(
317
+ store: Store,
318
+ action: "approve" | "reject",
319
+ id: string,
320
+ outcome: { readonly ok: false; readonly refusal: TransitionRefusal },
321
+ ): ListNotice {
322
+ const title = action === "approve" ? "Entry not approved" : "Entry not rejected";
323
+ if (outcome.refusal.kind === "unknown") {
324
+ return { kind: "refusal", title: "This entry no longer exists", message: "It's no longer in the store. The list below shows the current entries." };
325
+ }
326
+ const entry = getEntry(store, id);
327
+ if (entry === undefined) {
328
+ return { kind: "refusal", title, message: "The entry is no longer in the store." };
329
+ }
330
+ return { kind: "refusal", title, message: formatTransitionRefusal(action, outcome.refusal, entry.file) };
331
+ }
332
+
333
+ /** The batch report: how many moved, each refusal named; an empty selection is its own refusal. */
334
+ function batchNotice(store: Store, results: readonly BatchApproveResult[]): ListNotice {
335
+ if (results.length === 0) {
336
+ return { kind: "refusal", title: "Nothing to approve", message: "Select at least one entry, then approve again." };
337
+ }
338
+ const approved = results.filter((r) => r.ok).length;
339
+ const refused = results.filter((r) => !r.ok).map((r) => {
340
+ if (r.refusal.kind === "unknown") return "The entry is no longer in the store.";
341
+ const entry = getEntry(store, r.id);
342
+ return formatTransitionRefusal("approve", r.refusal, entry?.file ?? r.id);
343
+ });
344
+ return { kind: "batch", approved, total: results.length, refused };
345
+ }
346
+
347
+ /** A transition's answer to the editor: the region swapped in place, with any refusal on top. */
348
+ function transitionEditorReply(
349
+ c: Context,
350
+ store: Store,
351
+ id: string,
352
+ outcome: ApproveOutcome | RejectOutcome,
353
+ action: "approve" | "reject",
354
+ signedIn: boolean,
355
+ ) {
356
+ if (!outcome.ok && outcome.refusal.kind === "unknown") return c.html(entryGoneFragment());
357
+ const vm = loadEditor(store, id);
358
+ if (vm === null) return c.html(entryGoneFragment());
359
+ const state: EditorState = {
360
+ submittedText: vm.entry.human_text ?? vm.entry.agent_draft ?? "",
361
+ // The unknown case returned above, so this re-check is what narrows the refusal for the
362
+ // formatter: `outcome.ok` alone would leave `unknown` reachable in TS's view.
363
+ refusal:
364
+ !outcome.ok && outcome.refusal.kind !== "unknown"
365
+ ? { title: action === "approve" ? "Entry not approved" : "Entry not rejected", body: formatTransitionRefusal(action, outcome.refusal, vm.entry.file) }
366
+ : undefined,
367
+ };
368
+ if (c.req.header("HX-Request") === undefined) {
369
+ if (outcome.ok) return c.redirect(`/entries/${id}`, 303);
370
+ return c.html(html`<!doctype html>${editorPage(vm, state, signedIn)}`);
371
+ }
372
+ return c.html(editorFragment(vm, state));
373
+ }