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
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ <div align="center">
2
+
3
+ # reviewzy
4
+
5
+ **human-in-the-loop mcp review for user-facing text: agents file, a human approves, later sessions apply**
6
+
7
+ bulk-audit and rewrite user-facing copy across CLIs, TUIs, web UIs, and websites; prose authorship stays human
8
+
9
+ [![license](https://shields.uwuclxdy.dev/badge/license-AGPL--3.0-blue)](#license)
10
+ [![mcp](https://shields.uwuclxdy.dev/badge/mcp-2026--07--28-orange)](https://modelcontextprotocol.io)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ reviewzy is a self-hosted dashboard and mcp server for human-in-the-loop copy review. a coding agent files the exact strings it wants to write or rewrite. a human authors or approves each one on the dashboard. a later agent session fetches the approved words and applies them in place. no i18n keys, no content layer: each entry points at the string where it already lives.
17
+
18
+ file an entry:
19
+
20
+ ```json
21
+ {
22
+ "project": "my-app",
23
+ "entries": [
24
+ {
25
+ "repo": "https://github.com/me/my-app",
26
+ "file": "src/cli/main.ts",
27
+ "anchor_text": "error: something went wrong",
28
+ "agent_draft": "error: could not reach the reviewzy daemon",
29
+ "file_content": "#!/usr/bin/env bun\nconsole.error(\"error: something went wrong\")\n",
30
+ "constraints": { "max_len": 48, "tone": "plain, lowercase" }
31
+ }
32
+ ],
33
+ "filed_by": "help-text-audit"
34
+ }
35
+ ```
36
+
37
+ the call answers with the entry as `draft` and a dashboard link for the batch:
38
+
39
+ ```json
40
+ {
41
+ "batch_id": "01M060XY9GNK6ANVENN4Y4MK7N",
42
+ "results": [
43
+ { "id": "01M060XY9GNK6ANVENN4Y4MK7P", "status": "draft", "deduped": false, "updated": false }
44
+ ],
45
+ "dashboard_url": "http://127.0.0.1:3123/?project=my-app"
46
+ }
47
+ ```
48
+
49
+ ## Why
50
+
51
+ - **file + snippet anchors, no i18n keys:** apply-back is a targeted replace in place
52
+ - **humans author, agents apply:** the status machine is server-enforced; only the dashboard reaches `approved` or `rejected`
53
+ - **per-project style guide as an mcp resource:** merged with the global guide, embedded in `fetch_approved`
54
+ - **append-only revision history:** every save is undoable; history survives archiving
55
+ - **ntfy and webhook notifications, archive retention:** one ping per filing batch; applied entries archive after `ARCHIVE_AFTER_DAYS`
56
+
57
+ ## How it works
58
+
59
+ | stage | who | what happens |
60
+ |---|---|---|
61
+ | file | agent | `file_entries` through the stdio shim, which finds or spawns one shared daemon |
62
+ | notify | daemon | pings ntfy and/or the webhook with a dashboard link |
63
+ | author | human | edits, approves, or rejects on the dashboard; saving prose releases it as `approved` |
64
+ | fetch | agent | `fetch_approved` (or a blocking `await_approved`) returns text, anchor, constraints, and the merged style guide |
65
+ | apply | agent | replaces the anchor, reports through `mark_applied` |
66
+ | archive | daemon | applied entries past `ARCHIVE_AFTER_DAYS` move to the archive with their revisions |
67
+
68
+ ## Install
69
+
70
+ ```sh
71
+ bunx reviewzy@latest
72
+ ```
73
+
74
+ requires bun >= 1.3 (the store is `bun:sqlite`, bun-only; no `npx` form). the shim spawns one shared daemon per machine on first use.
75
+
76
+ from a checkout:
77
+
78
+ ```sh
79
+ bun install
80
+ bun run daemon
81
+ ```
82
+
83
+ ### as a Claude Code plugin
84
+
85
+ the repo is a plugin: it registers the MCP server and adds a `/reviewzy` slash command plus the reviewzy skill.
86
+
87
+ ```
88
+ /plugin marketplace add uwuclxdy/reviewzy
89
+ /plugin install reviewzy@reviewzy
90
+ ```
91
+
92
+ same bun requirement as the npm form.
93
+
94
+ ## Usage
95
+
96
+ ```sh
97
+ curl http://127.0.0.1:3123/health
98
+ ```
99
+
100
+ ```json
101
+ {"name":"reviewzy","version":"0.1.0","pid":4172598,"nonce":"881f0e93-8dde-4c75-b559-d2e3d3b223f0","startedAt":"2026-08-16T19:31:28.640Z"}
102
+ ```
103
+
104
+ open http://127.0.0.1:3123/ for the dashboard.
105
+
106
+ ## Configuration
107
+
108
+ copy `.env.example` to `.env` and edit. every key is optional; a bad value refuses startup.
109
+
110
+ | key | default | what it does |
111
+ |---|---|---|
112
+ | `REVIEWZY_PORT` | `3123` | dashboard and mcp port; the bind is `127.0.0.1` always |
113
+ | `REVIEWZY_DB` | `~/.local/share/reviewzy/reviewzy.db` | sqlite file (`$XDG_DATA_HOME` respected) |
114
+ | `REVIEWZY_BASE_URL` | `http://127.0.0.1:$REVIEWZY_PORT` | dashboard url used in notification links |
115
+ | `REVIEWZY_TOKEN` | unset | bearer token guarding mcp; set it and it is required |
116
+ | `DASHBOARD_PASSWORD` | unset | dashboard login; set it and it is required |
117
+ | `ARCHIVE_AFTER_DAYS` | `90` | days an applied entry waits before archiving |
118
+ | `NTFY_URL` + `NTFY_TOPIC` | unset | ntfy server and topic; ntfy fires only when both are set |
119
+ | `NTFY_PRIORITY` | `3` | ntfy priority, `1` to `5` |
120
+ | `WEBHOOK_URL` | unset | generic webhook; posts project, batch id, count, and a dashboard link |
121
+
122
+ a credential left unset keeps that surface loopback-only with a startup warning.
123
+
124
+ ## Integrations
125
+
126
+ point an mcp client at reviewzy to start filing:
127
+
128
+ ```json
129
+ {
130
+ "mcpServers": {
131
+ "reviewzy": {
132
+ "command": "bunx",
133
+ "args": ["reviewzy@latest"]
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ the endpoint speaks the stateless `2026-07-28` mcp revision.
140
+
141
+ ## Comparison
142
+
143
+ | | reviewzy | Contentrain | gotoHuman | Ditto |
144
+ |---|---|---|---|---|
145
+ | authorship | human authors, agent files and applies | agent authors, human approves | human edits form fields | team edits a live library |
146
+ | code integration | anchors on live strings, no externalization | restructures the repo into a content layer | generic forms, no write-back | no code patch-back |
147
+ | hosting | self-hosted | self-hosted | saas only | saas only |
148
+ | queue | async queue plus blocking long-poll | queue, git/PR-first review | async queue | none |
149
+
150
+ the blocking human-in-the-loop mcp family pops a dialog per call: no queue, no persistence, no dashboard.
151
+
152
+ ## FAQ
153
+
154
+ **How do I approve AI-generated text before it lands in my code?** the agent files strings as drafts, you author or approve them on the dashboard, the agent applies.
155
+
156
+ **Can an AI agent rewrite my CLI help text without i18n keys?** yes. entries anchor on the exact current strings; apply-back is a targeted replace.
157
+
158
+ **How do I bulk-rewrite all user-facing copy in a codebase?** file batches per project, group by project or batch on the dashboard, approve in bulk with a before/after diff, then let a later agent session fetch and apply.
159
+
160
+ **How do I update the style guide my agents read?** edit it on the dashboard at `/style-guide`. a saved edit reaches agents in up to 60 seconds.
161
+
162
+ ## Development
163
+
164
+ ```sh
165
+ bun run check # tsc --noEmit && bun test
166
+ ```
167
+
168
+ ## License
169
+
170
+ [AGPL-3.0](LICENSE). self-hosted stays self-hosted; a saas wrapping it publishes its changes.
package/bin/reviewzy ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bun
2
+ import { runShim } from "../src/shim/main.ts";
3
+
4
+ runShim().catch((error: unknown) => {
5
+ console.error(error instanceof Error ? error.message : String(error));
6
+ process.exit(1);
7
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "reviewzy",
3
+ "version": "0.1.0",
4
+ "description": "agents draft user-facing copy, humans sign off on a dashboard, agents apply the approved words back in place",
5
+ "license": "AGPL-3.0-only",
6
+ "type": "module",
7
+ "private": false,
8
+ "bin": {
9
+ "reviewzy": "bin/reviewzy"
10
+ },
11
+ "files": ["bin", "src", "tsconfig.json", ".env.example"],
12
+ "engines": {
13
+ "bun": ">=1.3.0"
14
+ },
15
+ "scripts": {
16
+ "check": "tsc --noEmit && bun test",
17
+ "daemon": "bun run src/daemon/main.ts"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/server": "^2.0.0",
21
+ "hono": "^4.13.2",
22
+ "ulid": "^3.0.2",
23
+ "zod": "^4.4.3"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "^1.3.0",
27
+ "typescript": "^5.9.0"
28
+ }
29
+ }
package/src/config.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+
5
+ const defaultDbPath = () =>
6
+ join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "reviewzy", "reviewzy.db");
7
+
8
+ /** Every value the daemon reads from the environment. `.env.example` mirrors this list. */
9
+ const ConfigSchema = z.object({
10
+ REVIEWZY_PORT: z.coerce.number().int().min(1).max(65535).default(3123),
11
+ REVIEWZY_DB: z.string().min(1).default(defaultDbPath),
12
+ REVIEWZY_BASE_URL: z.url().optional(),
13
+
14
+ REVIEWZY_TOKEN: z.string().min(1).optional(),
15
+ DASHBOARD_PASSWORD: z.string().min(1).optional(),
16
+
17
+ ARCHIVE_AFTER_DAYS: z.coerce.number().int().min(1).default(90),
18
+
19
+ NTFY_URL: z.url().optional(),
20
+ NTFY_TOPIC: z.string().min(1).optional(),
21
+ NTFY_PRIORITY: z.coerce.number().int().min(1).max(5).default(3),
22
+ WEBHOOK_URL: z.url().optional(),
23
+ });
24
+
25
+ export type Config = z.infer<typeof ConfigSchema> & { host: string; baseUrl: string };
26
+
27
+ /** The daemon binds loopback unconditionally, so a missing credential can never expose a network surface. */
28
+ export const HOST = "127.0.0.1";
29
+
30
+ /** Refuses to start on a bad value rather than silently falling back to a default. */
31
+ export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
32
+ // An empty string in the environment is an unset variable, not a value that fails `min(1)`.
33
+ const present = Object.fromEntries(
34
+ Object.entries(env).filter(([, v]) => v !== undefined && v !== ""),
35
+ );
36
+
37
+ const parsed = ConfigSchema.safeParse(present);
38
+ if (!parsed.success) {
39
+ const detail = parsed.error.issues
40
+ .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
41
+ .join("\n");
42
+ throw new ConfigError(`reviewzy: bad environment configuration\n${detail}\nfix the named variable in .env, or unset it to take the default.`);
43
+ }
44
+
45
+ return {
46
+ ...parsed.data,
47
+ host: HOST,
48
+ baseUrl: parsed.data.REVIEWZY_BASE_URL ?? `http://${HOST}:${parsed.data.REVIEWZY_PORT}`,
49
+ };
50
+ }
51
+
52
+ /** Bad input from the operator, distinct from a bug in the daemon. */
53
+ export class ConfigError extends Error {
54
+ override readonly name = "ConfigError";
55
+ }
56
+
57
+ /** Written to stderr at boot. Empty when both credentials are set. */
58
+ export function startupWarnings(config: Config): string[] {
59
+ const warnings: string[] = [];
60
+ if (!config.REVIEWZY_TOKEN) {
61
+ warnings.push(
62
+ "REVIEWZY_TOKEN is unset: the mcp endpoint accepts any request from this machine. set it in .env to require a bearer token.",
63
+ );
64
+ }
65
+ if (!config.DASHBOARD_PASSWORD) {
66
+ warnings.push(
67
+ "DASHBOARD_PASSWORD is unset: the dashboard is open to any process on this machine. set it in .env to require a login.",
68
+ );
69
+ }
70
+ if (config.NTFY_URL !== undefined && config.NTFY_TOPIC === undefined) {
71
+ warnings.push(
72
+ "NTFY_TOPIC is unset: ntfy notifications stay off even though NTFY_URL is set. set NTFY_TOPIC in .env to arm them.",
73
+ );
74
+ }
75
+ return warnings;
76
+ }
@@ -0,0 +1,159 @@
1
+ import {
2
+ bearerAuthChallengeResponse,
3
+ OAuthError,
4
+ OAuthErrorCode,
5
+ } from "@modelcontextprotocol/server";
6
+ import { Hono } from "hono";
7
+ import { timingSafeEqual } from "hono/utils/buffer";
8
+ import type { Config } from "../config.ts";
9
+ import type { Store } from "../db/store.ts";
10
+ import { mountDashboard } from "../dashboard/mount.ts";
11
+ import { mountMcp } from "../mcp/route.ts";
12
+ import { Notifier } from "../notify.ts";
13
+ import { originGate } from "./origin.ts";
14
+ import { NAME, VERSION } from "../version.ts";
15
+
16
+ export type HealthBody = {
17
+ name: string;
18
+ version: string;
19
+ pid: number;
20
+ /** One random value per boot; the shim records it in the lockfile and adoption requires it to match, so a port spoofed by a process that cannot read the lockfile can never be adopted. */
21
+ nonce: string;
22
+ startedAt: string;
23
+ };
24
+
25
+ /** The drain response must reach the caller before the server stops serving; the shim that asked waits out this beat and then polls pid liveness, so the exact value is not load-bearing. */
26
+ const DRAIN_FLUSH_MS = 25;
27
+
28
+ /**
29
+ * A refusal made before the SDK sees the request, in the same shape `src/mcp/route.ts` uses: the
30
+ * daemon family answers with one JSON-RPC error style, so a client special-casing a refusal sees
31
+ * the shape it already handles.
32
+ */
33
+ function refuse(status: number, message: string, headers: Record<string, string> = {}): Response {
34
+ return new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }), {
35
+ status,
36
+ headers: { "content-type": "application/json", ...headers },
37
+ });
38
+ }
39
+
40
+ /**
41
+ * The same credential rule `/mcp` applies (`docs/mcp-contract.md` auth row), duplicated here rather
42
+ * than exported from `src/mcp/route.ts` because that file owns the mcp surface alone. A malformed
43
+ * `Authorization` header is one 401, never a 400, so a missing token reads differently from a
44
+ * broken one.
45
+ */
46
+ async function tokenAccepted(header: string | undefined, token: string): Promise<boolean> {
47
+ const presented = /^bearer +(.+)$/i.exec(header ?? "")?.[1];
48
+ if (presented === undefined) return false;
49
+ return timingSafeEqual(token, presented);
50
+ }
51
+
52
+ /**
53
+ * Serves the daemon's routes. `onDrain` is the shutdown the drain route hands off to (stop
54
+ * in-flight, close the store, exit); tests omit it to observe the route without ending their own
55
+ * process, which is also why the route never exits on its own.
56
+ */
57
+ export function createApp(
58
+ config: Config,
59
+ store: Store,
60
+ startedAt: Date = new Date(),
61
+ onDrain?: () => void | Promise<void>,
62
+ notifier: Notifier = new Notifier({ config, baseUrl: config.baseUrl }),
63
+ ): Hono {
64
+ const app = new Hono();
65
+ let draining = false;
66
+ const nonce = crypto.randomUUID();
67
+
68
+ // App-wide because `docs/design.md` makes Origin validation unconditional rather than per-route:
69
+ // `/health` hands out a pid and a version, and the dashboard serves a browser. An absent `Origin`
70
+ // passes, so the shim's liveness probe is unaffected; only a browser sends one at all.
71
+ app.use(originGate(config));
72
+
73
+ // Mounted ahead of the mcp endpoint so a refusal here never reaches the SDK: once draining has
74
+ // begun, the daemon is going away, and the honest answer to a new request is "try again once a
75
+ // new daemon answers /health".
76
+ app.use(async (c, next) => {
77
+ if (draining && c.req.method === "POST" && c.req.path === "/mcp") {
78
+ // A notification has no id, and JSON-RPC 2.0 forbids answering one: it gets a bare empty 503,
79
+ // which the shim's empty-body rung turns into silence (a transport error is dropped for
80
+ // notifications). The clone keeps the body intact for the SDK rungs below (not reached here).
81
+ let bodyText = "";
82
+ try {
83
+ bodyText = await c.req.raw.clone().text();
84
+ } catch {
85
+ // Unreadable: treated as a request whose id could not be determined.
86
+ }
87
+ let parsed: unknown = null;
88
+ try {
89
+ parsed = JSON.parse(bodyText);
90
+ } catch {
91
+ // Not JSON: `null` is the right answer for a request whose id could not be determined.
92
+ }
93
+ if (parsed !== null && typeof parsed === "object" && !("id" in parsed)) {
94
+ return new Response(null, { status: 503, headers: { "retry-after": "1" } });
95
+ }
96
+
97
+ // Echo the request's own id rather than `null`: the frame this answers is well-formed and its
98
+ // id is in the body, so the client must be able to correlate the refusal.
99
+ const id = parsed !== null && typeof parsed === "object" ? (parsed as { id?: unknown }).id : null;
100
+ return new Response(
101
+ JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "daemon is draining; retry once a new daemon answers /health" }, id }),
102
+ {
103
+ status: 503,
104
+ headers: { "content-type": "application/json", "retry-after": "1" },
105
+ },
106
+ );
107
+ }
108
+ return next();
109
+ });
110
+
111
+ app.get("/health", (c) =>
112
+ c.json<HealthBody>({
113
+ name: NAME,
114
+ version: VERSION,
115
+ pid: process.pid,
116
+ nonce,
117
+ startedAt: startedAt.toISOString(),
118
+ }),
119
+ );
120
+
121
+ // POST /drain: loopback-only by construction (the daemon binds 127.0.0.1 unconditionally), and
122
+ // behind the same optional bearer token as `/mcp`. Idempotent: a second drain while draining gets
123
+ // the same answer rather than a second shutdown. The hook runs after a short beat so this
124
+ // response flushes before the server stops accepting.
125
+ app.all("/drain", async (c) => {
126
+ if (c.req.method !== "POST") {
127
+ return refuse(405, "Method not allowed.", { allow: "POST" });
128
+ }
129
+
130
+ const token = config.REVIEWZY_TOKEN;
131
+ if (token !== undefined && !(await tokenAccepted(c.req.header("Authorization"), token))) {
132
+ return bearerAuthChallengeResponse(
133
+ new OAuthError(OAuthErrorCode.InvalidToken, "REVIEWZY_TOKEN is set; a bearer token is required"),
134
+ );
135
+ }
136
+
137
+ if (!draining) {
138
+ draining = true;
139
+ // Long-poll responses must flush while the server still serves: an in-flight `await_approved`
140
+ // would otherwise hold this response open past `stop(false)`'s patience and get killed with
141
+ // nothing to show the caller, which then waits out its whole timeout against a dead daemon.
142
+ store.resolveInFlightWaiters();
143
+ if (onDrain !== undefined) {
144
+ setTimeout(() => {
145
+ void Promise.resolve(onDrain()).catch((error: unknown) => {
146
+ console.error(`reviewzy: drain failed: ${(error as Error).message}`);
147
+ process.exit(1);
148
+ });
149
+ }, DRAIN_FLUSH_MS);
150
+ }
151
+ }
152
+ return c.json({ status: "draining" });
153
+ });
154
+
155
+ mountMcp(app, config, store, notifier);
156
+ mountDashboard(app, config, store);
157
+
158
+ return app;
159
+ }
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bun
2
+ import { ConfigError, HOST, loadConfig, startupWarnings } from "../config.ts";
3
+ import { SWEEP_INTERVAL_MS, sweepArchive } from "../db/sweep.ts";
4
+ import { openStore } from "../db/store.ts";
5
+ import { Notifier } from "../notify.ts";
6
+ import { VERSION } from "../version.ts";
7
+ import { createApp } from "./app.ts";
8
+
9
+ /**
10
+ * Boots the daemon: opens the store first so a bad db fails before a port is bound, then serves.
11
+ * Returns the server, store, notifier, and sweep timer so a caller (tests, the shim) can shut
12
+ * them down. `hooks.onDrain` is what the `/drain` route hands off to; omitting it leaves the
13
+ * route refusing new requests without ending the process, which is exactly what an in-process
14
+ * test wants.
15
+ */
16
+ export function startDaemon(
17
+ config = loadConfig(),
18
+ hooks: { onDrain?: () => void | Promise<void> } = {},
19
+ sweepIntervalMs = SWEEP_INTERVAL_MS,
20
+ ) {
21
+ for (const warning of startupWarnings(config)) {
22
+ console.error(`reviewzy: warning: ${warning}`);
23
+ }
24
+
25
+ const store = openStore(config);
26
+ // Built here rather than inside `createApp` so the stop path below can dispose it: the debounce
27
+ // timers are unref'd and could never hold the daemon open, but a pending ping cancelled at
28
+ // shutdown is cleaner than one racing the store's close.
29
+ const notifier = new Notifier({ config, baseUrl: config.baseUrl });
30
+
31
+ // Unref'd like the notifier's timers, so a pending tick can never hold the daemon open. The
32
+ // stop path clears it before the store closes, so no tick fires on a closed database.
33
+ const sweepTimer = setInterval(
34
+ () => sweepArchive(store, Date.now(), config.ARCHIVE_AFTER_DAYS),
35
+ sweepIntervalMs,
36
+ );
37
+ sweepTimer.unref();
38
+
39
+ let server: ReturnType<typeof Bun.serve>;
40
+ try {
41
+ // One sweep before the port binds, so the dashboard never serves a queue the retention
42
+ // window has already retired. A failure lands in the same catch as a bind failure and
43
+ // closes the store either way, so neither leak.
44
+ sweepArchive(store, Date.now(), config.ARCHIVE_AFTER_DAYS);
45
+ server = Bun.serve({
46
+ hostname: HOST,
47
+ port: config.REVIEWZY_PORT,
48
+ fetch: createApp(config, store, new Date(), hooks.onDrain, notifier).fetch,
49
+ });
50
+ } catch (error) {
51
+ // A bind failure (e.g. a shim racing an already-running daemon on the same port) must not
52
+ // leak the store's open sqlite connection, and the armed sweep timer must not outlive it.
53
+ clearInterval(sweepTimer);
54
+ store.close();
55
+ throw error;
56
+ }
57
+
58
+ // The bound port, not `config.baseUrl`: a port-0 boot resolves to something the config never held.
59
+ console.error(`reviewzy ${VERSION} listening on http://${HOST}:${server.port} (pid ${process.pid})`);
60
+ return { server, store, notifier, sweepTimer };
61
+ }
62
+
63
+ if (import.meta.main) {
64
+ let daemon: ReturnType<typeof startDaemon>;
65
+ try {
66
+ // The drain hook closes over `stop`, which is declared below it: a function declaration is
67
+ // hoisted, and the hook only ever runs after `startDaemon` has returned, so the shutdown it
68
+ // names is always the daemon that just booted.
69
+ daemon = startDaemon(loadConfig(), { onDrain: () => stop() });
70
+ } catch (error) {
71
+ if (error instanceof ConfigError) {
72
+ console.error(error.message);
73
+ process.exit(1);
74
+ }
75
+ throw error;
76
+ }
77
+
78
+ // Graceful only (`false`): waits out in-flight requests before closing the store and exiting.
79
+ // SIGINT, SIGTERM, and the `/drain` route all land here, so a handoff drain and an operator's
80
+ // Ctrl-C shut down through one path.
81
+ let stopping = false;
82
+ function stop(): void {
83
+ if (stopping) return;
84
+ stopping = true;
85
+ void (async () => {
86
+ // An in-flight await_approved long-poll must flush before the graceful stop waits it out:
87
+ // the /drain route resolves waiters for the same reason, and an operator's Ctrl-C would
88
+ // otherwise hold the daemon up for the whole remaining wait.
89
+ daemon.store.resolveInFlightWaiters();
90
+ await daemon.server.stop(false);
91
+ // Cleared before the store closes: an armed tick firing on a closed database would crash.
92
+ clearInterval(daemon.sweepTimer);
93
+ // After the server stops, nothing can arm a new debounce, so this cancels exactly the set
94
+ // in-flight filings left behind; an in-flight ping already past its window still races the
95
+ // close, which is the accepted "dropped at exit" case.
96
+ daemon.notifier.dispose();
97
+ daemon.store.close();
98
+ process.exit(0);
99
+ })();
100
+ }
101
+ process.on("SIGINT", stop);
102
+ process.on("SIGTERM", stop);
103
+ }
@@ -0,0 +1,60 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { Config } from "../config.ts";
3
+
4
+ /**
5
+ * A refusal made before the SDK sees the request, in the same shape `src/mcp/route.ts` uses: the
6
+ * daemon family answers with one JSON-RPC error style, so a client special-casing a refusal sees
7
+ * the shape it already handles.
8
+ */
9
+ function refuse(status: 403 | 405, message: string, headers: Record<string, string> = {}) {
10
+ return new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }), {
11
+ status,
12
+ headers: { "content-type": "application/json", ...headers },
13
+ });
14
+ }
15
+
16
+ /**
17
+ * Answers "was this request issued by a page the daemon serves", the DNS-rebinding defence the
18
+ * design makes unconditional. An absent `Origin` passes: the shim, curl, and stdio clients send
19
+ * none, and only a browser sends one at all.
20
+ *
21
+ * The loopback set is matched against the port THIS request was addressed to. That equals
22
+ * `config.REVIEWZY_PORT` in every configuration `loadConfig` accepts, since the schema floors the
23
+ * port at 1 and the daemon binds it verbatim; reading it from the request keeps the gate off a
24
+ * config field rather than buying a safety property. A browser cannot forge that port either way,
25
+ * since reaching the daemon means addressing the port it bound.
26
+ */
27
+ function originAllowed(origin: string, requestUrl: URL, configuredOrigin: string): boolean {
28
+ let parsed: URL;
29
+ try {
30
+ parsed = new URL(origin);
31
+ } catch {
32
+ return false; // Unparseable, the literal `null` of an opaque browser context included.
33
+ }
34
+ if (parsed.origin === configuredOrigin) return true;
35
+ // The scheme is compared too, or a default-port `https://127.0.0.1` matches a default-port http
36
+ // daemon: both sides normalise to an empty `port`.
37
+ return (
38
+ parsed.protocol === requestUrl.protocol &&
39
+ (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "[::1]") &&
40
+ parsed.port === requestUrl.port
41
+ );
42
+ }
43
+
44
+ /**
45
+ * The DNS-rebinding gate, unconditional for every route the app serves: `/health` hands out a
46
+ * version and a pid, which is exactly the disclosure the check exists for, and the dashboard sits
47
+ * behind it too. The mcp rungs only re-check what is theirs to check.
48
+ */
49
+ export function originGate(config: Config): MiddlewareHandler {
50
+ const configuredOrigin = new URL(config.baseUrl).origin;
51
+ return async (c, next) => {
52
+ const origin = c.req.header("Origin");
53
+ if (origin !== undefined && !originAllowed(origin, new URL(c.req.url), configuredOrigin)) {
54
+ // Echoed so a developer wiring a browser client sees what was refused, truncated because the
55
+ // value is attacker-controlled and unbounded by nature.
56
+ return refuse(403, `Invalid Origin: ${origin.slice(0, 100)}`);
57
+ }
58
+ return next();
59
+ };
60
+ }