@felixdotgo/aidlc-kit 0.1.1 → 0.3.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 (198) hide show
  1. package/DEVELOPMENT.md +14 -13
  2. package/README.md +37 -43
  3. package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
  4. package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
  5. package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
  6. package/dist/assets/.agents/aidlc/orchestrator.md +2 -2
  7. package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
  8. package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
  9. package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
  10. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  12. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +261 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +206 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +100 -14
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +105 -32
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +17 -1
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +24 -7
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +42 -16
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +20 -6
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +33 -7
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +116 -28
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +114 -12
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +117 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +149 -6
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +44 -6
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +68 -2
  38. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +168 -73
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  42. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  43. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  44. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  45. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  46. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  47. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  48. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  49. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  50. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  51. package/dist/assets/ui/bulma.min.css +3 -0
  52. package/dist/assets/ui/changes.js +83 -0
  53. package/dist/assets/ui/common.js +664 -0
  54. package/dist/assets/ui/dashboard.css +636 -0
  55. package/dist/assets/ui/dashboard.js +77 -0
  56. package/dist/assets/ui/detail.js +159 -0
  57. package/dist/assets/ui/live-log.js +332 -0
  58. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  59. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  60. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  61. package/dist/services/aidlc-coordination/package.json +9 -4
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +68 -2
  63. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  64. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  66. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  67. package/dist/src/adapters.d.ts +0 -3
  68. package/dist/src/adapters.js +3 -5
  69. package/dist/src/cli/argv.d.ts +12 -0
  70. package/dist/src/cli/argv.js +68 -0
  71. package/dist/src/cli/commands.d.ts +8 -0
  72. package/dist/src/cli/commands.js +159 -0
  73. package/dist/src/cli/loop-launcher.d.ts +47 -0
  74. package/dist/src/cli/loop-launcher.js +322 -0
  75. package/dist/src/cli/options.d.ts +25 -0
  76. package/dist/src/cli/options.js +32 -0
  77. package/dist/src/cli/prompt.d.ts +9 -0
  78. package/dist/src/cli/prompt.js +66 -0
  79. package/dist/src/cli.js +28 -191
  80. package/dist/src/gate.js +3 -2
  81. package/dist/src/installer.d.ts +14 -7
  82. package/dist/src/installer.js +42 -143
  83. package/dist/src/legacy.js +1 -0
  84. package/dist/src/managed-content.d.ts +7 -0
  85. package/dist/src/managed-content.js +15 -4
  86. package/dist/src/managed-planning.d.ts +65 -0
  87. package/dist/src/managed-planning.js +219 -0
  88. package/dist/src/mcp-setup.js +3 -1
  89. package/dist/src/model/config-contracts.d.ts +65 -0
  90. package/dist/src/model/config-contracts.js +1 -0
  91. package/dist/src/model/install-contracts.d.ts +29 -0
  92. package/dist/src/model/install-contracts.js +1 -0
  93. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  94. package/dist/src/model/lifecycle-enums.js +1 -0
  95. package/dist/src/model/loop.d.ts +20 -0
  96. package/dist/src/model/loop.js +1 -0
  97. package/dist/src/model/manifest.d.ts +16 -0
  98. package/dist/src/model/manifest.js +1 -0
  99. package/dist/src/model/records.d.ts +142 -0
  100. package/dist/src/model/records.js +1 -0
  101. package/dist/src/model.d.ts +11 -264
  102. package/dist/src/model.js +1 -1
  103. package/dist/src/profiles.d.ts +2 -0
  104. package/dist/src/profiles.js +53 -9
  105. package/dist/src/state/archive-record.d.ts +9 -0
  106. package/dist/src/state/archive-record.js +26 -0
  107. package/dist/src/state/commands.d.ts +18 -0
  108. package/dist/src/state/commands.js +27 -0
  109. package/dist/src/state/domain.d.ts +9 -0
  110. package/dist/src/state/domain.js +9 -0
  111. package/dist/src/state/guards.d.ts +7 -0
  112. package/dist/src/state/guards.js +17 -0
  113. package/dist/src/state/io.d.ts +10 -0
  114. package/dist/src/state/io.js +35 -0
  115. package/dist/src/state/items.d.ts +3 -4
  116. package/dist/src/state/items.js +58 -57
  117. package/dist/src/state/lessons.js +14 -7
  118. package/dist/src/state/lifecycle.js +2 -2
  119. package/dist/src/state/lock.js +7 -6
  120. package/dist/src/state/memory.js +24 -12
  121. package/dist/src/state/{archive.js → migrate.js} +7 -7
  122. package/dist/src/state/partition.d.ts +15 -0
  123. package/dist/src/state/partition.js +90 -0
  124. package/dist/src/state/paths.d.ts +11 -0
  125. package/dist/src/state/paths.js +12 -0
  126. package/dist/src/state/render.js +9 -6
  127. package/dist/src/state/store.js +9 -15
  128. package/dist/src/state/transitions.js +9 -7
  129. package/dist/src/state/validate.js +55 -7
  130. package/dist/src/state.d.ts +16 -11
  131. package/dist/src/state.js +16 -11
  132. package/dist/src/task-ui.d.ts +33 -0
  133. package/dist/src/task-ui.js +33 -0
  134. package/dist/src/ui/assets.d.ts +14 -0
  135. package/dist/src/ui/assets.js +45 -0
  136. package/dist/src/ui/changes.d.ts +83 -0
  137. package/dist/src/ui/changes.js +498 -0
  138. package/dist/src/ui/containers.d.ts +71 -0
  139. package/dist/src/ui/containers.js +214 -0
  140. package/dist/src/ui/daemon.d.ts +75 -0
  141. package/dist/src/ui/daemon.js +232 -0
  142. package/dist/src/ui/data.d.ts +42 -0
  143. package/dist/src/ui/data.js +89 -0
  144. package/dist/src/ui/docs.d.ts +13 -0
  145. package/dist/src/ui/docs.js +39 -0
  146. package/dist/src/ui/files.d.ts +3 -0
  147. package/dist/src/ui/files.js +20 -0
  148. package/dist/src/ui/http.d.ts +14 -0
  149. package/dist/src/ui/http.js +80 -0
  150. package/dist/src/ui/live.d.ts +5 -0
  151. package/dist/src/ui/live.js +43 -0
  152. package/dist/src/ui/markdown.d.ts +2 -0
  153. package/dist/src/ui/markdown.js +127 -0
  154. package/dist/src/ui/options.d.ts +48 -0
  155. package/dist/src/ui/options.js +81 -0
  156. package/dist/src/ui/security.d.ts +34 -0
  157. package/dist/src/ui/security.js +38 -0
  158. package/dist/src/ui/server.d.ts +39 -0
  159. package/dist/src/ui/server.js +347 -0
  160. package/dist/src/ui/sse.d.ts +14 -0
  161. package/dist/src/ui/sse.js +281 -0
  162. package/dist/src/ui/views/board.d.ts +7 -0
  163. package/dist/src/ui/views/board.js +131 -0
  164. package/dist/src/ui/views/changes.d.ts +19 -0
  165. package/dist/src/ui/views/changes.js +154 -0
  166. package/dist/src/ui/views/components.d.ts +56 -0
  167. package/dist/src/ui/views/components.js +113 -0
  168. package/dist/src/ui/views/docs.d.ts +15 -0
  169. package/dist/src/ui/views/docs.js +64 -0
  170. package/dist/src/ui/views/layout.d.ts +44 -0
  171. package/dist/src/ui/views/layout.js +233 -0
  172. package/dist/src/ui/views/task.d.ts +25 -0
  173. package/dist/src/ui/views/task.js +320 -0
  174. package/dist/src/upgrade-board.js +10 -10
  175. package/dist/src/upgrade.d.ts +0 -1
  176. package/dist/src/upgrade.js +24 -85
  177. package/dist/src/workflow.d.ts +4 -0
  178. package/dist/src/workflow.js +36 -4
  179. package/docs/README.md +41 -22
  180. package/docs/architecture.md +101 -37
  181. package/docs/command-reference.md +289 -52
  182. package/docs/configuration.md +70 -30
  183. package/docs/coordination-service.md +91 -30
  184. package/docs/design-decisions.md +87 -0
  185. package/docs/development.md +124 -49
  186. package/docs/loop-local-run.md +228 -0
  187. package/docs/loop-runbook.md +214 -0
  188. package/docs/operating-workflow.md +55 -42
  189. package/docs/testing-and-release.md +72 -49
  190. package/docs/troubleshooting.md +221 -0
  191. package/package.json +4 -1
  192. package/dist/src/ledger.d.ts +0 -22
  193. package/dist/src/ledger.js +0 -119
  194. package/dist/src/state/shared.d.ts +0 -29
  195. package/dist/src/state/shared.js +0 -71
  196. package/dist/src/tracker.d.ts +0 -36
  197. package/dist/src/tracker.js +0 -44
  198. /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Authority-neutral read model: local reads canonical state in-process; fleet asks
