@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
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { buildPlaybookMarkdown, errorSignature, redactText, titleFromError } from "../shared/redaction";
|
|
2
|
+
import { faviconSvg } from "./assets";
|
|
3
|
+
import { encryptSensitivePayload } from "./crypto";
|
|
4
|
+
import { clampLimit, handleError, html, json, options, randomToken, readJson, sha256Hex, text } from "./http";
|
|
5
|
+
import { configured, requireScope, resolveActor, supabaseRest } from "./supabase";
|
|
6
|
+
import { renderDashboardUi, renderLandingUi, renderLlmsTxt, renderSetupGuideUi } from "./ui";
|
|
7
|
+
const selectPlaybook = "id,org_id,approved_by,title,error_signature,language,framework,package_manager,root_cause,playbook_md,verification_command,risk,confidence,visibility,worked_count,failed_count,created_at";
|
|
8
|
+
const emptyDashboard = {
|
|
9
|
+
agentKeys: []
|
|
10
|
+
};
|
|
11
|
+
const missingSchemaDashboard = {
|
|
12
|
+
...emptyDashboard,
|
|
13
|
+
setupWarning: "Your workspace storage is not initialized yet. Ask the workspace owner to finish setup, then refresh."
|
|
14
|
+
};
|
|
15
|
+
function isMissingSupabaseSchema(error) {
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
return /404|not found|Could not find the table|relation .* does not exist|schema cache/i.test(message);
|
|
18
|
+
}
|
|
19
|
+
export default {
|
|
20
|
+
async fetch(request, env, ctx) {
|
|
21
|
+
if (request.method === "OPTIONS")
|
|
22
|
+
return options();
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
26
|
+
return html(renderLandingUi(env));
|
|
27
|
+
}
|
|
28
|
+
if (request.method === "GET" && (url.pathname === "/favicon.svg" || url.pathname === "/public/favicon.svg")) {
|
|
29
|
+
return text(faviconSvg, 200, "image/svg+xml; charset=utf-8");
|
|
30
|
+
}
|
|
31
|
+
if (request.method === "GET" && url.pathname === "/setup") {
|
|
32
|
+
return html(renderSetupGuideUi(env, url.origin));
|
|
33
|
+
}
|
|
34
|
+
if (request.method === "GET" && (url.pathname === "/dashboard" || url.pathname.startsWith("/dashboard/"))) {
|
|
35
|
+
return html(renderDashboardUi(env));
|
|
36
|
+
}
|
|
37
|
+
if (request.method === "GET" && url.pathname === "/llms.txt") {
|
|
38
|
+
return text(renderLlmsTxt(url.origin));
|
|
39
|
+
}
|
|
40
|
+
if (request.method === "GET" && url.pathname === "/api/config") {
|
|
41
|
+
const missing = [
|
|
42
|
+
env.PUBLIC_SUPABASE_URL ? "" : "PUBLIC_SUPABASE_URL",
|
|
43
|
+
env.PUBLIC_SUPABASE_ANON_KEY ? "" : "PUBLIC_SUPABASE_ANON_KEY",
|
|
44
|
+
env.SUPABASE_SERVICE_ROLE_KEY ? "" : "SUPABASE_SERVICE_ROLE_KEY",
|
|
45
|
+
env.FIELD_ENCRYPTION_KEY ? "" : "FIELD_ENCRYPTION_KEY"
|
|
46
|
+
].filter(Boolean);
|
|
47
|
+
return json({
|
|
48
|
+
appName: env.APP_NAME || "ErrorAtlas",
|
|
49
|
+
supabaseUrl: env.PUBLIC_SUPABASE_URL || "",
|
|
50
|
+
supabaseAnonKey: env.PUBLIC_SUPABASE_ANON_KEY || "",
|
|
51
|
+
hasSupabaseUrl: Boolean(env.PUBLIC_SUPABASE_URL),
|
|
52
|
+
hasSupabaseAnonKey: Boolean(env.PUBLIC_SUPABASE_ANON_KEY),
|
|
53
|
+
hasSupabaseServiceRoleKey: Boolean(env.SUPABASE_SERVICE_ROLE_KEY),
|
|
54
|
+
hasFieldEncryptionKey: Boolean(env.FIELD_ENCRYPTION_KEY),
|
|
55
|
+
missing
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (request.method === "GET" && url.pathname === "/api/health") {
|
|
59
|
+
return json({
|
|
60
|
+
ok: true,
|
|
61
|
+
configured: configured(env),
|
|
62
|
+
encryptionConfigured: Boolean(env.FIELD_ENCRYPTION_KEY),
|
|
63
|
+
service: "erroratlas"
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (request.method === "GET" && url.pathname === "/api/dashboard") {
|
|
67
|
+
return getDashboard(env, request);
|
|
68
|
+
}
|
|
69
|
+
if (request.method === "GET" && url.pathname === "/api/playbooks") {
|
|
70
|
+
return getPlaybooks(env, request);
|
|
71
|
+
}
|
|
72
|
+
if (request.method === "GET" && url.pathname.startsWith("/api/playbooks/")) {
|
|
73
|
+
return getPlaybook(env, request, url.pathname.split("/").at(-1) || "");
|
|
74
|
+
}
|
|
75
|
+
if (request.method === "POST" && url.pathname === "/api/search") {
|
|
76
|
+
return search(env, request);
|
|
77
|
+
}
|
|
78
|
+
if (request.method === "POST" && url.pathname === "/api/agent-keys") {
|
|
79
|
+
return createAgentKey(env, request, ctx);
|
|
80
|
+
}
|
|
81
|
+
if (request.method === "POST" && /^\/api\/agent-keys\/[^/]+\/rotate$/.test(url.pathname)) {
|
|
82
|
+
const id = url.pathname.split("/")[3] || "";
|
|
83
|
+
return rotateAgentKey(env, request, ctx, id);
|
|
84
|
+
}
|
|
85
|
+
if (request.method === "POST" && url.pathname === "/api/incidents") {
|
|
86
|
+
return createIncident(env, request, ctx);
|
|
87
|
+
}
|
|
88
|
+
if (request.method === "POST" && url.pathname === "/api/resolutions") {
|
|
89
|
+
return createResolution(env, request, ctx);
|
|
90
|
+
}
|
|
91
|
+
return json({ error: "not_found" }, 404);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return handleError(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
async function getDashboard(env, request) {
|
|
99
|
+
if (!configured(env)) {
|
|
100
|
+
return json({
|
|
101
|
+
...emptyDashboard,
|
|
102
|
+
setupWarning: "This ErrorAtlas workspace is not connected yet. Finish workspace setup before using the dashboard."
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const actor = await resolveActor(env, request);
|
|
107
|
+
if (!actor || actor.kind !== "human")
|
|
108
|
+
return json({ error: "unauthorized", message: "Sign in to view the dashboard." }, 401);
|
|
109
|
+
const agentKeys = await supabaseRest(env, `agent_keys?select=id,name,key_prefix,created_at&org_id=eq.${actor.orgId}&revoked_at=is.null&order=created_at.desc&limit=20`);
|
|
110
|
+
await revokeStaleAgentKeys(env, actor, agentKeys);
|
|
111
|
+
return json({ agentKeys: agentKeys.slice(0, 1) });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (isMissingSupabaseSchema(error))
|
|
115
|
+
return json(missingSchemaDashboard);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function getPlaybooks(env, request) {
|
|
120
|
+
const actor = await resolveActor(env, request, false);
|
|
121
|
+
const query = actor
|
|
122
|
+
? `playbooks?select=${selectPlaybook}&or=(visibility.eq.public,org_id.eq.${actor.orgId})&order=worked_count.desc&limit=50`
|
|
123
|
+
: `playbooks?select=${selectPlaybook}&visibility=eq.public&order=worked_count.desc&limit=50`;
|
|
124
|
+
const rows = configured(env) ? await supabaseRest(env, query) : [];
|
|
125
|
+
const playbooks = configured(env) ? await enrichPlaybookAuthors(env, rows) : [];
|
|
126
|
+
return json({ playbooks });
|
|
127
|
+
}
|
|
128
|
+
async function getPlaybook(env, request, id) {
|
|
129
|
+
const actor = await resolveActor(env, request, false);
|
|
130
|
+
const visibility = actor ? `or=(visibility.eq.public,org_id.eq.${actor.orgId})&` : "visibility=eq.public&";
|
|
131
|
+
const rows = await supabaseRest(env, `playbooks?select=${selectPlaybook}&${visibility}id=eq.${encodeURIComponent(id)}&limit=1`);
|
|
132
|
+
const playbooks = await enrichPlaybookAuthors(env, rows);
|
|
133
|
+
return json({ playbook: playbooks[0] || null });
|
|
134
|
+
}
|
|
135
|
+
async function search(env, request) {
|
|
136
|
+
const actor = await resolveActor(env, request, false);
|
|
137
|
+
if (actor)
|
|
138
|
+
requireScope(actor, "playbooks:read");
|
|
139
|
+
const payload = await readJson(request);
|
|
140
|
+
const query = [payload.query, payload.error, payload.stack, payload.framework, payload.language, payload.packageManager]
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join(" ");
|
|
143
|
+
const limit = clampLimit(payload.limit, 8);
|
|
144
|
+
if (!configured(env)) {
|
|
145
|
+
return json({ query: redactText(query), results: [] });
|
|
146
|
+
}
|
|
147
|
+
const scopeFilter = actor
|
|
148
|
+
? `or=(visibility.eq.public,org_id.eq.${actor.orgId})`
|
|
149
|
+
: "visibility=eq.public";
|
|
150
|
+
const rows = await supabaseRest(env, `playbooks?select=${selectPlaybook}&${scopeFilter}&limit=100`);
|
|
151
|
+
const results = await enrichPlaybookAuthors(env, rank(rows, query).slice(0, limit));
|
|
152
|
+
return json({
|
|
153
|
+
query: redactText(query),
|
|
154
|
+
scope: actor ? "workspace_and_public" : "public",
|
|
155
|
+
results
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async function createAgentKey(env, request, ctx) {
|
|
159
|
+
const actor = await resolveActor(env, request);
|
|
160
|
+
if (!actor || actor.kind !== "human")
|
|
161
|
+
return json({ error: "unauthorized", message: "Sign in before creating agent keys." }, 401);
|
|
162
|
+
const body = await readJson(request);
|
|
163
|
+
const input = {};
|
|
164
|
+
if (typeof body?.projectId === "string" && body.projectId.trim())
|
|
165
|
+
input.projectId = body.projectId;
|
|
166
|
+
if (Array.isArray(body?.scopes))
|
|
167
|
+
input.scopes = body.scopes.filter((scope) => typeof scope === "string");
|
|
168
|
+
const result = await insertAgentKey(env, actor, input);
|
|
169
|
+
ctx.waitUntil(audit(env, actor, "agent_key.created", "agent_key", result.id));
|
|
170
|
+
return json(result, 201);
|
|
171
|
+
}
|
|
172
|
+
async function rotateAgentKey(env, request, ctx, id) {
|
|
173
|
+
const actor = await resolveActor(env, request);
|
|
174
|
+
if (!actor || actor.kind !== "human") {
|
|
175
|
+
return json({ error: "unauthorized", message: "Sign in before rotating agent keys." }, 401);
|
|
176
|
+
}
|
|
177
|
+
const rows = await supabaseRest(env, `agent_keys?select=id,name,project_id,scopes&org_id=eq.${actor.orgId}&id=eq.${encodeURIComponent(id)}&revoked_at=is.null&limit=1`);
|
|
178
|
+
const current = rows[0];
|
|
179
|
+
if (!current)
|
|
180
|
+
return json({ error: "not_found", message: "Agent key was not found or already revoked." }, 404);
|
|
181
|
+
await supabaseRest(env, `agent_keys?id=eq.${encodeURIComponent(current.id)}&org_id=eq.${actor.orgId}`, {
|
|
182
|
+
method: "PATCH",
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
revoked_at: new Date().toISOString(),
|
|
185
|
+
revoked_by: actor.userId,
|
|
186
|
+
revoked_reason: "rotated"
|
|
187
|
+
})
|
|
188
|
+
});
|
|
189
|
+
const rotateInput = {
|
|
190
|
+
scopes: current.scopes,
|
|
191
|
+
rotatedFrom: current.id
|
|
192
|
+
};
|
|
193
|
+
if (current.project_id)
|
|
194
|
+
rotateInput.projectId = current.project_id;
|
|
195
|
+
const result = await insertAgentKey(env, actor, rotateInput);
|
|
196
|
+
ctx.waitUntil(audit(env, actor, "agent_key.rotated", "agent_key", result.id));
|
|
197
|
+
return json(result, 201);
|
|
198
|
+
}
|
|
199
|
+
async function insertAgentKey(env, actor, input) {
|
|
200
|
+
const key = randomToken("ea_live");
|
|
201
|
+
const hash = await sha256Hex(key);
|
|
202
|
+
const allowedScopes = new Set(["playbooks:read", "incidents:write", "resolutions:publish"]);
|
|
203
|
+
const requestedScopes = input.scopes?.filter((scope) => allowedScopes.has(scope)) || [];
|
|
204
|
+
const scopes = requestedScopes.length ? requestedScopes : ["playbooks:read", "incidents:write", "resolutions:publish"];
|
|
205
|
+
const name = `agent-${hash.slice(0, 8)}`;
|
|
206
|
+
await revokeActiveAgentKeys(env, actor, input.rotatedFrom ? "rotated" : "replaced");
|
|
207
|
+
const rows = await supabaseRest(env, "agent_keys", {
|
|
208
|
+
method: "POST",
|
|
209
|
+
body: JSON.stringify([
|
|
210
|
+
{
|
|
211
|
+
org_id: actor.orgId,
|
|
212
|
+
project_id: input.projectId || null,
|
|
213
|
+
created_by: actor.kind === "human" ? actor.userId : null,
|
|
214
|
+
name,
|
|
215
|
+
secret_hash: hash,
|
|
216
|
+
key_prefix: key.slice(0, 14),
|
|
217
|
+
rotated_from: input.rotatedFrom || null,
|
|
218
|
+
scopes
|
|
219
|
+
}
|
|
220
|
+
])
|
|
221
|
+
});
|
|
222
|
+
const id = rows[0]?.id;
|
|
223
|
+
if (!id)
|
|
224
|
+
throw new Error("Agent key was created, but no id was returned.");
|
|
225
|
+
return { key, id, name, keyPrefix: key.slice(0, 14), scopes };
|
|
226
|
+
}
|
|
227
|
+
async function revokeActiveAgentKeys(env, actor, reason) {
|
|
228
|
+
await supabaseRest(env, `agent_keys?org_id=eq.${actor.orgId}&revoked_at=is.null`, {
|
|
229
|
+
method: "PATCH",
|
|
230
|
+
body: JSON.stringify({
|
|
231
|
+
revoked_at: new Date().toISOString(),
|
|
232
|
+
revoked_by: actor.kind === "human" ? actor.userId : null,
|
|
233
|
+
revoked_reason: reason
|
|
234
|
+
})
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
async function revokeStaleAgentKeys(env, actor, keys) {
|
|
238
|
+
const staleIds = keys
|
|
239
|
+
.slice(1)
|
|
240
|
+
.map((key) => (typeof key.id === "string" ? key.id : ""))
|
|
241
|
+
.filter(Boolean);
|
|
242
|
+
if (!staleIds.length)
|
|
243
|
+
return;
|
|
244
|
+
await supabaseRest(env, `agent_keys?org_id=eq.${actor.orgId}&id=in.(${staleIds.map(encodeURIComponent).join(",")})`, {
|
|
245
|
+
method: "PATCH",
|
|
246
|
+
body: JSON.stringify({
|
|
247
|
+
revoked_at: new Date().toISOString(),
|
|
248
|
+
revoked_by: actor.kind === "human" ? actor.userId : null,
|
|
249
|
+
revoked_reason: "single_active_key_cleanup"
|
|
250
|
+
})
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async function createIncident(env, request, ctx) {
|
|
254
|
+
const actor = await resolveActor(env, request);
|
|
255
|
+
if (!actor)
|
|
256
|
+
return json({ error: "unauthorized", message: "Sign in or provide an agent key before recording incidents." }, 401);
|
|
257
|
+
if (actor)
|
|
258
|
+
requireScope(actor, "incidents:write");
|
|
259
|
+
const body = await readJson(request);
|
|
260
|
+
const error = String(body.error || body.title || "Untitled incident");
|
|
261
|
+
const projectId = typeof body.projectId === "string" && body.projectId
|
|
262
|
+
? body.projectId
|
|
263
|
+
: actor.kind === "agent"
|
|
264
|
+
? actor.projectId || null
|
|
265
|
+
: null;
|
|
266
|
+
const rows = await supabaseRest(env, "incidents", {
|
|
267
|
+
method: "POST",
|
|
268
|
+
body: JSON.stringify([
|
|
269
|
+
{
|
|
270
|
+
org_id: actor.orgId,
|
|
271
|
+
project_id: projectId,
|
|
272
|
+
created_by: actor.kind === "human" ? actor.userId : null,
|
|
273
|
+
created_by_agent_key: actor.kind === "agent" ? actor.keyId : null,
|
|
274
|
+
title: titleFromError(error),
|
|
275
|
+
error_signature: errorSignature(error),
|
|
276
|
+
signal_type: safeSignalType(body.signalType),
|
|
277
|
+
language: body.language ? redactText(body.language) : null,
|
|
278
|
+
framework: body.framework ? redactText(body.framework) : null,
|
|
279
|
+
package_manager: body.packageManager ? redactText(body.packageManager) : null,
|
|
280
|
+
command: body.command ? redactText(body.command).slice(0, 500) : null,
|
|
281
|
+
exit_code: safeInteger(body.exitCode),
|
|
282
|
+
dependency_versions: safeRecord(body.dependencyVersions),
|
|
283
|
+
redacted_context: redactText(signalSummary(body)),
|
|
284
|
+
encrypted_context: await encryptSensitivePayload(env, body),
|
|
285
|
+
visibility: safeVisibility(body.visibility)
|
|
286
|
+
}
|
|
287
|
+
])
|
|
288
|
+
});
|
|
289
|
+
ctx.waitUntil(audit(env, actor, "incident.created", "incident", rows[0]?.id));
|
|
290
|
+
return json({ incidentId: rows[0]?.id, signalId: rows[0]?.id }, 201);
|
|
291
|
+
}
|
|
292
|
+
async function createResolution(env, request, ctx) {
|
|
293
|
+
const actor = await resolveActor(env, request);
|
|
294
|
+
if (!actor)
|
|
295
|
+
return json({ error: "unauthorized", message: "Sign in or provide an agent key before publishing resolutions." }, 401);
|
|
296
|
+
if (actor)
|
|
297
|
+
requireScope(actor, "resolutions:publish");
|
|
298
|
+
const body = await readJson(request);
|
|
299
|
+
const title = titleFromError(body.error);
|
|
300
|
+
const signature = errorSignature(body.error);
|
|
301
|
+
const playbookInput = {
|
|
302
|
+
title,
|
|
303
|
+
error: body.error,
|
|
304
|
+
rootCause: body.rootCause,
|
|
305
|
+
finalFix: body.finalFix
|
|
306
|
+
};
|
|
307
|
+
if (body.failedAttempts)
|
|
308
|
+
playbookInput.failedAttempts = body.failedAttempts;
|
|
309
|
+
if (body.verification)
|
|
310
|
+
playbookInput.verification = body.verification;
|
|
311
|
+
if (body.framework)
|
|
312
|
+
playbookInput.framework = body.framework;
|
|
313
|
+
if (body.language)
|
|
314
|
+
playbookInput.language = body.language;
|
|
315
|
+
if (body.risk)
|
|
316
|
+
playbookInput.risk = body.risk;
|
|
317
|
+
if (body.confidence)
|
|
318
|
+
playbookInput.confidence = body.confidence;
|
|
319
|
+
const playbookMd = buildPlaybookMarkdown(playbookInput);
|
|
320
|
+
const rows = await supabaseRest(env, "playbooks", {
|
|
321
|
+
method: "POST",
|
|
322
|
+
body: JSON.stringify([
|
|
323
|
+
{
|
|
324
|
+
org_id: actor.orgId,
|
|
325
|
+
project_id: body.projectId || (actor.kind === "agent" ? actor.projectId : null),
|
|
326
|
+
approved_by: actor.kind === "human" ? actor.userId : null,
|
|
327
|
+
title,
|
|
328
|
+
error_signature: signature,
|
|
329
|
+
language: body.language ? redactText(body.language) : null,
|
|
330
|
+
framework: body.framework ? redactText(body.framework) : null,
|
|
331
|
+
package_manager: body.packageManager ? redactText(body.packageManager) : null,
|
|
332
|
+
root_cause: redactText(body.rootCause),
|
|
333
|
+
verification_command: body.verification ? redactText(body.verification) : null,
|
|
334
|
+
playbook_md: playbookMd,
|
|
335
|
+
risk: body.risk || "medium",
|
|
336
|
+
confidence: body.confidence || "medium",
|
|
337
|
+
visibility: body.visibility || "team"
|
|
338
|
+
}
|
|
339
|
+
])
|
|
340
|
+
});
|
|
341
|
+
ctx.waitUntil(audit(env, actor, "resolution.published", "playbook", rows[0]?.id));
|
|
342
|
+
return json({ status: "published", playbookId: rows[0]?.id }, 201);
|
|
343
|
+
}
|
|
344
|
+
function rank(rows, query) {
|
|
345
|
+
const terms = redactText(query)
|
|
346
|
+
.toLowerCase()
|
|
347
|
+
.split(/[^a-z0-9_@.-]+/)
|
|
348
|
+
.filter((term) => term.length > 2);
|
|
349
|
+
return rows
|
|
350
|
+
.map((row) => {
|
|
351
|
+
const haystack = [
|
|
352
|
+
row.title,
|
|
353
|
+
row.error_signature,
|
|
354
|
+
row.framework,
|
|
355
|
+
row.language,
|
|
356
|
+
row.root_cause,
|
|
357
|
+
row.playbook_md
|
|
358
|
+
]
|
|
359
|
+
.join(" ")
|
|
360
|
+
.toLowerCase();
|
|
361
|
+
const overlap = terms.reduce((score, term) => score + (haystack.includes(term) ? 1 : 0), 0);
|
|
362
|
+
const trust = Number(row.worked_count || 0) - Number(row.failed_count || 0);
|
|
363
|
+
return { ...row, score: overlap * 10 + trust };
|
|
364
|
+
})
|
|
365
|
+
.sort((a, b) => Number(b.score) - Number(a.score));
|
|
366
|
+
}
|
|
367
|
+
async function enrichPlaybookAuthors(env, rows) {
|
|
368
|
+
if (!rows.length)
|
|
369
|
+
return [];
|
|
370
|
+
const orgIds = Array.from(new Set(rows.map((row) => String(row.org_id || "")).filter(Boolean)));
|
|
371
|
+
const directUserIds = new Set(rows.map((row) => String(row.approved_by || "")).filter(Boolean));
|
|
372
|
+
const ownerByOrg = new Map();
|
|
373
|
+
if (orgIds.length) {
|
|
374
|
+
const owners = await supabaseRest(env, `organization_members?select=org_id,user_id&role=eq.owner&org_id=in.(${orgIds.map(encodeURIComponent).join(",")})`);
|
|
375
|
+
for (const owner of owners) {
|
|
376
|
+
if (!ownerByOrg.has(owner.org_id))
|
|
377
|
+
ownerByOrg.set(owner.org_id, owner.user_id);
|
|
378
|
+
directUserIds.add(owner.user_id);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const profileByUser = new Map();
|
|
382
|
+
const userIds = Array.from(directUserIds);
|
|
383
|
+
if (userIds.length) {
|
|
384
|
+
const profiles = await supabaseRest(env, `user_profiles?select=user_id,username&user_id=in.(${userIds.map(encodeURIComponent).join(",")})`);
|
|
385
|
+
for (const profile of profiles)
|
|
386
|
+
profileByUser.set(String(profile.user_id), profile.username);
|
|
387
|
+
}
|
|
388
|
+
return rows.map((row) => {
|
|
389
|
+
const authorId = String(row.approved_by || ownerByOrg.get(String(row.org_id || "")) || "");
|
|
390
|
+
const username = authorId ? profileByUser.get(authorId) : null;
|
|
391
|
+
const { org_id: _orgId, approved_by: _approvedBy, ...safeRow } = row;
|
|
392
|
+
return {
|
|
393
|
+
...safeRow,
|
|
394
|
+
author: username ? { username } : null
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
function safeVisibility(value) {
|
|
399
|
+
return value === "private" || value === "public" || value === "team" ? value : "team";
|
|
400
|
+
}
|
|
401
|
+
function safeSignalType(value) {
|
|
402
|
+
return value === "failed_fix" || value === "verification" || value === "note" ? value : "error";
|
|
403
|
+
}
|
|
404
|
+
function safeInteger(value) {
|
|
405
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
406
|
+
if (!Number.isFinite(parsed))
|
|
407
|
+
return null;
|
|
408
|
+
return Math.trunc(parsed);
|
|
409
|
+
}
|
|
410
|
+
function safeRecord(value) {
|
|
411
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
412
|
+
return {};
|
|
413
|
+
return Object.fromEntries(Object.entries(value)
|
|
414
|
+
.slice(0, 50)
|
|
415
|
+
.map(([key, entry]) => [redactText(key).slice(0, 80), redactText(entry).slice(0, 240)]));
|
|
416
|
+
}
|
|
417
|
+
function signalSummary(body) {
|
|
418
|
+
return {
|
|
419
|
+
error: body.error,
|
|
420
|
+
stack: body.stack,
|
|
421
|
+
context: body.context,
|
|
422
|
+
attemptedFixes: body.attemptedFixes,
|
|
423
|
+
command: body.command,
|
|
424
|
+
exitCode: body.exitCode,
|
|
425
|
+
packageManager: body.packageManager,
|
|
426
|
+
framework: body.framework,
|
|
427
|
+
language: body.language,
|
|
428
|
+
dependencyVersions: body.dependencyVersions
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function audit(env, actor, eventName, targetType, targetId) {
|
|
432
|
+
if (!configured(env))
|
|
433
|
+
return;
|
|
434
|
+
await supabaseRest(env, "audit_events", {
|
|
435
|
+
method: "POST",
|
|
436
|
+
body: JSON.stringify([
|
|
437
|
+
{
|
|
438
|
+
org_id: actor?.orgId || null,
|
|
439
|
+
actor_user_id: actor?.kind === "human" ? actor.userId : null,
|
|
440
|
+
actor_agent_key_id: actor?.kind === "agent" ? actor.keyId : null,
|
|
441
|
+
event_name: eventName,
|
|
442
|
+
target_type: targetType || null,
|
|
443
|
+
target_id: targetId || null
|
|
444
|
+
}
|
|
445
|
+
])
|
|
446
|
+
});
|
|
447
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { bearerToken, HttpError, sha256Hex } from "./http";
|
|
2
|
+
export function configured(env) {
|
|
3
|
+
return Boolean(env.PUBLIC_SUPABASE_URL && env.PUBLIC_SUPABASE_ANON_KEY && env.SUPABASE_SERVICE_ROLE_KEY);
|
|
4
|
+
}
|
|
5
|
+
function baseUrl(env) {
|
|
6
|
+
const url = env.PUBLIC_SUPABASE_URL;
|
|
7
|
+
if (!url)
|
|
8
|
+
throw new HttpError(503, "supabase_not_configured", "Supabase URL is not configured.");
|
|
9
|
+
return url.replace(/\/$/, "");
|
|
10
|
+
}
|
|
11
|
+
export async function supabaseRest(env, path, init = {}) {
|
|
12
|
+
if (!env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
13
|
+
throw new HttpError(503, "supabase_not_configured", "Supabase service role key is not configured.");
|
|
14
|
+
}
|
|
15
|
+
const response = await fetch(`${baseUrl(env)}/rest/v1/${path}`, {
|
|
16
|
+
...init,
|
|
17
|
+
headers: {
|
|
18
|
+
apikey: env.SUPABASE_SERVICE_ROLE_KEY,
|
|
19
|
+
Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
Prefer: "return=representation",
|
|
22
|
+
...(init.headers || {})
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
const detail = await response.text();
|
|
27
|
+
throw new HttpError(response.status, "supabase_error", detail || "Supabase request failed.");
|
|
28
|
+
}
|
|
29
|
+
if (response.status === 204)
|
|
30
|
+
return null;
|
|
31
|
+
return (await response.json());
|
|
32
|
+
}
|
|
33
|
+
async function getSupabaseUser(env, request) {
|
|
34
|
+
const token = bearerToken(request);
|
|
35
|
+
if (!token || token.startsWith("ea_"))
|
|
36
|
+
return null;
|
|
37
|
+
if (!env.PUBLIC_SUPABASE_ANON_KEY)
|
|
38
|
+
return null;
|
|
39
|
+
const response = await fetch(`${baseUrl(env)}/auth/v1/user`, {
|
|
40
|
+
headers: {
|
|
41
|
+
apikey: env.PUBLIC_SUPABASE_ANON_KEY,
|
|
42
|
+
Authorization: `Bearer ${token}`
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
return null;
|
|
47
|
+
return (await response.json());
|
|
48
|
+
}
|
|
49
|
+
async function getOrCreateHumanActor(env, user) {
|
|
50
|
+
const profile = await ensureUserProfile(env, user);
|
|
51
|
+
const existing = await supabaseRest(env, `organization_members?select=org_id,role&user_id=eq.${encodeURIComponent(user.id)}&limit=1`);
|
|
52
|
+
if (existing[0]) {
|
|
53
|
+
return {
|
|
54
|
+
kind: "human",
|
|
55
|
+
userId: user.id,
|
|
56
|
+
...(user.email ? { email: user.email } : {}),
|
|
57
|
+
username: profile.username,
|
|
58
|
+
...(profile.display_name ? { displayName: profile.display_name } : {}),
|
|
59
|
+
orgId: existing[0].org_id,
|
|
60
|
+
role: existing[0].role
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const slug = `personal-${user.id.slice(0, 8)}`;
|
|
64
|
+
const orgName = "Personal atlas";
|
|
65
|
+
const org = await supabaseRest(env, "organizations", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
body: JSON.stringify([{ name: orgName, slug }])
|
|
68
|
+
});
|
|
69
|
+
const orgId = org[0]?.id;
|
|
70
|
+
if (!orgId)
|
|
71
|
+
throw new HttpError(500, "org_create_failed", "Could not create organization.");
|
|
72
|
+
await supabaseRest(env, "organization_members", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
body: JSON.stringify([{ org_id: orgId, user_id: user.id, role: "owner" }])
|
|
75
|
+
});
|
|
76
|
+
await supabaseRest(env, "projects", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
body: JSON.stringify([{ org_id: orgId, name: "Default", slug: "default", visibility: "team" }])
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
kind: "human",
|
|
82
|
+
userId: user.id,
|
|
83
|
+
...(user.email ? { email: user.email } : {}),
|
|
84
|
+
username: profile.username,
|
|
85
|
+
...(profile.display_name ? { displayName: profile.display_name } : {}),
|
|
86
|
+
orgId,
|
|
87
|
+
role: "owner"
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async function ensureUserProfile(env, user) {
|
|
91
|
+
const existing = await supabaseRest(env, `user_profiles?select=user_id,username,display_name,bio&user_id=eq.${encodeURIComponent(user.id)}&limit=1`);
|
|
92
|
+
if (existing[0])
|
|
93
|
+
return existing[0];
|
|
94
|
+
const base = usernameFromUser(user);
|
|
95
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
96
|
+
const suffix = attempt === 0 ? "" : `-${attempt + 1}`;
|
|
97
|
+
const username = `${base.slice(0, 32 - suffix.length)}${suffix}`;
|
|
98
|
+
try {
|
|
99
|
+
const rows = await supabaseRest(env, "user_profiles", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
body: JSON.stringify([
|
|
102
|
+
{
|
|
103
|
+
user_id: user.id,
|
|
104
|
+
username,
|
|
105
|
+
display_name: displayNameFromUser(user),
|
|
106
|
+
bio: null
|
|
107
|
+
}
|
|
108
|
+
])
|
|
109
|
+
});
|
|
110
|
+
if (rows[0])
|
|
111
|
+
return rows[0];
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof HttpError && /duplicate|unique|23505/i.test(error.message))
|
|
115
|
+
continue;
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw new HttpError(409, "username_unavailable", "Could not reserve a default username.");
|
|
120
|
+
}
|
|
121
|
+
function usernameFromUser(user) {
|
|
122
|
+
const metadata = user.user_metadata || {};
|
|
123
|
+
const candidates = [
|
|
124
|
+
metadata.user_name,
|
|
125
|
+
metadata.preferred_username,
|
|
126
|
+
metadata.name,
|
|
127
|
+
user.email?.split("@")[0],
|
|
128
|
+
`dev-${user.id.slice(0, 8)}`
|
|
129
|
+
];
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
const username = normalizeUsername(candidate);
|
|
132
|
+
if (username)
|
|
133
|
+
return username;
|
|
134
|
+
}
|
|
135
|
+
return `dev-${user.id.slice(0, 8)}`;
|
|
136
|
+
}
|
|
137
|
+
function displayNameFromUser(user) {
|
|
138
|
+
const metadata = user.user_metadata || {};
|
|
139
|
+
const raw = [metadata.full_name, metadata.name, metadata.user_name].find((value) => typeof value === "string" && value.trim());
|
|
140
|
+
if (typeof raw !== "string")
|
|
141
|
+
return null;
|
|
142
|
+
return raw.replace(/\s+/g, " ").trim().slice(0, 80) || null;
|
|
143
|
+
}
|
|
144
|
+
export function normalizeUsername(value) {
|
|
145
|
+
const cleaned = String(value || "")
|
|
146
|
+
.toLowerCase()
|
|
147
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
148
|
+
.replace(/^-+/, "")
|
|
149
|
+
.replace(/-+$/, "")
|
|
150
|
+
.replace(/-{2,}/g, "-")
|
|
151
|
+
.slice(0, 32);
|
|
152
|
+
if (!/^[a-z0-9][a-z0-9_-]{2,31}$/.test(cleaned))
|
|
153
|
+
return "";
|
|
154
|
+
return cleaned;
|
|
155
|
+
}
|
|
156
|
+
async function getAgentActor(env, request) {
|
|
157
|
+
const key = request.headers.get("X-ErrorAtlas-Key") || bearerToken(request);
|
|
158
|
+
if (!key?.startsWith("ea_"))
|
|
159
|
+
return null;
|
|
160
|
+
const secretHash = await sha256Hex(key);
|
|
161
|
+
const rows = await supabaseRest(env, `agent_keys?select=id,org_id,project_id,scopes&secret_hash=eq.${encodeURIComponent(secretHash)}&revoked_at=is.null&limit=1`);
|
|
162
|
+
const row = rows[0];
|
|
163
|
+
if (!row)
|
|
164
|
+
throw new HttpError(401, "invalid_agent_key", "Agent key is invalid or revoked.");
|
|
165
|
+
await supabaseRest(env, `agent_keys?id=eq.${encodeURIComponent(row.id)}`, {
|
|
166
|
+
method: "PATCH",
|
|
167
|
+
body: JSON.stringify({ last_used_at: new Date().toISOString() })
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
kind: "agent",
|
|
171
|
+
keyId: row.id,
|
|
172
|
+
orgId: row.org_id,
|
|
173
|
+
...(row.project_id ? { projectId: row.project_id } : {}),
|
|
174
|
+
scopes: row.scopes || []
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export async function resolveActor(env, request, required = true) {
|
|
178
|
+
if (!configured(env)) {
|
|
179
|
+
if (required)
|
|
180
|
+
throw new HttpError(503, "supabase_not_configured", "Supabase is not configured.");
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const agent = await getAgentActor(env, request);
|
|
184
|
+
if (agent)
|
|
185
|
+
return agent;
|
|
186
|
+
const user = await getSupabaseUser(env, request);
|
|
187
|
+
if (user)
|
|
188
|
+
return getOrCreateHumanActor(env, user);
|
|
189
|
+
if (required)
|
|
190
|
+
throw new HttpError(401, "unauthorized", "Sign in or provide an ErrorAtlas agent key.");
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
export function requireScope(actor, scope) {
|
|
194
|
+
if (actor.kind === "human")
|
|
195
|
+
return;
|
|
196
|
+
if (!actor.scopes.includes(scope)) {
|
|
197
|
+
throw new HttpError(403, "missing_scope", `Agent key requires ${scope}.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|