@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.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * GitHub adapter — pure mapping from GitHub webhook payloads (and REST objects) to Retrace EventInputs,
3
+ * plus HMAC-SHA256 signature verification. Runs in Workers and Node.
4
+ *
5
+ * Handled events
6
+ * pull_request opened/reopened → created · synchronize → edited · closed(merged) → merged · closed → other:closed
7
+ * ready_for_review → other:ready_for_review · converted_to_draft → other:converted_to_draft
8
+ * pull_request_review approved → approved · changes_requested → rejected · commented → other:reviewed
9
+ * issue_comment (on PR) → sent
10
+ * workflow_run completed → executed (system actor = GitHub Actions)
11
+ * push → skipped by default (the git adapter covers commits); enable with includePush
12
+ *
13
+ * Artifact ids line up with the git adapter: `pr:<owner/repo>#<n>`, `commit:<owner/repo>@<sha12>`, `repo:<owner/repo>#<path>`.
14
+ * WHY: PR body / review body; `caused_by` from a `Retrace-Caused-By: evt_…` line in the PR body.
15
+ * PROV role (what the webhook authoritatively knows): opened → PR generated · synchronize → PR both · review/comment → PR used ·
16
+ * merged → PR used + merge commit generated · workflow_run → run generated, PRs/commit used · push → commit + files generated ·
17
+ * closed-unmerged / ready_for_review / converted_to_draft / edited → absent (state changes, not content).
18
+ */
19
+ import { EventInput, Actor } from "./schema.js";
20
+ export declare function verifyGithubSignature(secret: string, rawBody: string, signatureHeader: string | null): Promise<boolean>;
21
+ export declare function githubActor(user: any, fallback?: string): Actor;
22
+ export interface GithubMapOptions {
23
+ project?: string;
24
+ includePush?: boolean;
25
+ deliveryId?: string;
26
+ }
27
+ /** Map one webhook delivery to zero or more events. `event` = X-GitHub-Event header. */
28
+ export declare function mapGithubWebhook(event: string, payload: any, opts?: GithubMapOptions): EventInput[];
29
+ /** Map a REST /pulls item (+ its reviews) for backfill. */
30
+ export declare function mapGithubPullRest(repoFull: string, pr: any, reviews?: any[], project?: string): EventInput[];
package/dist/github.js ADDED
@@ -0,0 +1,112 @@
1
+ const enc = new TextEncoder();
2
+ const subtle = globalThis.crypto.subtle;
3
+ export async function verifyGithubSignature(secret, rawBody, signatureHeader) {
4
+ if (!signatureHeader?.startsWith("sha256="))
5
+ return false;
6
+ const key = await subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
7
+ const mac = new Uint8Array(await subtle.sign("HMAC", key, enc.encode(rawBody)));
8
+ const hex = [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
9
+ const given = signatureHeader.slice(7).toLowerCase();
10
+ if (given.length !== hex.length)
11
+ return false;
12
+ let diff = 0;
13
+ for (let i = 0; i < hex.length; i++)
14
+ diff |= hex.charCodeAt(i) ^ given.charCodeAt(i);
15
+ return diff === 0;
16
+ }
17
+ const BOT_RE = /\[bot\]$|^copilot$|^dependabot|^renovate|^github-actions/i;
18
+ export function githubActor(user, fallback = "github") {
19
+ const login = user?.login ?? fallback;
20
+ const bot = user?.type === "Bot" || BOT_RE.test(login);
21
+ const agenty = /copilot|claude|codex|devin|cursor|aider|sweep/i.test(login);
22
+ return {
23
+ type: bot ? (agenty ? "agent" : "system") : "human",
24
+ id: bot ? login : (user?.email ? user.email : `github:${login}`),
25
+ display_name: user?.name ?? login,
26
+ ...(agenty ? { model: undefined } : {}),
27
+ };
28
+ }
29
+ const causedByFrom = (text) => text?.match(/Retrace-Caused-By:\s*(evt_[a-f0-9]+)/i)?.[1];
30
+ const trim = (s, n = 300) => { const t = s?.replace(/^\s*Retrace-[\w-]+:.*$/gim, "").trim(); return t ? (t.length > n ? t.slice(0, n - 1) + "…" : t) : undefined; };
31
+ /** Map one webhook delivery to zero or more events. `event` = X-GitHub-Event header. */
32
+ export function mapGithubWebhook(event, payload, opts = {}) {
33
+ const repoFull = payload?.repository?.full_name ?? "unknown/unknown";
34
+ const project = opts.project ?? repoFull;
35
+ const idem = (suffix) => (opts.deliveryId ? `gh:${opts.deliveryId}` : `gh:${repoFull}:${suffix}`);
36
+ const where = (url) => ({ system: "github", url });
37
+ const prArtifact = (pr, role) => ({ id: `pr:${repoFull}#${pr.number}`, kind: "pr", label: `PR #${pr.number} ${pr.title ?? ""}`.trim(), derived_from: pr.head?.sha ? [`commit:${repoFull}@${pr.head.sha.slice(0, 12)}`] : undefined, ...(role ? { role } : {}) });
38
+ if (event === "pull_request") {
39
+ const pr = payload.pull_request;
40
+ const a = payload.action;
41
+ const base = {
42
+ project, actor: githubActor(payload.sender), artifacts: [prArtifact(pr)], timestamp: pr.updated_at ?? new Date().toISOString(),
43
+ location: where(pr.html_url), caused_by: causedByFrom(pr.body), method: { tool: "github", automated: false, params: { action: a, head: pr.head?.ref, base: pr.base?.ref, head_sha: pr.head?.sha, additions: pr.additions, deletions: pr.deletions, changed_files: pr.changed_files } },
44
+ idempotency_key: idem(`pr${pr.number}:${a}:${pr.updated_at}`), tags: ["github", "pr"],
45
+ };
46
+ if (a === "opened" || a === "reopened")
47
+ return [{ ...base, action: "created", artifacts: [prArtifact(pr, "generated")], intent: trim(pr.body) ?? `Opened PR: ${pr.title}`, change: { summary: `${pr.title} (${pr.head?.ref} → ${pr.base?.ref})`, after_hash: pr.head?.sha } }];
48
+ if (a === "synchronize")
49
+ return [{ ...base, action: "edited", artifacts: [prArtifact(pr, "both")], intent: `pushed new commits to PR #${pr.number}`, change: { before_hash: payload.before, after_hash: payload.after, summary: `head ${String(payload.before).slice(0, 7)} → ${String(payload.after).slice(0, 7)}` } }];
50
+ if (a === "closed") {
51
+ if (pr.merged)
52
+ return [{ ...base, actor: githubActor(pr.merged_by ?? payload.sender), action: "merged", timestamp: pr.merged_at ?? base.timestamp, intent: trim(pr.body) ?? `Merged PR #${pr.number}`, change: { summary: `${pr.title} — merged ${pr.head?.ref} into ${pr.base?.ref} (+${pr.additions ?? "?"} −${pr.deletions ?? "?"}, ${pr.changed_files ?? "?"} files)`, after_hash: pr.merge_commit_sha }, artifacts: [prArtifact(pr, "used"), ...(pr.merge_commit_sha ? [{ id: `commit:${repoFull}@${pr.merge_commit_sha.slice(0, 12)}`, kind: "commit", label: `${repoFull}@${pr.merge_commit_sha.slice(0, 7)}`, derived_from: [`pr:${repoFull}#${pr.number}`], role: "generated" }] : [])], tags: ["github", "pr", "merge"] }];
53
+ return [{ ...base, action: "other", action_detail: "closed", intent: `closed PR #${pr.number} without merging` }];
54
+ }
55
+ if (a === "ready_for_review" || a === "converted_to_draft" || a === "edited")
56
+ return [{ ...base, action: "other", action_detail: a, intent: `${a.replace(/_/g, " ")}: ${pr.title}` }];
57
+ return [];
58
+ }
59
+ if (event === "pull_request_review" && payload.action === "submitted") {
60
+ const pr = payload.pull_request, r = payload.review;
61
+ const state = String(r.state).toLowerCase();
62
+ const action = state === "approved" ? "approved" : state === "changes_requested" ? "rejected" : "other";
63
+ return [{
64
+ project, actor: githubActor(r.user ?? payload.sender), action, action_detail: action === "other" ? "reviewed" : undefined,
65
+ artifacts: [prArtifact(pr, "used")], timestamp: r.submitted_at ?? new Date().toISOString(), location: where(r.html_url ?? pr.html_url),
66
+ intent: trim(r.body) ?? (state === "approved" ? `approved PR #${pr.number}` : state === "changes_requested" ? `requested changes on PR #${pr.number}` : `reviewed PR #${pr.number}`),
67
+ caused_by: causedByFrom(r.body) ?? causedByFrom(pr.body), method: { tool: "github-review", automated: false, params: { state, commit: r.commit_id } },
68
+ idempotency_key: idem(`review${r.id}`), tags: ["github", "review"],
69
+ }];
70
+ }
71
+ if (event === "issue_comment" && payload.action === "created" && payload.issue?.pull_request) {
72
+ const c = payload.comment, n = payload.issue.number;
73
+ return [{
74
+ project, actor: githubActor(c.user ?? payload.sender), action: "sent", artifacts: [{ id: `pr:${repoFull}#${n}`, kind: "pr", label: `PR #${n} ${payload.issue.title ?? ""}`.trim(), role: "used" }],
75
+ timestamp: c.created_at, location: where(c.html_url), intent: trim(c.body), caused_by: causedByFrom(c.body), method: { tool: "github-comment", automated: false },
76
+ idempotency_key: idem(`comment${c.id}`), tags: ["github", "comment"],
77
+ }];
78
+ }
79
+ if (event === "workflow_run" && payload.action === "completed") {
80
+ const w = payload.workflow_run;
81
+ const prs = w.pull_requests ?? [];
82
+ return [{
83
+ project, actor: { type: "system", id: "github-actions", display_name: `GitHub Actions · ${w.name}` }, action: "executed",
84
+ artifacts: [{ id: `run:${repoFull}#${w.id}`, kind: "workflow_run", label: `${w.name} #${w.run_number}`, role: "generated" }, ...prs.map((p) => ({ id: `pr:${repoFull}#${p.number}`, kind: "pr", label: `PR #${p.number}`, role: "used" })), { id: `commit:${repoFull}@${String(w.head_sha).slice(0, 12)}`, kind: "commit", label: `${repoFull}@${String(w.head_sha).slice(0, 7)}`, role: "used" }],
85
+ timestamp: w.updated_at, duration_ms: w.run_started_at && w.updated_at ? Date.parse(w.updated_at) - Date.parse(w.run_started_at) : undefined,
86
+ location: where(w.html_url), intent: `${w.name} on ${w.head_branch}: ${w.conclusion}`, change: { summary: `conclusion: ${w.conclusion}`, after_hash: w.head_sha },
87
+ method: { tool: "github-actions", automated: true, params: { event: w.event, conclusion: w.conclusion, attempt: w.run_attempt } },
88
+ idempotency_key: idem(`run${w.id}:${w.run_attempt}`), tags: ["github", "ci", String(w.conclusion)],
89
+ }];
90
+ }
91
+ if (event === "push" && opts.includePush) {
92
+ return (payload.commits ?? []).map((c) => ({
93
+ project, actor: { type: "human", id: c.author?.email ?? `github:${c.author?.username}`, display_name: c.author?.name }, action: "committed",
94
+ artifacts: [{ id: `commit:${repoFull}@${String(c.id).slice(0, 12)}`, kind: "commit", label: `${repoFull}@${String(c.id).slice(0, 7)}`, role: "generated" }, ...[...(c.added ?? []), ...(c.modified ?? []), ...(c.removed ?? [])].map((f) => ({ id: `repo:${repoFull}#${f}`, kind: "file", label: f, role: "generated" }))],
95
+ timestamp: c.timestamp, location: where(c.url), intent: c.message, method: { tool: "git", automated: false }, idempotency_key: `git:${c.id}`, tags: ["github", "push"],
96
+ }));
97
+ }
98
+ return [];
99
+ }
100
+ /** Map a REST /pulls item (+ its reviews) for backfill. */
101
+ export function mapGithubPullRest(repoFull, pr, reviews = [], project = repoFull) {
102
+ const fake = { repository: { full_name: repoFull } };
103
+ const out = [];
104
+ out.push(...mapGithubWebhook("pull_request", { ...fake, action: "opened", sender: pr.user, pull_request: { ...pr, updated_at: pr.created_at } }, { project }));
105
+ for (const r of reviews)
106
+ out.push(...mapGithubWebhook("pull_request_review", { ...fake, action: "submitted", sender: r.user, review: r, pull_request: pr }, { project }));
107
+ if (pr.merged_at)
108
+ out.push(...mapGithubWebhook("pull_request", { ...fake, action: "closed", sender: pr.merged_by ?? pr.user, pull_request: { ...pr, merged: true, updated_at: pr.merged_at } }, { project }));
109
+ else if (pr.state === "closed")
110
+ out.push(...mapGithubWebhook("pull_request", { ...fake, action: "closed", sender: pr.user, pull_request: { ...pr, merged: false, updated_at: pr.closed_at ?? pr.updated_at } }, { project }));
111
+ return out.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
112
+ }
@@ -0,0 +1,12 @@
1
+ export * from "./schema.js";
2
+ export * from "./chain.js";
3
+ export * from "./store.js";
4
+ export * from "./explain.js";
5
+ export * from "./router.js";
6
+ export * from "./signing.js";
7
+ export * from "./export.js";
8
+ export * from "./report.js";
9
+ export * from "./lineage.js";
10
+ export * from "./github.js";
11
+ export * from "./gdrive.js";
12
+ export * from "./status.js";
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ export * from "./schema.js";
2
+ export * from "./chain.js";
3
+ export * from "./store.js";
4
+ export * from "./explain.js";
5
+ export * from "./router.js";
6
+ export * from "./signing.js";
7
+ export * from "./export.js";
8
+ export * from "./report.js";
9
+ export * from "./lineage.js";
10
+ export * from "./github.js";
11
+ export * from "./gdrive.js";
12
+ export * from "./status.js";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Artifact lineage graph.
3
+ * Nodes: artifacts (+ optionally actors).
4
+ * Edges:
5
+ * derived — explicit ArtifactRef.derived_from (strongest signal)
6
+ * flow — causal: event B (caused_by A) touched artifact Y while A touched artifact X, X≠Y ⇒ X → Y
7
+ * (e.g. human instructs on task T → agent edits file F ⇒ T → F; agent reads F1 → then creates F2 ⇒ F1 → F2)
8
+ * touched — actor → artifact (only when includeActors)
9
+ * Pure function of events; the same code runs in the UI (embedded), MCP server and Worker.
10
+ */
11
+ import { Event } from "./schema.js";
12
+ export interface LineageNode {
13
+ id: string;
14
+ type: "artifact" | "actor";
15
+ label: string;
16
+ kind?: string;
17
+ events: number;
18
+ first_seq: number;
19
+ last_seq: number;
20
+ actors?: string[];
21
+ actions?: Record<string, number>;
22
+ }
23
+ export interface LineageEdge {
24
+ from: string;
25
+ to: string;
26
+ type: "derived" | "flow" | "touched";
27
+ weight: number;
28
+ via?: string[];
29
+ }
30
+ export interface Lineage {
31
+ nodes: LineageNode[];
32
+ edges: LineageEdge[];
33
+ }
34
+ export interface LineageOptions {
35
+ includeActors?: boolean;
36
+ maxVia?: number;
37
+ }
38
+ /**
39
+ * Latest known label per artifact id: the label on the last event (by seq) that carries one.
40
+ * Drive "created" events arrive titled "Untitled" and later edits/renames carry the real title,
41
+ * so anywhere the UI names an artifact it should resolve through this. Events stay untouched —
42
+ * an individual event keeps its own as-at label.
43
+ */
44
+ export declare function latestArtifactLabels(events: Pick<Event, "seq" | "artifacts">[]): Map<string, string>;
45
+ export declare function buildLineage(events: Event[], opts?: LineageOptions): Lineage;
46
+ /** Roots → leaves layering by longest path (cycles broken by seq order). Returns node id → layer. */
47
+ export declare function layerLineage(l: Lineage): Map<string, number>;
48
+ export declare function renderLineageDot(l: Lineage): string;
49
+ export declare function renderLineageMermaid(l: Lineage): string;
50
+ export declare function renderLineageText(l: Lineage): string;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Latest known label per artifact id: the label on the last event (by seq) that carries one.
3
+ * Drive "created" events arrive titled "Untitled" and later edits/renames carry the real title,
4
+ * so anywhere the UI names an artifact it should resolve through this. Events stay untouched —
5
+ * an individual event keeps its own as-at label.
6
+ */
7
+ export function latestArtifactLabels(events) {
8
+ const out = new Map();
9
+ for (const e of [...events].sort((a, b) => a.seq - b.seq))
10
+ for (const a of e.artifacts)
11
+ if (a.label)
12
+ out.set(a.id, a.label);
13
+ return out;
14
+ }
15
+ export function buildLineage(events, opts = {}) {
16
+ const maxVia = opts.maxVia ?? 5;
17
+ const nodes = new Map();
18
+ const edges = new Map();
19
+ const byId = new Map(events.map((e) => [e.id, e]));
20
+ const sorted = [...events].sort((a, b) => a.seq - b.seq);
21
+ const artNode = (id, label, kind, seq = 0) => {
22
+ let n = nodes.get("a:" + id);
23
+ if (!n) {
24
+ n = { id, type: "artifact", label: label ?? id, kind, events: 0, first_seq: seq, last_seq: seq, actors: [], actions: {} };
25
+ nodes.set("a:" + id, n);
26
+ }
27
+ if (label)
28
+ n.label = label; // events arrive in seq order, so the last label seen is the latest
29
+ if (kind && !n.kind)
30
+ n.kind = kind;
31
+ return n;
32
+ };
33
+ const actorNode = (e) => {
34
+ const id = e.actor.id;
35
+ let n = nodes.get("u:" + id);
36
+ if (!n) {
37
+ n = { id, type: "actor", label: e.actor.display_name ?? id, kind: e.actor.type, events: 0, first_seq: e.seq, last_seq: e.seq };
38
+ nodes.set("u:" + id, n);
39
+ }
40
+ return n;
41
+ };
42
+ const addEdge = (from, to, type, via) => {
43
+ if (from === to)
44
+ return;
45
+ const k = `${type}|${from}|${to}`;
46
+ let ed = edges.get(k);
47
+ if (!ed) {
48
+ ed = { from, to, type, weight: 0, via: [] };
49
+ edges.set(k, ed);
50
+ }
51
+ ed.weight++;
52
+ if (via && ed.via.length < maxVia)
53
+ ed.via.push(via);
54
+ };
55
+ for (const e of sorted) {
56
+ for (const a of e.artifacts) {
57
+ const n = artNode(a.id, a.label, a.kind, e.seq);
58
+ n.events++;
59
+ n.last_seq = e.seq;
60
+ n.first_seq = Math.min(n.first_seq, e.seq);
61
+ if (!n.actors.includes(e.actor.id))
62
+ n.actors.push(e.actor.id);
63
+ n.actions[e.action] = (n.actions[e.action] ?? 0) + 1;
64
+ for (const src of a.derived_from ?? []) {
65
+ artNode(src, undefined, undefined, e.seq);
66
+ addEdge(src, a.id, "derived", e.id);
67
+ }
68
+ if (opts.includeActors) {
69
+ const u = actorNode(e);
70
+ u.events++;
71
+ u.last_seq = e.seq;
72
+ addEdge("u:" + e.actor.id, a.id, "touched", e.id);
73
+ }
74
+ }
75
+ if (e.caused_by) {
76
+ const parent = byId.get(e.caused_by);
77
+ if (parent) {
78
+ for (const pa of parent.artifacts)
79
+ for (const ca of e.artifacts)
80
+ if (pa.id !== ca.id)
81
+ addEdge(pa.id, ca.id, "flow", e.id);
82
+ }
83
+ }
84
+ }
85
+ return { nodes: [...nodes.values()], edges: [...edges.values()] };
86
+ }
87
+ /** Roots → leaves layering by longest path (cycles broken by seq order). Returns node id → layer. */
88
+ export function layerLineage(l) {
89
+ const ids = l.nodes.filter((n) => n.type === "artifact").map((n) => n.id);
90
+ const inc = new Map(ids.map((id) => [id, []]));
91
+ for (const e of l.edges)
92
+ if (e.type !== "touched" && inc.has(e.to) && inc.has(e.from))
93
+ inc.get(e.to).push(e.from);
94
+ const seq = new Map(l.nodes.map((n) => [n.id, n.first_seq]));
95
+ const layer = new Map();
96
+ const visiting = new Set();
97
+ const depth = (id) => {
98
+ if (layer.has(id))
99
+ return layer.get(id);
100
+ if (visiting.has(id))
101
+ return 0;
102
+ visiting.add(id);
103
+ let d = 0;
104
+ for (const p of inc.get(id) ?? [])
105
+ if ((seq.get(p) ?? 0) <= (seq.get(id) ?? 0))
106
+ d = Math.max(d, depth(p) + 1);
107
+ visiting.delete(id);
108
+ layer.set(id, d);
109
+ return d;
110
+ };
111
+ for (const id of ids)
112
+ depth(id);
113
+ return layer;
114
+ }
115
+ const q = (s) => JSON.stringify(s);
116
+ export function renderLineageDot(l) {
117
+ const shape = (n) => n.type === "actor" ? (n.kind === "human" ? "ellipse" : "hexagon") : n.kind === "commit" ? "note" : n.kind === "task" ? "folder" : "box";
118
+ const lines = ["digraph retrace {", " rankdir=LR; node [fontname=Helvetica, fontsize=10]; edge [fontsize=9];"];
119
+ for (const n of l.nodes)
120
+ lines.push(` ${q((n.type === "actor" ? "u:" : "") + n.id)} [label=${q(`${n.label}${n.type === "artifact" ? `\n${n.events} event${n.events === 1 ? "" : "s"}` : ""}`)}, shape=${shape(n)}${n.type === "actor" ? ", style=dashed" : ""}];`);
121
+ for (const e of l.edges)
122
+ lines.push(` ${q(e.from)} -> ${q(e.to)} [label=${q(e.type === "derived" ? "derived" : e.type === "flow" ? `flow ×${e.weight}` : "")}${e.type === "derived" ? ", penwidth=2" : e.type === "touched" ? ", style=dashed, color=gray" : ""}];`);
123
+ lines.push("}");
124
+ return lines.join("\n");
125
+ }
126
+ export function renderLineageMermaid(l) {
127
+ const idOf = new Map();
128
+ let i = 0;
129
+ const nid = (s) => { if (!idOf.has(s))
130
+ idOf.set(s, "n" + i++); return idOf.get(s); };
131
+ const lines = ["graph LR"];
132
+ for (const n of l.nodes) {
133
+ const key = (n.type === "actor" ? "u:" : "") + n.id;
134
+ const lbl = `${n.label}${n.type === "artifact" ? ` (${n.events})` : ""}`.replace(/"/g, "'");
135
+ lines.push(n.type === "actor" ? ` ${nid(key)}(["${lbl}"])` : ` ${nid(key)}["${lbl}"]`);
136
+ }
137
+ for (const e of l.edges)
138
+ lines.push(` ${nid(e.from)} ${e.type === "derived" ? "==>" : e.type === "flow" ? "-->" : "-.->"}${e.type === "flow" && e.weight > 1 ? `|×${e.weight}|` : ""} ${nid(e.to)}`);
139
+ return lines.join("\n");
140
+ }
141
+ export function renderLineageText(l) {
142
+ const arts = l.nodes.filter((n) => n.type === "artifact");
143
+ const out = [`${arts.length} artifacts, ${l.edges.length} edges`];
144
+ for (const n of arts) {
145
+ const ins = l.edges.filter((e) => e.to === n.id && e.type !== "touched").map((e) => `${e.from} (${e.type})`);
146
+ const outs = l.edges.filter((e) => e.from === n.id && e.type !== "touched").map((e) => `${e.to} (${e.type})`);
147
+ out.push(`- ${n.label}${n.kind ? ` [${n.kind}]` : ""} · ${n.events} ev · by ${n.actors?.join(", ")}${ins.length ? `\n ← ${ins.join("; ")}` : ""}${outs.length ? `\n → ${outs.join("; ")}` : ""}`);
148
+ }
149
+ return out.join("\n");
150
+ }
@@ -0,0 +1,6 @@
1
+ /** Printable provenance report (HTML → "Save as PDF" in any browser). Self-contained, no scripts required. */
2
+ import { ExportBundle, ExportVerdict } from "./export.js";
3
+ export declare function renderReportHtml(bundle: ExportBundle, verdict?: ExportVerdict, opts?: {
4
+ title?: string;
5
+ baseUrl?: string;
6
+ }): string;
package/dist/report.js ADDED
@@ -0,0 +1,79 @@
1
+ import { latestArtifactLabels } from "./lineage.js";
2
+ import { roleMark } from "./explain.js";
3
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
4
+ const actorName = (e) => e.actor.display_name ?? e.actor.id;
5
+ const verb = (e) => (e.action === "other" ? e.action_detail ?? "acted on" : e.action);
6
+ export function renderReportHtml(bundle, verdict, opts = {}) {
7
+ const title = opts.title ?? `Provenance report — ${bundle.scope.project}${bundle.scope.artifact_id ? " · " + bundle.scope.artifact_id : ""}`;
8
+ const events = [...bundle.events].sort((a, b) => a.seq - b.seq);
9
+ const byId = new Map(events.map((e) => [e.id, e]));
10
+ const labels = latestArtifactLabels(events);
11
+ const humans = new Set(events.filter((e) => e.actor.type === "human").map((e) => e.actor.id));
12
+ const agents = new Set(events.filter((e) => e.actor.type === "agent").map((e) => e.actor.id));
13
+ const arts = new Set(events.flatMap((e) => e.artifacts.map((a) => a.id)));
14
+ const first = events[0]?.timestamp, last = events.at(-1)?.timestamp;
15
+ const sigLine = !bundle.signature ? "unsigned" : verdict ? (verdict.signature === "valid" ? "valid" : "INVALID") : "present (not verified here)";
16
+ const chainLine = bundle.chain.ok ? `intact — ${bundle.chain.checked} of ${bundle.chain.total_events} events verified at export` : `BROKEN at #${bundle.chain.first_bad_seq}: ${bundle.chain.reason}`;
17
+ const rows = events.map((e) => {
18
+ const cause = e.caused_by ? byId.get(e.caused_by) : undefined;
19
+ // each part is escaped individually (intent, actor names, verbs and caused_by ids are caller-supplied) and only
20
+ // then joined with a literal <br> — the one piece of markup this cell is allowed to contain
21
+ const why = [e.intent, cause ? `↳ because #${cause.seq} (${actorName(cause)} ${verb(cause)})` : e.caused_by ? `↳ caused by ${e.caused_by}` : ""].filter(Boolean).map(esc).join("<br>");
22
+ const where = [e.location?.system, e.location?.environment, e.location?.path ?? e.location?.url].filter(Boolean).join(" · ");
23
+ const how = [e.method?.tool, e.method?.automated == null ? "" : e.method.automated ? "automated" : "manual", e.method?.tokens != null ? `${e.method.tokens} tokens` : ""].filter(Boolean).join(" · ");
24
+ return `<tr class="${esc(e.actor.type)}">
25
+ <td class="mono">#${e.seq}<br><small>${esc(new Date(e.timestamp).toISOString().replace("T", " ").slice(0, 19))}Z</small></td>
26
+ <td><b>${esc(actorName(e))}</b><br><small>${esc(e.actor.type)}${e.actor.model ? " · " + esc(e.actor.model) : ""}${e.actor.on_behalf_of ? "<br>for " + esc(e.actor.on_behalf_of) : ""}</small></td>
27
+ <td><b>${esc(verb(e))}</b> ${e.artifacts.map((a) => `${roleMark(a.role) ? `<small class="role">${roleMark(a.role)}</small>` : ""}<code>${esc(labels.get(a.id) ?? a.label ?? a.id)}</code>`).join(" ")}${e.change?.summary ? `<br><small>${esc(e.change.summary)}</small>` : ""}</td>
28
+ <td><small>${esc(where)}</small></td>
29
+ <td>${why}</td>
30
+ <td><small>${esc(how)}</small></td>
31
+ <td class="mono"><small>${esc(e.hash.slice(0, 12))}…</small></td>
32
+ </tr>`;
33
+ }).join("\n");
34
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>
35
+ <style>
36
+ body{font:12px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#111;margin:32px;max-width:1100px}
37
+ h1{font-size:20px;margin:0 0 4px} h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:#666;margin:22px 0 8px}
38
+ .meta{display:grid;grid-template-columns:140px 1fr;gap:4px 12px;font-size:12px} .meta .k{color:#666}
39
+ table{border-collapse:collapse;width:100%;font-size:11.5px} th,td{border-top:1px solid #ddd;padding:6px 6px;vertical-align:top;text-align:left}
40
+ th{font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:#666;border-top:0}
41
+ tr.human td:first-child{border-left:3px solid #f2a93b} tr.agent td:first-child{border-left:3px solid #5aa9ff} tr.system td:first-child{border-left:3px solid #9aa3b5}
42
+ code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;background:#f2f3f5;padding:0 3px;border-radius:3px}
43
+ .mono{font-family:ui-monospace,Menlo,Consolas,monospace} small{color:#555}
44
+ .role{font-size:8.5px;text-transform:uppercase;letter-spacing:.05em;color:#777;margin-right:2px;vertical-align:middle}
45
+ .ok{color:#178f4f;font-weight:600} .bad{color:#d1303f;font-weight:600}
46
+ .box{border:1px solid #ddd;border-radius:6px;padding:10px 12px;background:#fafbfc}
47
+ .print{position:fixed;top:12px;right:12px;padding:8px 12px;border:1px solid #bbb;border-radius:6px;background:#fff;cursor:pointer}
48
+ @media print{.print{display:none} body{margin:12mm}}
49
+ </style></head><body>
50
+ <button class="print" onclick="window.print()">Print / Save as PDF</button>
51
+ <h1>${esc(title)}</h1>
52
+ <div><small>Generated ${esc(bundle.generated_at)} · Retrace ${esc(bundle.format)}</small></div>
53
+
54
+ <h2>Summary</h2>
55
+ <div class="meta">
56
+ <div class="k">Project</div><div>${esc(bundle.scope.project)}</div>
57
+ ${bundle.scope.artifact_id ? `<div class="k">Artifact</div><div><code>${esc(bundle.scope.artifact_id)}</code></div>` : ""}
58
+ <div class="k">Events</div><div>${events.length}${bundle.context_events ? ` (${bundle.context_events} included as causal context)` : ""}${first ? ` — from ${esc(first)} to ${esc(last)}` : ""}</div>
59
+ <div class="k">Actors</div><div>${[humans.size ? `${humans.size} human${humans.size === 1 ? "" : "s"} (${esc([...humans].join(", "))})` : "", agents.size ? `${agents.size} agent${agents.size === 1 ? "" : "s"} (${esc([...agents].join(", "))})` : ""].filter(Boolean).join("; ") || "—"}</div>
60
+ <div class="k">Artifacts</div><div>${arts.size}</div>
61
+ <div class="k">Chain integrity</div><div class="${bundle.chain.ok ? "ok" : "bad"}">${esc(chainLine)}</div>
62
+ <div class="k">Signature</div><div class="${sigLine === "valid" ? "ok" : sigLine === "INVALID" ? "bad" : ""}">${esc(sigLine)}${bundle.issuer ? ` · Ed25519 key <span class="mono">${esc(bundle.issuer.kid)}</span>${bundle.issuer.name ? " · " + esc(bundle.issuer.name) : ""}` : ""}</div>
63
+ ${bundle.chain.head_hash ? `<div class="k">Head hash</div><div class="mono">${esc(bundle.chain.head_hash)}</div>` : ""}
64
+ </div>
65
+
66
+ <h2>Timeline — who · what · where · why · how</h2>
67
+ <table><thead><tr><th>#/when (UTC)</th><th>who</th><th>what</th><th>where</th><th>why</th><th>how</th><th>hash</th></tr></thead><tbody>
68
+ ${rows}
69
+ </tbody></table>
70
+
71
+ <h2>How to verify this report</h2>
72
+ <div class="box">
73
+ Each event is hashed (SHA-256) together with the previous event's hash, so any alteration or deletion breaks the chain. The JSON bundle this report was rendered from is signed with the issuer's Ed25519 key${bundle.issuer ? ` (kid <span class="mono">${esc(bundle.issuer.kid)}</span>)` : ""}.
74
+ Verify offline with <code>retrace-export verify bundle.json</code>${opts.baseUrl ? `, or fetch the issuer's public key from <code>${esc(opts.baseUrl)}/.well-known/retrace-pubkey</code>` : ""}.
75
+ ${bundle.issuer ? `<br><br>Issuer public key (JWK): <span class="mono">${esc(JSON.stringify(bundle.issuer.public_key))}</span>` : ""}
76
+ ${bundle.signature ? `<br>Signature: <span class="mono" style="word-break:break-all">${esc(bundle.signature)}</span>` : ""}
77
+ </div>
78
+ </body></html>`;
79
+ }
@@ -0,0 +1,136 @@
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 } from "./schema.js";
32
+ import { EventStore } from "./store.js";
33
+ /** A per-actor credential (security review 2026-08-21, backlog #6). Holders can POST /events and read; they cannot
34
+ * DELETE or create shares. trust "pinned" (default): the Worker stamps `actor` from the credential — the body may only
35
+ * add display_name/version and may not claim human/system, except that an agent credential with `actor.on_behalf_of`
36
+ * may record "instructed" roots attributed to exactly that human (see resolveActor). trust "assert": the body actor
37
+ * is stored verbatim IF it appears in the credential's allowed_actors list (git hook, backfill, Drive forwarder —
38
+ * callers that legitimately relay other people's actions, bounded to the actors they are expected to relay). */
39
+ export declare const Credential: z.ZodObject<{
40
+ token: z.ZodString;
41
+ /** Human-readable name, recorded nowhere — for the operator's own bookkeeping */
42
+ name: z.ZodOptional<z.ZodString>;
43
+ actor: z.ZodObject<{
44
+ type: z.ZodEnum<["human", "agent", "system"]>;
45
+ id: z.ZodString;
46
+ display_name: z.ZodOptional<z.ZodString>;
47
+ model: z.ZodOptional<z.ZodString>;
48
+ version: z.ZodOptional<z.ZodString>;
49
+ on_behalf_of: z.ZodOptional<z.ZodString>;
50
+ }, "strip", z.ZodTypeAny, {
51
+ type: "human" | "agent" | "system";
52
+ id: string;
53
+ display_name?: string | undefined;
54
+ model?: string | undefined;
55
+ version?: string | undefined;
56
+ on_behalf_of?: string | undefined;
57
+ }, {
58
+ type: "human" | "agent" | "system";
59
+ id: string;
60
+ display_name?: string | undefined;
61
+ model?: string | undefined;
62
+ version?: string | undefined;
63
+ on_behalf_of?: string | undefined;
64
+ }>;
65
+ trust: z.ZodDefault<z.ZodEnum<["pinned", "assert"]>>;
66
+ /** For assert trust: the ONLY actors this credential may assert on POST /events, matched on exact type + id
67
+ * (audit 2026-08-22, P1: an unbounded assert credential could seal events claiming any actor — a human, or the
68
+ * pinned agent's id — into any project). Absent or empty = may assert none; routes that map actors server-side
69
+ * (/hooks/gdrive) are unaffected. Ignored for pinned trust. */
70
+ allowed_actors: z.ZodOptional<z.ZodArray<z.ZodObject<{
71
+ type: z.ZodEnum<["human", "agent", "system"]>;
72
+ id: z.ZodString;
73
+ }, "strip", z.ZodTypeAny, {
74
+ type: "human" | "agent" | "system";
75
+ id: string;
76
+ }, {
77
+ type: "human" | "agent" | "system";
78
+ id: string;
79
+ }>, "many">>;
80
+ }, "strip", z.ZodTypeAny, {
81
+ actor: {
82
+ type: "human" | "agent" | "system";
83
+ id: string;
84
+ display_name?: string | undefined;
85
+ model?: string | undefined;
86
+ version?: string | undefined;
87
+ on_behalf_of?: string | undefined;
88
+ };
89
+ token: string;
90
+ trust: "pinned" | "assert";
91
+ name?: string | undefined;
92
+ allowed_actors?: {
93
+ type: "human" | "agent" | "system";
94
+ id: string;
95
+ }[] | undefined;
96
+ }, {
97
+ actor: {
98
+ type: "human" | "agent" | "system";
99
+ id: string;
100
+ display_name?: string | undefined;
101
+ model?: string | undefined;
102
+ version?: string | undefined;
103
+ on_behalf_of?: string | undefined;
104
+ };
105
+ token: string;
106
+ name?: string | undefined;
107
+ trust?: "pinned" | "assert" | undefined;
108
+ allowed_actors?: {
109
+ type: "human" | "agent" | "system";
110
+ id: string;
111
+ }[] | undefined;
112
+ }>;
113
+ export type Credential = z.infer<typeof Credential>;
114
+ /** Parse the RETRACE_CREDENTIALS secret (JSON array). Throws on malformed config so a bad deploy fails loudly. */
115
+ export declare function parseCredentials(raw?: string | null): Credential[];
116
+ export interface RouterOptions {
117
+ /** Owner token: full access, body actor stored verbatim. Accepted as Bearer or ?token= (the UI uses the latter). */
118
+ token?: string;
119
+ /** Per-actor credentials; see Credential. Bearer only (never ?token=, which leaks into logs). */
120
+ credentials?: Credential[];
121
+ signingKey?: JsonWebKey | null;
122
+ issuerName?: string;
123
+ /** Public base URL used when building share links (defaults to request origin) */
124
+ publicUrl?: string;
125
+ /** GitHub webhook secret; POST /hooks/github?project=… is authenticated by X-Hub-Signature-256 instead of the bearer token */
126
+ githubSecret?: string;
127
+ /** Also log `push` commits from GitHub (off by default — the git adapter covers commits, and idempotency keys match anyway) */
128
+ githubIncludePush?: boolean;
129
+ /** Project that receives the audit event when a project is deleted (default "retrace") */
130
+ opsProject?: string;
131
+ /** Who the owner token is. Owner-only actions (DELETE /projects/:p) are audited as this actor — typically the operator,
132
+ * e.g. {type:"human", id:"jordan@example.com"} from RETRACE_OWNER. Unset → {type:"system", id:"worker"}, which says
133
+ * only that *the server* did it (security review 2026-08-21, audit-event actor). */
134
+ ownerActor?: Actor;
135
+ }
136
+ export declare function createHandler(store: EventStore, tokenOrOpts?: string | RouterOptions): (req: Request) => Promise<Response>;