3
+ * `loop-entry.mjs` (the only place that speaks to the coordination service).
4
+ */
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { checkGate, gateView } from "../gate.js";
8
+ import { assertTaskArtifactPath, loadState, loadTask } from "../state.js";
9
+ import { runNode } from "./daemon.js";
10
+ import { loopArgs, uiMode } from "./options.js";
11
+ export const ARTIFACT_NAMES = ["intent", "design", "workplan"];
12
+ const FEEDBACK_DETAIL = /^Cancelled gate wait at (G0_confirm|G1_review|G2_codereview): /;
13
+ /** Feedback entries not yet followed by a passing approval of that gate
14
+ * (mirrors `pendingFeedback` in the installed `loop-runtime.mjs`). */
15
+ export const pendingFeedback = (task) => {
16
+ const pending = [];
17
+ task.evidence.forEach((entry, index) => {
18
+ if (entry.kind !== "diagnostic" || typeof entry.detail !== "string")
19
+ return;
20
+ const match = FEEDBACK_DETAIL.exec(entry.detail);
21
+ if (!match)
22
+ return;
23
+ const gate = match[1] ?? "";
24
+ const approvedAfter = task.evidence.slice(index + 1).some((later) => later.kind === "approval" && later.result === "pass" && later.gate === gate);
25
+ if (!approvedAfter)
26
+ pending.push({ gate, text: entry.detail.slice(match[0].length), recordedAt: entry.recordedAt });
27
+ });
28
+ return pending;
29
+ };
30
+ /* Fleet reads spawn `loop-entry.mjs`; live polling (every ~1.5s per open tab)
31
+ * shares one in-flight or recent result per key instead of spawning per request. */
32
+ const fleetReads = new Map();
33
+ const sharedFleetRead = (key, maxAgeMs, read) => {
34
+ if (maxAgeMs <= 0)
35
+ return read();
36
+ const now = Date.now();
37
+ const cached = fleetReads.get(key);
38
+ if (cached && now - cached.at < maxAgeMs)
39
+ return cached.value;
40
+ const value = read();
41
+ fleetReads.set(key, { at: now, value });
42
+ value.catch(() => { if (fleetReads.get(key)?.value === value)
43
+ fleetReads.delete(key); });
44
+ return value;
45
+ };
46
+ /** How stale a fleet read on the live-update path may be. Mutations always read fresh. */
47
+ export const LIVE_FLEET_MAX_AGE_MS = 1000;
48
+ export const readUiState = async (options, maxAgeMs = 0) => {
49
+ if (uiMode(options) === "local")
50
+ return loadState(options.root);
51
+ return sharedFleetRead(`${options.root}\0tasks`, maxAgeMs, async () => {
52
+ const parsed = JSON.parse(await runNode(options.root, loopArgs(options.root, "tasks"), { timeout: 15000 }));
53
+ if (!Array.isArray(parsed.tasks))
54
+ throw new Error("Fleet task list is unavailable");
55
+ return { schemaVersion: 4, tasks: Object.fromEntries(parsed.tasks.map((task) => [task.id, task])), archive: {} };
56
+ });
57
+ };
58
+ export const readUiTask = (options, id, state) => uiMode(options) === "local" ? loadTask(options.root, id, state) : state.tasks[id];
59
+ export const fleetGate = (options, id, maxAgeMs = 0) => sharedFleetRead(`${options.root}\0gate\0${id}`, maxAgeMs, async () => JSON.parse(await runNode(options.root, loopArgs(options.root, "gate", id), { timeout: 15000 })));
60
+ export const taskGatePayload = async (options, id, known, maxAgeMs = 0) => {
61
+ const state = known ?? await readUiState(options, maxAgeMs);
62
+ const task = readUiTask(options, id, state);
63
+ if (!task)
64
+ return { ok: false, error: `Task not found: ${id}` };
65
+ if (task.status !== "blocked_on_user" || task.gate === "none")
66
+ return { ok: false, error: "Task is not waiting at a human gate" };
67
+ if (uiMode(options) === "fleet") {
68
+ const gate = await fleetGate(options, id, maxAgeMs);
69
+ return gate.snapshot ? { ok: true, gate: task.gate, snapshot: gate.snapshot } : { ok: false, error: "No current review snapshot for this gate yet" };
70
+ }
71
+ const diagnostics = checkGate(options.root, state, id, task.gate);
72
+ return { ok: true, gate: task.gate, view: gateView(task, diagnostics) };
73
+ };
74
+ export const artifactPayload = (root, id, name, task = loadTask(root, id)) => {
75
+ if (!ARTIFACT_NAMES.includes(name))
76
+ return { ok: false, error: `Unknown artifact: ${name}` };
77
+ if (!task)
78
+ return { ok: false, error: `Task not found: ${id}` };
79
+ const artifactPath = task.artifacts?.[name];
80
+ if (!artifactPath)
81
+ return { ok: false, error: `Artifact ${name} not recorded for ${id}` };
82
+ assertTaskArtifactPath(id, artifactPath);
83
+ try {
84
+ return { ok: true, name, content: readFileSync(join(root, artifactPath), "utf8") };
85
+ }
86
+ catch {
87
+ return { ok: false, error: `Artifact ${name} is missing for ${id}` };
88
+ }
89
+ };
@@ -0,0 +1,13 @@
1
+ export interface DocPage {
2
+ slug: string;
3
+ label: string;
4
+ }
5
+ /** Sidebar order; the first entry is the landing page of `/docs`. */
6
+ export declare const DOC_PAGES: readonly DocPage[];
7
+ export declare const DEFAULT_DOC: string;
8
+ export declare const findDocPage: (slug: string) => DocPage | undefined;
9
+ /** The markdown of a whitelisted page, or undefined (unknown slug or missing file). */
10
+ export declare const readDoc: (slug: string) => {
11
+ page: DocPage;
12
+ markdown: string;
13
+ } | undefined;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * In-app documentation: the package's own `docs/*.md` (shipped via package.json `files`),
3
+ * served read-only by slug from a fixed whitelist — never by a request-supplied path.
4
+ */
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ /** Sidebar order; the first entry is the landing page of `/docs`. */
9
+ export const DOC_PAGES = [
10
+ { slug: "loop-runbook", label: "Getting started" },
11
+ { slug: "loop-local-run", label: "Local loop walkthrough" },
12
+ { slug: "configuration", label: "Configuration" },
13
+ { slug: "command-reference", label: "Command reference" },
14
+ { slug: "troubleshooting", label: "Troubleshooting" },
15
+ { slug: "operating-workflow", label: "Operating the workflow" },
16
+ { slug: "architecture", label: "Architecture" },
17
+ ];
18
+ export const DEFAULT_DOC = DOC_PAGES[0]?.slug ?? "loop-runbook";
19
+ /** `docs/` next to the package's `dist/` (dist/src/ui/docs.js → ../../../docs). */
20
+ const docsDir = () => fileURLToPath(new URL("../../../docs/", import.meta.url));
21
+ const cache = new Map();
22
+ export const findDocPage = (slug) => DOC_PAGES.find((page) => page.slug === slug);
23
+ /** The markdown of a whitelisted page, or undefined (unknown slug or missing file). */
24
+ export const readDoc = (slug) => {
25
+ const page = findDocPage(slug);
26
+ if (!page)
27
+ return undefined;
28
+ let markdown = cache.get(slug);
29
+ if (markdown === undefined) {
30
+ try {
31
+ markdown = readFileSync(join(docsDir(), `${page.slug}.md`), "utf8");
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ cache.set(slug, markdown);
37
+ }
38
+ return { page, markdown };
39
+ };
@@ -0,0 +1,3 @@
1
+ /** Reads bytes `[start, end)` of a file (shorter if the file shrank meanwhile). With `noFollow`
2
+ * the open refuses a symlink where the platform supports `O_NOFOLLOW`. */
3
+ export declare const readRange: (path: string, start: number, end: number, noFollow?: boolean) => Buffer;
@@ -0,0 +1,20 @@
1
+ import { closeSync, constants, openSync, readSync } from "node:fs";
2
+ /** Reads bytes `[start, end)` of a file (shorter if the file shrank meanwhile). With `noFollow`
3
+ * the open refuses a symlink where the platform supports `O_NOFOLLOW`. */
4
+ export const readRange = (path, start, end, noFollow = false) => {
5
+ const fd = openSync(path, noFollow ? constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) : "r");
6
+ try {
7
+ const buffer = Buffer.alloc(Math.max(0, end - start));
8
+ let read = 0;
9
+ while (read < buffer.length) {
10
+ const count = readSync(fd, buffer, read, buffer.length - read, start + read);
11
+ if (!count)
12
+ break;
13
+ read += count;
14
+ }
15
+ return buffer.subarray(0, read);
16
+ }
17
+ finally {
18
+ closeSync(fd);
19
+ }
20
+ };
@@ -0,0 +1,14 @@
1
+ /** HTTP response helpers, HTML escaping, and request-body parsing. */
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ export declare const sendJson: (res: ServerResponse, status: number, body: unknown) => void;
4
+ export declare const sendHtml: (res: ServerResponse, body: string, status?: number) => void;
5
+ /** Static assets (CSS, client JS) are cacheable; script URLs carry a content hash to bust upgrades. */
6
+ export declare const sendAsset: (res: ServerResponse, contentType: string, body: string) => void;
7
+ export declare const escapeHtml: (value: string) => string;
8
+ /** A client error mapped to HTTP 400 by the request handler. */
9
+ export declare class BadRequest extends Error {
10
+ }
11
+ /** Reads a request body up to `limit` bytes (default 8 KiB). Routes that carry an 8192-byte
12
+ * text field pass a larger limit so JSON escaping does not count against the field. */
13
+ export declare const readBody: (req: IncomingMessage, limit?: number) => Promise<string>;
14
+ export declare const readFields: (body: string, contentType: string | undefined) => Record<string, string>;
@@ -0,0 +1,80 @@
1
+ export const sendJson = (res, status, body) => {
2
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
3
+ res.end(JSON.stringify(body));
4
+ };
5
+ export const sendHtml = (res, body, status = 200) => {
6
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
7
+ res.end(body);
8
+ };
9
+ /** Static assets (CSS, client JS) are cacheable; script URLs carry a content hash to bust upgrades. */
10
+ export const sendAsset = (res, contentType, body) => {
11
+ res.writeHead(200, { "content-type": contentType, "cache-control": "public, max-age=3600" });
12
+ res.end(body);
13
+ };
14
+ export const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
15
+ switch (char) {
16
+ case "&": return "&amp;";
17
+ case "<": return "&lt;";
18
+ case ">": return "&gt;";
19
+ case '"': return "&quot;";
20
+ default: return "&#39;";
21
+ }
22
+ });
23
+ /** A client error mapped to HTTP 400 by the request handler. */
24
+ export class BadRequest extends Error {
25
+ }
26
+ /** Reads a request body up to `limit` bytes (default 8 KiB). Routes that carry an 8192-byte
27
+ * text field pass a larger limit so JSON escaping does not count against the field. */
28
+ export const readBody = (req, limit = 8192) => new Promise((resolveBody, reject) => {
29
+ const chunks = [];
30
+ let size = 0;
31
+ req.on("data", (chunk) => {
32
+ size += chunk.length;
33
+ if (size > limit) {
34
+ reject(new BadRequest(`Request body exceeds ${limit} bytes`));
35
+ req.destroy();
36
+ return;
37
+ }
38
+ chunks.push(chunk);
39
+ });
40
+ req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
41
+ req.on("error", reject);
42
+ });
43
+ const decode = (value) => { try {
44
+ return decodeURIComponent(value.replace(/\+/g, " "));
45
+ }
46
+ catch {
47
+ return value;
48
+ } };
49
+ const parseForm = (body) => {
50
+ const result = {};
51
+ for (const pair of body.split("&")) {
52
+ if (!pair)
53
+ continue;
54
+ const [rawKey, rawValue = ""] = pair.split("=");
55
+ const key = decode(rawKey ?? "");
56
+ if (key)
57
+ result[key] = decode(rawValue);
58
+ }
59
+ return result;
60
+ };
61
+ const isJson = (contentType) => (contentType ?? "").includes("application/json");
62
+ export const readFields = (body, contentType) => {
63
+ if (isJson(contentType)) {
64
+ let value;
65
+ try {
66
+ value = JSON.parse(body);
67
+ }
68
+ catch {
69
+ throw new BadRequest("Request body is not valid JSON");
70
+ }
71
+ if (value === null || typeof value !== "object" || Array.isArray(value))
72
+ throw new BadRequest("JSON body must be an object");
73
+ const result = {};
74
+ for (const [key, entry] of Object.entries(value))
75
+ if (typeof entry === "string")
76
+ result[key] = entry;
77
+ return result;
78
+ }
79
+ return parseForm(body);
80
+ };
@@ -0,0 +1,5 @@
1
+ import { type UiOptions } from "./options.js";
2
+ /** `/api/live`: re-renders the regions the client lists in `known` (name:hash)
3
+ * from one state read and returns only those whose hash changed, plus the full
4
+ * review list for the tab title and new-review toasts. */
5
+ export declare const livePayload: (options: UiOptions, url: URL) => Promise<unknown>;
@@ -0,0 +1,43 @@
1
+ import { LIVE_FLEET_MAX_AGE_MS, readUiState } from "./data.js";
2
+ import { uiMode } from "./options.js";
3
+ import { renderBoard } from "./views/board.js";
4
+ import { gateLabel } from "./views/components.js";
5
+ import { LIVE_REGIONS, notifyLimit, regionHash, renderDaemonPill, renderNotifications, reviewQueue } from "./views/layout.js";
6
+ import { drawerNotFound, renderTaskBody, taskNotFound } from "./views/task.js";
7
+ /** `/api/live`: re-renders the regions the client lists in `known` (name:hash)
8
+ * from one state read and returns only those whose hash changed, plus the full
9
+ * review list for the tab title and new-review toasts. */
10
+ export const livePayload = async (options, url) => {
11
+ const known = new Map();
12
+ for (const entry of (url.searchParams.get("known") ?? "").split(",")) {
13
+ const at = entry.indexOf(":");
14
+ const name = at < 0 ? entry : entry.slice(0, at);
15
+ if (LIVE_REGIONS.includes(name))
16
+ known.set(name, at < 0 ? "" : entry.slice(at + 1));
17
+ }
18
+ const maxAgeMs = uiMode(options) === "fleet" ? LIVE_FLEET_MAX_AGE_MS : 0;
19
+ const state = await readUiState(options, maxAgeMs);
20
+ const task = url.searchParams.get("task") ?? "";
21
+ const drawer = url.searchParams.get("drawer") ?? "";
22
+ const reviews = reviewQueue(state);
23
+ const render = async (name) => {
24
+ switch (name) {
25
+ case "notify": return renderNotifications(reviews, task, notifyLimit(url.searchParams.get("notify")));
26
+ case "daemon": return renderDaemonPill(options.root);
27
+ case "board": return renderBoard(state);
28
+ case "detail": return task ? (await renderTaskBody(options, state, task, false, maxAgeMs))?.html ?? taskNotFound(options, task) : undefined;
29
+ case "drawer": return drawer ? (await renderTaskBody(options, state, drawer, true, maxAgeMs))?.html ?? drawerNotFound(drawer) : undefined;
30
+ default: return undefined;
31
+ }
32
+ };
33
+ const regions = {};
34
+ for (const [name, hash] of known) {
35
+ const html = await render(name);
36
+ if (html === undefined)
37
+ continue;
38
+ const next = regionHash(html);
39
+ if (next !== hash)
40
+ regions[name] = { hash: next, html };
41
+ }
42
+ return { ok: true, ...(drawer ? { drawer } : {}), regions, reviews: { count: reviews.length, items: reviews.map((item) => ({ id: item.id, title: item.title, gate: gateLabel(item.gate) })) } };
43
+ };
@@ -0,0 +1,2 @@
1
+ /** Renders a small subset of Markdown to escaped HTML. Exported for tests. */
2
+ export declare const markdownToHtml: (markdown: string) => string;
@@ -0,0 +1,127 @@
1
+ /** Markdown renderer (minimal, escaping-first, no runtime dependency). */
2
+ import { escapeHtml } from "./http.js";
3
+ const safeUrl = (raw) => {
4
+ const url = raw.trim();
5
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(url);
6
+ if (scheme && !/^(https?|mailto)$/i.test(scheme[1] ?? ""))
7
+ return "#";
8
+ return escapeHtml(url);
9
+ };
10
+ const renderInline = (text) => {
11
+ let out = escapeHtml(text);
12
+ out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
13
+ out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
14
+ out = out.replace(/__([^_]+)__/g, "<strong>$1</strong>");
15
+ out = out.replace(/\*([^*\s][^*]*)\*/g, "<em>$1</em>");
16
+ out = out.replace(/(^|[^\w])_([^_\s][^_]*)_(?![\w])/g, "$1<em>$2</em>");
17
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, url) => `<a href="${safeUrl(url)}">${label}</a>`);
18
+ return out;
19
+ };
20
+ const splitRow = (line) => line.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|");
21
+ const isTableSeparator = (line) => /^[\s|:-]+$/.test(line.trim()) && line.includes("-");
22
+ /** Renders a small subset of Markdown to escaped HTML. Exported for tests. */
23
+ export const markdownToHtml = (markdown) => {
24
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
25
+ const html = [];
26
+ let i = 0;
27
+ let listKind = null;
28
+ let paragraph = [];
29
+ const flushParagraph = () => {
30
+ if (paragraph.length) {
31
+ html.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
32
+ paragraph = [];
33
+ }
34
+ };
35
+ const closeList = () => {
36
+ if (listKind) {
37
+ html.push(`</${listKind}>`);
38
+ listKind = null;
39
+ }
40
+ };
41
+ while (i < lines.length) {
42
+ const line = lines[i] ?? "";
43
+ const trimmed = line.trim();
44
+ if (trimmed.startsWith("```")) {
45
+ flushParagraph();
46
+ closeList();
47
+ const codeLines = [];
48
+ i += 1;
49
+ while (i < lines.length && !(lines[i] ?? "").trim().startsWith("```")) {
50
+ codeLines.push(lines[i] ?? "");
51
+ i += 1;
52
+ }
53
+ i += 1;
54
+ html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
55
+ continue;
56
+ }
57
+ const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed);
58
+ if (heading) {
59
+ flushParagraph();
60
+ closeList();
61
+ const level = (heading[1] ?? "#").length;
62
+ html.push(`<h${level}>${renderInline(heading[2] ?? "")}</h${level}>`);
63
+ i += 1;
64
+ continue;
65
+ }
66
+ if (/^([-*_])(\s*\1){2,}\s*$/.test(trimmed)) {
67
+ flushParagraph();
68
+ closeList();
69
+ html.push("<hr>");
70
+ i += 1;
71
+ continue;
72
+ }
73
+ if (trimmed.startsWith(">")) {
74
+ flushParagraph();
75
+ closeList();
76
+ const quote = [];
77
+ while (i < lines.length && (lines[i] ?? "").trim().startsWith(">")) {
78
+ quote.push((lines[i] ?? "").trim().replace(/^>\s?/, ""));
79
+ i += 1;
80
+ }
81
+ html.push(`<blockquote>${renderInline(quote.join(" "))}</blockquote>`);
82
+ continue;
83
+ }
84
+ if (trimmed.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1] ?? "")) {
85
+ flushParagraph();
86
+ closeList();
87
+ const header = splitRow(trimmed);
88
+ i += 2;
89
+ const rows = [];
90
+ while (i < lines.length && (lines[i] ?? "").trim().includes("|")) {
91
+ rows.push(splitRow((lines[i] ?? "").trim()));
92
+ i += 1;
93
+ }
94
+ const cols = header.length;
95
+ const ths = header.map((c) => `<th>${renderInline(c.trim())}</th>`).join("");
96
+ const trs = rows.map((r) => `<tr>${Array.from({ length: cols }, (_x, k) => `<td>${renderInline((r[k] ?? "").trim())}</td>`).join("")}</tr>`).join("");
97
+ html.push(`<table class="table is-fullwidth is-striped is-bordered"><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table>`);
98
+ continue;
99
+ }
100
+ const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
101
+ const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
102
+ if (ul || ol) {
103
+ flushParagraph();
104
+ const kind = ol ? "ol" : "ul";
105
+ if (listKind !== kind) {
106
+ closeList();
107
+ html.push(`<${kind}>`);
108
+ listKind = kind;
109
+ }
110
+ html.push(`<li>${renderInline((ul?.[1] ?? ol?.[1] ?? "").trim())}</li>`);
111
+ i += 1;
112
+ continue;
113
+ }
114
+ if (trimmed === "") {
115
+ flushParagraph();
116
+ closeList();
117
+ i += 1;
118
+ continue;
119
+ }
120
+ closeList();
121
+ paragraph.push(trimmed);
122
+ i += 1;
123
+ }
124
+ flushParagraph();
125
+ closeList();
126
+ return html.join("\n");
127
+ };
@@ -0,0 +1,48 @@
1
+ export type UiAuthorityMode = "local" | "fleet";
2
+ export interface UiOptions {
3
+ root: string;
4
+ host: string;
5
+ port: number;
6
+ /** Loop authority the dashboard reads and acts on; defaults to `.agents/config.json` `loop.mode` (local unless fleet). */
7
+ mode?: UiAuthorityMode;
8
+ /** Fleet reviewer credential held server-side; passed only to the approve child process. */
9
+ reviewerToken?: string;
10
+ /** Random id of this dashboard process, served at `/api/instance` so `loop down` signals
11
+ * only the dashboard it recorded (not whatever now answers on the same port). */
12
+ instance?: string;
13
+ }
14
+ export declare const scrubbedEnv: (extra?: Record<string, string>) => NodeJS.ProcessEnv;
15
+ /**
16
+ * A credential from a file (`--reviewer-token-file`: the fleet reviewer credential the approve
17
+ * child process receives). The file must live outside the project (agents work inside it), be a
18
+ * regular non-symlink file, and be private to the owner (mode 600/400). With `create`, a missing
19
+ * file is created with a random token. The credential is never passed to the daemon, lead, or
20
+ * workers; it reaches only the single approve child process.
21
+ */
22
+ export declare const readCredentialFile: (root: string, path: string, { create }: {
23
+ create: boolean;
24
+ }) => string;
25
+ /** Best-effort browser launch for `aidlc-kit ui --open`; failures are ignored (the URL is printed anyway). */
26
+ export declare const openBrowser: (url: string) => void;
27
+ /** The configured loop authority: `fleet` only when `loop.mode` says so; everything else reads local state. */
28
+ export declare const resolveUiMode: (root: string) => UiAuthorityMode;
29
+ export declare const uiMode: (options: UiOptions) => UiAuthorityMode;
30
+ export declare const loopEntryPath: (root: string) => string;
31
+ export declare const stateScriptPath: (root: string) => string;
32
+ export declare const loopDataDir: (root: string) => string;
33
+ export declare const eventsLogPath: (root: string) => string;
34
+ /** Canonical task id (orchestrator §Turn routing); also the only names the per-task event logs use. */
35
+ export declare const TASK_ID_PATTERN: RegExp;
36
+ /** `.agents/data/tasks`: one directory per task (review artifacts at its root, operational data under `runtime/`). */
37
+ export declare const tasksDataDir: (root: string) => string;
38
+ /** `.agents/data/tasks/<taskId>/runtime`: the task's operational files (validate with TASK_ID_PATTERN first). */
39
+ export declare const taskRuntimeDir: (root: string, taskId: string) => string;
40
+ /** Where aidlc-kit versions before the `runtime/` layout kept the per-task event logs (`<taskId>.jsonl`, rotated to `.1`). */
41
+ export declare const legacyTaskEventsDir: (root: string) => string;
42
+ /** Per-task event log (`runtime/events.jsonl`, rotated to `events.jsonl.1`) of a canonical task id (validate with TASK_ID_PATTERN first). */
43
+ export declare const taskEventsLogPath: (root: string, taskId: string) => string;
44
+ /** The same log where older versions wrote it; read as a fallback until `loop migrate-data` has run. */
45
+ export declare const legacyTaskEventsLogPath: (root: string, taskId: string) => string;
46
+ export declare const daemonLockPath: (root: string) => string;
47
+ export declare const daemonLogPath: (root: string) => string;
48
+ export declare const loopArgs: (root: string, action: string, taskId?: string) => string[];
@@ -0,0 +1,81 @@
1
+ /** Server options, credential hygiene for child processes, and installed-workflow paths. */
2
+ import { spawn } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
4
+ import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join, resolve, sep } from "node:path";
6
+ /** Credentials that never reach the daemon or read-only child processes. */
7
+ const SCRUBBED_ENV = ["AIDLC_FLEET_REVIEWER_TOKEN", "AIDLC_UI_REVIEWER_TOKEN_FILE"];
8
+ export const scrubbedEnv = (extra = {}) => {
9
+ const env = { ...process.env };
10
+ for (const name of SCRUBBED_ENV)
11
+ delete env[name];
12
+ return { ...env, ...extra };
13
+ };
14
+ /**
15
+ * A credential from a file (`--reviewer-token-file`: the fleet reviewer credential the approve
16
+ * child process receives). The file must live outside the project (agents work inside it), be a
17
+ * regular non-symlink file, and be private to the owner (mode 600/400). With `create`, a missing
18
+ * file is created with a random token. The credential is never passed to the daemon, lead, or
19
+ * workers; it reaches only the single approve child process.
20
+ */
21
+ export const readCredentialFile = (root, path, { create }) => {
22
+ const target = resolve(path);
23
+ const project = resolve(root);
24
+ if (target === project || target.startsWith(`${project}${sep}`))
25
+ throw new Error("Credential file must live outside the project directory");
26
+ if (!existsSync(target)) {
27
+ if (!create)
28
+ throw new Error(`Credential file not found: ${target}`);
29
+ const token = randomBytes(32).toString("base64url");
30
+ writeFileSync(target, `${token}\n`, { flag: "wx", mode: 0o600 });
31
+ return token;
32
+ }
33
+ const stat = lstatSync(target);
34
+ if (stat.isSymbolicLink() || !stat.isFile())
35
+ throw new Error("Credential file must be a regular file, not a symlink");
36
+ if (process.platform !== "win32" && (stat.mode & 0o077) !== 0)
37
+ throw new Error(`Credential file must be private to its owner (chmod 600 ${target})`);
38
+ const token = readFileSync(target, "utf8").trim();
39
+ if (token.length < 16 || token.length > 256 || /\s/.test(token))
40
+ throw new Error("Credential file must contain one token of 16–256 non-whitespace characters");
41
+ return token;
42
+ };
43
+ /** Best-effort browser launch for `aidlc-kit ui --open`; failures are ignored (the URL is printed anyway). */
44
+ export const openBrowser = (url) => {
45
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
46
+ try {
47
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
48
+ child.on("error", () => { });
49
+ child.unref();
50
+ }
51
+ catch { /* no browser launcher */ }
52
+ };
53
+ /** The configured loop authority: `fleet` only when `loop.mode` says so; everything else reads local state. */
54
+ export const resolveUiMode = (root) => {
55
+ try {
56
+ return JSON.parse(readFileSync(join(root, ".agents", "config.json"), "utf8"))?.loop?.mode === "fleet" ? "fleet" : "local";
57
+ }
58
+ catch {
59
+ return "local";
60
+ }
61
+ };
62
+ export const uiMode = (options) => options.mode ?? "local";
63
+ export const loopEntryPath = (root) => join(root, ".agents", "aidlc", "scripts", "loop-entry.mjs");
64
+ export const stateScriptPath = (root) => join(root, ".agents", "aidlc", "scripts", "state.mjs");
65
+ export const loopDataDir = (root) => join(root, ".agents", "data", "loop");
66
+ export const eventsLogPath = (root) => join(loopDataDir(root), "ui-events.jsonl");
67
+ /** Canonical task id (orchestrator §Turn routing); also the only names the per-task event logs use. */
68
+ export const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
69
+ /** `.agents/data/tasks`: one directory per task (review artifacts at its root, operational data under `runtime/`). */
70
+ export const tasksDataDir = (root) => join(root, ".agents", "data", "tasks");
71
+ /** `.agents/data/tasks/<taskId>/runtime`: the task's operational files (validate with TASK_ID_PATTERN first). */
72
+ export const taskRuntimeDir = (root, taskId) => join(tasksDataDir(root), taskId, "runtime");
73
+ /** Where aidlc-kit versions before the `runtime/` layout kept the per-task event logs (`<taskId>.jsonl`, rotated to `.1`). */
74
+ export const legacyTaskEventsDir = (root) => join(loopDataDir(root), "events");
75
+ /** Per-task event log (`runtime/events.jsonl`, rotated to `events.jsonl.1`) of a canonical task id (validate with TASK_ID_PATTERN first). */
76
+ export const taskEventsLogPath = (root, taskId) => join(taskRuntimeDir(root, taskId), "events.jsonl");
77
+ /** The same log where older versions wrote it; read as a fallback until `loop migrate-data` has run. */
78
+ export const legacyTaskEventsLogPath = (root, taskId) => join(legacyTaskEventsDir(root), `${taskId}.jsonl`);
79
+ export const daemonLockPath = (root) => join(loopDataDir(root), "daemon.lock");
80
+ export const daemonLogPath = (root) => join(loopDataDir(root), "daemon.log");
81
+ export const loopArgs = (root, action, taskId = "") => [loopEntryPath(root), action, taskId, root, "", "", "", ""];
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Request guards for a dashboard that has no session credential: the dashboard is meant for
3
+ * trusted local/LAN networks, so what remains to defend against is a browser on the same
4
+ * machine being used as a confused deputy (DNS rebinding, cross-site form posts, cross-site
5
+ * `fetch`). Three request-shape checks cover that:
6
+ *
7
+ * - **Host** must name the bound address (any loopback name when bound to loopback). A DNS
8
+ * rebinding attack reaches the server with the attacker's hostname in `Host`, so it is
9
+ * rejected before any route runs. A wildcard bind cannot be pinned to one name, so it
10
+ * accepts only IP-literal or `localhost` Hosts (rebinding needs a DNS name) and otherwise
11
+ * relies on the remaining checks (the launcher warns when exposing it).
12
+ * - **Origin** must be present and equal to `http://<Host>` on every state-changing request.
13
+ * Browsers always attach `Origin` to cross-site `POST`s (forms and `fetch` alike), so a
14
+ * foreign page can never make it match; a missing `Origin` is treated as foreign too.
15
+ * **Sec-Fetch-Site**, when the browser sends it, must say `same-origin` (or `none` for a
16
+ * direct navigation), which closes the same hole from the browser's own point of view.
17
+ * - A custom **x-aidlc-request** header must be present. It is not a "simple" header, so a
18
+ * cross-site `fetch` cannot send it without a CORS preflight that this server never
19
+ * answers, and an HTML form cannot set headers at all. The client sends `x-aidlc-request: 1`
20
+ * on every `POST` (see `assets/ui/common.js` `post()`).
21
+ *
22
+ * `GET`/`HEAD`/`OPTIONS` are read-only and pass with just the Host check.
23
+ */
24
+ import type { IncomingMessage } from "node:http";
25
+ import type { UiOptions } from "./options.js";
26
+ /** Custom header every state-changing request must carry (any value). */
27
+ export declare const REQUEST_HEADER = "x-aidlc-request";
28
+ /** DNS-rebinding guard plus the request-shape CSRF guards described in the module doc. */
29
+ export declare const requestAllowed: (options: Pick<UiOptions, "host">, req: IncomingMessage) => {
30
+ ok: true;
31
+ } | {
32
+ ok: false;
33
+ error: string;
34
+ };
@@ -0,0 +1,38 @@
1
+ const LOOPBACK_HOSTS = ["127.0.0.1", "localhost", "[::1]"];
2
+ /** Methods that never change state and therefore need only the Host check. */
3
+ const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
4
+ /** `host:port` forms that are not DNS names: IPv4, bracketed IPv6, or `localhost`. */
5
+ const ADDRESS_HOST = /^(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-f:.]+\]|localhost):\d{1,5}$/;
6
+ /** Custom header every state-changing request must carry (any value). */
7
+ export const REQUEST_HEADER = "x-aidlc-request";
8
+ /** DNS-rebinding guard plus the request-shape CSRF guards described in the module doc. */
9
+ export const requestAllowed = (options, req) => {
10
+ const host = String(req.headers.host ?? "").toLowerCase();
11
+ const port = req.socket?.localPort;
12
+ const bind = (options.host || "127.0.0.1").toLowerCase();
13
+ const wildcard = bind === "0.0.0.0" || bind === "::" || bind === "[::]";
14
+ if (!wildcard) {
15
+ const named = bind.includes(":") && !bind.startsWith("[") ? `[${bind}]` : bind;
16
+ const loopback = LOOPBACK_HOSTS.includes(named) || named === "::1";
17
+ const allowed = new Set([named, ...(loopback ? LOOPBACK_HOSTS : [])].map((name) => `${name}:${port}`));
18
+ if (!allowed.has(host))
19
+ return { ok: false, error: "Host header does not match this dashboard; open it at its bound address" };
20
+ }
21
+ else if (!ADDRESS_HOST.test(host)) {
22
+ // A wildcard bind cannot be pinned to one name, but DNS rebinding needs a DNS name:
23
+ // accept only IP literals and `localhost` (with the bound port) so a rebound page is still refused.
24
+ return { ok: false, error: "Host header must be an IP address or localhost on a wildcard bind" };
25
+ }
26
+ const method = (req.method ?? "GET").toUpperCase();
27
+ if (READ_METHODS.has(method))
28
+ return { ok: true };
29
+ const origin = req.headers.origin;
30
+ if (typeof origin !== "string" || origin.toLowerCase() !== `http://${host}`)
31
+ return { ok: false, error: "Cross-origin request rejected: Origin must match this dashboard" };
32
+ const site = req.headers["sec-fetch-site"];
33
+ if (typeof site === "string" && site !== "same-origin" && site !== "none")
34
+ return { ok: false, error: "Cross-site request rejected" };
35
+ if (req.headers[REQUEST_HEADER] === undefined)
36
+ return { ok: false, error: `Request rejected: missing ${REQUEST_HEADER} header` };
37
+ return { ok: true };
38
+ };