@mneme-ai/xray 2.150.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 (79) hide show
  1. package/README.md +71 -0
  2. package/dist/battery/age.d.ts +3 -0
  3. package/dist/battery/age.d.ts.map +1 -0
  4. package/dist/battery/age.js +65 -0
  5. package/dist/battery/age.js.map +1 -0
  6. package/dist/battery/busfactor.d.ts +3 -0
  7. package/dist/battery/busfactor.d.ts.map +1 -0
  8. package/dist/battery/busfactor.js +92 -0
  9. package/dist/battery/busfactor.js.map +1 -0
  10. package/dist/battery/complexity.d.ts +3 -0
  11. package/dist/battery/complexity.d.ts.map +1 -0
  12. package/dist/battery/complexity.js +50 -0
  13. package/dist/battery/complexity.js.map +1 -0
  14. package/dist/battery/deps.d.ts +15 -0
  15. package/dist/battery/deps.d.ts.map +1 -0
  16. package/dist/battery/deps.js +107 -0
  17. package/dist/battery/deps.js.map +1 -0
  18. package/dist/battery/hotspots.d.ts +3 -0
  19. package/dist/battery/hotspots.d.ts.map +1 -0
  20. package/dist/battery/hotspots.js +61 -0
  21. package/dist/battery/hotspots.js.map +1 -0
  22. package/dist/battery/secrets.d.ts +3 -0
  23. package/dist/battery/secrets.d.ts.map +1 -0
  24. package/dist/battery/secrets.js +64 -0
  25. package/dist/battery/secrets.js.map +1 -0
  26. package/dist/bin.d.ts +3 -0
  27. package/dist/bin.d.ts.map +1 -0
  28. package/dist/bin.js +76 -0
  29. package/dist/bin.js.map +1 -0
  30. package/dist/clone.d.ts +13 -0
  31. package/dist/clone.d.ts.map +1 -0
  32. package/dist/clone.js +42 -0
  33. package/dist/clone.js.map +1 -0
  34. package/dist/cosmic.d.ts +35 -0
  35. package/dist/cosmic.d.ts.map +1 -0
  36. package/dist/cosmic.js +122 -0
  37. package/dist/cosmic.js.map +1 -0
  38. package/dist/engine.d.ts +8 -0
  39. package/dist/engine.d.ts.map +1 -0
  40. package/dist/engine.js +138 -0
  41. package/dist/engine.js.map +1 -0
  42. package/dist/gauntlet.d.ts +9 -0
  43. package/dist/gauntlet.d.ts.map +1 -0
  44. package/dist/gauntlet.js +47 -0
  45. package/dist/gauntlet.js.map +1 -0
  46. package/dist/index.d.ts +21 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +21 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/privacy.d.ts +12 -0
  51. package/dist/privacy.d.ts.map +1 -0
  52. package/dist/privacy.js +43 -0
  53. package/dist/privacy.js.map +1 -0
  54. package/dist/publish.d.ts +9 -0
  55. package/dist/publish.d.ts.map +1 -0
  56. package/dist/publish.js +28 -0
  57. package/dist/publish.js.map +1 -0
  58. package/dist/server.d.ts +29 -0
  59. package/dist/server.d.ts.map +1 -0
  60. package/dist/server.js +482 -0
  61. package/dist/server.js.map +1 -0
  62. package/dist/sign.d.ts +7 -0
  63. package/dist/sign.d.ts.map +1 -0
  64. package/dist/sign.js +33 -0
  65. package/dist/sign.js.map +1 -0
  66. package/dist/types.d.ts +148 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/types.js +16 -0
  69. package/dist/types.js.map +1 -0
  70. package/dist/util.d.ts +21 -0
  71. package/dist/util.d.ts.map +1 -0
  72. package/dist/util.js +111 -0
  73. package/dist/util.js.map +1 -0
  74. package/package.json +55 -0
  75. package/public/card.js +45 -0
  76. package/public/cosmic.html +74 -0
  77. package/public/favicon.svg +1 -0
  78. package/public/index.html +294 -0
  79. package/public/report.html +76 -0
