@obsunified/collector 2.2.1 → 2.2.3

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.
Files changed (33) hide show
  1. package/dist/auth.js +1 -1
  2. package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
  3. package/dist/chunk-2FALTDCE.js.map +1 -0
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.js +497 -96
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -3
  8. package/src/__tests__/autosre-closed-loop.test.ts +590 -0
  9. package/src/auth/dashboard-auth.test.ts +61 -0
  10. package/src/auth/dashboard-auth.ts +17 -2
  11. package/src/lib/ai-store/calls.ts +43 -23
  12. package/src/lib/ai-store.ts +12 -1
  13. package/src/lib/autosre-findings.ts +169 -14
  14. package/src/lib/autosre-remediation-gate.ts +156 -26
  15. package/src/lib/projects-store.test.ts +38 -1
  16. package/src/lib/projects-store.ts +23 -4
  17. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  18. package/src/lib/usage-store.ts +9 -3
  19. package/src/migrations/052_remediation_callback.sql +10 -0
  20. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  21. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  22. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  23. package/src/plugins/ai-call-by-id.test.ts +73 -0
  24. package/src/plugins/ai-receiver.ts +10 -0
  25. package/src/plugins/autosre-routes.ts +167 -16
  26. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  27. package/src/plugins/connected-routes/manifest.test.ts +142 -0
  28. package/src/plugins/connected-routes/manifest.ts +127 -7
  29. package/src/plugins/connected-routes-links.test.ts +96 -0
  30. package/src/plugins/connected-routes.test.ts +3 -3
  31. package/src/plugins/connected-routes.ts +11 -8
  32. package/src/plugins/evidence-retrieval-routes.ts +30 -0
  33. package/dist/chunk-KMEZKGSO.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { sha256Hex } from "./hash";
3
- import { ProjectsStore } from "./projects-store";
3
+ import { displayPrefix, ProjectsStore } from "./projects-store";
4
4
  import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
5
5
 
6
6
  describe("ProjectsStore.revokeKey", () => {
@@ -23,3 +23,40 @@ describe("ProjectsStore.revokeKey", () => {
23
23
  db.close();
24
24
  });
25
25
  });
