@obsunified/collector 2.2.0 → 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 (45) 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 +61 -3
  5. package/dist/index.js +10933 -9769
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -3
  8. package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
  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/analyses-runner.ts +44 -7
  14. package/src/lib/autosre-change-correlation.ts +39 -12
  15. package/src/lib/autosre-findings.ts +237 -44
  16. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  17. package/src/lib/autosre-ontology.ts +52 -28
  18. package/src/lib/autosre-remediation-gate.ts +942 -348
  19. package/src/lib/housekeeping.test.ts +70 -0
  20. package/src/lib/housekeeping.ts +91 -83
  21. package/src/lib/projects-store.test.ts +38 -1
  22. package/src/lib/projects-store.ts +23 -4
  23. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  24. package/src/lib/store/ingest.ts +8 -56
  25. package/src/lib/usage-store.ts +9 -3
  26. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  27. package/src/migrations/052_remediation_callback.sql +10 -0
  28. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  29. package/src/migrations/alert-state-notified.test.ts +3 -3
  30. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  31. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  32. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  33. package/src/plugins/ai-call-by-id.test.ts +73 -0
  34. package/src/plugins/ai-receiver.ts +10 -0
  35. package/src/plugins/alerts-evaluator.ts +15 -3
  36. package/src/plugins/autosre-routes.ts +374 -108
  37. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  38. package/src/plugins/connected-routes/manifest.test.ts +142 -0
  39. package/src/plugins/connected-routes/manifest.ts +123 -6
  40. package/src/plugins/connected-routes-links.test.ts +96 -0
  41. package/src/plugins/connected-routes.test.ts +3 -3
  42. package/src/plugins/connected-routes.ts +56 -83
  43. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  44. package/src/plugins/evidence-retrieval-routes.ts +397 -25
  45. package/dist/chunk-KMEZKGSO.js.map +0 -1
@@ -11,14 +11,26 @@ import type {
11
11
  TelemetrySpanDetail,
12
12
  TelemetryTraceDetailResponse,
13
13
  } from "@obsunified/types";
14
+ import { buildFindingFingerprint } from "@obsunified/types";
14
15
  import type { CollectorPlugin } from "../framework/collector";
15
16
  import {
16
17
  computeChangeBlastRadius,
18
+ getChangeEvent,
17
19
  listChangeEvents,
18
20
  } from "../lib/autosre-change-correlation";