@@ -0,0 +1,43 @@
1
+ // Things that should NEVER appear in a metric-only report.
2
+ const CODE_SHAPES = [
3
+ [/\bfunction\s+\w+\s*\(/, "function definition"],
4
+ [/=>\s*\{/, "arrow-function body"],
5
+ [/\bclass\s+\w+\s*\{/, "class body"],
6
+ [/\bimport\s+.+\s+from\s+['"]/, "import statement"],
7
+ [/\b(const|let|var)\s+\w+\s*=/, "variable assignment"],
8
+ [/-----BEGIN [A-Z ]+-----/, "PEM block"],
9
+ ];
10
+ /**
11
+ * Field-aware: symbol NAMES and signatures are allowed (they are structural),
12
+ * but they live in known fields. We scan the JSON with those known structural
13
+ * fields stripped, so a signature in `hotspots[].symbol` can't be mistaken for
14
+ * a leak, while an injected code body anywhere else is caught.
15
+ */
16
+ export function xrayLeaksRaw(report) {
17
+ // fail-closed: a non-report is not safe to emit.
18
+ if (report === null || typeof report !== "object") {
19
+ return { leaks: true, reasons: ["not a report object"] };
20
+ }
21
+ let json;
22
+ try {
23
+ const clone = JSON.parse(JSON.stringify(report));
24
+ // strip the legitimately-structural string fields before scanning
25
+ if (clone && clone.complexity && Array.isArray(clone.complexity.hotspots)) {
26
+ clone.complexity = { ...clone.complexity, hotspots: clone.complexity.hotspots.map((h) => ({ ...h, symbol: "" })) };
27
+ }
28
+ json = JSON.stringify(clone);
29
+ }
30
+ catch {
31
+ return { leaks: true, reasons: ["report is not serializable JSON"] };
32
+ }
33
+ const reasons = [];
34
+ for (const [re, label] of CODE_SHAPES) {
35
+ if (re.test(json))
36
+ reasons.push(`contains ${label}`);
37
+ }
38
+ // very long unbroken token ⇒ likely an embedded blob/secret/source line
39
+ if (/[^\s"]{200,}/.test(json))
40
+ reasons.push("contains a 200+ char unbroken token (possible blob)");
41
+ return { leaks: reasons.length > 0, reasons };
42
+ }
43
+ //# sourceMappingURL=privacy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"privacy.js","sourceRoot":"","sources":["../src/privacy.ts"],"names":[],"mappings":"AAcA,2DAA2D;AAC3D,MAAM,WAAW,GAA4B;IAC3C,CAAC,uBAAuB,EAAE,qBAAqB,CAAC;IAChD,CAAC,SAAS,EAAE,qBAAqB,CAAC;IAClC,CAAC,oBAAoB,EAAE,YAAY,CAAC;IACpC,CAAC,6BAA6B,EAAE,kBAAkB,CAAC;IACnD,CAAC,6BAA6B,EAAE,qBAAqB,CAAC;IACtD,CAAC,yBAAyB,EAAE,WAAW,CAAC;CACzC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,MAAe;IAC1C,iDAAiD;IACjD,IAAI,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAClD,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,qBAAqB,CAAC,EAAE,CAAC;IAC3D,CAAC;IACD,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAkD,CAAC;QAClG,kEAAkE;QAClE,IAAI,KAAK,IAAI,KAAK,CAAC,UAAU,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC1E,KAAK,CAAC,UAAU,GAAG,EAAE,GAAG,KAAK,CAAC,UAAU,EAAE,QAAQ,EAAE,KAAK,CAAC,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC;QACrH,CAAC;QACD,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,iCAAiC,CAAC,EAAE,CAAC;IACvE,CAAC;IAED,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,CAAC,EAAE,EAAE,KAAK,CAAC,IAAI,WAAW,EAAE,CAAC;QACtC,IAAI,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,CAAC,IAAI,CAAC,YAAY,KAAK,EAAE,CAAC,CAAC;IACvD,CAAC;IACD,wEAAwE;IACxE,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,CAAC,IAAI,CAAC,qDAAqD,CAAC,CAAC;IAEnG,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,OAAO,EAAE,CAAC;AAChD,CAAC"}
@@ -0,0 +1,9 @@
1
+ import type { SignedXRay } from "./types.js";
2
+ export interface PublishResult {
3
+ ok: boolean;
4
+ profileId?: string;
5
+ fingerprint?: string;
6
+ error?: string;
7
+ }
8
+ export declare function publishReport(server: string, token: string, signed: SignedXRay): Promise<PublishResult>;
9
+ //# sourceMappingURL=publish.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish.d.ts","sourceRoot":"","sources":["../src/publish.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,aAAa;IAAG,EAAE,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE;AAExG,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC,CAiB7G"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The local agent's side of the bridge. After building a signed, raw-free
3
+ * report on a PRIVATE repo locally, publish ONLY that report to a Lighthouse
4
+ * server. The source never leaves the machine — only metrics + a signature go.
5
+ */
6
+ import { xrayLeaksRaw } from "./privacy.js";
7
+ export async function publishReport(server, token, signed) {
8
+ // belt-and-braces: refuse to transmit anything that isn't raw-free
9
+ const leak = xrayLeaksRaw(signed.report);
10
+ if (leak.leaks)
11
+ return { ok: false, error: "refusing to publish: report is not raw-free (" + leak.reasons.join("; ") + ")" };
12
+ const base = server.replace(/\/+$/, "");
13
+ try {
14
+ const res = await fetch(base + "/api/ingest", {
15
+ method: "POST",
16
+ headers: { "content-type": "application/json", authorization: "Bearer " + token },
17
+ body: JSON.stringify(signed),
18
+ });
19
+ const data = (await res.json().catch(() => ({})));
20
+ if (!res.ok)
21
+ return { ok: false, error: data.error || `server returned ${res.status}` };
22
+ return { ok: true, profileId: data.profileId, fingerprint: data.fingerprint };
23
+ }
24
+ catch (e) {
25
+ return { ok: false, error: e.message };
26
+ }
27
+ }
28
+ //# sourceMappingURL=publish.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish.js","sourceRoot":"","sources":["../src/publish.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAK5C,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,MAAc,EAAE,KAAa,EAAE,MAAkB;IACnF,mEAAmE;IACnE,MAAM,IAAI,GAAG,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACzC,IAAI,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,+CAA+C,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,GAAG,EAAE,CAAC;IAC7H,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACxC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,IAAI,GAAG,aAAa,EAAE;YAC5C,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,aAAa,EAAE,SAAS,GAAG,KAAK,EAAE;YACjF,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;SAC7B,CAAC,CAAC;QACH,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAuC,CAAC;QACxF,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,mBAAmB,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC;QACxF,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC;IAChF,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAG,CAAW,CAAC,OAAO,EAAE,CAAC;IACpD,CAAC;AACH,CAAC"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Mneme X-Ray server — the "Lighthouse".
3
+ *
4
+ * A single, dependency-light node:http server that:
5
+ * POST /api/xray { gitUrl } → shallow-clone a PUBLIC repo, run the
6
+ * deterministic battery, enforce the
7
+ * raw-free gate, seal with NOTARY, return.
8
+ * POST /api/verify { signed } → verify a report's receipt offline.
9
+ * POST /api/ingest { signed } → THE BRIDGE: a local agent publishes a
10
+ * report it built on a PRIVATE repo. The
11
+ * server verifies the Ed25519 receipt +
12
+ * raw-free gate and files it under the
13
+ * caller's profile — WITHOUT ever seeing
14
+ * the source (it was analysed locally).
15
+ * GET /api/profile/:id → a profile's reports (raw-free).
16
+ * GET /api/report/:fingerprint → a stored full signed report (deep-view).
17
+ * GET /api/board → recent public X-Rays (the board).
18
+ * GET /api/health → liveness.
19
+ * GET / → the clean white UI.
20
+ *
21
+ * PRIVACY: PUBLIC git URLs are shallow-cloned, analysed, and DELETED. PRIVATE
22
+ * repos never touch this server — the local agent (`mneme-xray <path> --publish`)
23
+ * analyses them on the user's machine and POSTs only the signed, raw-free
24
+ * report. Every persisted report passes xrayLeaksRaw first.
25
+ */
26
+ import { type IncomingMessage, type ServerResponse } from "node:http";
27
+ import { CosmicMonitor } from "./cosmic.js";
28
+ export declare function createXRayServer(monitor?: CosmicMonitor): import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
29
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAC;AASpF,OAAO,EAAE,aAAa,EAAoC,MAAM,aAAa,CAAC;AA+O9E,wBAAgB,gBAAgB,CAAC,OAAO,CAAC,EAAE,aAAa,wEA6IvD"}
package/dist/server.js ADDED
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Mneme X-Ray server — the "Lighthouse".
3
+ *
4
+ * A single, dependency-light node:http server that:
5
+ * POST /api/xray { gitUrl } → shallow-clone a PUBLIC repo, run the
6
+ * deterministic battery, enforce the
7
+ * raw-free gate, seal with NOTARY, return.
8
+ * POST /api/verify { signed } → verify a report's receipt offline.
9
+ * POST /api/ingest { signed } → THE BRIDGE: a local agent publishes a
10
+ * report it built on a PRIVATE repo. The
11
+ * server verifies the Ed25519 receipt +
12
+ * raw-free gate and files it under the
13
+ * caller's profile — WITHOUT ever seeing
14
+ * the source (it was analysed locally).
15
+ * GET /api/profile/:id → a profile's reports (raw-free).
16
+ * GET /api/report/:fingerprint → a stored full signed report (deep-view).
17
+ * GET /api/board → recent public X-Rays (the board).
18
+ * GET /api/health → liveness.
19
+ * GET / → the clean white UI.
20
+ *
21
+ * PRIVACY: PUBLIC git URLs are shallow-cloned, analysed, and DELETED. PRIVATE
22
+ * repos never touch this server — the local agent (`mneme-xray <path> --publish`)
23
+ * analyses them on the user's machine and POSTs only the signed, raw-free
24
+ * report. Every persisted report passes xrayLeaksRaw first.
25
+ */
26
+ import { createServer } from "node:http";
27
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, readFileSync as rf } from "node:fs";
28
+ import { dirname, join } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+ import { createHash } from "node:crypto";
31
+ import { buildXRay } from "./engine.js";
32
+ import { sealXRay, verifyXRay } from "./sign.js";
33
+ import { xrayLeaksRaw } from "./privacy.js";
34
+ import { isAllowedPublicUrl } from "./clone.js";
35
+ import { CosmicMonitor, cosmicBadgeSvg, signCosmicStatus } from "./cosmic.js";
36
+ const HERE = dirname(fileURLToPath(import.meta.url));
37
+ const PUBLIC_DIR = join(HERE, "..", "public");
38
+ // lazy so XRAY_DATA_DIR can be set per-process (incl. tests) before first use
39
+ const dataDir = () => process.env.XRAY_DATA_DIR || join(process.cwd(), ".xray-data");
40
+ const BOARD_FILE = () => join(dataDir(), "board.jsonl");
41
+ const REPORTS_DIR = () => join(dataDir(), "reports");
42
+ const PROFILES_DIR = () => join(dataDir(), "profiles");
43
+ const PORT = parseInt(process.env.PORT || "8787", 10);
44
+ const HOST = process.env.HOST || "0.0.0.0";
45
+ /** Profile id is the hash of the caller's token (an API key they choose). The
46
+ * raw token is never stored — only its hash names the profile dir. */
47
+ function profileIdFromToken(token) {
48
+ return createHash("sha256").update("mneme-xray-profile:" + token).digest("hex").slice(0, 16);
49
+ }
50
+ function bearer(req) {
51
+ const h = (req.headers.authorization || "").trim();
52
+ return h.toLowerCase().startsWith("bearer ") ? h.slice(7).trim() : null;
53
+ }
54
+ const FP_RE = /^[a-f0-9]{16,64}$/;
55
+ // crude per-IP rate limit: N requests / window
56
+ const RL_MAX = 20, RL_WINDOW_MS = 60_000;
57
+ const rl = new Map();
58
+ function rateLimited(ip) {
59
+ const now = Date.now();
60
+ const e = rl.get(ip);
61
+ if (!e || e.resetAt < now) {
62
+ rl.set(ip, { n: 1, resetAt: now + RL_WINDOW_MS });
63
+ return false;
64
+ }
65
+ e.n++;
66
+ return e.n > RL_MAX;
67
+ }
68
+ function send(res, status, body, type = "application/json") {
69
+ const payload = type === "application/json" ? JSON.stringify(body) : String(body);
70
+ res.writeHead(status, {
71
+ "content-type": type === "application/json" ? "application/json; charset=utf-8" : type,
72
+ "access-control-allow-origin": "*",
73
+ "access-control-allow-methods": "GET,POST,OPTIONS",
74
+ "access-control-allow-headers": "content-type",
75
+ });
76
+ res.end(payload);
77
+ }
78
+ function readBody(req, limit = 256 * 1024) {
79
+ return new Promise((resolve, reject) => {
80
+ let data = "", size = 0;
81
+ req.on("data", (c) => { size += c.length; if (size > limit) {
82
+ reject(new Error("body too large"));
83
+ req.destroy();
84
+ }
85
+ else
86
+ data += c; });
87
+ req.on("end", () => resolve(data));
88
+ req.on("error", reject);
89
+ });
90
+ }
91
+ function recordBoard(signed) {
92
+ try {
93
+ if (!existsSync(dataDir()))
94
+ mkdirSync(dataDir(), { recursive: true });
95
+ const r = signed.report;
96
+ // board carries only the headline metrics — already raw-free
97
+ appendFileSync(BOARD_FILE(), JSON.stringify({
98
+ at: r.generatedAt, repoName: r.subject.repoName, ref: r.subject.ref,
99
+ grade: r.summary.grade, headline: r.summary.headline, fingerprint: r.fingerprint,
100
+ }) + "\n");
101
+ }
102
+ catch { /* board is best-effort */ }
103
+ }
104
+ function readBoardRows() {
105
+ try {
106
+ if (!existsSync(BOARD_FILE()))
107
+ return [];
108
+ return rf(BOARD_FILE(), "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
109
+ }
110
+ catch {
111
+ return [];
112
+ }
113
+ }
114
+ function readProfileRows(profileId) {
115
+ try {
116
+ const p = join(PROFILES_DIR(), profileId + ".jsonl");
117
+ if (!existsSync(p))
118
+ return [];
119
+ return rf(p, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ }
125
+ function summaryOf(r, visibility) {
126
+ return {
127
+ at: r.generatedAt, repoName: r.subject.repoName, ref: r.subject.kind === "git-url" ? r.subject.ref : "private",
128
+ grade: r.summary.grade, headline: r.summary.headline, fingerprint: r.fingerprint, visibility,
129
+ };
130
+ }
131
+ /** Persist the full signed report + a tiny meta sidecar (visibility/owner) for
132
+ * access control. Idempotent by fingerprint. */
133
+ function saveReport(signed, visibility = "public", profileId = "") {
134
+ try {
135
+ if (!existsSync(REPORTS_DIR()))
136
+ mkdirSync(REPORTS_DIR(), { recursive: true });
137
+ const fp = signed.report.fingerprint;
138
+ if (!FP_RE.test(fp))
139
+ return;
140
+ writeFileSync(join(REPORTS_DIR(), fp + ".json"), JSON.stringify(signed));
141
+ writeFileSync(join(REPORTS_DIR(), fp + ".meta.json"), JSON.stringify({ visibility, profileId }));
142
+ }
143
+ catch { /* best-effort */ }
144
+ }
145
+ function getReport(fingerprint) {
146
+ try {
147
+ if (!FP_RE.test(fingerprint))
148
+ return null;
149
+ const p = join(REPORTS_DIR(), fingerprint + ".json");
150
+ return existsSync(p) ? JSON.parse(rf(p, "utf8")) : null;
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ function getReportMeta(fingerprint) {
157
+ try {
158
+ if (!FP_RE.test(fingerprint))
159
+ return null;
160
+ const p = join(REPORTS_DIR(), fingerprint + ".meta.json");
161
+ return existsSync(p) ? JSON.parse(rf(p, "utf8")) : null;
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ /** Group raw scan-event rows by repo → first-seen + last-seen + scan count +
168
+ * the latest grade/fingerprint. Newest activity first. Powers the listview. */
169
+ function aggregateByRepo(rows) {
170
+ const map = new Map();
171
+ for (const r of rows) {
172
+ const key = String(r.repoName || r.ref || r.fingerprint);
173
+ const at = String(r.at || "");
174
+ const e = map.get(key);
175
+ if (!e) {
176
+ map.set(key, { repoName: String(r.repoName || ""), ref: String(r.ref || ""), firstAt: at, lastAt: at, count: 1, grade: String(r.grade || "?"), fingerprint: String(r.fingerprint || ""), visibility: String(r.visibility || "public") });
177
+ }
178
+ else {
179
+ e.count++;
180
+ if (at < e.firstAt)
181
+ e.firstAt = at;
182
+ if (at >= e.lastAt) {
183
+ e.lastAt = at;
184
+ e.grade = String(r.grade || e.grade);
185
+ e.fingerprint = String(r.fingerprint || e.fingerprint);
186
+ }
187
+ }
188
+ }
189
+ return [...map.values()].sort((a, b) => (a.lastAt < b.lastAt ? 1 : -1));
190
+ }
191
+ function page(items, offset, limit) {
192
+ const o = Math.max(0, offset | 0), l = Math.min(100, Math.max(1, limit | 0));
193
+ return { items: items.slice(o, o + l), total: items.length, offset: o, limit: l };
194
+ }
195
+ function recordProfile(profileId, r, visibility) {
196
+ try {
197
+ if (!existsSync(PROFILES_DIR()))
198
+ mkdirSync(PROFILES_DIR(), { recursive: true });
199
+ appendFileSync(join(PROFILES_DIR(), profileId + ".jsonl"), JSON.stringify(summaryOf(r, visibility)) + "\n");
200
+ }
201
+ catch { /* best-effort */ }
202
+ }
203
+ function serveStatic(res, file) {
204
+ const path = join(PUBLIC_DIR, file);
205
+ if (!existsSync(path)) {
206
+ send(res, 404, { error: "not found" });
207
+ return;
208
+ }
209
+ const type = file.endsWith(".html") ? "text/html; charset=utf-8"
210
+ : file.endsWith(".svg") ? "image/svg+xml"
211
+ : file.endsWith(".js") ? "text/javascript; charset=utf-8"
212
+ : "text/plain";
213
+ send(res, 200, readFileSync(path, "utf8"), type);
214
+ }
215
+ // ---- the growth engine: shareable badges + social cards + permalinks ----
216
+ const GRADE_COLOR = { A: "#16a34a", B: "#65a30d", C: "#d97706", D: "#ea580c", F: "#dc2626" };
217
+ const xesc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
218
+ function sendSvg(res, svg, maxAgeSec = 300) {
219
+ res.writeHead(200, {
220
+ "content-type": "image/svg+xml; charset=utf-8",
221
+ "cache-control": `public, max-age=${maxAgeSec}`,
222
+ "access-control-allow-origin": "*",
223
+ });
224
+ res.end(svg);
225
+ }
226
+ /** shields-style flat badge: "mneme x-ray | <grade>". */
227
+ function badgeSvg(grade) {
228
+ const color = GRADE_COLOR[grade] || "#6b7280";
229
+ const label = "mneme x-ray", val = grade || "?";
230
+ const lw = 78, vw = 26, w = lw + vw, h = 20;
231
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" role="img" aria-label="${label}: ${val}">
232
+ <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
233
+ <clipPath id="r"><rect width="${w}" height="${h}" rx="3" fill="#fff"/></clipPath>
234
+ <g clip-path="url(#r)"><rect width="${lw}" height="${h}" fill="#0a0a0a"/><rect x="${lw}" width="${vw}" height="${h}" fill="${color}"/><rect width="${w}" height="${h}" fill="url(#s)"/></g>
235
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
236
+ <text x="${lw / 2}" y="14">${label}</text><text x="${lw + vw / 2}" y="14" font-weight="bold">${val}</text></g></svg>`;
237
+ }
238
+ /** 1200×630 social card for og:image (white, one big grade). NOTE: some platforms
239
+ * (notably X/Twitter) do not render SVG og:image; Discord/Slack/LinkedIn do. A PNG
240
+ * rasteriser is the future upgrade. */
241
+ function socialCardSvg(r) {
242
+ const color = GRADE_COLOR[r.summary.grade] || "#6b7280";
243
+ const bullets = (r.summary.bullets || []).slice(0, 5);
244
+ const lines = bullets.map((b, i) => `<text x="90" y="${330 + i * 46}" font-size="26" fill="#374151">${xesc(b.replace(/[^\x20-\x7E]/g, "").trim())}</text>`).join("");
245
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
246
+ <rect width="1200" height="630" fill="#ffffff"/><rect x="0" y="0" width="1200" height="8" fill="${color}"/>
247
+ <text x="90" y="120" font-family="Verdana,sans-serif" font-size="26" letter-spacing="4" fill="#6b7280">MNEME · REPO X-RAY</text>
248
+ <rect x="90" y="160" width="120" height="120" rx="24" fill="${color}"/>
249
+ <text x="150" y="252" font-family="Verdana,sans-serif" font-size="74" font-weight="bold" fill="#fff" text-anchor="middle">${xesc(r.summary.grade)}</text>
250
+ <text x="240" y="232" font-family="Verdana,sans-serif" font-size="46" font-weight="bold" fill="#0a0a0a">${xesc(r.subject.repoName)}</text>
251
+ <text x="240" y="272" font-family="Verdana,sans-serif" font-size="24" fill="#6b7280">${xesc(r.summary.headline)}</text>
252
+ ${lines}
253
+ <text x="90" y="590" font-family="Verdana,sans-serif" font-size="20" fill="#16a34a">✓ deterministic · signed · offline-verifiable — no AI guessed any number</text></svg>`;
254
+ }
255
+ /** latest stored report fingerprint for a repo slug like "github/owner/repo". */
256
+ function latestByRepoSlug(slug) {
257
+ try {
258
+ if (!existsSync(BOARD_FILE()))
259
+ return null;
260
+ const ownerRepo = slug.split("/").slice(1).join("/").toLowerCase();
261
+ const host = slug.split("/")[0].toLowerCase();
262
+ const lines = rf(BOARD_FILE(), "utf8").trim().split("\n").filter(Boolean).reverse();
263
+ for (const l of lines) {
264
+ const row = JSON.parse(l);
265
+ const ref = String(row.ref || "").toLowerCase();
266
+ if (ref.includes(host) && ref.includes(ownerRepo) && row.fingerprint) {
267
+ return { fingerprint: row.fingerprint, grade: String(row.grade || "?") };
268
+ }
269
+ }
270
+ }
271
+ catch { /* ignore */ }
272
+ return null;
273
+ }
274
+ function reportPageWithOg(signed, origin) {
275
+ const tpl = readFileSync(join(PUBLIC_DIR, "report.html"), "utf8");
276
+ const r = signed.report;
277
+ const title = `${r.subject.repoName} — Grade ${r.summary.grade} · Mneme X-Ray`;
278
+ const desc = (r.summary.bullets || []).slice(0, 4).map((b) => b.replace(/[^\x20-\x7E]/g, "").trim()).join(" · ") || r.summary.headline;
279
+ const url = `${origin}/r/${r.fingerprint}`;
280
+ const img = `${origin}/og/${r.fingerprint}.svg`;
281
+ const og = [
282
+ `<meta property="og:type" content="website"/>`,
283
+ `<meta property="og:title" content="${xesc(title)}"/>`,
284
+ `<meta property="og:description" content="${xesc(desc)}"/>`,
285
+ `<meta property="og:url" content="${xesc(url)}"/>`,
286
+ `<meta property="og:image" content="${xesc(img)}"/>`,
287
+ `<meta name="twitter:card" content="summary_large_image"/>`,
288
+ `<meta name="twitter:title" content="${xesc(title)}"/>`,
289
+ `<meta name="twitter:description" content="${xesc(desc)}"/>`,
290
+ `<meta name="twitter:image" content="${xesc(img)}"/>`,
291
+ `<meta name="description" content="${xesc(desc)}"/>`,
292
+ ].join("\n");
293
+ return tpl.replace("<title>Mneme · Repo X-Ray</title>", `<title>${xesc(title)}</title>`).replace("<!--OGMETA-->", og);
294
+ }
295
+ export function createXRayServer(monitor) {
296
+ return createServer(async (req, res) => {
297
+ const ip = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket.remoteAddress || "?";
298
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
299
+ if (req.method === "OPTIONS")
300
+ return send(res, 204, "");
301
+ if (req.method === "GET" && url.pathname === "/api/health")
302
+ return send(res, 200, { ok: true, ts: Date.now() });
303
+ if (req.method === "GET" && url.pathname === "/api/board") {
304
+ const offset = parseInt(url.searchParams.get("offset") || "0", 10);
305
+ const limit = parseInt(url.searchParams.get("limit") || "20", 10);
306
+ return send(res, 200, page(aggregateByRepo(readBoardRows()), offset, limit));
307
+ }
308
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html"))
309
+ return serveStatic(res, "index.html");
310
+ if (req.method === "GET" && url.pathname === "/favicon.svg")
311
+ return serveStatic(res, "favicon.svg");
312
+ if (req.method === "GET" && url.pathname === "/card.js")
313
+ return serveStatic(res, "card.js");
314
+ if (req.method === "GET" && url.pathname === "/cosmic")
315
+ return serveStatic(res, "cosmic.html");
316
+ // COSMIC MONITOR (additive superpower for the cosmic-link server) — measured + signed
317
+ if (req.method === "GET" && url.pathname === "/badge/cosmic.svg") {
318
+ if (!monitor)
319
+ return sendSvg(res, cosmicBadgeSvg({ url: "", up: false, lastCheck: null, uptimePct: 0, checks: 0, p50Ms: 0, p95Ms: 0, sinceTs: null, windowMs: 0 }), 30);
320
+ return sendSvg(res, cosmicBadgeSvg(monitor.status()), 30);
321
+ }
322
+ if (req.method === "GET" && url.pathname === "/api/cosmic/status") {
323
+ if (!monitor)
324
+ return send(res, 200, { configured: false, note: "cosmic monitor not enabled on this instance" });
325
+ const st = monitor.status();
326
+ return send(res, 200, { ...st, attestation: signCosmicStatus(process.cwd(), st) });
327
+ }
328
+ // embeddable badge — /badge/<fingerprint>.svg OR /badge/github/owner/repo.svg
329
+ if (req.method === "GET" && url.pathname.startsWith("/badge/") && url.pathname.endsWith(".svg")) {
330
+ const target = decodeURIComponent(url.pathname.slice("/badge/".length, -4));
331
+ let grade = "?";
332
+ if (FP_RE.test(target)) {
333
+ grade = getReport(target)?.report.summary.grade ?? "?";
334
+ }
335
+ else {
336
+ grade = latestByRepoSlug(target)?.grade ?? "?";
337
+ }
338
+ return sendSvg(res, badgeSvg(grade));
339
+ }
340
+ // social card (og:image) — /og/<fingerprint>.svg
341
+ if (req.method === "GET" && url.pathname.startsWith("/og/") && url.pathname.endsWith(".svg")) {
342
+ const fp = decodeURIComponent(url.pathname.slice("/og/".length, -4));
343
+ const signed = getReport(fp);
344
+ if (!signed || getReportMeta(fp)?.visibility === "private")
345
+ return send(res, 404, { error: "not found" });
346
+ return sendSvg(res, socialCardSvg(signed.report));
347
+ }
348
+ // shareable permalink — /r/<fingerprint> (server-renders OG meta for social previews)
349
+ if (req.method === "GET" && url.pathname.startsWith("/r/")) {
350
+ const fp = decodeURIComponent(url.pathname.slice("/r/".length).replace(/\/$/, ""));
351
+ const signed = getReport(fp);
352
+ const meta = getReportMeta(fp);
353
+ const origin = `${req.headers["x-forwarded-proto"] || "http"}://${req.headers.host || "localhost"}`;
354
+ // private (or unknown) → serve the bare shell with NO OG meta, so the repo
355
+ // name never leaks; the page's API fetch will 404 for anyone but the owner.
356
+ if (!signed || meta?.visibility === "private") {
357
+ return send(res, 200, readFileSync(join(PUBLIC_DIR, "report.html"), "utf8"), "text/html; charset=utf-8");
358
+ }
359
+ return send(res, 200, reportPageWithOg(signed, origin), "text/html; charset=utf-8");
360
+ }
361
+ // deep-view: fetch a stored full signed report by fingerprint.
362
+ // PRIVATE reports are access-controlled: only the owning key may fetch them,
363
+ // and we return 404 (not 403) so their very existence isn't revealed.
364
+ if (req.method === "GET" && url.pathname.startsWith("/api/report/")) {
365
+ const fp = decodeURIComponent(url.pathname.slice("/api/report/".length));
366
+ const signed = getReport(fp);
367
+ if (!signed)
368
+ return send(res, 404, { error: "report not found" });
369
+ const meta = getReportMeta(fp);
370
+ if (meta?.visibility === "private") {
371
+ const tok = bearer(req);
372
+ if (!tok || profileIdFromToken(tok) !== meta.profileId)
373
+ return send(res, 404, { error: "report not found" });
374
+ }
375
+ return send(res, 200, signed);
376
+ }
377
+ // a profile's reports — aggregated by repo (first/last seen + count), paged.
378
+ // id is the profile hash; knowing it is required to list. Private items live
379
+ // ONLY here, never on the public board.
380
+ if (req.method === "GET" && url.pathname.startsWith("/api/profile/")) {
381
+ const id = decodeURIComponent(url.pathname.slice("/api/profile/".length));
382
+ if (!/^[a-f0-9]{8,32}$/.test(id))
383
+ return send(res, 400, { error: "bad profile id" });
384
+ const offset = parseInt(url.searchParams.get("offset") || "0", 10);
385
+ const limit = parseInt(url.searchParams.get("limit") || "20", 10);
386
+ return send(res, 200, { profileId: id, ...page(aggregateByRepo(readProfileRows(id)), offset, limit) });
387
+ }
388
+ if (req.method === "POST" && url.pathname === "/api/xray") {
389
+ if (rateLimited(ip))
390
+ return send(res, 429, { error: "rate limit — try again in a minute" });
391
+ let body;
392
+ try {
393
+ body = JSON.parse(await readBody(req) || "{}");
394
+ }
395
+ catch {
396
+ return send(res, 400, { error: "invalid JSON" });
397
+ }
398
+ const gitUrl = (body.gitUrl || "").trim();
399
+ if (!isAllowedPublicUrl(gitUrl)) {
400
+ return send(res, 400, { error: "Only public github.com / gitlab.com / bitbucket.org URLs (no credentials) are accepted. For private repos, run mneme-xray locally." });
401
+ }
402
+ try {
403
+ const report = await buildXRay({ gitUrl });
404
+ const leak = xrayLeaksRaw(report);
405
+ if (leak.leaks)
406
+ return send(res, 500, { error: "internal: report failed raw-free gate", reasons: leak.reasons });
407
+ const signed = sealXRay(process.cwd(), report);
408
+ const tok = bearer(req);
409
+ recordBoard(signed); // public repos are public by definition
410
+ saveReport(signed, "public", tok ? profileIdFromToken(tok) : "");
411
+ if (tok)
412
+ recordProfile(profileIdFromToken(tok), report, "public");
413
+ return send(res, 200, signed);
414
+ }
415
+ catch (e) {
416
+ return send(res, 502, { error: e.message.slice(0, 300) });
417
+ }
418
+ }
419
+ // THE BRIDGE — a local agent publishes a report it built on a PRIVATE repo.
420
+ // The server NEVER sees the source: it only verifies the signature + raw-free
421
+ // gate and files the report under the caller's profile.
422
+ if (req.method === "POST" && url.pathname === "/api/ingest") {
423
+ const tok = bearer(req);
424
+ if (!tok)
425
+ return send(res, 401, { error: "missing bearer token (your X-Ray key)" });
426
+ if (rateLimited("ingest:" + ip))
427
+ return send(res, 429, { error: "rate limit — try again in a minute" });
428
+ let signed;
429
+ try {
430
+ signed = JSON.parse(await readBody(req, 2 * 1024 * 1024));
431
+ }
432
+ catch {
433
+ return send(res, 400, { error: "invalid signed report" });
434
+ }
435
+ if (!signed?.report || !signed.receipt)
436
+ return send(res, 400, { error: "expected { report, receipt }" });
437
+ // 1) the report a local agent sends must itself be raw-free
438
+ const leak = xrayLeaksRaw(signed.report);
439
+ if (leak.leaks)
440
+ return send(res, 422, { error: "report is not raw-free — refused", reasons: leak.reasons });
441
+ // 2) the Ed25519 receipt must verify offline
442
+ const v = verifyXRay(signed);
443
+ if (!v.valid)
444
+ return send(res, 422, { error: "signature does not verify: " + v.reason });
445
+ const visibility = signed.report.subject.kind === "git-url" ? "public" : "private";
446
+ saveReport(signed, visibility, profileIdFromToken(tok));
447
+ recordProfile(profileIdFromToken(tok), signed.report, visibility);
448
+ if (visibility === "public")
449
+ recordBoard(signed);
450
+ return send(res, 200, { ok: true, profileId: profileIdFromToken(tok), fingerprint: signed.report.fingerprint });
451
+ }
452
+ if (req.method === "POST" && url.pathname === "/api/verify") {
453
+ try {
454
+ const signed = JSON.parse(await readBody(req));
455
+ return send(res, 200, verifyXRay(signed));
456
+ }
457
+ catch {
458
+ return send(res, 400, { error: "invalid signed report" });
459
+ }
460
+ }
461
+ return send(res, 404, { error: "not found" });
462
+ });
463
+ }
464
+ // run when invoked directly (npm run serve / node dist/server.js)
465
+ const invokedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
466
+ if (invokedDirectly) {
467
+ // COSMIC MONITOR — observe the cosmic-link server over localhost (additive,
468
+ // never touches it). Disable with COSMIC_URL=off.
469
+ const cosmicUrl = process.env.COSMIC_URL ?? "http://127.0.0.1:8081/";
470
+ const monitor = cosmicUrl && cosmicUrl !== "off"
471
+ ? new CosmicMonitor(cosmicUrl, join(dataDir(), "cosmic-samples.jsonl"))
472
+ : undefined;
473
+ if (monitor)
474
+ monitor.start(15000);
475
+ const server = createXRayServer(monitor);
476
+ server.listen(PORT, HOST, () => {
477
+ process.stdout.write(`Mneme X-Ray server on http://${HOST}:${PORT} (data: ${dataDir()})${monitor ? ` · cosmic monitor → ${cosmicUrl}` : ""}\n`);
478
+ });
479
+ process.on("SIGTERM", () => server.close(() => process.exit(0)));
480
+ process.on("SIGINT", () => server.close(() => process.exit(0)));
481
+ }
482
+ //# sourceMappingURL=server.js.map