@retrace-dev/core 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/LICENSE +202 -0
- package/README.md +5 -0
- package/dist/chain.d.ts +25 -0
- package/dist/chain.js +70 -0
- package/dist/explain.d.ts +8 -0
- package/dist/explain.js +30 -0
- package/dist/export.d.ts +53 -0
- package/dist/export.js +85 -0
- package/dist/gdrive.d.ts +32 -0
- package/dist/gdrive.js +129 -0
- package/dist/github.d.ts +30 -0
- package/dist/github.js +112 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/lineage.d.ts +50 -0
- package/dist/lineage.js +150 -0
- package/dist/report.d.ts +6 -0
- package/dist/report.js +79 -0
- package/dist/router.d.ts +136 -0
- package/dist/router.js +391 -0
- package/dist/schema.d.ts +708 -0
- package/dist/schema.js +177 -0
- package/dist/signing.d.ts +13 -0
- package/dist/signing.js +55 -0
- package/dist/status.d.ts +49 -0
- package/dist/status.js +85 -0
- package/dist/store.d.ts +66 -0
- package/dist/store.js +84 -0
- package/dist/ui-html.d.ts +1 -0
- package/dist/ui-html.js +2 -0
- package/package.json +26 -0
- package/ui/retrace.html +479 -0
package/dist/router.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP router (fetch API) — used by the Cloudflare Worker and the local Node server.
|
|
3
|
+
*
|
|
4
|
+
* Owner routes (auth: Bearer token or ?token= when a token is configured). Per-actor credentials (RETRACE_CREDENTIALS,
|
|
5
|
+
* Bearer only) may also POST /events and read; "pinned" credentials have their actor stamped by the server (with one
|
|
6
|
+
* carve-out: an agent credential may record "instructed" roots for its configured on_behalf_of human), "assert"
|
|
7
|
+
* credentials (git hook, backfill forwarders) may assert only the actors in their allowed_actors list. DELETE/share
|
|
8
|
+
* stay owner-only.
|
|
9
|
+
* GET / | /ui UI
|
|
10
|
+
* GET /api health + the schema surface this build understands (public; see schemaSurface)
|
|
11
|
+
* GET /.well-known/retrace-pubkey issuer public key (JWK) — public
|
|
12
|
+
* POST /events append (EventInput)
|
|
13
|
+
* GET /events/:id · /events/:id/why
|
|
14
|
+
* GET /projects · /projects/:p/events?… · /projects/:p/head · /projects/:p/verify · /projects/:p/status
|
|
15
|
+
* GET /projects/:p/export?artifact_id=… signed JSON bundle
|
|
16
|
+
* GET /projects/:p/report?artifact_id=… printable HTML report
|
|
17
|
+
* GET /projects/:p/lineage?artifact_id=&format=json|dot|mermaid&actors=1 artifact lineage graph
|
|
18
|
+
* POST /projects/:p/share {artifact_id?, label?, expires_in_days?} → {share, url}
|
|
19
|
+
* DELETE /projects/:p?confirm=:p[&caused_by=evt_…] delete a project (junk cleanup; confirm must equal the project name) → per-table counts +
|
|
20
|
+
* an ops-project audit event attributed to ownerActor (RETRACE_OWNER) and linked to caused_by
|
|
21
|
+
*
|
|
22
|
+
* Webhooks (auth: HMAC signature, not the bearer token):
|
|
23
|
+
* POST /hooks/github?project=<name> GitHub webhook receiver (pull_request, pull_request_review, issue_comment, workflow_run[, push])
|
|
24
|
+
* POST /hooks/gdrive?project=<name> Google Drive Activity forwarder (Apps Script / retrace-gdrive CLI); bearer-token auth
|
|
25
|
+
*
|
|
26
|
+
* Share routes (no auth; scope locked to the share's project/artifact; read-only):
|
|
27
|
+
* GET /s/:id UI in shared mode
|
|
28
|
+
* GET /s/:id/meta · /s/:id/events · /s/:id/verify · /s/:id/export · /s/:id/report · /s/:id/lineage
|
|
29
|
+
*/
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import { Actor, ActorType, EventInput, schemaSurface } from "./schema.js";
|
|
32
|
+
import { appendEvent, verifyProject, explainEvent, newShareId, shareIsLive, isHeadMovedError } from "./store.js";
|
|
33
|
+
import { sealEvent } from "./chain.js";
|
|
34
|
+
import { buildExportBundle, verifyExportBundle } from "./export.js";
|
|
35
|
+
import { renderReportHtml } from "./report.js";
|
|
36
|
+
import { buildLineage, renderLineageDot, renderLineageMermaid } from "./lineage.js";
|
|
37
|
+
import { mapGithubWebhook, verifyGithubSignature } from "./github.js";
|
|
38
|
+
import { mapDriveActivities } from "./gdrive.js";
|
|
39
|
+
import { buildProjectStatus } from "./status.js";
|
|
40
|
+
import { publicFromPrivate, keyId } from "./signing.js";
|
|
41
|
+
import { UI_HTML } from "./ui-html.js";
|
|
42
|
+
/** A per-actor credential (security review 2026-08-21, backlog #6). Holders can POST /events and read; they cannot
|
|
43
|
+
* DELETE or create shares. trust "pinned" (default): the Worker stamps `actor` from the credential — the body may only
|
|
44
|
+
* add display_name/version and may not claim human/system, except that an agent credential with `actor.on_behalf_of`
|
|
45
|
+
* may record "instructed" roots attributed to exactly that human (see resolveActor). trust "assert": the body actor
|
|
46
|
+
* is stored verbatim IF it appears in the credential's allowed_actors list (git hook, backfill, Drive forwarder —
|
|
47
|
+
* callers that legitimately relay other people's actions, bounded to the actors they are expected to relay). */
|
|
48
|
+
export const Credential = z.object({
|
|
49
|
+
token: z.string().min(16),
|
|
50
|
+
/** Human-readable name, recorded nowhere — for the operator's own bookkeeping */
|
|
51
|
+
name: z.string().optional(),
|
|
52
|
+
actor: Actor,
|
|
53
|
+
trust: z.enum(["pinned", "assert"]).default("pinned"),
|
|
54
|
+
/** For assert trust: the ONLY actors this credential may assert on POST /events, matched on exact type + id
|
|
55
|
+
* (audit 2026-08-22, P1: an unbounded assert credential could seal events claiming any actor — a human, or the
|
|
56
|
+
* pinned agent's id — into any project). Absent or empty = may assert none; routes that map actors server-side
|
|
57
|
+
* (/hooks/gdrive) are unaffected. Ignored for pinned trust. */
|
|
58
|
+
allowed_actors: z.array(z.object({ type: ActorType, id: z.string().min(1) })).optional(),
|
|
59
|
+
});
|
|
60
|
+
/** Parse the RETRACE_CREDENTIALS secret (JSON array). Throws on malformed config so a bad deploy fails loudly. */
|
|
61
|
+
export function parseCredentials(raw) {
|
|
62
|
+
if (!raw)
|
|
63
|
+
return [];
|
|
64
|
+
return z.array(Credential).parse(JSON.parse(raw));
|
|
65
|
+
}
|
|
66
|
+
const CORS = { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization,content-type", "access-control-allow-methods": "GET,POST,DELETE,OPTIONS" };
|
|
67
|
+
const json = (data, status = 200) => new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json", ...CORS } });
|
|
68
|
+
const html = (body, status = 200) => new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });
|
|
69
|
+
/** deliveries that map to exactly one event can use the delivery id as idempotency key */
|
|
70
|
+
const inputs_needs_unique = (ghEvent) => ghEvent !== "push";
|
|
71
|
+
export function createHandler(store, tokenOrOpts) {
|
|
72
|
+
const opts = typeof tokenOrOpts === "string" ? { token: tokenOrOpts } : tokenOrOpts ?? {};
|
|
73
|
+
const { token } = opts;
|
|
74
|
+
const credentials = opts.credentials ?? [];
|
|
75
|
+
/** Who is calling. `null` = unauthenticated (only valid when no token is configured at all). */
|
|
76
|
+
const authenticate = (req, url) => {
|
|
77
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
78
|
+
const bearer = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
|
79
|
+
if (token && (bearer === token || url.searchParams.get("token") === token))
|
|
80
|
+
return { kind: "owner" };
|
|
81
|
+
const credential = bearer ? credentials.find((c) => c.token === bearer) : undefined;
|
|
82
|
+
if (credential)
|
|
83
|
+
return { kind: "credential", credential };
|
|
84
|
+
return token || credentials.length ? "unauthorized" : null;
|
|
85
|
+
};
|
|
86
|
+
/** Actor to store for POST /events under this principal (mirrors the MCP server's RETRACE_ACTOR_LOCK).
|
|
87
|
+
* One narrow carve-out so retrace_instruct works over a pinned credential: a pinned AGENT credential may record a
|
|
88
|
+
* HUMAN actor, but only the human it is configured to work for (`actor.on_behalf_of`) and only as an "instructed"
|
|
89
|
+
* root — it can relay its operator's instructions (mirroring the MCP server's RETRACE_ON_BEHALF_OF lock) but cannot
|
|
90
|
+
* forge arbitrary human actors or attribute other actions to its operator. */
|
|
91
|
+
const resolveActor = (principal, body, action) => {
|
|
92
|
+
if (principal?.kind !== "credential")
|
|
93
|
+
return { actor: body };
|
|
94
|
+
if (principal.credential.trust === "assert") {
|
|
95
|
+
// Assert trust is now bounded (audit 2026-08-22, P1): the body actor must be in the credential's explicit
|
|
96
|
+
// allow-list — otherwise the holder of e.g. the git-hook token could forge events from any human or agent.
|
|
97
|
+
const allowed = principal.credential.allowed_actors ?? [];
|
|
98
|
+
if (!allowed.some((a) => a.type === body.type && a.id === body.id))
|
|
99
|
+
return { error: `actor ${body.type} "${body.id}" is not in this credential's allowed_actors: an assert credential may only record the actors explicitly listed for it in RETRACE_CREDENTIALS.` };
|
|
100
|
+
return { actor: body };
|
|
101
|
+
}
|
|
102
|
+
const { actor } = principal.credential;
|
|
103
|
+
if (body.type === "human" && actor.type === "agent") {
|
|
104
|
+
if (!actor.on_behalf_of)
|
|
105
|
+
return { error: `actor.type "human" is not allowed: this credential is pinned to agent "${actor.id}" and has no on_behalf_of human configured. Add actor.on_behalf_of to this credential in RETRACE_CREDENTIALS to let it relay its operator's instruction roots.` };
|
|
106
|
+
if (body.id !== actor.on_behalf_of)
|
|
107
|
+
return { error: `human "${body.id}" is not allowed: this credential is pinned to agent "${actor.id}" and may only relay instructions from its operator "${actor.on_behalf_of}".` };
|
|
108
|
+
if (action !== "instructed")
|
|
109
|
+
return { error: `action "${action}" is not allowed for a human actor on this credential: agent "${actor.id}" may only record "instructed" roots for "${actor.on_behalf_of}". Log the agent's own work as the agent.` };
|
|
110
|
+
// `relayed` marks that THIS branch produced the actor — the stamp at the call site keys off it, so the stamp
|
|
111
|
+
// can never drift onto other human-actor paths (assert-trust passthrough, pinned human credentials).
|
|
112
|
+
return {
|
|
113
|
+
relayed: true,
|
|
114
|
+
actor: {
|
|
115
|
+
type: "human",
|
|
116
|
+
id: actor.on_behalf_of,
|
|
117
|
+
...(body.display_name !== undefined ? { display_name: body.display_name } : {}),
|
|
118
|
+
...(body.version !== undefined ? { version: body.version } : {}),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (body.type !== actor.type)
|
|
123
|
+
return { error: `actor.type "${body.type}" is not allowed: this credential is pinned to ${actor.type} "${actor.id}". Use an owner or assert-trust credential to record other actors.` };
|
|
124
|
+
return {
|
|
125
|
+
actor: {
|
|
126
|
+
...actor,
|
|
127
|
+
...(body.display_name !== undefined ? { display_name: body.display_name } : {}),
|
|
128
|
+
...(body.version !== undefined ? { version: body.version } : {}),
|
|
129
|
+
// A pinned credential fixes WHO is acting, not WHICH MODEL ran: it is issued once and outlives every model
|
|
130
|
+
// swap, so a model pinned here goes stale silently and seals the wrong author into an append-only ledger.
|
|
131
|
+
// If the credential names a model it stays authoritative; if it omits one, the producer — the only side that
|
|
132
|
+
// knows what actually ran — reports it. Identity is stamped from the credential either way, so this widens
|
|
133
|
+
// nothing an operator did not opt into by leaving `model` out.
|
|
134
|
+
...(actor.type === "agent" && actor.model === undefined && body.model !== undefined ? { model: body.model } : {}),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
const lineageResponse = async (events, fmt, actors) => {
|
|
139
|
+
const l = buildLineage(events, { includeActors: actors });
|
|
140
|
+
if (fmt === "dot")
|
|
141
|
+
return new Response(renderLineageDot(l), { headers: { "content-type": "text/vnd.graphviz; charset=utf-8", ...CORS } });
|
|
142
|
+
if (fmt === "mermaid")
|
|
143
|
+
return new Response(renderLineageMermaid(l), { headers: { "content-type": "text/plain; charset=utf-8", ...CORS } });
|
|
144
|
+
return json(l);
|
|
145
|
+
};
|
|
146
|
+
const exportFor = (scope) => buildExportBundle(store, scope, { signingKey: opts.signingKey, issuerName: opts.issuerName });
|
|
147
|
+
return async function handle(req) {
|
|
148
|
+
const url = new URL(req.url);
|
|
149
|
+
const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
150
|
+
const base = (opts.publicUrl ?? url.origin).replace(/\/$/, "");
|
|
151
|
+
if (req.method === "OPTIONS")
|
|
152
|
+
return new Response(null, { headers: CORS });
|
|
153
|
+
if (parts.length === 0 || parts[0] === "ui")
|
|
154
|
+
return html(UI_HTML);
|
|
155
|
+
if (parts[0] === "favicon.ico")
|
|
156
|
+
return new Response(null, { status: 204 });
|
|
157
|
+
// `schema` is what makes version skew detectable: a producer newer than this deployment would have its unknown
|
|
158
|
+
// fields silently stripped by EventInput.safeParse below. Public, like the rest of this probe — the field NAMES
|
|
159
|
+
// are already in the README, and being readable without a token is the point (you can check a deployment you
|
|
160
|
+
// hold no credential for). See schemaSurface() and scripts/check-deploy.mjs.
|
|
161
|
+
if (parts[0] === "api" && parts.length === 1)
|
|
162
|
+
return json({ name: "retrace-api", version: "0.1.0", ok: true, auth: !!token || credentials.length > 0, credentials: credentials.length, signing: !!opts.signingKey, schema: schemaSurface() });
|
|
163
|
+
if (parts[0] === ".well-known" && parts[1] === "retrace-pubkey") {
|
|
164
|
+
if (!opts.signingKey)
|
|
165
|
+
return json({ error: "no signing key configured" }, 404);
|
|
166
|
+
const pub = publicFromPrivate(opts.signingKey);
|
|
167
|
+
return json({ kid: await keyId(pub), alg: "Ed25519", public_key: pub, name: opts.issuerName });
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
// ---- GitHub webhook ----
|
|
171
|
+
if (req.method === "POST" && parts[0] === "hooks" && parts[1] === "github") {
|
|
172
|
+
if (!opts.githubSecret)
|
|
173
|
+
return json({ error: "github webhook not configured (set RETRACE_GITHUB_SECRET)" }, 404);
|
|
174
|
+
const raw = await req.text();
|
|
175
|
+
if (!(await verifyGithubSignature(opts.githubSecret, raw, req.headers.get("x-hub-signature-256"))))
|
|
176
|
+
return json({ error: "bad signature" }, 401);
|
|
177
|
+
const ghEvent = req.headers.get("x-github-event") ?? "";
|
|
178
|
+
const delivery = req.headers.get("x-github-delivery") ?? undefined;
|
|
179
|
+
if (ghEvent === "ping")
|
|
180
|
+
return json({ ok: true, pong: true });
|
|
181
|
+
const inputs = mapGithubWebhook(ghEvent, JSON.parse(raw), { project: url.searchParams.get("project") ?? undefined, includePush: opts.githubIncludePush, deliveryId: delivery && inputs_needs_unique(ghEvent) ? delivery : undefined });
|
|
182
|
+
const results = [];
|
|
183
|
+
for (const input of inputs) {
|
|
184
|
+
const parsed = EventInput.safeParse(input);
|
|
185
|
+
if (!parsed.success) {
|
|
186
|
+
results.push({ error: parsed.error.issues });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
for (let attempt = 0;; attempt++) {
|
|
190
|
+
try {
|
|
191
|
+
const r = await appendEvent(store, parsed.data);
|
|
192
|
+
results.push({ id: r.event.id, seq: r.event.seq, deduped: r.deduped });
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
if (!/UNIQUE/i.test(String(e?.message)) || attempt >= 4)
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return json({ ok: true, event: ghEvent, logged: results }, 201);
|
|
202
|
+
}
|
|
203
|
+
// ---- Google Drive activity forwarder (owner token or an assert-trust credential: it relays other people's edits) ----
|
|
204
|
+
if (req.method === "POST" && parts[0] === "hooks" && parts[1] === "gdrive") {
|
|
205
|
+
const principal = authenticate(req, url);
|
|
206
|
+
if (principal === "unauthorized")
|
|
207
|
+
return json({ error: "unauthorized" }, 401);
|
|
208
|
+
if (principal?.kind === "credential" && principal.credential.trust !== "assert")
|
|
209
|
+
return json({ error: "forbidden: the Drive forwarder needs the owner token or an assert-trust credential" }, 403);
|
|
210
|
+
const payload = (await req.json());
|
|
211
|
+
const inputs = mapDriveActivities({ ...payload, project: url.searchParams.get("project") ?? payload.project }, "google-drive");
|
|
212
|
+
const results = [];
|
|
213
|
+
for (const input of inputs) {
|
|
214
|
+
const parsed = EventInput.safeParse(input);
|
|
215
|
+
if (!parsed.success) {
|
|
216
|
+
results.push({ error: parsed.error.issues });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
for (let attempt = 0;; attempt++) {
|
|
220
|
+
try {
|
|
221
|
+
const r = await appendEvent(store, parsed.data);
|
|
222
|
+
results.push({ id: r.event.id, seq: r.event.seq, deduped: r.deduped });
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
if (!/UNIQUE/i.test(String(e?.message)) || attempt >= 4)
|
|
227
|
+
throw e;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return json({ ok: true, received: (payload.activities ?? []).length, logged: results.filter((r) => r.id && !r.deduped).length, deduped: results.filter((r) => r.deduped).length, results }, 201);
|
|
232
|
+
}
|
|
233
|
+
// ---- shared, read-only, scope-locked ----
|
|
234
|
+
if (parts[0] === "s" && parts[1]) {
|
|
235
|
+
const share = await store.getShare(parts[1]);
|
|
236
|
+
if (!share || !shareIsLive(share))
|
|
237
|
+
return parts.length === 2 ? html("<h1>Share link not found or expired</h1>", 404) : json({ error: "share not found or expired" }, 404);
|
|
238
|
+
const sub = parts[2];
|
|
239
|
+
if (!sub)
|
|
240
|
+
return html(UI_HTML);
|
|
241
|
+
if (sub === "meta")
|
|
242
|
+
return json(share);
|
|
243
|
+
if (sub === "verify")
|
|
244
|
+
return json(await verifyProject(store, share.project));
|
|
245
|
+
if (sub === "events") {
|
|
246
|
+
// same selection as the export (in-scope events + causal ancestors) so the shared UI can answer "why"
|
|
247
|
+
const b = await buildExportBundle(store, { project: share.project, artifact_id: share.artifact_id });
|
|
248
|
+
return json(b.events);
|
|
249
|
+
}
|
|
250
|
+
if (sub === "export")
|
|
251
|
+
return json(await exportFor({ project: share.project, artifact_id: share.artifact_id }));
|
|
252
|
+
if (sub === "lineage") {
|
|
253
|
+
const b = await buildExportBundle(store, { project: share.project, artifact_id: share.artifact_id });
|
|
254
|
+
return lineageResponse(b.events, url.searchParams.get("format"), url.searchParams.get("actors") === "1");
|
|
255
|
+
}
|
|
256
|
+
if (sub === "report") {
|
|
257
|
+
const b = await exportFor({ project: share.project, artifact_id: share.artifact_id });
|
|
258
|
+
return html(renderReportHtml(b, await verifyExportBundle(b), { baseUrl: base, title: share.label ? `Provenance report — ${share.label}` : undefined }));
|
|
259
|
+
}
|
|
260
|
+
return json({ error: "not found" }, 404);
|
|
261
|
+
}
|
|
262
|
+
// ---- owner routes ----
|
|
263
|
+
const principal = authenticate(req, url);
|
|
264
|
+
if (principal === "unauthorized")
|
|
265
|
+
return json({ error: "unauthorized" }, 401);
|
|
266
|
+
// Per-actor credentials may append and read; anything destructive or outward-facing stays owner-only.
|
|
267
|
+
if (principal?.kind === "credential" && (req.method === "DELETE" || (req.method === "POST" && parts[0] !== "events")))
|
|
268
|
+
return json({ error: "forbidden: this route needs the owner token" }, 403);
|
|
269
|
+
if (req.method === "POST" && parts[0] === "events" && parts.length === 1) {
|
|
270
|
+
const parsed = EventInput.safeParse(await req.json());
|
|
271
|
+
if (!parsed.success)
|
|
272
|
+
return json({ error: "invalid event", issues: parsed.error.issues }, 400);
|
|
273
|
+
const resolved = resolveActor(principal, parsed.data.actor, parsed.data.action);
|
|
274
|
+
if ("error" in resolved)
|
|
275
|
+
return json(resolved, 403);
|
|
276
|
+
const input = { ...parsed.data, actor: resolved.actor };
|
|
277
|
+
// A relayed instruction root is stamped with the credential's agent id, so the chain records that the human
|
|
278
|
+
// did not write this event themselves — their agent did, on their behalf. Keyed on the carve-out's own flag:
|
|
279
|
+
// assert-trust human events (git hook, forwarders) and other human paths must NOT get a relayed_by stamp.
|
|
280
|
+
if (resolved.relayed && principal?.kind === "credential")
|
|
281
|
+
input.method = { ...input.method, params: { ...input.method?.params, relayed_by: principal.credential.actor.id } };
|
|
282
|
+
for (let attempt = 0;; attempt++) {
|
|
283
|
+
try {
|
|
284
|
+
const r = await appendEvent(store, input);
|
|
285
|
+
return json(r, r.deduped ? 200 : 201);
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
if (!/UNIQUE/i.test(String(e?.message)) || attempt >= 4)
|
|
289
|
+
throw e;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (req.method === "GET" && parts[0] === "events" && parts[1]) {
|
|
294
|
+
if (parts[2] === "why")
|
|
295
|
+
return json(await explainEvent(store, parts[1]));
|
|
296
|
+
const e = await store.get(parts[1]);
|
|
297
|
+
return e ? json(e) : json({ error: "not found" }, 404);
|
|
298
|
+
}
|
|
299
|
+
if (parts[0] === "projects") {
|
|
300
|
+
if (parts.length === 1)
|
|
301
|
+
return json(await store.projects());
|
|
302
|
+
const project = parts[1];
|
|
303
|
+
const sub = parts[2];
|
|
304
|
+
const q = Object.fromEntries(url.searchParams);
|
|
305
|
+
delete q.token;
|
|
306
|
+
if (req.method === "GET") {
|
|
307
|
+
if (sub === "head")
|
|
308
|
+
return json(await store.head(project));
|
|
309
|
+
if (sub === "verify")
|
|
310
|
+
return json(await verifyProject(store, project));
|
|
311
|
+
if (sub === "status")
|
|
312
|
+
return json(await buildProjectStatus(store, project));
|
|
313
|
+
if (sub === "events")
|
|
314
|
+
return json(await store.history({ ...q, project, limit: q.limit ? Number(q.limit) : undefined }));
|
|
315
|
+
if (sub === "export")
|
|
316
|
+
return json(await exportFor({ project, artifact_id: q.artifact_id }));
|
|
317
|
+
if (sub === "lineage") {
|
|
318
|
+
const evs = q.artifact_id ? (await buildExportBundle(store, { project, artifact_id: q.artifact_id })).events : await store.all(project);
|
|
319
|
+
return lineageResponse(evs, q.format ?? null, q.actors === "1");
|
|
320
|
+
}
|
|
321
|
+
if (sub === "report") {
|
|
322
|
+
const b = await exportFor({ project, artifact_id: q.artifact_id });
|
|
323
|
+
return html(renderReportHtml(b, await verifyExportBundle(b), { baseUrl: base }));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (req.method === "DELETE" && parts.length === 2) {
|
|
327
|
+
if (!store.deleteProject)
|
|
328
|
+
return json({ error: "project deletion not supported by this store" }, 501);
|
|
329
|
+
const confirm = url.searchParams.get("confirm");
|
|
330
|
+
if (confirm !== project)
|
|
331
|
+
return json({ error: `destructive route: repeat the exact project name as ?confirm=${encodeURIComponent(project)} to delete it` }, 400);
|
|
332
|
+
const opsProject = opts.opsProject ?? "retrace";
|
|
333
|
+
if (project === opsProject)
|
|
334
|
+
return json({ error: "refusing to delete the ops/audit project" }, 403);
|
|
335
|
+
for (let attempt = 0;; attempt++) {
|
|
336
|
+
// Seal the audit event first, then let the store delete + insert it in ONE transaction (B3): a deletion can
|
|
337
|
+
// never exist without its audit record. The final head is recorded so a later re-seed of this project from
|
|
338
|
+
// genesis is detectable against the ops chain. Seq is 0-based, so the event count is head.seq + 1. The head
|
|
339
|
+
// is read inside the loop and handed to the store as the expected head: if a write to the project races the
|
|
340
|
+
// delete, the store throws HeadMovedError instead of committing an audit that misreports the head/count.
|
|
341
|
+
// Attribute the audit to whoever holds the owner token (the only principal allowed here), not to the server.
|
|
342
|
+
const head = await store.head(project);
|
|
343
|
+
if (!head)
|
|
344
|
+
return json({ error: "project not found" }, 404);
|
|
345
|
+
const auditInput = {
|
|
346
|
+
project: opsProject,
|
|
347
|
+
actor: opts.ownerActor ?? { type: "system", id: "worker" },
|
|
348
|
+
action: "deleted",
|
|
349
|
+
artifacts: [{ id: `project:${project}`, kind: "project", label: project }],
|
|
350
|
+
intent: "project deleted via DELETE route",
|
|
351
|
+
caused_by: url.searchParams.get("caused_by") ?? undefined,
|
|
352
|
+
method: { tool: "http", automated: !opts.ownerActor, params: { route: "DELETE /projects/:p", principal: "owner" } },
|
|
353
|
+
location: { url: `${base}/projects/${encodeURIComponent(project)}`, system: "retrace-api" },
|
|
354
|
+
change: { summary: `deleted project "${project}" (${head.seq + 1} events) at head ${head.hash} seq ${head.seq}`, before_hash: head.hash },
|
|
355
|
+
};
|
|
356
|
+
const audit = await sealEvent(auditInput, await store.head(opsProject));
|
|
357
|
+
try {
|
|
358
|
+
const deleted = await store.deleteProject(project, audit, head);
|
|
359
|
+
return json({ ok: true, project, deleted, ops_event: audit.id });
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
// Either a concurrent ops-project write took our seq (UNIQUE) or the target project was written to
|
|
363
|
+
// (HeadMovedError): the whole transaction rolled back, so re-read both heads, re-seal and retry.
|
|
364
|
+
if (!(isHeadMovedError(e) || /UNIQUE/i.test(String(e?.message))) || attempt >= 4)
|
|
365
|
+
throw e;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (req.method === "POST" && sub === "share") {
|
|
370
|
+
const body = (await req.json().catch(() => ({})));
|
|
371
|
+
const now = new Date();
|
|
372
|
+
const share = {
|
|
373
|
+
id: newShareId(),
|
|
374
|
+
project,
|
|
375
|
+
artifact_id: body.artifact_id || undefined,
|
|
376
|
+
label: body.label || undefined,
|
|
377
|
+
created_at: now.toISOString(),
|
|
378
|
+
expires_at: body.expires_in_days ? new Date(now.getTime() + body.expires_in_days * 86400000).toISOString() : undefined,
|
|
379
|
+
created_by: body.created_by,
|
|
380
|
+
};
|
|
381
|
+
await store.createShare(share);
|
|
382
|
+
return json({ share, url: `${base}/s/${share.id}`, report_url: `${base}/s/${share.id}/report`, export_url: `${base}/s/${share.id}/export` }, 201);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return json({ error: "not found" }, 404);
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
return json({ error: String(e?.message ?? e) }, 500);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
}
|