19
- import { searchInvestigationFindings } from "../lib/autosre-findings";
20
- import { loadTraceFaultLocusFromDb } from "../lib/autosre-ontology";
21
- import { getIncidentInvestigation } from "../lib/autosre-remediation-gate";
21
+ import {
22
+ fingerprintInputFromSpan,
23
+ getInvestigationFinding,
24
+ searchInvestigationFindings,
25
+ } from "../lib/autosre-findings";
26
+ import {
27
+ getOntologyGraph,
28
+ loadTraceFaultLocusFromDb,
29
+ } from "../lib/autosre-ontology";
30
+ import {
31
+ getIncidentInvestigation,
32
+ listRemediationProposals,
33
+ } from "../lib/autosre-remediation-gate";
22
34
  import type {
23
35
  ActionRef,
24
36
  EntityManifestExtended,
@@ -1468,11 +1480,56 @@ const buildActionGraphBundle = (
1468
1480
  return bundle;
1469
1481
  };
1470
1482
 
1483
+ /**
1484
+ * Resolves the trace to enrich an anchor with. Prefers the anchor's own
1485
+ * symptom entity (a trace id, or the trace owning a span); only when that
1486
+ * cannot be resolved does it fall back to the most recent error span in the
1487
+ * environment (a heuristic: it may belong to an unrelated request).
1488
+ */
1489
+ const resolveAnchorTraceId = async (
1490
+ db: SqlDb,
1491
+ projectId: string,
1492
+ environment: string,
1493
+ symptom?: { kind: string; id: string } | null,
1494
+ ): Promise<string | null> => {
1495
+ if (symptom?.id) {
1496
+ if (symptom.kind === "trace") {
1497
+ const row = await db
1498
+ .prepare(
1499
+ `SELECT trace_id FROM telemetry_spans
1500
+ WHERE project_id = ? AND trace_id = ? LIMIT 1`,
1501
+ )
1502
+ .bind(projectId, symptom.id)
1503
+ .first<{ trace_id: string }>();
1504
+ if (row?.trace_id) return row.trace_id;
1505
+ } else if (symptom.kind === "span") {
1506
+ const row = await db
1507
+ .prepare(
1508
+ `SELECT trace_id FROM telemetry_spans
1509
+ WHERE project_id = ? AND span_id = ? LIMIT 1`,
1510
+ )
1511
+ .bind(projectId, symptom.id)
1512
+ .first<{ trace_id: string }>();
1513
+ if (row?.trace_id) return row.trace_id;
1514
+ }
1515
+ }
1516
+ const recent = await db
1517
+ .prepare(
1518
+ `SELECT trace_id FROM telemetry_spans
1519
+ WHERE project_id = ? AND environment = ? AND status_code = 2
1520
+ ORDER BY start_time DESC LIMIT 1`,
1521
+ )
1522
+ .bind(projectId, environment)
1523
+ .first<{ trace_id: string }>();
1524
+ return recent?.trace_id ?? null;
1525
+ };
1526
+
1471
1527
  const attachAutoSreTriageContext = async (
1472
1528
  db: SqlDb,
1473
1529
  projectId: string,
1474
1530
  traceId: string | null,
1475
1531
  bundle: EvidenceBundle,
1532
+ logger?: { warn: (message: string, meta?: Record<string, unknown>) => void },
1476
1533
  ): Promise<EvidenceBundle> => {
1477
1534
  try {
1478
1535
  const faultLocus = traceId
@@ -1483,11 +1540,24 @@ const attachAutoSreTriageContext = async (
1483
1540
  bundle.environment ??
1484
1541
  "default"
1485
1542
  ).toLowerCase();
1486
- const recentChanges = await listChangeEvents(db, {
1543
+ const locusService = faultLocus?.faultLocusSpan?.service_name ?? null;
1544
+ const allRecentChanges = await listChangeEvents(db, {
1487
1545
  projectId,
1488
1546
  environment,
1489
- limit: 5,
1547
+ limit: locusService ? 25 : 5,
1490
1548
  });
1549
+ // When the fault locus service is known, only changes that target it
1550
+ // (or its node) are candidates; unrelated deploys are noise.
1551
+ const recentChanges = locusService
1552
+ ? allRecentChanges
1553
+ .filter(
1554
+ (ch) =>
1555
+ ch.serviceName === locusService ||
1556
+ (faultLocus?.faultLocusNodeId &&
1557
+ ch.targetNodeId === faultLocus.faultLocusNodeId),
1558
+ )
1559
+ .slice(0, 5)
1560
+ : allRecentChanges;
1491
1561
  const blastRadii = (
1492
1562
  await Promise.all(
1493
1563
  recentChanges
@@ -1539,13 +1609,36 @@ const attachAutoSreTriageContext = async (
1539
1609
  limit: 5,
1540
1610
  });
1541
1611
  }
1542
- if (priorFindingsList.length === 0) {
1543
- priorFindingsList = await searchInvestigationFindings(db, {
1612
+ // Secondary match: the same recurrence fingerprint (service + route +
1613
+ // error signature) in this project, so a failure that recurs on a
1614
+ // different span id still surfaces the findings and fixes recorded
1615
+ // before. Entity-id matches stay first.
1616
+ if (faultLocus?.faultLocusSpan) {
1617
+ const spanFp = fingerprintInputFromSpan(
1618
+ faultLocus.faultLocusSpan,
1619
+ environment,
1620
+ );
1621
+ const fingerprint = buildFindingFingerprint({
1622
+ ...spanFp,
1623
+ normalizedOperation: spanFp.normalizedOperation ?? "",
1624
+ errorSignature: spanFp.errorSignature ?? "",
1625
+ }).fingerprint;
1626
+ const byFingerprint = await searchInvestigationFindings(db, {
1544
1627
  projectId,
1545
1628
  environment,
1629
+ fingerprint,
1546
1630
  limit: 5,
1547
1631
  });
1632
+ const seen = new Set(priorFindingsList.map((f) => f.id));
1633
+ for (const f of byFingerprint) {
1634
+ if (!seen.has(f.id) && priorFindingsList.length < 5) {
1635
+ priorFindingsList.push(f);
1636
+ seen.add(f.id);
1637
+ }
1638
+ }
1548
1639
  }
1640
+ // No unfiltered fallback: unrelated findings would read as relevant
1641
+ // prior art. An empty list is the honest answer.
1549
1642
  const priorFindings = priorFindingsList.map((f) => ({
1550
1643
  findingId: f.id,
1551
1644
  title: f.title,
@@ -1567,8 +1660,7 @@ const attachAutoSreTriageContext = async (
1567
1660
  source: br.overlappingCodePaths.length
1568
1661
  ? "stack_frame_overlap"
1569
1662
  : "cohort_comparison",
1570
- confidence:
1571
- br.cohortVerdict === "isolated_to_new_version" ? 0.9 : 0.65,
1663
+ confidence: br.cohortVerdict === "isolated_to_new_version" ? 0.9 : 0.65,
1572
1664
  relationshipCertainty: br.relationshipCertainty,
1573
1665
  causalProofTier: br.causalProofTier,
1574
1666
  reason: br.summary,
@@ -1624,13 +1716,28 @@ const attachAutoSreTriageContext = async (
1624
1716
  });
1625
1717
  }
1626
1718
 
1719
+ let isBlackoutSuspected = false;
1720
+ if (faultLocus?.faultLocusNodeId) {
1721
+ try {
1722
+ const graph = await getOntologyGraph(db, { projectId, environment });
1723
+ isBlackoutSuspected = Boolean(
1724
+ graph.nodes.find((n) => n.id === faultLocus.faultLocusNodeId)
1725
+ ?.isBlackoutSuspected,
1726
+ );
1727
+ } catch (err) {
1728
+ logger?.warn("[evidence-retrieval] blackout check failed", {
1729
+ error: err instanceof Error ? err.message : String(err),
1730
+ });
1731
+ }
1732
+ }
1733
+
1627
1734
  bundle.autosre = {
1628
1735
  faultLocus: faultLocus?.faultLocusNodeId
1629
1736
  ? {
1630
1737
  nodeId: faultLocus.faultLocusNodeId,
1631
1738
  spanId: faultLocus.faultLocusSpan?.span_id,
1632
1739
  relationshipCertainty: faultLocus.relationshipCertainty,
1633
- isBlackoutSuspected: false,
1740
+ isBlackoutSuspected,
1634
1741
  }
1635
1742
  : undefined,
1636
1743
  candidateChanges,
@@ -1638,13 +1745,39 @@ const attachAutoSreTriageContext = async (
1638
1745
  priorFindings,
1639
1746
  };
1640
1747
  if (bundle.budget) {
1748
+ // Keep the autosre section inside the caller's token budget by
1749
+ // dropping the least-relevant tail entries (and their evidence refs).
1750
+ const autosre = bundle.autosre;
1751
+ const target = bundle.budget.targetTokens ?? Number.POSITIVE_INFINITY;
1752
+ const dropRef = (evidenceId: string) => {
1753
+ bundle.evidenceReferences = bundle.evidenceReferences.filter(
1754
+ (ref) => ref.evidenceId !== evidenceId,
1755
+ );
1756
+ };
1757
+ while (estimateTokens(bundle) > target) {
1758
+ if (autosre.priorFindings.length > 0) {
1759
+ const dropped = autosre.priorFindings.pop();
1760
+ if (dropped) dropRef(`finding:${dropped.findingId}`);
1761
+ } else if (autosre.contentionCandidates.length > 0) {
1762
+ autosre.contentionCandidates.pop();
1763
+ } else if (autosre.candidateChanges.length > 0) {
1764
+ const dropped = autosre.candidateChanges.pop();
1765
+ if (dropped) dropRef(`change:${dropped.changeId}`);
1766
+ } else {
1767
+ break;
1768
+ }
1769
+ }
1641
1770
  bundle.budget = {
1642
1771
  ...bundle.budget,
1643
1772
  estimatedTokens: estimateTokens(bundle),
1644
1773
  };
1645
1774
  }
1646
- } catch {
1647
- // Gracefully degrade if AutoSRE tables are absent in minimal test setups
1775
+ } catch (err) {
1776
+ // Gracefully degrade if AutoSRE tables are absent in minimal test setups.
1777
+ logger?.warn("[evidence-retrieval] autosre triage enrichment skipped", {
1778
+ trace_id: traceId,
1779
+ error: err instanceof Error ? err.message : String(err),
1780
+ });
1648
1781
  }
1649
1782
  return bundle;
1650
1783
  };
@@ -1679,11 +1812,17 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1679
1812
  );
1680
1813
  }
1681
1814
 
1815
+ const isAutoSreAnchor =
1816
+ anchor.entityKind === "incident" ||
1817
+ anchor.entityKind === "change" ||
1818
+ anchor.entityKind === "finding";
1682
1819
  const intent =
1683
1820
  typeof body.intent === "string" &&
1684
1821
  EVIDENCE_INTENTS.has(body.intent as EvidenceBundleIntent)
1685
1822
  ? (body.intent as EvidenceBundleIntent)
1686
- : "debug_failure";
1823
+ : isAutoSreAnchor
1824
+ ? "triage_incident"
1825
+ : "debug_failure";
1687
1826
  const budget = body.budget as Record<string, unknown> | undefined;
1688
1827
  const targetTokens = clampInt(
1689
1828
  budget?.targetTokens,
@@ -1755,6 +1894,7 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1755
1894
  projectId,
1756
1895
  traceIds[0] ?? null,
1757
1896
  bundle,
1897
+ runtime.logger,
1758
1898
  );
1759
1899
  }
1760
1900
  return c.json(await materializeBundle(db, projectId, bundle));
@@ -1772,14 +1912,12 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1772
1912
  404,
1773
1913
  );
1774
1914
  }
1775
- const recentErrSpan = await db
1776
- .prepare(
1777
- `SELECT trace_id FROM telemetry_spans
1778
- WHERE project_id = ? AND environment = ? AND status_code = 2
1779
- ORDER BY start_time DESC LIMIT 1`,
1780
- )
1781
- .bind(projectId, incident.environment)
1782
- .first<{ trace_id: string }>();
1915
+ const anchorTraceId = await resolveAnchorTraceId(
1916
+ db,
1917
+ projectId,
1918
+ incident.environment,
1919
+ { kind: incident.symptomAnchorKind, id: incident.symptomAnchorId },
1920
+ );
1783
1921
 
1784
1922
  const incRoute = `#/incidents/${encodeURIComponent(incident.id)}`;
1785
1923
  const baseBundle: EvidenceBundle = {
@@ -1826,7 +1964,8 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1826
1964
  entityKind: "incident",
1827
1965
  entityId: incident.id,
1828
1966
  route: incRoute,
1829
- reason: "Inspect incident investigation and candidate changes.",
1967
+ reason:
1968
+ "Inspect incident investigation and candidate changes.",
1830
1969
  },
1831
1970
  ],
1832
1971
  },
@@ -1845,8 +1984,240 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1845
1984
  const enriched = await attachAutoSreTriageContext(
1846
1985
  db,
1847
1986
  projectId,
1848
- recentErrSpan?.trace_id ?? null,
1987
+ anchorTraceId,
1988
+ baseBundle,
1989
+ runtime.logger,
1990
+ );
1991
+ return c.json(await materializeBundle(db, projectId, enriched));
1992
+ }
1993
+
1994
+ if (anchor.entityKind === "change") {
1995
+ const change = await getChangeEvent(db, projectId, anchor.entityId);
1996
+ if (!change) {
1997
+ return c.json(
1998
+ { error: "Not Found", message: "Change event not found" },
1999
+ 404,
2000
+ );
2001
+ }
2002
+ const blastRadius = await computeChangeBlastRadius(
2003
+ db,
2004
+ projectId,
2005
+ change.id,
2006
+ { environment: change.environment },
2007
+ );
2008
+ const anchorTraceId = await resolveAnchorTraceId(
2009
+ db,
2010
+ projectId,
2011
+ change.environment,
2012
+ null,
2013
+ );
2014
+
2015
+ const changeRoute = `#/changes/${encodeURIComponent(change.id)}`;
2016
+ const summary =
2017
+ blastRadius?.summary ??
2018
+ `Change ${change.id} (${change.kind} ${change.version ?? ""}) in ${change.environment}: ${change.summary}`;
2019
+ const baseBundle: EvidenceBundle = {
2020
+ schemaVersion: BUNDLE_SCHEMA_VERSION,
2021
+ intent,
2022
+ environment: change.environment,
2023
+ anchor: { entityKind: "change", entityId: change.id },
2024
+ budget: { targetTokens, detailLevel },
2025
+ summary,
2026
+ derivedSummaries: [
2027
+ {
2028
+ title: `Change ${change.id} (${change.kind})`,
2029
+ reason: summary,
2030
+ confidence:
2031
+ blastRadius?.cohortVerdict === "isolated_to_new_version"
2032
+ ? 0.9
2033
+ : 0.7,
2034
+ evidenceIds: [`change:${change.id}`],
2035
+ retrievalRefIds: [],
2036
+ },
2037
+ ],
2038
+ findings: blastRadius
2039
+ ? [
2040
+ {
2041
+ title: `Blast radius (${blastRadius.cohortVerdict})`,
2042
+ reason: blastRadius.summary,
2043
+ confidence:
2044
+ blastRadius.cohortVerdict === "isolated_to_new_version"
2045
+ ? 0.9
2046
+ : 0.65,
2047
+ evidenceIds: [`change:${change.id}`],
2048
+ retrievalRefIds: [],
2049
+ },
2050
+ ]
2051
+ : [],
2052
+ compactions: [],
2053
+ evidenceReferences: [
2054
+ {
2055
+ evidenceId: `change:${change.id}`,
2056
+ entityKind: "change",
2057
+ entityId: change.id,
2058
+ route: changeRoute,
2059
+ source: "change_events",
2060
+ confidence: 1.0,
2061
+ relationshipCertainty:
2062
+ blastRadius?.relationshipCertainty ??
2063
+ change.relationshipCertainty,
2064
+ causalProofTier: blastRadius?.causalProofTier,
2065
+ reason: summary,
2066
+ citations: [
2067
+ {
2068
+ label: `${change.kind} ${change.version ?? change.id}`,
2069
+ entityKind: "change",
2070
+ entityId: change.id,
2071
+ route: changeRoute,
2072
+ },
2073
+ ],
2074
+ suggestedNextPivots: [
2075
+ {
2076
+ label: "Inspect change blast radius",
2077
+ entityKind: "change",
2078
+ entityId: change.id,
2079
+ route: changeRoute,
2080
+ reason:
2081
+ "Inspect traffic-normalized cohort lift and contention.",
2082
+ },
2083
+ ],
2084
+ },
2085
+ ],
2086
+ retrievalRefs: [],
2087
+ suggestedNextPivots: [
2088
+ {
2089
+ label: "Inspect change blast radius",
2090
+ entityKind: "change",
2091
+ entityId: change.id,
2092
+ route: changeRoute,
2093
+ reason: "Inspect traffic-normalized cohort lift and contention.",
2094
+ },
2095
+ ],
2096
+ };
2097
+ const enriched = await attachAutoSreTriageContext(
2098
+ db,
2099
+ projectId,
2100
+ anchorTraceId,
2101
+ baseBundle,
2102
+ runtime.logger,
2103
+ );
2104
+ return c.json(await materializeBundle(db, projectId, enriched));
2105
+ }
2106
+
2107
+ if (anchor.entityKind === "finding") {
2108
+ const finding = await getInvestigationFinding(
2109
+ db,
2110
+ projectId,
2111
+ anchor.entityId,
2112
+ );
2113
+ if (!finding) {
2114
+ return c.json(
2115
+ { error: "Not Found", message: "Finding not found" },
2116
+ 404,
2117
+ );
2118
+ }
2119
+ const proposals = await listRemediationProposals(db, {
2120
+ projectId,
2121
+ environment: finding.environment,
2122
+ findingId: finding.id,
2123
+ limit: 10,
2124
+ });
2125
+ const anchorTraceId = await resolveAnchorTraceId(
2126
+ db,
2127
+ projectId,
2128
+ finding.environment,
2129
+ { kind: finding.symptomEntityKind, id: finding.symptomEntityId },
2130
+ );
2131
+
2132
+ const findingRoute = `#/findings/${encodeURIComponent(finding.id)}`;
2133
+ const summary = `${finding.title} (${finding.causalProofTier}, verification=${finding.causalVerificationStatus}, recovery=${finding.recoveryStatus}): ${finding.summary}`;
2134
+ const baseBundle: EvidenceBundle = {
2135
+ schemaVersion: BUNDLE_SCHEMA_VERSION,
2136
+ intent,
2137
+ environment: finding.environment,
2138
+ incidentId: finding.incidentId ?? undefined,
2139
+ symptomFingerprint: finding.fingerprint,
2140
+ anchor: { entityKind: "finding", entityId: finding.id },
2141
+ budget: { targetTokens, detailLevel },
2142
+ summary,
2143
+ derivedSummaries: [
2144
+ {
2145
+ title: finding.title,
2146
+ reason: summary,
2147
+ confidence: finding.confidence,
2148
+ evidenceIds: [`finding:${finding.id}`],
2149
+ retrievalRefIds: [],
2150
+ },
2151
+ ],
2152
+ findings: [
2153
+ {
2154
+ title: finding.title,
2155
+ reason: finding.summary,
2156
+ confidence: finding.confidence,
2157
+ evidenceIds: [
2158
+ `finding:${finding.id}`,
2159
+ ...finding.citedEvidenceIds,
2160
+ ],
2161
+ retrievalRefIds: [],
2162
+ },
2163
+ ...proposals.map((p) => ({
2164
+ title: `Remediation proposal ${p.id} (${p.actionKind})`,
2165
+ reason: `status=${p.status}, autonomy=${p.effectiveAutonomy} — ${p.policyDecisionReason}`,
2166
+ confidence: finding.confidence,
2167
+ evidenceIds: [`finding:${finding.id}`],
2168
+ retrievalRefIds: [],
2169
+ })),
2170
+ ],
2171
+ compactions: [],
2172
+ evidenceReferences: [
2173
+ {
2174
+ evidenceId: `finding:${finding.id}`,
2175
+ entityKind: "finding",
2176
+ entityId: finding.id,
2177
+ route: findingRoute,
2178
+ source: "investigation_findings",
2179
+ confidence: finding.confidence,
2180
+ relationshipCertainty: "exact",
2181
+ causalProofTier: finding.causalProofTier,
2182
+ reason: summary,
2183
+ citations: [
2184
+ {
2185
+ label: finding.title,
2186
+ entityKind: "finding",
2187
+ entityId: finding.id,
2188
+ route: findingRoute,
2189
+ },
2190
+ ],
2191
+ suggestedNextPivots: [
2192
+ {
2193
+ label: "Open finding",
2194
+ entityKind: "finding",
2195
+ entityId: finding.id,
2196
+ route: findingRoute,
2197
+ reason:
2198
+ "Inspect causal chain, counterfactual verification, and remediation proposals.",
2199
+ },
2200
+ ],
2201
+ },
2202
+ ],
2203
+ retrievalRefs: [],
2204
+ suggestedNextPivots: [
2205
+ {
2206
+ label: "Open finding",
2207
+ entityKind: "finding",
2208
+ entityId: finding.id,
2209
+ route: findingRoute,
2210
+ reason:
2211
+ "Inspect causal chain, counterfactual verification, and remediation proposals.",
2212
+ },
2213
+ ],
2214
+ };
2215
+ const enriched = await attachAutoSreTriageContext(
2216
+ db,
2217
+ projectId,
2218
+ anchorTraceId,
1849
2219
  baseBundle,
2220
+ runtime.logger,
1850
2221
  );
1851
2222
  return c.json(await materializeBundle(db, projectId, enriched));
1852
2223
  }
@@ -1856,7 +2227,7 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1856
2227
  {
1857
2228
  error: "Bad Request",
1858
2229
  message:
1859
- "Supported evidence bundle anchors are trace, action, agent_run, tool_call, and incident.",
2230
+ "Supported evidence bundle anchors are trace, action, agent_run, tool_call, change, finding, and incident.",
1860
2231
  },
1861
2232
  400,
1862
2233
  );
