@sansynx/erroratlas 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.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # ErrorAtlas
2
+
3
+ ErrorAtlas is hosted debugging memory for coding agents.
4
+
5
+ Developers do not clone this repo, create Supabase projects, deploy Workers, or manage ErrorAtlas infrastructure. They install the npm MCP client, generate one hosted MCP key, run Supermemory locally, and let their coding agent search and publish verified fixes.
6
+
7
+ ## End-user flow
8
+
9
+ 1. Open the hosted dashboard:
10
+
11
+ ```txt
12
+ https://erroratlas.sansynx.workers.dev/dashboard
13
+ ```
14
+
15
+ 2. Sign in and click `Generate secret`.
16
+
17
+ Copy the MCP secret immediately. Existing raw secrets cannot be recovered later.
18
+
19
+ 3. Install ErrorAtlas in the project where the coding agent works:
20
+
21
+ ```bash
22
+ npm install -D @sansynx/erroratlas
23
+ npx erroratlas init
24
+ ```
25
+
26
+ 4. Run Supermemory local:
27
+
28
+ ```bash
29
+ OPENAI_API_KEY=your_model_provider_key
30
+ npx supermemory local --port 6767
31
+ ```
32
+
33
+ Windows/WSL fallback:
34
+
35
+ ```bash
36
+ export OPENAI_API_KEY=your_model_provider_key
37
+ PORT=6767 ~/.supermemory/bin/supermemory-server
38
+ ```
39
+
40
+ 5. Add the MCP server to the agent client:
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "erroratlas": {
46
+ "command": "npx",
47
+ "args": ["-y", "--package", "@sansynx/erroratlas", "erroratlas", "mcp"],
48
+ "env": {
49
+ "ERRORATLAS_API_URL": "https://erroratlas.sansynx.workers.dev",
50
+ "ERRORATLAS_API_KEY": "ea_live_from_dashboard",
51
+ "SUPERMEMORY_URL": "http://localhost:6767"
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ If Supermemory local prints an API key, also add:
59
+
60
+ ```txt
61
+ SUPERMEMORY_API_KEY=optional_local_supermemory_key
62
+ ```
63
+
64
+ ## What the CLI creates
65
+
66
+ `npx erroratlas init` creates only an `.erroratlas` folder in the developer project:
67
+
68
+ ```txt
69
+ .erroratlas/config.json
70
+ .erroratlas/instructions.md
71
+ .erroratlas/mcp.json.example
72
+ ```
73
+
74
+ It does not edit `AGENTS.md`, `CLAUDE.md`, Cursor rules, Windsurf rules, or existing project instruction files.
75
+
76
+ ## MCP tools
77
+
78
+ ```txt
79
+ erroratlas.search_error
80
+ erroratlas.capture_error_signal
81
+ erroratlas.record_incident
82
+ erroratlas.publish_resolution
83
+ erroratlas.get_playbook
84
+ ```
85
+
86
+ ## Storage model
87
+
88
+ - Supermemory local stores private project memory on the developer machine.
89
+ - ErrorAtlas remote stores sanitized searchable incidents and playbooks.
90
+ - Agent keys are shown once and stored remotely only as hashes.
91
+ - Sensitive incident payload snapshots are encrypted before database insert.
92
+
93
+ ## Operator setup
94
+
95
+ This section is only for the ErrorAtlas service owner.
96
+
97
+ ```bash
98
+ cp .dev.vars.example .dev.vars
99
+ npm install
100
+ npm run dev
101
+ ```
102
+
103
+ Apply the database schema from:
104
+
105
+ ```txt
106
+ supabase/schema.sql
107
+ ```
108
+
109
+ Set production Worker secrets:
110
+
111
+ ```bash
112
+ npx wrangler secret put PUBLIC_SUPABASE_URL
113
+ npx wrangler secret put PUBLIC_SUPABASE_ANON_KEY
114
+ npx wrangler secret put SUPABASE_SERVICE_ROLE_KEY
115
+ npx wrangler secret put FIELD_ENCRYPTION_KEY
116
+ ```
117
+
118
+ Deploy:
119
+
120
+ ```bash
121
+ npm run deploy
122
+ ```
123
+
124
+ Publish the npm package from an npm account or organization you control:
125
+
126
+ ```bash
127
+ npm login
128
+ npm publish --access public
129
+ ```
130
+
131
+ The package name is `@sansynx/erroratlas` because the unscoped npm name `erroratlas` is already owned by another package. The CLI command remains `erroratlas`.
132
+
133
+ ## Project structure
134
+
135
+ ```txt
136
+ src/worker Cloudflare Worker UI and API
137
+ src/mcp local MCP server for coding agents
138
+ src/cli npm package CLI for init and MCP startup
139
+ src/shared redaction and playbook utilities
140
+ supabase service-owner database schema
141
+ templates files copied by npx erroratlas init
142
+ public Worker-served public assets
143
+ scripts maintainer build and smoke-test scripts
144
+ ```
145
+
146
+ ## Important routes
147
+
148
+ ```txt
149
+ GET / landing page
150
+ GET /setup public setup guide
151
+ GET /dashboard protected MCP key console
152
+ GET /llms.txt machine-readable agent guide
153
+ GET /api/health service health
154
+ POST /api/search search approved fixes
155
+ POST /api/agent-keys create or rotate MCP key
156
+ POST /api/incidents capture sanitized error signal
157
+ POST /api/resolutions publish verified resolution
158
+ ```
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const root = process.cwd();
6
+ const command = process.argv[2] || "help";
7
+ if (command === "init") {
8
+ init();
9
+ }
10
+ else if (command === "mcp") {
11
+ await import("../mcp/server.js");
12
+ }
13
+ else {
14
+ printHelp();
15
+ }
16
+ function printHelp() {
17
+ console.log(`ErrorAtlas
18
+
19
+ Commands:
20
+ init create .erroratlas config and MCP template files
21
+ mcp start the ErrorAtlas MCP server over stdio
22
+ `);
23
+ }
24
+ function init() {
25
+ const files = [
26
+ [".erroratlas/config.json", template("config.json.example")],
27
+ [".erroratlas/instructions.md", template("instructions.md")],
28
+ [".erroratlas/mcp.json.example", template("mcp.json.example")]
29
+ ];
30
+ for (const [target, body] of files) {
31
+ writeIfMissing(join(root, target), body);
32
+ }
33
+ console.log(`Created ErrorAtlas project files:
34
+ - .erroratlas/config.json
35
+ - .erroratlas/instructions.md
36
+ - .erroratlas/mcp.json.example
37
+
38
+ No existing agent instruction file was modified.`);
39
+ }
40
+ function template(name) {
41
+ const projectTemplate = join(root, "templates", name);
42
+ const packageTemplate = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates", name);
43
+ if (existsSync(projectTemplate))
44
+ return readFileSync(projectTemplate, "utf8");
45
+ return readFileSync(packageTemplate, "utf8");
46
+ }
47
+ function writeIfMissing(path, body) {
48
+ if (existsSync(path))
49
+ return;
50
+ mkdirSync(dirname(path), { recursive: true });
51
+ writeFileSync(path, body);
52
+ }
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import { buildPlaybookMarkdown, errorSignature, redactText, titleFromError } from "../shared/redaction.js";
8
+ function localConfig() {
9
+ const path = join(process.cwd(), ".erroratlas", "config.json");
10
+ if (!existsSync(path))
11
+ return {};
12
+ return JSON.parse(readFileSync(path, "utf8"));
13
+ }
14
+ const config = localConfig();
15
+ const apiUrl = (process.env.ERRORATLAS_API_URL || config.apiUrl || "http://localhost:8787").replace(/\/$/, "");
16
+ const agentKey = process.env.ERRORATLAS_API_KEY || "";
17
+ const supermemoryUrl = (process.env.SUPERMEMORY_URL || config.supermemoryUrl || "http://localhost:6767").replace(/\/$/, "");
18
+ const supermemoryKey = process.env.SUPERMEMORY_API_KEY || "";
19
+ const server = new McpServer({
20
+ name: "erroratlas",
21
+ version: "0.1.0"
22
+ });
23
+ server.tool("search_error", {
24
+ error: z.string(),
25
+ stack: z.string().optional(),
26
+ command: z.string().optional(),
27
+ exitCode: z.number().optional(),
28
+ language: z.string().optional(),
29
+ framework: z.string().optional(),
30
+ packageManager: z.string().optional(),
31
+ dependencyVersions: z.record(z.string()).optional(),
32
+ limit: z.number().optional()
33
+ }, async (input) => {
34
+ const local = await searchSupermemory(input);
35
+ const remote = await callApi("/api/search", input);
36
+ return respond({ local, remote });
37
+ });
38
+ server.tool("capture_error_signal", {
39
+ error: z.string(),
40
+ context: z.string().optional(),
41
+ stack: z.string().optional(),
42
+ attemptedFixes: z.array(z.string()).optional(),
43
+ command: z.string().optional(),
44
+ exitCode: z.number().optional(),
45
+ language: z.string().optional(),
46
+ framework: z.string().optional(),
47
+ packageManager: z.string().optional(),
48
+ dependencyVersions: z.record(z.string()).optional(),
49
+ signalType: z.enum(["error", "failed_fix", "verification", "note"]).optional(),
50
+ visibility: z.enum(["private", "team", "public"]).optional()
51
+ }, async (input) => {
52
+ const memory = await writeSupermemory({
53
+ kind: "incident",
54
+ title: titleFromError(input.error),
55
+ error_signature: errorSignature(input.error),
56
+ ...input
57
+ });
58
+ const remote = await callApi("/api/incidents", input);
59
+ return respond({ memory, remote });
60
+ });
61
+ server.tool("record_incident", {
62
+ error: z.string(),
63
+ context: z.string().optional(),
64
+ stack: z.string().optional(),
65
+ attemptedFixes: z.array(z.string()).optional(),
66
+ command: z.string().optional(),
67
+ exitCode: z.number().optional(),
68
+ language: z.string().optional(),
69
+ framework: z.string().optional(),
70
+ packageManager: z.string().optional(),
71
+ dependencyVersions: z.record(z.string()).optional(),
72
+ signalType: z.enum(["error", "failed_fix", "verification", "note"]).optional(),
73
+ visibility: z.enum(["private", "team", "public"]).optional()
74
+ }, async (input) => {
75
+ const memory = await writeSupermemory({
76
+ kind: "incident",
77
+ title: titleFromError(input.error),
78
+ error_signature: errorSignature(input.error),
79
+ ...input
80
+ });
81
+ const remote = await callApi("/api/incidents", input);
82
+ return respond({ memory, remote });
83
+ });
84
+ server.tool("publish_resolution", {
85
+ error: z.string(),
86
+ rootCause: z.string(),
87
+ finalFix: z.string(),
88
+ failedAttempts: z.array(z.string()).optional(),
89
+ verification: z.string().optional(),
90
+ language: z.string().optional(),
91
+ framework: z.string().optional(),
92
+ packageManager: z.string().optional(),
93
+ visibility: z.enum(["private", "team", "public"]).optional(),
94
+ risk: z.enum(["low", "medium", "high"]).optional(),
95
+ confidence: z.enum(["low", "medium", "high"]).optional()
96
+ }, async (input) => {
97
+ const title = titleFromError(input.error);
98
+ const playbookInput = {
99
+ title,
100
+ error: input.error,
101
+ rootCause: input.rootCause,
102
+ finalFix: input.finalFix
103
+ };
104
+ if (input.failedAttempts)
105
+ playbookInput.failedAttempts = input.failedAttempts;
106
+ if (input.verification)
107
+ playbookInput.verification = input.verification;
108
+ if (input.framework)
109
+ playbookInput.framework = input.framework;
110
+ if (input.language)
111
+ playbookInput.language = input.language;
112
+ if (input.risk)
113
+ playbookInput.risk = input.risk;
114
+ if (input.confidence)
115
+ playbookInput.confidence = input.confidence;
116
+ const playbook = buildPlaybookMarkdown(playbookInput);
117
+ const memory = await writeSupermemory({
118
+ kind: "resolution",
119
+ title,
120
+ error_signature: errorSignature(input.error),
121
+ playbook,
122
+ ...input
123
+ });
124
+ const remote = await callApi("/api/resolutions", {
125
+ ...input,
126
+ visibility: input.visibility || config.defaultVisibility || "team"
127
+ });
128
+ return respond({ memory, remote, playbook });
129
+ });
130
+ server.tool("get_playbook", {
131
+ id: z.string()
132
+ }, async (input) => {
133
+ const response = await fetch(`${apiUrl}/api/playbooks/${encodeURIComponent(input.id)}`, {
134
+ headers: authHeaders()
135
+ });
136
+ return respond(await response.json());
137
+ });
138
+ async function callApi(path, body) {
139
+ if (!agentKey) {
140
+ return { skipped: true, reason: "ERRORATLAS_API_KEY is not set" };
141
+ }
142
+ const response = await fetch(`${apiUrl}${path}`, {
143
+ method: "POST",
144
+ headers: {
145
+ ...authHeaders(),
146
+ "Content-Type": "application/json"
147
+ },
148
+ body: JSON.stringify(sanitizeForRemote(body))
149
+ });
150
+ const text = await response.text();
151
+ try {
152
+ return JSON.parse(text);
153
+ }
154
+ catch {
155
+ return { status: response.status, body: text };
156
+ }
157
+ }
158
+ function authHeaders() {
159
+ return agentKey ? { "X-ErrorAtlas-Key": agentKey } : {};
160
+ }
161
+ async function searchSupermemory(input) {
162
+ try {
163
+ const query = redactText(input);
164
+ const response = await fetch(`${supermemoryUrl}/v3/search`, {
165
+ method: "POST",
166
+ headers: supermemoryHeaders(),
167
+ body: JSON.stringify({ q: query, limit: 5 })
168
+ });
169
+ if (!response.ok)
170
+ return { skipped: true, status: response.status };
171
+ return response.json();
172
+ }
173
+ catch (error) {
174
+ return { skipped: true, reason: error instanceof Error ? error.message : String(error) };
175
+ }
176
+ }
177
+ async function writeSupermemory(payload) {
178
+ try {
179
+ const response = await fetch(`${supermemoryUrl}/v3/documents`, {
180
+ method: "POST",
181
+ headers: supermemoryHeaders(),
182
+ body: JSON.stringify({
183
+ content: localMemoryContent(payload),
184
+ metadata: { source: "erroratlas" }
185
+ })
186
+ });
187
+ if (!response.ok)
188
+ return { skipped: true, status: response.status };
189
+ return response.json();
190
+ }
191
+ catch (error) {
192
+ return { skipped: true, reason: error instanceof Error ? error.message : String(error) };
193
+ }
194
+ }
195
+ function supermemoryHeaders() {
196
+ return {
197
+ "Content-Type": "application/json",
198
+ ...(supermemoryKey ? { Authorization: `Bearer ${supermemoryKey}` } : {})
199
+ };
200
+ }
201
+ function localMemoryContent(payload) {
202
+ const content = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
203
+ return content.slice(0, 20000);
204
+ }
205
+ function sanitizeForRemote(value) {
206
+ if (typeof value === "string")
207
+ return redactText(value);
208
+ if (Array.isArray(value))
209
+ return value.map(sanitizeForRemote);
210
+ if (!value || typeof value !== "object")
211
+ return value;
212
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [redactText(key), sanitizeForRemote(entry)]));
213
+ }
214
+ function respond(value) {
215
+ return {
216
+ content: [
217
+ {
218
+ type: "text",
219
+ text: JSON.stringify(value, null, 2)
220
+ }
221
+ ]
222
+ };
223
+ }
224
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,56 @@
1
+ const TOKEN_PATTERNS = [
2
+ [/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g, "[redacted-private-key]"],
3
+ [/\b(?:sk|pk|ghp|github_pat|xoxb|xoxp|ea_live|ea_test)_[A-Za-z0-9_\-=]{12,}\b/g, "[redacted-token]"],
4
+ [/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, "[redacted-jwt]"],
5
+ [/\b[A-Fa-f0-9]{32,}\b/g, "[redacted-hex-secret]"],
6
+ [/[A-Z]:\\Users\\[^\\\s]+/gi, "C:\\Users\\[user]"],
7
+ [/\/Users\/[^/\s]+/g, "/Users/[user]"],
8
+ [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted-email]"],
9
+ [/https?:\/\/[^\s"'<>)]*/gi, "[redacted-url]"],
10
+ [/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[redacted-ip]"]
11
+ ];
12
+ export function redactText(value) {
13
+ const input = typeof value === "string" ? value : JSON.stringify(value ?? "");
14
+ return TOKEN_PATTERNS.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), input).slice(0, 12000);
15
+ }
16
+ export function errorSignature(error) {
17
+ return redactText(error)
18
+ .replace(/\b0x[a-f0-9]+\b/gi, "0xADDR")
19
+ .replace(/\b\d{4,}\b/g, "N")
20
+ .replace(/\s+/g, " ")
21
+ .trim()
22
+ .slice(0, 240);
23
+ }
24
+ export function titleFromError(error) {
25
+ const firstLine = redactText(error).split(/\r?\n/).find(Boolean) ?? "Untitled incident";
26
+ return firstLine.replace(/\s+/g, " ").trim().slice(0, 96);
27
+ }
28
+ export function buildPlaybookMarkdown(input) {
29
+ const failed = input.failedAttempts?.length
30
+ ? input.failedAttempts.map((attempt) => `- ${redactText(attempt)}`).join("\n")
31
+ : "- None recorded";
32
+ return [
33
+ `# ${redactText(input.title)}`,
34
+ "",
35
+ "## Error",
36
+ redactText(input.error),
37
+ "",
38
+ "## Root Cause",
39
+ redactText(input.rootCause),
40
+ "",
41
+ "## Fix",
42
+ redactText(input.finalFix),
43
+ "",
44
+ "## Failed Attempts",
45
+ failed,
46
+ "",
47
+ "## Verification",
48
+ redactText(input.verification || "Not recorded"),
49
+ "",
50
+ "## Context",
51
+ `Language: ${redactText(input.language || "unknown")}`,
52
+ `Framework: ${redactText(input.framework || "unknown")}`,
53
+ `Risk: ${redactText(input.risk || "medium")}`,
54
+ `Confidence: ${redactText(input.confidence || "medium")}`
55
+ ].join("\n");
56
+ }
@@ -0,0 +1 @@
1
+ export const faviconSvg = "<svg width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <rect width=\"64\" height=\"64\" rx=\"16\" fill=\"#0F0F0E\"/>\n <path d=\"M18 42V20H47\" stroke=\"#F4EEE7\" stroke-width=\"5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n <path d=\"M18 42H46\" stroke=\"#FF5A1F\" stroke-width=\"5\" stroke-linecap=\"round\"/>\n <path d=\"M28 31H43\" stroke=\"#F4EEE7\" stroke-width=\"5\" stroke-linecap=\"round\"/>\n</svg>";
@@ -0,0 +1,36 @@
1
+ import { HttpError } from "./http";
2
+ import { redactText } from "../shared/redaction";
3
+ function decodeKey(value) {
4
+ const trimmed = value.trim();
5
+ if (/^[a-f0-9]{64}$/i.test(trimmed)) {
6
+ return new Uint8Array(trimmed.match(/.{2}/g)?.map((part) => Number.parseInt(part, 16)) || []);
7
+ }
8
+ const normalized = trimmed.replace(/-/g, "+").replace(/_/g, "/");
9
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
10
+ const binary = atob(padded);
11
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
12
+ }
13
+ async function importEncryptionKey(env) {
14
+ if (!env.FIELD_ENCRYPTION_KEY) {
15
+ throw new HttpError(503, "encryption_not_configured", "FIELD_ENCRYPTION_KEY is required before storing incident or resolution payloads.");
16
+ }
17
+ const raw = decodeKey(env.FIELD_ENCRYPTION_KEY);
18
+ if (raw.byteLength !== 32) {
19
+ throw new HttpError(503, "invalid_encryption_key", "FIELD_ENCRYPTION_KEY must decode to 32 bytes.");
20
+ }
21
+ const keyData = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength);
22
+ return crypto.subtle.importKey("raw", keyData, "AES-GCM", false, ["encrypt", "decrypt"]);
23
+ }
24
+ function base64(bytes) {
25
+ let binary = "";
26
+ for (const byte of bytes)
27
+ binary += String.fromCharCode(byte);
28
+ return btoa(binary);
29
+ }
30
+ export async function encryptSensitivePayload(env, value) {
31
+ const key = await importEncryptionKey(env);
32
+ const iv = crypto.getRandomValues(new Uint8Array(12));
33
+ const plaintext = new TextEncoder().encode(redactText(value));
34
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext));
35
+ return `v1:${base64(iv)}:${base64(ciphertext)}`;
36
+ }
@@ -0,0 +1,84 @@
1
+ export class HttpError extends Error {
2
+ status;
3
+ code;
4
+ constructor(status, code, message) {
5
+ super(message);
6
+ this.status = status;
7
+ this.code = code;
8
+ }
9
+ }
10
+ const corsHeaders = {
11
+ "Access-Control-Allow-Origin": "*",
12
+ "Access-Control-Allow-Methods": "GET,POST,PATCH,DELETE,OPTIONS",
13
+ "Access-Control-Allow-Headers": "Content-Type,Authorization,X-ErrorAtlas-Key"
14
+ };
15
+ export function json(data, status = 200, headers = {}) {
16
+ return new Response(JSON.stringify(data), {
17
+ status,
18
+ headers: {
19
+ "Content-Type": "application/json; charset=utf-8",
20
+ "Cache-Control": "no-store",
21
+ ...corsHeaders,
22
+ ...headers
23
+ }
24
+ });
25
+ }
26
+ export function html(data, status = 200) {
27
+ return new Response(data, {
28
+ status,
29
+ headers: {
30
+ "Content-Type": "text/html; charset=utf-8",
31
+ "Cache-Control": "no-store"
32
+ }
33
+ });
34
+ }
35
+ export function text(data, status = 200, contentType = "text/plain; charset=utf-8") {
36
+ return new Response(data, {
37
+ status,
38
+ headers: {
39
+ "Content-Type": contentType,
40
+ "Cache-Control": "public, max-age=300",
41
+ ...corsHeaders
42
+ }
43
+ });
44
+ }
45
+ export function options() {
46
+ return new Response(null, { status: 204, headers: corsHeaders });
47
+ }
48
+ export async function readJson(request) {
49
+ try {
50
+ return (await request.json());
51
+ }
52
+ catch {
53
+ throw new HttpError(400, "invalid_json", "Request body must be valid JSON.");
54
+ }
55
+ }
56
+ export function bearerToken(request) {
57
+ const value = request.headers.get("Authorization");
58
+ if (!value?.startsWith("Bearer "))
59
+ return null;
60
+ return value.slice("Bearer ".length).trim();
61
+ }
62
+ export async function sha256Hex(value) {
63
+ const bytes = new TextEncoder().encode(value);
64
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
65
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
66
+ }
67
+ export function randomToken(prefix = "ea_live") {
68
+ const bytes = new Uint8Array(32);
69
+ crypto.getRandomValues(bytes);
70
+ const body = btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
71
+ return `${prefix}_${body}`;
72
+ }
73
+ export function clampLimit(value, fallback = 8) {
74
+ const parsed = typeof value === "number" ? value : Number(value);
75
+ if (!Number.isFinite(parsed))
76
+ return fallback;
77
+ return Math.max(1, Math.min(20, Math.trunc(parsed)));
78
+ }
79
+ export function handleError(error) {
80
+ if (error instanceof HttpError) {
81
+ return json({ error: error.code, message: error.message }, error.status);
82
+ }
83
+ return json({ error: "internal_error", message: "Unexpected server error." }, 500);
84
+ }