26
+
27
+ describe("bootstrap key display prefix", () => {
28
+ it("never exposes a short bootstrap key in full", async () => {
29
+ const db = createMigratedSqliteDb();
30
+ const store = new ProjectsStore(db);
31
+ await store.bootstrapEnvKey("short-key-12345"); // 15 chars
32
+ const [key] = (await store.listKeys("default")).filter(
33
+ (k) => k.name === "bootstrap",
34
+ );
35
+ expect(key?.keyPrefix).toBe("short");
36
+ expect("short-key-12345".startsWith(key?.keyPrefix ?? "")).toBe(true);
37
+ expect(key?.keyPrefix.length).toBeLessThan("short-key-12345".length);
38
+ db.close();
39
+ });
40
+
41
+ it("keeps 16 chars for long keys, matching minted keys", () => {
42
+ expect(displayPrefix("a".repeat(64))).toHaveLength(16);
43
+ expect(displayPrefix("abc")).toBe("a");
44
+ expect(displayPrefix("")).toBe("");
45
+ });
46
+
47
+ it("shrinks a legacy full-length prefix on the next bootstrap", async () => {
48
+ const db = createMigratedSqliteDb();
49
+ const store = new ProjectsStore(db);
50
+ await store.bootstrapEnvKey("legacy-key-1234");
51
+ await db
52
+ .prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE name = 'bootstrap'`)
53
+ .bind("legacy-key-1234")
54
+ .run();
55
+ await store.bootstrapEnvKey("legacy-key-1234");
56
+ const [key] = (await store.listKeys("default")).filter(
57
+ (k) => k.name === "bootstrap",
58
+ );
59
+ expect(key?.keyPrefix).toBe("legac");
60
+ db.close();
61
+ });
62
+ });
@@ -31,6 +31,15 @@ const rowToKey = (row: IngestKeyRow): IngestKey => ({
31
31
 
32
32
  const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$/;
33
33
 
34
+ /**
35
+ * Display identifier for a secret: at most 16 chars (what minted keys show),
36
+ * but never more than a third of the secret, so a short operator-chosen
37
+ * INGEST_KEY can never be returned in full by `keys list`.
38
+ */
39
+ export function displayPrefix(plaintext: string): string {
40
+ return plaintext.slice(0, Math.min(16, Math.floor(plaintext.length / 3)));
41
+ }
42
+
34
43
  export class ProjectsStore {
35
44
  constructor(private readonly db: SqlDb) {}
36
45
 
@@ -189,14 +198,24 @@ export class ProjectsStore {
189
198
  if (!plaintext) return;
190
199
  await this.ensureDefaultProject();
191
200
  const keyHash = await sha256Hex(plaintext);
201
+ const keyPrefix = displayPrefix(plaintext);
192
202
  const existing = await this.db
193
- .prepare(`SELECT id FROM ingest_keys WHERE key_hash = ?`)
203
+ .prepare(`SELECT id, key_prefix FROM ingest_keys WHERE key_hash = ?`)
194
204
  .bind(keyHash)
195
- .first<{ id: string }>();
196
- if (existing) return;
205
+ .first<{ id: string; key_prefix: string }>();
206
+ if (existing) {
207
+ // Rows written before the prefix was length-capped may hold a short
208
+ // bootstrap key in full; shrink them to the safe display prefix.
209
+ if (existing.key_prefix.length > keyPrefix.length) {
210
+ await this.db
211
+ .prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE id = ?`)
212
+ .bind(keyPrefix, existing.id)
213
+ .run();
214
+ }
215
+ return;
216
+ }
197
217
 
198
218
  const id = randomHex(16);
199
- const keyPrefix = plaintext.slice(0, Math.min(16, plaintext.length));
200
219
  const createdAt = new Date().toISOString();
201
220
  await this.db
202
221
  .prepare(
@@ -28,20 +28,22 @@ import type {
28
28
  StoredSpan,
29
29
  UsageEventRecord,
30
30
  } from "@obsunified/types";
31
- import type { MiddlewareHandler } from "hono";
31
+ import { Hono, type MiddlewareHandler } from "hono";
32
32
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
33
33
  import { deriveAnalysesForProject } from "../analyses/derive";
34
34
  import { INVESTIGATION_ANALYSES } from "../analyses/investigations";
35
35
  import { TIER0_ANALYSES } from "../analyses/tier0";
36
36
  import {
37
- type CollectorRuntime,
37
+ CollectorRuntime,
38
38
  createTelemetryCollectorApp,
39
39
  type SpanProcessorPlugin,
40
40
  } from "../framework/collector";
41
41
  import type { CollectorEnv, CollectorRouteContext } from "../framework/env";
42
42
  import type { DecodedMetricPoint } from "../otlp/decode";
43
43
  import { aiSpanPayloadsProcessorPlugin } from "../plugins/ai-span-payloads-processor";
44
+ import { connectedRoutesPlugin } from "../plugins/connected-routes";
44
45
  import { identityReceiverPlugin } from "../plugins/identity-receiver";
46
+ import { usageQueryRoutesPlugin } from "../plugins/usage-query-routes";
45
47
  import { purgeExpiredActionGraph } from "./action-retention";
46
48
  import { ActionStore } from "./actions-store";
47
49
  import { getAICallsOverview, ingestAICallBatch } from "./ai-store/calls";
@@ -1222,4 +1224,75 @@ describe.skipIf(!databaseUrl)("PostgresAdapter against a live Postgres", () => {
1222
1224
  expect(errorRate.primaryValue).toBe(0.5);
1223
1225
  expect(Array.isArray(errorRate.payload.sparkline)).toBe(true);
1224
1226
  });
1227
+
1228
+ it("serves the change connected-rail and usage sessions routes without Postgres query errors", async () => {
1229
+ const now = isoNow();
1230
+ const later = isoIn(60 * 60 * 1000);
1231
+ const insertSpan = (spanId: string, startTime: string) =>
1232
+ db
1233
+ .prepare(
1234
+ `INSERT INTO telemetry_spans (
1235
+ project_id, environment, trace_id, span_id, service_name, service_version,
1236
+ span_name, status_code, start_time, end_time, duration_ms, received_at, expires_at
1237
+ ) VALUES ('default', 'default', 'trace-pg-1', ?, 'checkout', 'v9.9.9', 'POST /pay', 2, ?, ?, 5, ?, ?)`,
1238
+ )
1239
+ .bind(spanId, startTime, startTime, now, later)
1240
+ .run();
1241
+ await insertSpan("span-pg-a", "2026-01-01T00:00:00.000Z");
1242
+ await insertSpan("span-pg-b", "2026-01-01T00:00:01.000Z");
1243
+ await db
1244
+ .prepare(
1245
+ `INSERT INTO change_events (
1246
+ project_id, environment, id, target_node_id, kind, source, summary,
1247
+ service_name, version, occurred_at, received_at
1248
+ ) VALUES ('default', 'default', 'chg-pg-1', 'service:checkout', 'deploy', 'explicit', 'deploy v9.9.9', 'checkout', 'v9.9.9', ?, ?)`,
1249
+ )
1250
+ .bind(now, now)
1251
+ .run();
1252
+ await new UsageStore(db).ingest([
1253
+ usageEvent({
1254
+ eventId: "pg-s1-a",
1255
+ sessionId: "pg-sess-1",
1256
+ pagePath: "/a",
1257
+ }),
1258
+ usageEvent({
1259
+ eventId: "pg-s1-b",
1260
+ sessionId: "pg-sess-1",
1261
+ pagePath: "/b",
1262
+ eventType: "interaction",
1263
+ }),
1264
+ ]);
1265
+
1266
+ const app = new Hono<{ Bindings: CollectorEnv }>();
1267
+ const runtime = new CollectorRuntime();
1268
+ connectedRoutesPlugin.register(app, runtime);
1269
+ usageQueryRoutesPlugin.register(app, runtime);
1270
+ const env = { DB: db as unknown as D1Database } as CollectorEnv;
1271
+
1272
+ const change = await app.request(
1273
+ "/internal/connected/change/chg-pg-1",
1274
+ {},
1275
+ env,
1276
+ );
1277
+ expect(change.status).toBe(200);
1278
+ const manifest = (await change.json()) as {
1279
+ across: Array<{ links: Array<{ entityId: string }> }>;
1280
+ };
1281
+ const spanIds = manifest.across.flatMap((s) =>
1282
+ s.links.map((l) => l.entityId),
1283
+ );
1284
+ // Most recent first, one link per span.
1285
+ expect(spanIds).toEqual(["trace-pg-1:span-pg-b", "trace-pg-1:span-pg-a"]);
1286
+
1287
+ const sessions = await app.request(
1288
+ "/internal/usage/sessions?hours=24",
1289
+ {},
1290
+ env,
1291
+ );
1292
+ expect(sessions.status).toBe(200);
1293
+ const body = (await sessions.json()) as {
1294
+ sessions: Array<{ sessionId: string; lastPath: string | null }>;
1295
+ };
1296
+ expect(body.sessions.map((r) => r.sessionId)).toContain("pg-sess-1");
1297
+ });
1225
1298
  });