@@ -1886,12 +2257,13 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1886
2257
  detailLevel,
1887
2258
  hours,
1888
2259
  );
1889
- if (intent === "triage_incident" || intent === "debug_failure") {
2260
+ if (intent === "triage_incident") {
1890
2261
  bundle = await attachAutoSreTriageContext(
1891
2262
  db,
1892
2263
  projectId,
1893
2264
  traceId,
1894
2265
  bundle,
2266
+ runtime.logger,
1895
2267
  );
1896
2268
  }
1897
2269
  return c.json(await materializeBundle(db, projectId, bundle));
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/lib/hash.ts","../src/lib/projects-store.ts","../src/lib/sql-db.ts","../src/auth/ingest-auth.ts"],"sourcesContent":["/**\n * Cross-runtime crypto helpers using Web Crypto API.\n * Works in Cloudflare Workers, Node 20+, Deno, Bun.\n */\n\n/** SHA-256 hash of a string, returned as lowercase hex. */\nexport async function sha256Hex(input: string): Promise<string> {\n\tconst encoder = new TextEncoder();\n\tconst digest = await crypto.subtle.digest(\"SHA-256\", encoder.encode(input));\n\treturn bytesToHex(new Uint8Array(digest));\n}\n\n/** Generate `bytes` cryptographically random bytes and return as lowercase hex. */\nexport function randomHex(bytes: number): string {\n\tconst buf = new Uint8Array(bytes);\n\tcrypto.getRandomValues(buf);\n\treturn bytesToHex(buf);\n}\n\nfunction bytesToHex(bytes: Uint8Array): string {\n\tlet out = \"\";\n\tfor (let i = 0; i < bytes.length; i++) {\n\t\tout += bytes[i].toString(16).padStart(2, \"0\");\n\t}\n\treturn out;\n}\n","import type {\n\tIngestKey,\n\tIngestKeyRow,\n\tIngestKeyWithPlaintext,\n\tProject,\n\tProjectRow,\n} from \"@obsunified/types\";\nimport { randomHex, sha256Hex } from \"./hash\";\nimport type { SqlDb } from \"./sql-db\";\n\nconst rowToProject = (row: ProjectRow): Project => ({\n\tid: row.id,\n\tname: row.name,\n\tslug: row.slug,\n\tcreatedAt: row.created_at,\n\tpayloadCaptureEnabled:\n\t\trow.payload_capture_enabled === true ||\n\t\trow.payload_capture_enabled === 1 ||\n\t\trow.payload_capture_enabled === \"1\" ||\n\t\trow.payload_capture_enabled === \"true\",\n});\n\nconst rowToKey = (row: IngestKeyRow): IngestKey => ({\n\tid: row.id,\n\tprojectId: row.project_id,\n\tname: row.name,\n\tkeyPrefix: row.key_prefix,\n\tcreatedAt: row.created_at,\n\trevokedAt: row.revoked_at,\n});\n\nconst SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$/;\n\nexport class ProjectsStore {\n\tconstructor(private readonly db: SqlDb) {}\n\n\tasync listProjects(): Promise<Project[]> {\n\t\tconst rs = await this.db\n\t\t\t.prepare(`SELECT * FROM projects ORDER BY created_at ASC`)\n\t\t\t.all<ProjectRow>();\n\t\treturn (rs.results ?? []).map(rowToProject);\n\t}\n\n\tasync getProject(id: string): Promise<Project | null> {\n\t\tconst row = await this.db\n\t\t\t.prepare(`SELECT * FROM projects WHERE id = ?`)\n\t\t\t.bind(id)\n\t\t\t.first<ProjectRow>();\n\t\treturn row ? rowToProject(row) : null;\n\t}\n\n\tasync createProject(input: { name: string; slug: string }): Promise<Project> {\n\t\tconst name = input.name.trim();\n\t\tconst slug = input.slug.trim().toLowerCase();\n\t\tif (!name) throw new Error(\"Project name is required\");\n\t\tif (!SLUG_PATTERN.test(slug))\n\t\t\tthrow new Error(\n\t\t\t\t\"Slug must be lowercase letters, digits, and hyphens (max 50 chars)\",\n\t\t\t);\n\n\t\tconst id = randomHex(16);\n\t\tconst createdAt = new Date().toISOString();\n\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT INTO projects (id, name, slug, created_at) VALUES (?, ?, ?, ?)`,\n\t\t\t)\n\t\t\t.bind(id, name, slug, createdAt)\n\t\t\t.run();\n\n\t\treturn { id, name, slug, createdAt, payloadCaptureEnabled: false };\n\t}\n\n\tasync updateProject(\n\t\tid: string,\n\t\tpatch: { name?: string; payloadCaptureEnabled?: boolean },\n\t): Promise<Project | null> {\n\t\tconst existing = await this.getProject(id);\n\t\tif (!existing) return null;\n\n\t\tconst name = patch.name?.trim();\n\t\tlet next = existing;\n\t\tif (name !== undefined && name !== existing.name) {\n\t\t\tawait this.db\n\t\t\t\t.prepare(`UPDATE projects SET name = ? WHERE id = ?`)\n\t\t\t\t.bind(name, id)\n\t\t\t\t.run();\n\t\t\tnext = { ...next, name };\n\t\t}\n\t\tif (\n\t\t\tpatch.payloadCaptureEnabled !== undefined &&\n\t\t\tpatch.payloadCaptureEnabled !== existing.payloadCaptureEnabled\n\t\t) {\n\t\t\tawait this.db\n\t\t\t\t.prepare(`UPDATE projects SET payload_capture_enabled = ? WHERE id = ?`)\n\t\t\t\t.bind(patch.payloadCaptureEnabled ? 1 : 0, id)\n\t\t\t\t.run();\n\t\t\tnext = {\n\t\t\t\t...next,\n\t\t\t\tpayloadCaptureEnabled: patch.payloadCaptureEnabled,\n\t\t\t};\n\t\t}\n\t\treturn next;\n\t}\n\n\tasync ensureDefaultProject(): Promise<void> {\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT OR IGNORE INTO projects (id, name, slug, created_at) VALUES ('default', 'Default', 'default', ?)`,\n\t\t\t)\n\t\t\t.bind(new Date().toISOString())\n\t\t\t.run();\n\t}\n\n\t// ── Ingest keys ──\n\n\tasync listKeys(projectId: string): Promise<IngestKey[]> {\n\t\tconst rs = await this.db\n\t\t\t.prepare(\n\t\t\t\t`SELECT * FROM ingest_keys WHERE project_id = ? ORDER BY created_at DESC`,\n\t\t\t)\n\t\t\t.bind(projectId)\n\t\t\t.all<IngestKeyRow>();\n\t\treturn (rs.results ?? []).map(rowToKey);\n\t}\n\n\tasync createKey(\n\t\tprojectId: string,\n\t\tname: string,\n\t): Promise<IngestKeyWithPlaintext> {\n\t\tconst project = await this.getProject(projectId);\n\t\tif (!project) throw new Error(\"Project not found\");\n\n\t\tconst trimmedName = name.trim() || \"unnamed\";\n\t\tconst id = randomHex(16);\n\t\tconst plaintext = `obs_${project.slug}_${randomHex(24)}`;\n\t\tconst keyHash = await sha256Hex(plaintext);\n\t\t// Prefix: show enough to identify but not recover (first 16 chars).\n\t\tconst keyPrefix = plaintext.slice(0, 16);\n\t\tconst createdAt = new Date().toISOString();\n\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at) VALUES (?, ?, ?, ?, ?, ?)`,\n\t\t\t)\n\t\t\t.bind(id, projectId, keyHash, keyPrefix, trimmedName, createdAt)\n\t\t\t.run();\n\n\t\treturn {\n\t\t\tid,\n\t\t\tprojectId,\n\t\t\tname: trimmedName,\n\t\t\tkeyPrefix,\n\t\t\tcreatedAt,\n\t\t\trevokedAt: null,\n\t\t\tkey: plaintext,\n\t\t\twarning: \"This key will not be shown again. Store it somewhere safe.\",\n\t\t};\n\t}\n\n\tasync revokeKey(keyId: string, projectId: string): Promise<boolean> {\n\t\tconst result = await this.db\n\t\t\t.prepare(\n\t\t\t\t`UPDATE ingest_keys SET revoked_at = ?\n\t\t\t\t WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,\n\t\t\t)\n\t\t\t.bind(new Date().toISOString(), keyId, projectId)\n\t\t\t.run();\n\t\treturn (result.meta?.changes ?? 0) > 0;\n\t}\n\n\tasync findByKeyHash(\n\t\tkeyHash: string,\n\t): Promise<{ projectId: string; keyId: string } | null> {\n\t\tconst row = await this.db\n\t\t\t.prepare(\n\t\t\t\t`SELECT id, project_id FROM ingest_keys WHERE key_hash = ? AND revoked_at IS NULL`,\n\t\t\t)\n\t\t\t.bind(keyHash)\n\t\t\t.first<{ id: string; project_id: string }>();\n\t\treturn row ? { projectId: row.project_id, keyId: row.id } : null;\n\t}\n\n\t/**\n\t * Idempotently register the legacy env INGEST_KEY as a bootstrap key on the\n\t * default project, so existing deployments keep working without manual migration.\n\t */\n\tasync bootstrapEnvKey(plaintext: string): Promise<void> {\n\t\tif (!plaintext) return;\n\t\tawait this.ensureDefaultProject();\n\t\tconst keyHash = await sha256Hex(plaintext);\n\t\tconst existing = await this.db\n\t\t\t.prepare(`SELECT id FROM ingest_keys WHERE key_hash = ?`)\n\t\t\t.bind(keyHash)\n\t\t\t.first<{ id: string }>();\n\t\tif (existing) return;\n\n\t\tconst id = randomHex(16);\n\t\tconst keyPrefix = plaintext.slice(0, Math.min(16, plaintext.length));\n\t\tconst createdAt = new Date().toISOString();\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at) VALUES (?, 'default', ?, ?, 'bootstrap', ?)`,\n\t\t\t)\n\t\t\t.bind(id, keyHash, keyPrefix, createdAt)\n\t\t\t.run();\n\t}\n}\n","/**\n * Storage seam for the collector — RFC 0008.\n *\n * Stores and plugins target this interface instead of `D1Database` directly,\n * so a future Node / better-sqlite3 / ClickHouse runtime is one new adapter\n * away rather than a fork-and-rewrite. The shape is deliberately D1's\n * existing `prepare → bind → first|all|run` chain so the migration of\n * existing stores is mechanical (search-and-replace the constructor type).\n *\n * Today there is one implementation, `D1Adapter`. A `MemSqlDb` test double\n * lives next door under `test-utils/`; `BetterSqliteAdapter` and\n * `ClickHouseAdapter` are deferred until they have a real runtime to serve.\n */\n\nexport interface SqlDb {\n\treadonly dialect?: SqlDialect;\n\tprepare(sql: string): SqlStatement;\n\t/**\n\t * Run multiple prepared statements as a batch. On D1 this maps to a\n\t * single network round-trip; on a future better-sqlite3 adapter it\n\t * wraps the calls in a transaction. Returns one result per statement\n\t * in input order, mirroring D1's `batch`.\n\t *\n\t * Statements MUST have been produced by this same `SqlDb` instance —\n\t * mixing adapters is an error and runtime behavior is undefined.\n\t */\n\tbatch(\n\t\tstatements: SqlStatement[],\n\t): Promise<Array<{ meta: { changes: number } }>>;\n}\n\nexport type SqlDialectName = \"sqlite\" | \"postgres\";\n\nexport interface SqlDialect {\n\treadonly name: SqlDialectName;\n\tnow(): string;\n\tsinceHours(param: string): string;\n\tsinceMinutes(param: string): string;\n\tjsonText(expr: string, path: string): string;\n}\n\nexport interface SqlStatement {\n\tbind(...args: unknown[]): SqlStatement;\n\tfirst<T = Record<string, unknown>>(): Promise<T | null>;\n\tall<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n\trun(): Promise<{ meta: { changes: number } }>;\n}\n\n/**\n * D1 already conforms to this shape almost exactly; the adapter normalizes\n * two small differences:\n * - `all()` on D1 returns `results?: T[]` (optional). We coerce to `[]`.\n * - `run()`'s `meta.changes` is optional on D1. We coerce to `0`.\n * Both differences are runtime-undetectable for the queries this codebase\n * issues (every successful all() returns results; every run() against an\n * INSERT/UPDATE/DELETE reports changes), but coercing keeps the typed\n * interface honest.\n */\nexport class D1Adapter implements SqlDb {\n\treadonly dialect = sqliteDialect;\n\n\tconstructor(private readonly d1: D1Database) {}\n\n\tprepare(sql: string): SqlStatement {\n\t\treturn new D1StatementAdapter(this.d1.prepare(sql));\n\t}\n\n\tasync batch(\n\t\tstatements: SqlStatement[],\n\t): Promise<Array<{ meta: { changes: number } }>> {\n\t\t// Unwrap each statement back to its D1 form — D1's batch takes\n\t\t// D1PreparedStatement[], not our typed wrapper.\n\t\tconst unwrapped = statements.map((s) => {\n\t\t\tif (!(s instanceof D1StatementAdapter)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"D1Adapter.batch: statement was not produced by D1Adapter\",\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn s.unwrap();\n\t\t});\n\t\tconst results = await this.d1.batch(unwrapped);\n\t\treturn results.map((r) => ({\n\t\t\tmeta: { changes: r.meta?.changes ?? 0 },\n\t\t}));\n\t}\n}\n\n/**\n * Convenience wrapper for the common case: take a `CollectorEnv`-shaped\n * object and return an `SqlDb`. Most plugins don't have access to the\n * `CollectorRuntime` (they get it at register time, not handler time)\n * so they call this directly. Plugins that *do* hold the runtime should\n * prefer `runtime.getSqlDb(env)` so a host-supplied `sqlDb` factory in\n * `CollectorConfig` takes effect.\n */\nexport const sqlDbFor = (env: { DB: D1Database | SqlDb }): SqlDb =>\n\tisSqlDb(env.DB) ? env.DB : new D1Adapter(env.DB);\n\n/**\n * SQLite `strftime` format that reproduces `Date#toISOString()` output\n * (`YYYY-MM-DDTHH:MM:SS.mmmZ`). Every stored timestamp column holds that\n * shape, so time windows must be rendered the same way: `datetime('now')`\n * yields `YYYY-MM-DD HH:MM:SS` and in a text comparison the space sorts\n * before the `T`, which made \"last 5 minutes\" match the whole current UTC\n * day on D1 and made `expires_at < now` purges run a day late.\n */\nexport const SQLITE_ISO_FORMAT = \"%Y-%m-%dT%H:%M:%fZ\";\n\n/** Render SQLite `now` (optionally shifted by a modifier expression) as ISO text. */\nexport const sqliteIsoNow = (modifier?: string): string =>\n\tmodifier\n\t\t? `strftime('${SQLITE_ISO_FORMAT}', 'now', ${modifier})`\n\t\t: `strftime('${SQLITE_ISO_FORMAT}', 'now')`;\n\nexport const sqliteDialect: SqlDialect = {\n\tname: \"sqlite\",\n\tnow: () => sqliteIsoNow(),\n\tsinceHours: (param) => sqliteIsoNow(`'-' || ${param} || ' hours'`),\n\tsinceMinutes: (param) => sqliteIsoNow(`'-' || ${param} || ' minutes'`),\n\tjsonText: (expr, path) => `json_extract(${expr}, '${path}')`,\n};\n\n/**\n * The collector stores every timestamp column (`received_at`, `expires_at`,\n * `occurred_at`, …) as TEXT holding `Date#toISOString()` output on both\n * D1 and Postgres. Postgres will not compare TEXT against a\n * `timestamptz`, so time-window expressions have to be rendered as ISO\n * text in the same `YYYY-MM-DDTHH:MM:SS.mmmZ` shape the collector writes.\n */\nconst POSTGRES_ISO_TEXT_FORMAT = `'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'`;\nconst POSTGRES_UTC_NOW = \"(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')\";\n\n/** Render a Postgres `timestamp` expression as collector-style ISO text. */\nexport const postgresIsoText = (expr: string): string =>\n\t`to_char(${expr}, ${POSTGRES_ISO_TEXT_FORMAT})`;\n\nexport const postgresDialect: SqlDialect = {\n\tname: \"postgres\",\n\tnow: () => postgresIsoText(POSTGRES_UTC_NOW),\n\tsinceHours: (param) =>\n\t\tpostgresIsoText(\n\t\t\t`${POSTGRES_UTC_NOW} - (${param}::text || ' hours')::interval`,\n\t\t),\n\tsinceMinutes: (param) =>\n\t\tpostgresIsoText(\n\t\t\t`${POSTGRES_UTC_NOW} - (${param}::text || ' minutes')::interval`,\n\t\t),\n\tjsonText: (expr, path) => {\n\t\tconst key = path\n\t\t\t.replace(/^\\$\\./, \"\")\n\t\t\t.replace(/^\"|\"$/g, \"\")\n\t\t\t.replace(/\\\\u002E/g, \".\");\n\t\treturn `(${expr}::jsonb ->> '${key.replace(/'/g, \"''\")}')`;\n\t},\n};\n\nexport const dialectFor = (db: SqlDb): SqlDialect =>\n\tdb.dialect ?? sqliteDialect;\n\nconst isSqlDb = (db: unknown): db is SqlDb => {\n\tif (!db || typeof db !== \"object\") return false;\n\tconst candidate = db as {\n\t\tprepare?: unknown;\n\t\tbatch?: unknown;\n\t\texec?: unknown;\n\t};\n\treturn (\n\t\ttypeof candidate.prepare === \"function\" &&\n\t\ttypeof candidate.batch === \"function\" &&\n\t\ttypeof candidate.exec !== \"function\"\n\t);\n};\n\nclass D1StatementAdapter implements SqlStatement {\n\tconstructor(private readonly stmt: D1PreparedStatement) {}\n\n\t/** Internal escape hatch for `D1Adapter.batch` to recover the wrapped\n\t * D1 statement. Not part of the public `SqlStatement` shape. */\n\tunwrap(): D1PreparedStatement {\n\t\treturn this.stmt;\n\t}\n\n\tbind(...args: unknown[]): SqlStatement {\n\t\treturn new D1StatementAdapter(this.stmt.bind(...args));\n\t}\n\n\tfirst<T = Record<string, unknown>>(): Promise<T | null> {\n\t\treturn this.stmt.first<T>();\n\t}\n\n\tasync all<T = Record<string, unknown>>(): Promise<{ results: T[] }> {\n\t\tconst r = await this.stmt.all<T>();\n\t\treturn { results: r.results ?? [] };\n\t}\n\n\tasync run(): Promise<{ meta: { changes: number } }> {\n\t\tconst r = await this.stmt.run();\n\t\treturn { meta: { changes: r.meta?.changes ?? 0 } };\n\t}\n}\n","import type { MiddlewareHandler } from \"hono\";\nimport type { CollectorEnv } from \"../framework/env\";\nimport { sha256Hex } from \"../lib/hash\";\nimport { ProjectsStore } from \"../lib/projects-store\";\nimport { sqlDbFor } from \"../lib/sql-db\";\n\nconst CACHE_TTL_MS = 60_000; // 60 seconds\nconst CACHE_MAX_ENTRIES = 500;\n\ninterface CacheEntry {\n\tprojectId: string;\n\texpiresAt: number;\n}\n\nlet keyCaches = new WeakMap<object, Map<string, CacheEntry>>();\nlet bootstrapDone = false;\n\nfunction cacheFor(dbKey: object): Map<string, CacheEntry> {\n\tlet cache = keyCaches.get(dbKey);\n\tif (!cache) {\n\t\tcache = new Map();\n\t\tkeyCaches.set(dbKey, cache);\n\t}\n\treturn cache;\n}\n\nfunction getCached(\n\tcache: Map<string, CacheEntry>,\n\thash: string,\n\tnow: number,\n): string | null {\n\tpruneKeyCache(cache, now);\n\tconst entry = cache.get(hash);\n\tif (!entry) return null;\n\tif (entry.expiresAt <= now) {\n\t\tcache.delete(hash);\n\t\treturn null;\n\t}\n\treturn entry.projectId;\n}\n\nfunction setCached(\n\tcache: Map<string, CacheEntry>,\n\thash: string,\n\tprojectId: string,\n\tnow: number,\n): void {\n\tpruneKeyCache(cache, now);\n\twhile (cache.size >= CACHE_MAX_ENTRIES) {\n\t\tconst oldestKey = cache.keys().next().value;\n\t\tif (!oldestKey) break;\n\t\tcache.delete(oldestKey);\n\t}\n\tcache.set(hash, { projectId, expiresAt: now + CACHE_TTL_MS });\n}\n\nfunction pruneKeyCache(cache: Map<string, CacheEntry>, now: number): void {\n\tfor (const [hash, entry] of cache) {\n\t\tif (entry.expiresAt <= now) cache.delete(hash);\n\t}\n}\n\n/** Test helper — clears the in-memory cache and bootstrap flag. */\nexport function resetIngestAuthCache(): void {\n\tkeyCaches = new WeakMap();\n\tbootstrapDone = false;\n}\n\n/**\n * Creates middleware that validates ingest API keys against the ingest_keys table.\n *\n * Accepts the key from either:\n * - Authorization: Bearer <key>\n * - X-API-Key: <key>\n *\n * On success, sets `projectId` on the Hono context via `c.set('projectId', ...)`.\n *\n * On the first request per isolate, if `env.INGEST_KEY` is set, the middleware\n * registers it as a `bootstrap` key on the default project — so existing\n * single-tenant deployments continue to work without manual migration.\n */\nexport function createIngestAuth(config?: {\n\tallowUnauthenticated?: boolean;\n}): MiddlewareHandler<{\n\tBindings: CollectorEnv;\n\tVariables: { projectId: string };\n}> {\n\treturn async (c, next) => {\n\t\t// Lazy bootstrap of legacy env key (idempotent).\n\t\tif (!bootstrapDone) {\n\t\t\tconst legacy = c.env.INGEST_KEY || c.env.TELEMETRY_INGEST_TOKEN;\n\t\t\tif (legacy) {\n\t\t\t\tconst store = new ProjectsStore(sqlDbFor(c.env));\n\t\t\t\ttry {\n\t\t\t\t\tawait store.bootstrapEnvKey(legacy);\n\t\t\t\t\t// Latch only after success: a transient DB error on the first\n\t\t\t\t\t// request must not permanently disable legacy-key bootstrap for\n\t\t\t\t\t// the isolate. bootstrapEnvKey is idempotent, so concurrent\n\t\t\t\t\t// first-requests racing here are harmless.\n\t\t\t\t\tbootstrapDone = true;\n\t\t\t\t} catch (err) {\n\t\t\t\t\tconsole.error(\"[ingest-auth] bootstrap failed:\", err);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Nothing to bootstrap — don't keep re-checking on every request.\n\t\t\t\tbootstrapDone = true;\n\t\t\t}\n\t\t}\n\n\t\tconst bearer = c.req.header(\"Authorization\")?.replace(/^Bearer\\s+/i, \"\");\n\t\tconst apiKey = c.req.header(\"X-API-Key\");\n\t\tconst token = bearer || apiKey;\n\n\t\tif (!token) {\n\t\t\tif (\n\t\t\t\tconfig?.allowUnauthenticated ||\n\t\t\t\tc.env.ALLOW_UNAUTHENTICATED === \"true\"\n\t\t\t) {\n\t\t\t\tc.set(\"projectId\", \"default\");\n\t\t\t\treturn next();\n\t\t\t}\n\t\t\treturn c.json({ error: \"Unauthorized\" }, 401);\n\t\t}\n\n\t\tconst now = Date.now();\n\t\tconst hash = await sha256Hex(token);\n\t\tconst rawDb =\n\t\t\t(c.env.DB as unknown as Record<string, unknown>)?.__unwrapped_db ||\n\t\t\tc.env.DB;\n\t\tconst cache = cacheFor(rawDb);\n\t\tconst cached = getCached(cache, hash, now);\n\t\tif (cached) {\n\t\t\tc.set(\"projectId\", cached);\n\t\t\treturn next();\n\t\t}\n\n\t\tconst store = new ProjectsStore(sqlDbFor(c.env));\n\t\tconst match = await store.findByKeyHash(hash);\n\t\tif (!match) {\n\t\t\treturn c.json({ error: \"Unauthorized\" }, 401);\n\t\t}\n\n\t\tsetCached(cache, hash, match.projectId, now);\n\t\tc.set(\"projectId\", match.projectId);\n\t\treturn next();\n\t};\n}\n"],"mappings":"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","names":["store"]}