@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 +158 -0
- package/dist/cli/index.js +52 -0
- package/dist/mcp/server.js +224 -0
- package/dist/shared/redaction.js +56 -0
- package/dist/worker/assets.js +1 -0
- package/dist/worker/crypto.js +36 -0
- package/dist/worker/http.js +84 -0
- package/dist/worker/index.js +447 -0
- package/dist/worker/supabase.js +199 -0
- package/dist/worker/types.js +1 -0
- package/dist/worker/ui.js +2295 -0
- package/package.json +53 -0
- package/public/favicon.svg +6 -0
- package/templates/config.json.example +7 -0
- package/templates/instructions.md +13 -0
- package/templates/mcp.json.example +14 -0
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
|
+
}
|