@@ -503,15 +503,21 @@ export class UsageStore {
503
503
  SUM(CASE WHEN event_type = 'interaction' THEN 1 ELSE 0 END) AS interaction_count,
504
504
  SUM(CASE WHEN event_type = 'frontend_error' OR severity = 'error' THEN 1 ELSE 0 END) AS error_count,
505
505
  MAX(CASE WHEN event_type = 'page_view' THEN CAST(COALESCE(${loadMsExpr}, ${loadTimeMsExpr}, ${durationMsExpr}) AS REAL) ELSE NULL END) AS max_load_ms,
506
- (SELECT referrer FROM usage_events ref WHERE ref.project_id = ue.project_id AND ref.session_id = ue.session_id ORDER BY occurred_at ASC LIMIT 1) AS referrer,
507
- (SELECT page_path FROM usage_events lp WHERE lp.project_id = ue.project_id AND lp.session_id = ue.session_id ORDER BY occurred_at DESC LIMIT 1) AS last_path
506
+ (SELECT referrer FROM usage_events ref WHERE ref.project_id = ? AND ref.session_id = ue.session_id ORDER BY occurred_at ASC LIMIT 1) AS referrer,
507
+ (SELECT page_path FROM usage_events lp WHERE lp.project_id = ? AND lp.session_id = ue.session_id ORDER BY occurred_at DESC LIMIT 1) AS last_path
508
508
  FROM usage_events ue
509
509
  WHERE project_id = ? AND received_at >= ?
510
510
  GROUP BY session_id
511
511
  ORDER BY last_seen DESC
512
512
  LIMIT ?`,
513
513
  )
514
- .bind(options.projectId, cutoff, limit * 3)
514
+ .bind(
515
+ options.projectId,
516
+ options.projectId,
517
+ options.projectId,
518
+ cutoff,
519
+ limit * 3,
520
+ )
515
521
  .all<{
516
522
  session_id: string;
517
523
  visitor_id: string;
@@ -0,0 +1,10 @@
1
+ -- Persisted grant-handoff callback (RFC 0013 § 6.B, journey review J17).
2
+ --
3
+ -- A proposal parked for human approval may carry a webhook that receives the
4
+ -- one-time grant token when the approval happens later. `callback_url` is the
5
+ -- target (SSRF-guarded again at delivery time); `callback_secret` is the HMAC
6
+ -- signing secret, stored as supplied (the collector has no at-rest encryption
7
+ -- helper) and never returned by any GET. Both are cleared once the grant is
8
+ -- delivered, rejected or expired.
9
+ ALTER TABLE remediation_proposals ADD COLUMN callback_url TEXT;
10
+ ALTER TABLE remediation_proposals ADD COLUMN callback_secret TEXT;
@@ -0,0 +1,7 @@
1
+ -- Last grant-handoff webhook delivery outcome (RFC 0013 § 6.B).
2
+ --
3
+ -- JSON `{status: 'delivered'|'failed', attempts, lastError, at}` recorded after
4
+ -- the grant webhook is attempted (up to 3 attempts). It never contains the
5
+ -- callback URL or secret, so unlike callback_url it is kept after approval so
6
+ -- the dashboard can flag a failed handoff.
7
+ ALTER TABLE remediation_proposals ADD COLUMN callback_delivery TEXT;
@@ -0,0 +1,10 @@
1
+ -- (PostgreSQL mirror) Persisted grant-handoff callback (RFC 0013 § 6.B, journey review J17).
2
+ --
3
+ -- A proposal parked for human approval may carry a webhook that receives the
4
+ -- one-time grant token when the approval happens later. `callback_url` is the
5
+ -- target (SSRF-guarded again at delivery time); `callback_secret` is the HMAC
6
+ -- signing secret, stored as supplied (the collector has no at-rest encryption
7
+ -- helper) and never returned by any GET. Both are cleared once the grant is
8
+ -- delivered, rejected or expired.
9
+ ALTER TABLE remediation_proposals ADD COLUMN IF NOT EXISTS callback_url TEXT;
10
+ ALTER TABLE remediation_proposals ADD COLUMN IF NOT EXISTS callback_secret TEXT;
@@ -0,0 +1,7 @@
1
+ -- (PostgreSQL mirror) Last grant-handoff webhook delivery outcome (RFC 0013 § 6.B).
2
+ --
3
+ -- JSON `{status: 'delivered'|'failed', attempts, lastError, at}` recorded after
4
+ -- the grant webhook is attempted (up to 3 attempts). It never contains the
5
+ -- callback URL or secret, so unlike callback_url it is kept after approval so
6
+ -- the dashboard can flag a failed handoff.
7
+ ALTER TABLE remediation_proposals ADD COLUMN IF NOT EXISTS callback_delivery TEXT;
@@ -0,0 +1,73 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { describe, expect, it } from "vitest";
3
+ import { createTelemetryCollectorApp } from "../framework/collector";
4
+ import type { CollectorEnv } from "../framework/env";
5
+ import { MemSqlDb } from "../lib/test-utils/mem-sql-db";
6
+ import { aiReceiverPlugin } from "./ai-receiver";
7
+
8
+ const passthrough: MiddlewareHandler<{
9
+ Bindings: CollectorEnv;
10
+ Variables: { projectId: string };
11
+ }> = async (c, next) => {
12
+ c.set("projectId", "default");
13
+ await next();
14
+ };
15
+
16
+ const app = createTelemetryCollectorApp({
17
+ plugins: [aiReceiverPlugin],
18
+ auth: { middleware: passthrough },
19
+ dashboardAuth: { middleware: passthrough, registerRoutes: () => {} },
20
+ });
21
+
22
+ const row = {
23
+ project_id: "default",
24
+ call_id: "call-1",
25
+ trace_id: "trace-1",
26
+ span_id: "span-1",
27
+ service_name: "svc",
28
+ model_name: "gpt-4.1",
29
+ provider: "openai",
30
+ call_type: "chat",
31
+ request_json: null,
32
+ response_json: null,
33
+ prompt_tokens: 10,
34
+ completion_tokens: 5,
35
+ total_cost_usd: 0.01,
36
+ latency_ms: 120,
37
+ is_error: 0,
38
+ error_message: null,
39
+ occurred_at: "2026-05-01T00:00:00.000Z",
40
+ received_at: "2026-05-01T00:00:01.000Z",
41
+ expires_at: "2026-05-02T00:00:00.000Z",
42
+ };
43
+
44
+ describe("GET /internal/ai/calls/:callId", () => {
45
+ it("returns the call with its trace and span ids", async () => {
46
+ const db = new MemSqlDb({
47
+ first: (sql, binds) =>
48
+ sql.includes("FROM ai_calls") && binds[1] === "call-1" ? row : null,
49
+ });
50
+ const res = await app.request("/internal/ai/calls/call-1", {}, {
51
+ DB: db,
52
+ } as unknown as CollectorEnv);
53
+ expect(res.status).toBe(200);
54
+ const body = (await res.json()) as {
55
+ call: { callId: string; modelName: string };
56
+ traceId: string;
57
+ spanId: string;
58
+ };
59
+ expect(body.call.callId).toBe("call-1");
60
+ expect(body.call.modelName).toBe("gpt-4.1");
61
+ expect(body.traceId).toBe("trace-1");
62
+ expect(body.spanId).toBe("span-1");
63
+ expect(db.calls[0].binds).toEqual(["default", "call-1"]);
64
+ });
65
+
66
+ it("answers 404 for an unknown call id", async () => {
67
+ const db = new MemSqlDb({ first: () => null });
68
+ const res = await app.request("/internal/ai/calls/nope", {}, {
69
+ DB: db,
70
+ } as unknown as CollectorEnv);
71
+ expect(res.status).toBe(404);
72
+ });
73
+ });
@@ -97,6 +97,16 @@ export const aiReceiverPlugin: CollectorPlugin = {
97
97
  return c.json(response);
98
98
  });
99
99
 
100
+ // One AI call by its call id, with the trace/span it belongs to. Backs
101
+ // the `#/ai/calls/<id>` deep link when the call is outside the loaded list.
102
+ app.get("/internal/ai/calls/:callId", async (c) => {
103
+ const projectId = getProjectId(c);
104
+ const store = new AIStore(sqlDbFor(c.env));
105
+ const call = await store.getAICall(projectId, c.req.param("callId"));
106
+ if (!call) return c.json({ error: "AI call not found" }, 404);
107
+ return c.json({ call, traceId: call.traceId, spanId: call.spanId });
108
+ });
109
+
100
110
  // OpenInference-kind spans (LLM/TOOL/RETRIEVER/CHAIN/AGENT/...) joined
101
111
  // with their payload side-table. This is the replacement for the flat
102
112
  // ai_calls endpoint — it returns typed spans that live inside the
@@ -4,6 +4,8 @@ import type {
4
4
  ChangeEventKind,
5
5
  CohortAttributionVerdict,
6
6
  EvidenceEntityKind,
7
+ GrantCallbackDelivery,
8
+ GrantDeliveryResult,
7
9
  InvestigationFindingInput,
8
10
  JsonValue,
9
11
  RemediationActionKind,
@@ -46,16 +48,19 @@ import {
46
48
  evaluateRemediationWatchdogs,
47
49
  failRemediationProposal,
48
50
  getIncidentInvestigation,
51
+ getRemediationCallback,
49
52
  getRemediationProposal,
50
53
  listIncidentInvestigations,
51
54
  listRemediationProposals,
52
55
  openOrDeduplicateIncident,
53
56
  proposeRemediationAction,
54
57
  RemediationGateError,
58
+ recordCallbackDelivery,
55
59
  rejectRemediationProposal,
56
60
  } from "../lib/autosre-remediation-gate";
57
61
  import { sha256Hex } from "../lib/hash";
58
62
  import { sqlDbFor } from "../lib/sql-db";
63
+ import { assertSafeWebhookUrl } from "../lib/webhook-url";
59
64
  import { getProjectId } from "./_context";
60
65
  import { readJsonObject } from "./_json";
61
66
 
@@ -126,6 +131,12 @@ export const autosreRoutesPlugin: CollectorPlugin = {
126
131
  * `webhookUrl` (+ optional `secret`) in that same request and the token is
127
132
  * pushed to the runner over the SSRF-guarded, HMAC-signed incident webhook.
128
133
  * Without a `webhookUrl` the token is simply returned in the response.
134
+ *
135
+ * A `webhookUrl` (+ `secret`) given to `propose` for a proposal that is
136
+ * parked for human approval is persisted on the proposal and used when
137
+ * the approve request carries none. A callback on the approve request
138
+ * wins over the stored one. The stored callback is cleared on approve
139
+ * or reject, and only its host is ever returned by GET.
129
140
  */
130
141
  const deliverGrant = async (
131
142
  env: CollectorEnv | undefined,
@@ -139,23 +150,87 @@ export const autosreRoutesPlugin: CollectorPlugin = {
139
150
  status: string;
140
151
  grantToken?: string;
141
152
  },
153
+ /** Callback persisted at propose time; used when the request has none. */
154
+ stored?: { url: string; secret?: string } | null,
142
155
  ) => {
143
- const webhookUrl =
156
+ const bodyUrl =
144
157
  typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : "";
145
- if (!webhookUrl || !proposal.grantToken) return undefined;
146
- if (proposal.status !== "grant_issued") return undefined;
147
- return dispatchIncidentWebhook(db, projectId, proposal.incidentId, {
148
- url: webhookUrl,
149
- secret: typeof body.secret === "string" ? body.secret : undefined,
150
- environment: proposal.environment,
151
- allowPrivateNetworks: env?.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
152
- logger: runtime.logger,
153
- grant: {
154
- proposalId: proposal.id,
155
- status: proposal.status,
156
- grantToken: proposal.grantToken,
158
+ const target = bodyUrl
159
+ ? {
160
+ url: bodyUrl,
161
+ secret: typeof body.secret === "string" ? body.secret : undefined,
162
+ }
163
+ : (stored ?? null);
164
+ const notAttempted: GrantDeliveryResult = {
165
+ attempted: false,
166
+ delivered: false,
167
+ attempts: 0,
168
+ host: null,
169
+ };
170
+ if (!target || !proposal.grantToken) {
171
+ return { webhookDelivery: undefined, grantDelivery: notAttempted };
172
+ }
173
+ if (proposal.status !== "grant_issued") {
174
+ return { webhookDelivery: undefined, grantDelivery: notAttempted };
175
+ }
176
+ let host: string | null = null;
177
+ try {
178
+ host = new URL(target.url).host || null;
179
+ } catch {
180
+ host = null;
181
+ }
182
+ const webhookDelivery = await dispatchIncidentWebhook(
183
+ db,
184
+ projectId,
185
+ proposal.incidentId,
186
+ {
187
+ url: target.url,
188
+ secret: target.secret,
189
+ environment: proposal.environment,
190
+ allowPrivateNetworks: env?.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
191
+ logger: runtime.logger,
192
+ grant: {
193
+ proposalId: proposal.id,
194
+ status: proposal.status,
195
+ grantToken: proposal.grantToken,
196
+ },
157
197
  },
158
- });
198
+ );
199
+ // Error text is surfaced to the human: strip anything URL-shaped and
200
+ // the secret so neither the query string nor the HMAC key can leak.
201
+ let error = webhookDelivery.error
202
+ ? webhookDelivery.error.replace(
203
+ /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi,
204
+ "<url>",
205
+ )
206
+ : undefined;
207
+ if (error && target.secret) {
208
+ error = error.split(target.secret).join("<redacted>");
209
+ }
210
+ const record = await recordCallbackDelivery(
211
+ db,
212
+ projectId,
213
+ proposal.environment,
214
+ proposal.id,
215
+ {
216
+ delivered: webhookDelivery.delivered,
217
+ attempts: webhookDelivery.attempts,
218
+ error,
219
+ },
220
+ );
221
+ (
222
+ proposal as { callbackDelivery?: GrantCallbackDelivery | null }
223
+ ).callbackDelivery = record;
224
+ const grantDelivery: GrantDeliveryResult = {
225
+ attempted: true,
226
+ delivered: webhookDelivery.delivered,
227
+ attempts: webhookDelivery.attempts,
228
+ host,
229
+ ...(webhookDelivery.delivered
230
+ ? {}
231
+ : { error: error ?? "unknown error" }),
232
+ };
233
+ return { webhookDelivery, grantDelivery };
159
234
  };
160
235
 
161
236
  // 1. Change Ingestion (Ingest Auth /v1/changes)
@@ -585,6 +660,10 @@ export const autosreRoutesPlugin: CollectorPlugin = {
585
660
  const db = sqlDbFor(c.env);
586
661
  const input: InvestigationFindingInput = {
587
662
  id: typeof body.id === "string" ? body.id : undefined,
663
+ idempotencyKey:
664
+ typeof body.idempotencyKey === "string" && body.idempotencyKey.trim()
665
+ ? body.idempotencyKey.trim()
666
+ : undefined,
588
667
  environment:
589
668
  typeof body.environment === "string" ? body.environment : undefined,
590
669
  incidentId:
@@ -633,6 +712,9 @@ export const autosreRoutesPlugin: CollectorPlugin = {
633
712
  (id): id is string => typeof id === "string",
634
713
  )
635
714
  : [],
715
+ codeReferences: Array.isArray(body.codeReferences)
716
+ ? (body.codeReferences as InvestigationFindingInput["codeReferences"])
717
+ : undefined,
636
718
  agentRunId:
637
719
  typeof body.agentRunId === "string" ? body.agentRunId : null,
638
720
  };
@@ -702,6 +784,40 @@ export const autosreRoutesPlugin: CollectorPlugin = {
702
784
  }
703
785
  });
704
786
 
787
+ // Lightweight existence check so callers can validate counterfactual
788
+ // prerequisites before persisting a finding.
789
+ app.get("/internal/evals/runs/:id", async (c) => {
790
+ const projectId = getProjectId(c);
791
+ const runId = c.req.param("id");
792
+ const db = sqlDbFor(c.env);
793
+ const row = await db
794
+ .prepare(
795
+ `SELECT id, eval_case_id, service_version, commit_sha,
796
+ pass_count, fail_count
797
+ FROM eval_runs WHERE project_id = ? AND id = ? LIMIT 1`,
798
+ )
799
+ .bind(projectId, runId)
800
+ .first<{
801
+ id: string;
802
+ eval_case_id: string | null;
803
+ service_version: string | null;
804
+ commit_sha: string | null;
805
+ pass_count: number | null;
806
+ fail_count: number | null;
807
+ }>();
808
+ if (!row) return c.json({ error: "Eval run not found" }, 404);
809
+ return c.json({
810
+ run: {
811
+ id: row.id,
812
+ evalCaseId: row.eval_case_id,
813
+ serviceVersion: row.service_version,
814
+ commitSha: row.commit_sha,
815
+ passCount: Number(row.pass_count ?? 0),
816
+ failCount: Number(row.fail_count ?? 0),
817
+ },
818
+ });
819
+ });
820
+
705
821
  // 6. Remediation Proposals, Human Approval, Claim Grant, Watchdog
706
822
  app.get("/internal/remediations", async (c) => {
707
823
  const projectId = getProjectId(c);
@@ -774,6 +890,22 @@ export const autosreRoutesPlugin: CollectorPlugin = {
774
890
  : "autonomous_write";
775
891
 
776
892
  const db = sqlDbFor(c.env);
893
+ const callbackUrl =
894
+ typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : "";
895
+ if (callbackUrl) {
896
+ try {
897
+ assertSafeWebhookUrl(callbackUrl, {
898
+ allowPrivateNetworks: c.env?.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
899
+ });
900
+ } catch (err) {
901
+ return c.json(
902
+ {
903
+ error: `webhookUrl rejected: ${err instanceof Error ? err.message : String(err)}`,
904
+ },
905
+ 400,
906
+ );
907
+ }
908
+ }
777
909
  const input: RemediationProposalInput = {
778
910
  environment:
779
911
  typeof body.environment === "string" ? body.environment : undefined,
@@ -790,8 +922,17 @@ export const autosreRoutesPlugin: CollectorPlugin = {
790
922
  try {
791
923
  const proposal = await proposeRemediationAction(db, projectId, input, {
792
924
  logger: runtime.logger,
925
+ callback: callbackUrl
926
+ ? {
927
+ url: callbackUrl,
928
+ secret:
929
+ typeof body.secret === "string" && body.secret
930
+ ? body.secret
931
+ : undefined,
932
+ }
933
+ : undefined,
793
934
  });
794
- const webhookDelivery = await deliverGrant(
935
+ const { webhookDelivery, grantDelivery } = await deliverGrant(
795
936
  c.env,
796
937
  db,
797
938
  projectId,
@@ -803,6 +944,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
803
944
  proposal,
804
945
  ...proposal,
805
946
  grantToken: proposal.grantToken ?? null,
947
+ grantDelivery,
806
948
  ...(webhookDelivery ? { webhookDelivery } : {}),
807
949
  },
808
950
  201,
@@ -836,6 +978,13 @@ export const autosreRoutesPlugin: CollectorPlugin = {
836
978
 
837
979
  const db = sqlDbFor(c.env);
838
980
  try {
981
+ // Read before approving: the approve UPDATE clears the stored callback.
982
+ const storedCallback = await getRemediationCallback(
983
+ db,
984
+ projectId,
985
+ proposalId,
986
+ environment,
987
+ );
839
988
  const proposal = await approveRemediationProposal(
840
989
  db,
841
990
  projectId,
@@ -845,17 +994,19 @@ export const autosreRoutesPlugin: CollectorPlugin = {
845
994
  approvedByUserId,
846
995
  },
847
996
  );
848
- const webhookDelivery = await deliverGrant(
997
+ const { webhookDelivery, grantDelivery } = await deliverGrant(
849
998
  c.env,
850
999
  db,
851
1000
  projectId,
852
1001
  body,
853
1002
  proposal,
1003
+ storedCallback,
854
1004
  );
855
1005
  return c.json({
856
1006
  proposal,
857
1007
  ...proposal,
858
1008
  grantToken: proposal.grantToken ?? null,
1009
+ grantDelivery,
859
1010
  ...(webhookDelivery ? { webhookDelivery } : {}),
860
1011
  });
861
1012
  } catch (err) {
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hrefForEntity, linksFromAi, linksFromEvalResults } from "./manifest";
3
+
4
+ describe("connected-routes manifest link shapes", () => {
5
+ it("routes eval, service and ai_call entities to deep-linkable hashes", () => {
6
+ expect(hrefForEntity("eval", "case 1")).toBe("#/evaluations/case%201");
7
+ expect(hrefForEntity("service", "checkout")).toBe(
8
+ "#/service-map?service=checkout",
9
+ );
10
+ expect(hrefForEntity("ai_call", "call-1")).toBe("#/ai/calls/call-1");
11
+ });
12
+
13
+ it("carries the eval id and the ai call id in section links", () => {
14
+ const evals = linksFromEvalResults(
15
+ [
16
+ {
17
+ id: "eval-1",
18
+ actionId: "act-1",
19
+ projectId: "default",
20
+ evaluatorName: "groundedness",
21
+ evaluatorVersion: "1",
22
+ score: 1,
23
+ passed: 1,
24
+ reasoning: null,
25
+ rubricJson: null,
26
+ },
27
+ ],
28
+ "Evals",
29
+ );
30
+ expect(evals.links[0].href).toBe("#/evaluations/eval-1");
31
+ const ai = linksFromAi(
32
+ [
33
+ {
34
+ callId: "call/1",
35
+ provider: "openai",
36
+ modelName: "gpt",
37
+ } as unknown as Parameters<typeof linksFromAi>[0][number],
38
+ ],
39
+ "AI",
40
+ );
41
+ expect(ai.links[0].href).toBe("#/ai/calls/call%2F1");
42
+ expect(ai.links[0].entityId).toBe("call/1");
43
+ });
44
+ });