@obsunified/collector 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
- package/dist/chunk-2FALTDCE.js.map +1 -0
- package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
- package/dist/chunk-RASSTGNB.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +2892 -2240
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BmP5-f96.d.ts} +32 -1
- package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
- package/package.json +4 -3
- package/src/__tests__/autosre-closed-loop.test.ts +700 -0
- package/src/__tests__/db-parity.integration.test.ts +961 -0
- package/src/__tests__/internal-contracts-routes.test.ts +451 -0
- package/src/auth/dashboard-auth.test.ts +61 -0
- package/src/auth/dashboard-auth.ts +17 -2
- package/src/framework/env.ts +105 -0
- package/src/index.ts +13 -4
- package/src/lib/ai-store/calls.ts +43 -23
- package/src/lib/ai-store/evaluations.test.ts +3 -3
- package/src/lib/ai-store.ts +12 -1
- package/src/lib/analyses-store.test.ts +6 -6
- package/src/lib/autosre-findings.ts +169 -14
- package/src/lib/autosre-remediation-gate.ts +156 -26
- package/src/lib/db-parity.ts +883 -0
- package/src/lib/eval-cases-store.test.ts +1 -1
- package/src/lib/eval-cases-store.ts +16 -4
- package/src/lib/evidence-references.ts +30 -22
- package/src/lib/projects-store.test.ts +38 -1
- package/src/lib/projects-store.ts +23 -4
- package/src/lib/sql-db-postgres.integration.test.ts +75 -2
- package/src/lib/stage6.test.ts +1 -1
- package/src/lib/usage-store.ts +9 -3
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -0
- package/src/migrations/parity.test.ts +105 -3
- package/src/migrations-postgres/052_remediation_callback.sql +10 -0
- package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
- package/src/plugins/ai-call-by-id.test.ts +73 -0
- package/src/plugins/ai-receiver.ts +15 -0
- package/src/plugins/autosre-routes.ts +409 -147
- package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
- package/src/plugins/connected-routes/manifest.test.ts +224 -0
- package/src/plugins/connected-routes/manifest.ts +131 -28
- package/src/plugins/connected-routes-links.test.ts +96 -0
- package/src/plugins/connected-routes.test.ts +13 -11
- package/src/plugins/connected-routes.ts +53 -20
- package/src/plugins/eval-cases-routes.ts +33 -10
- package/src/plugins/evidence-retrieval-routes.ts +89 -24
- package/dist/chunk-KMEZKGSO.js.map +0 -1
- package/dist/chunk-ZSF5XP3B.js.map +0 -1
- /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ContractMismatchError,
|
|
3
|
+
createInternalClient,
|
|
4
|
+
InternalClientHttpError,
|
|
5
|
+
type InternalEndpoints,
|
|
6
|
+
} from "@obsunified/types";
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { CollectorRuntime } from "../framework/collector";
|
|
10
|
+
import type { CollectorEnv } from "../framework/env";
|
|
11
|
+
import { upsertOntologyFromSpans } from "../lib/autosre-ontology";
|
|
12
|
+
import {
|
|
13
|
+
createMigratedSqliteDb,
|
|
14
|
+
type NodeSqliteDb,
|
|
15
|
+
} from "../lib/test-utils/node-sqlite-db";
|
|
16
|
+
import { aiReceiverPlugin } from "../plugins/ai-receiver";
|
|
17
|
+
import {
|
|
18
|
+
autosreRoutesPlugin,
|
|
19
|
+
respondWithContract,
|
|
20
|
+
} from "../plugins/autosre-routes";
|
|
21
|
+
import { connectedRoutesPlugin } from "../plugins/connected-routes";
|
|
22
|
+
import { evalCasesRoutesPlugin } from "../plugins/eval-cases-routes";
|
|
23
|
+
import { evidenceRetrievalRoutesPlugin } from "../plugins/evidence-retrieval-routes";
|
|
24
|
+
|
|
25
|
+
const createTestApp = (db: NodeSqliteDb) => {
|
|
26
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
27
|
+
const runtime = new CollectorRuntime();
|
|
28
|
+
autosreRoutesPlugin.register(app, runtime);
|
|
29
|
+
evalCasesRoutesPlugin.register(app, runtime);
|
|
30
|
+
connectedRoutesPlugin.register(app, runtime);
|
|
31
|
+
evidenceRetrievalRoutesPlugin.register(app, runtime);
|
|
32
|
+
aiReceiverPlugin.register(app, runtime);
|
|
33
|
+
const env: CollectorEnv = {
|
|
34
|
+
DB: db as unknown as D1Database,
|
|
35
|
+
};
|
|
36
|
+
return { app, env };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("Collector internal HTTP contract integration (RFC 0014 WS5)", () => {
|
|
40
|
+
it("respondWithContract throws ContractMismatchError if a handler attempts to return a mistyped payload", () => {
|
|
41
|
+
const fakeContext = {
|
|
42
|
+
json: (data: unknown, status = 200) =>
|
|
43
|
+
new Response(JSON.stringify(data), { status }),
|
|
44
|
+
};
|
|
45
|
+
expect(() =>
|
|
46
|
+
respondWithContract(
|
|
47
|
+
fakeContext,
|
|
48
|
+
"GET /internal/incidents",
|
|
49
|
+
// Intentionally mistyped payload
|
|
50
|
+
{
|
|
51
|
+
incidents: "not-an-array",
|
|
52
|
+
} as unknown as InternalEndpoints["GET /internal/incidents"]["response"],
|
|
53
|
+
),
|
|
54
|
+
).toThrow(ContractMismatchError);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("createInternalClient round-trips changes, incidents, findings, counterfactuals, remediations, evals, connected, AI calls, and evidence against collector routes", async () => {
|
|
58
|
+
const db = createMigratedSqliteDb();
|
|
59
|
+
const { app, env } = createTestApp(db);
|
|
60
|
+
|
|
61
|
+
const makeClient = (defaultHeaders?: Record<string, string>) =>
|
|
62
|
+
createInternalClient({
|
|
63
|
+
baseUrl: "http://localhost",
|
|
64
|
+
defaultHeaders,
|
|
65
|
+
fetchImpl: (input, init) => app.request(input, init, env),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const client = makeClient();
|
|
69
|
+
|
|
70
|
+
// Seed a trace + span so eval cases and ontology have a source entity
|
|
71
|
+
db.raw
|
|
72
|
+
.prepare(
|
|
73
|
+
`INSERT INTO telemetry_spans (
|
|
74
|
+
project_id, trace_id, span_id, parent_span_id, service_name,
|
|
75
|
+
span_name, span_kind, start_time, end_time, duration_ms,
|
|
76
|
+
status_code, status_message, attributes_json, resource_attributes_json,
|
|
77
|
+
events_json, links_json, received_at, expires_at,
|
|
78
|
+
environment, service_version, commit_sha, target_node_id, error_signature
|
|
79
|
+
) VALUES (
|
|
80
|
+
'default', 'trace-ws5', 'span-ws5', NULL, 'checkout',
|
|
81
|
+
'POST /charge', 2, '2026-04-16T00:00:00.000Z', '2026-04-16T00:00:00.500Z', 500,
|
|
82
|
+
2, 'Timeout', '{}', '{}',
|
|
83
|
+
'[]', '[]', '2026-04-16T00:00:00.500Z', '2099-01-01T00:00:00.000Z',
|
|
84
|
+
'production', '2.1.0', 'abc1234', 'service:default:checkout', 'Timeout'
|
|
85
|
+
)`,
|
|
86
|
+
)
|
|
87
|
+
.run();
|
|
88
|
+
await upsertOntologyFromSpans(db, [
|
|
89
|
+
{
|
|
90
|
+
projectId: "default",
|
|
91
|
+
traceId: "trace-ws5",
|
|
92
|
+
spanId: "span-ws5",
|
|
93
|
+
parentSpanId: null,
|
|
94
|
+
traceState: null,
|
|
95
|
+
serviceName: "checkout",
|
|
96
|
+
spanName: "POST /charge",
|
|
97
|
+
spanKind: 2,
|
|
98
|
+
statusCode: 2,
|
|
99
|
+
statusMessage: "Timeout",
|
|
100
|
+
startTime: "2026-04-16T00:00:00.000Z",
|
|
101
|
+
endTime: "2026-04-16T00:00:00.500Z",
|
|
102
|
+
durationMs: 500,
|
|
103
|
+
environment: "prod",
|
|
104
|
+
scopeName: null,
|
|
105
|
+
scopeVersion: null,
|
|
106
|
+
attributesJson: "{}",
|
|
107
|
+
resourceAttributesJson: "{}",
|
|
108
|
+
eventsJson: "[]",
|
|
109
|
+
linksJson: "[]",
|
|
110
|
+
droppedAttributesCount: 0,
|
|
111
|
+
droppedEventsCount: 0,
|
|
112
|
+
droppedLinksCount: 0,
|
|
113
|
+
receivedAt: "2026-04-16T00:00:00.500Z",
|
|
114
|
+
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
// 1. Changes
|
|
119
|
+
const createdChange = await client.request("POST /v1/changes", {
|
|
120
|
+
body: {
|
|
121
|
+
environment: "prod",
|
|
122
|
+
kind: "deploy",
|
|
123
|
+
serviceName: "checkout",
|
|
124
|
+
version: "2.1.0",
|
|
125
|
+
previousVersion: "2.0.0",
|
|
126
|
+
commitSha: "abc1234",
|
|
127
|
+
summary: "Deploy checkout 2.1.0",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
expect(createdChange.id).toBeTruthy();
|
|
131
|
+
expect(createdChange.change.id).toBe(createdChange.id);
|
|
132
|
+
expect(createdChange.changeEvent?.id).toBe(createdChange.id);
|
|
133
|
+
|
|
134
|
+
const listedChanges = await client.request("GET /internal/changes", {
|
|
135
|
+
query: { environment: "prod", serviceName: "checkout" },
|
|
136
|
+
});
|
|
137
|
+
expect(listedChanges.changes.some((c) => c.id === createdChange.id)).toBe(
|
|
138
|
+
true,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const fetchedChange = await client.request("GET /internal/changes/:id", {
|
|
142
|
+
params: { id: createdChange.id },
|
|
143
|
+
});
|
|
144
|
+
expect(fetchedChange.change.id).toBe(createdChange.id);
|
|
145
|
+
|
|
146
|
+
const blastRadius = await client.request(
|
|
147
|
+
"GET /internal/changes/:id/blast-radius",
|
|
148
|
+
{
|
|
149
|
+
params: { id: createdChange.id },
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
expect(blastRadius.change.id).toBe(createdChange.id);
|
|
153
|
+
expect(blastRadius.blastRadius.change.id).toBe(createdChange.id);
|
|
154
|
+
|
|
155
|
+
// 2. Ontology
|
|
156
|
+
const ontology = await client.request("GET /internal/ontology", {
|
|
157
|
+
query: { environment: "prod", traceId: "trace-ws5" },
|
|
158
|
+
});
|
|
159
|
+
expect(Array.isArray(ontology.nodes)).toBe(true);
|
|
160
|
+
|
|
161
|
+
// 3. Incidents (both /internal/incidents and /internal/incidents/open)
|
|
162
|
+
const openedIncident = await client.request("POST /internal/incidents", {
|
|
163
|
+
body: {
|
|
164
|
+
environment: "prod",
|
|
165
|
+
targetNodeId: "service:default:checkout",
|
|
166
|
+
symptomAnchorKind: "trace",
|
|
167
|
+
symptomAnchorId: "trace-ws5",
|
|
168
|
+
symptomFingerprint: "fp-checkout-timeout",
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
expect(openedIncident.deduplicated).toBe(false);
|
|
172
|
+
|
|
173
|
+
const dedupedIncident = await client.request(
|
|
174
|
+
"POST /internal/incidents/open",
|
|
175
|
+
{
|
|
176
|
+
body: {
|
|
177
|
+
environment: "prod",
|
|
178
|
+
targetNodeId: "service:default:checkout",
|
|
179
|
+
symptomAnchorKind: "trace",
|
|
180
|
+
symptomAnchorId: "trace-ws5",
|
|
181
|
+
symptomFingerprint: "fp-checkout-timeout",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
expect(dedupedIncident.deduplicated).toBe(true);
|
|
186
|
+
expect(dedupedIncident.incident.id).toBe(openedIncident.incident.id);
|
|
187
|
+
|
|
188
|
+
const leaseResult = await client.request(
|
|
189
|
+
"POST /internal/incidents/:id/lease",
|
|
190
|
+
{
|
|
191
|
+
params: { id: openedIncident.incident.id },
|
|
192
|
+
body: {
|
|
193
|
+
runnerId: "runner-ws5",
|
|
194
|
+
leaseDurationSeconds: 120,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
expect(leaseResult.acquired).toBe(true);
|
|
199
|
+
|
|
200
|
+
const incidentsList = await client.request("GET /internal/incidents", {
|
|
201
|
+
query: { environment: "prod" },
|
|
202
|
+
});
|
|
203
|
+
expect(
|
|
204
|
+
incidentsList.incidents.some((i) => i.id === openedIncident.incident.id),
|
|
205
|
+
).toBe(true);
|
|
206
|
+
|
|
207
|
+
// 4. Findings & Counterfactuals
|
|
208
|
+
const createdFinding = await client.request("POST /internal/findings", {
|
|
209
|
+
body: {
|
|
210
|
+
environment: "prod",
|
|
211
|
+
incidentId: openedIncident.incident.id,
|
|
212
|
+
title: "Checkout timeout after 2.1.0 deploy",
|
|
213
|
+
summary: "Cohort isolated to version 2.1.0",
|
|
214
|
+
symptomEntityKind: "trace",
|
|
215
|
+
symptomEntityId: "trace-ws5",
|
|
216
|
+
faultLocusEntityKind: "span",
|
|
217
|
+
faultLocusEntityId: "trace-ws5:span-ws5",
|
|
218
|
+
rootCauseEntityKind: "change",
|
|
219
|
+
rootCauseEntityId: createdChange.id,
|
|
220
|
+
contributingChangeId: createdChange.id,
|
|
221
|
+
causalChain: [
|
|
222
|
+
{
|
|
223
|
+
subjectKind: "change",
|
|
224
|
+
subjectId: createdChange.id,
|
|
225
|
+
predicate: "degraded_after_change",
|
|
226
|
+
objectKind: "span",
|
|
227
|
+
objectId: "trace-ws5:span-ws5",
|
|
228
|
+
relationshipCertainty: "exact",
|
|
229
|
+
causalProofTier: "corroborated_candidate",
|
|
230
|
+
confidence: 0.9,
|
|
231
|
+
reason: "Version 2.1.0 deployed immediately before errors",
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
citedEvidenceIds: [`change:${createdChange.id}`, "trace:trace-ws5"],
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
expect(createdFinding.finding.id).toBe(createdFinding.id);
|
|
238
|
+
|
|
239
|
+
const fetchedFinding = await client.request("GET /internal/findings/:id", {
|
|
240
|
+
params: { id: createdFinding.id },
|
|
241
|
+
});
|
|
242
|
+
expect(fetchedFinding.finding.id).toBe(createdFinding.id);
|
|
243
|
+
|
|
244
|
+
const findingsList = await client.request("GET /internal/findings", {
|
|
245
|
+
query: { incidentId: openedIncident.incident.id },
|
|
246
|
+
});
|
|
247
|
+
expect(findingsList.findings.some((f) => f.id === createdFinding.id)).toBe(
|
|
248
|
+
true,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// Eval case + runs for counterfactual verification
|
|
252
|
+
const createdCase = await client.request("POST /internal/eval-cases", {
|
|
253
|
+
body: {
|
|
254
|
+
sourceEntityType: "trace",
|
|
255
|
+
sourceEntityId: "trace-ws5",
|
|
256
|
+
name: "Checkout timeout regression case",
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
expect(createdCase.evalCase.id).toBeTruthy();
|
|
260
|
+
|
|
261
|
+
const listedCases = await client.request("GET /internal/eval-cases", {
|
|
262
|
+
query: { sourceEntityType: "trace" },
|
|
263
|
+
});
|
|
264
|
+
expect(
|
|
265
|
+
listedCases.evalCases.some((ec) => ec.id === createdCase.evalCase.id),
|
|
266
|
+
).toBe(true);
|
|
267
|
+
|
|
268
|
+
const failingRun = await client.request("POST /internal/eval-runs", {
|
|
269
|
+
body: {
|
|
270
|
+
evalCaseId: createdCase.evalCase.id,
|
|
271
|
+
status: "failed",
|
|
272
|
+
environment: "prod",
|
|
273
|
+
serviceVersion: "2.1.0",
|
|
274
|
+
commitSha: "abc1234",
|
|
275
|
+
inputPayloadHash: "hash-ws5",
|
|
276
|
+
failureFingerprint: createdFinding.finding.fingerprint,
|
|
277
|
+
totalCount: 1,
|
|
278
|
+
passCount: 0,
|
|
279
|
+
failCount: 1,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
await client.request("POST /internal/eval-cases/:id/results", {
|
|
283
|
+
params: { id: createdCase.evalCase.id },
|
|
284
|
+
body: {
|
|
285
|
+
runId: failingRun.evalRun.id,
|
|
286
|
+
passed: false,
|
|
287
|
+
inputPayloadHash: "hash-ws5",
|
|
288
|
+
failureFingerprint: createdFinding.finding.fingerprint,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const passingRunIds: string[] = [];
|
|
293
|
+
for (let i = 0; i < 3; i += 1) {
|
|
294
|
+
const passRun = await client.request("POST /internal/eval-runs", {
|
|
295
|
+
body: {
|
|
296
|
+
evalCaseId: createdCase.evalCase.id,
|
|
297
|
+
status: "completed",
|
|
298
|
+
environment: "prod",
|
|
299
|
+
serviceVersion: "2.0.0",
|
|
300
|
+
commitSha: "prev000",
|
|
301
|
+
inputPayloadHash: "hash-ws5",
|
|
302
|
+
totalCount: 1,
|
|
303
|
+
passCount: 1,
|
|
304
|
+
failCount: 0,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
await client.request("POST /internal/eval-cases/:id/results", {
|
|
308
|
+
params: { id: createdCase.evalCase.id },
|
|
309
|
+
body: {
|
|
310
|
+
runId: passRun.evalRun.id,
|
|
311
|
+
passed: true,
|
|
312
|
+
inputPayloadHash: "hash-ws5",
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
passingRunIds.push(passRun.evalRun.id);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const evalRunSummary = await client.request(
|
|
319
|
+
"GET /internal/evals/runs/:id",
|
|
320
|
+
{
|
|
321
|
+
params: { id: failingRun.evalRun.id },
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
expect(evalRunSummary.run.id).toBe(failingRun.evalRun.id);
|
|
325
|
+
|
|
326
|
+
const counterfactual = await client.request(
|
|
327
|
+
"POST /internal/findings/:id/counterfactual",
|
|
328
|
+
{
|
|
329
|
+
params: { id: createdFinding.id },
|
|
330
|
+
body: {
|
|
331
|
+
environment: "prod",
|
|
332
|
+
evalCaseId: createdCase.evalCase.id,
|
|
333
|
+
failingRunId: failingRun.evalRun.id,
|
|
334
|
+
passingRunIds,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
expect(counterfactual.verified).toBe(true);
|
|
339
|
+
expect(counterfactual.finding.causalProofTier).toBe(
|
|
340
|
+
"counterfactual_verified",
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// 5. Remediations
|
|
344
|
+
const proposedHuman = await client.request(
|
|
345
|
+
"POST /internal/remediations/propose",
|
|
346
|
+
{
|
|
347
|
+
body: {
|
|
348
|
+
environment: "prod",
|
|
349
|
+
incidentId: openedIncident.incident.id,
|
|
350
|
+
findingId: createdFinding.id,
|
|
351
|
+
idempotencyKey: "idem-ws5-human",
|
|
352
|
+
actionKind: "rollback_deploy",
|
|
353
|
+
targetNodeId: "service:default:checkout",
|
|
354
|
+
expectedCurrentState: { version: "2.1.0" },
|
|
355
|
+
desiredTargetState: { version: "2.0.0" },
|
|
356
|
+
requestedAutonomy: "human_approved_write",
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
expect(proposedHuman.proposal.status).toBe("pending_human_approval");
|
|
361
|
+
|
|
362
|
+
// Bearer token approval must fail with 403 InternalClientHttpError
|
|
363
|
+
const bearerClient = makeClient({
|
|
364
|
+
Authorization: "Bearer obs_secret_agent_token",
|
|
365
|
+
});
|
|
366
|
+
await expect(
|
|
367
|
+
bearerClient.request("POST /internal/remediations/:id/approve", {
|
|
368
|
+
params: { id: proposedHuman.proposal.id },
|
|
369
|
+
body: {},
|
|
370
|
+
}),
|
|
371
|
+
).rejects.toThrow(InternalClientHttpError);
|
|
372
|
+
|
|
373
|
+
// Cookie session approval succeeds and mints grantToken
|
|
374
|
+
const humanSessionClient = makeClient({
|
|
375
|
+
Cookie: "obs_session=valid_human_session_cookie",
|
|
376
|
+
});
|
|
377
|
+
const approved = await humanSessionClient.request(
|
|
378
|
+
"POST /internal/remediations/:id/approve",
|
|
379
|
+
{
|
|
380
|
+
params: { id: proposedHuman.proposal.id },
|
|
381
|
+
body: {},
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
expect(approved.proposal.status).toBe("grant_issued");
|
|
385
|
+
expect(typeof approved.grantToken).toBe("string");
|
|
386
|
+
|
|
387
|
+
const claimed = await client.request(
|
|
388
|
+
"POST /internal/remediations/:id/claim",
|
|
389
|
+
{
|
|
390
|
+
params: { id: proposedHuman.proposal.id },
|
|
391
|
+
body: {
|
|
392
|
+
grantToken: approved.grantToken as string,
|
|
393
|
+
runnerId: "runner-ws5",
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
expect(claimed.claimed).toBe(true);
|
|
398
|
+
expect(claimed.proposal.status).toBe("claimed_executing");
|
|
399
|
+
|
|
400
|
+
const completed = await client.request(
|
|
401
|
+
"POST /internal/remediations/:id/complete",
|
|
402
|
+
{
|
|
403
|
+
params: { id: proposedHuman.proposal.id },
|
|
404
|
+
body: {
|
|
405
|
+
runnerId: "runner-ws5",
|
|
406
|
+
resultingChangeId: createdChange.id,
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
expect(completed.proposal.status).toBe("completed");
|
|
411
|
+
|
|
412
|
+
const watchdog = await client.request(
|
|
413
|
+
"POST /internal/remediations/watchdog",
|
|
414
|
+
{
|
|
415
|
+
body: {},
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
expect(typeof watchdog.summary.evaluated).toBe("number");
|
|
419
|
+
|
|
420
|
+
const incidentDetail = await client.request("GET /internal/incidents/:id", {
|
|
421
|
+
params: { id: openedIncident.incident.id },
|
|
422
|
+
});
|
|
423
|
+
expect(incidentDetail.incident.id).toBe(openedIncident.incident.id);
|
|
424
|
+
expect(incidentDetail.findings.length).toBeGreaterThanOrEqual(1);
|
|
425
|
+
expect(incidentDetail.proposals.length).toBeGreaterThanOrEqual(1);
|
|
426
|
+
|
|
427
|
+
// 6. Connected, Evidence Bundle, Evidence Stats
|
|
428
|
+
const connected = await client.request(
|
|
429
|
+
"GET /internal/connected/:kind/:id",
|
|
430
|
+
{
|
|
431
|
+
params: { kind: "incident", id: openedIncident.incident.id },
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
expect(connected.entity.id).toBe(openedIncident.incident.id);
|
|
435
|
+
|
|
436
|
+
const bundle = await client.request("POST /internal/evidence/bundle", {
|
|
437
|
+
body: {
|
|
438
|
+
anchor: {
|
|
439
|
+
entityKind: "incident",
|
|
440
|
+
entityId: openedIncident.incident.id,
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
expect(bundle.schemaVersion).toBe("obs-unified.evidence-bundle.v1");
|
|
445
|
+
|
|
446
|
+
const stats = await client.request("GET /internal/evidence/stats", {
|
|
447
|
+
query: { limit: 10 },
|
|
448
|
+
});
|
|
449
|
+
expect(stats.projectId).toBe("default");
|
|
450
|
+
});
|
|
451
|
+
});
|
|
@@ -789,3 +789,64 @@ describe("dashboard-auth middleware & programmatic token contract", () => {
|
|
|
789
789
|
});
|
|
790
790
|
});
|
|
791
791
|
});
|
|
792
|
+
|
|
793
|
+
describe("session cookie Secure attribute", () => {
|
|
794
|
+
const loginCookie = async (
|
|
795
|
+
url: string,
|
|
796
|
+
headers: Record<string, string>,
|
|
797
|
+
trustProxyHeaders: boolean | undefined,
|
|
798
|
+
): Promise<string> => {
|
|
799
|
+
const auth = createDashboardAuth({
|
|
800
|
+
password: TEST_PASSWORD,
|
|
801
|
+
token: TEST_TOKEN,
|
|
802
|
+
loginRateLimit: { trustProxyHeaders, warn: () => {} },
|
|
803
|
+
});
|
|
804
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
805
|
+
auth.registerRoutes(app);
|
|
806
|
+
const res = await app.fetch(
|
|
807
|
+
new Request(url, {
|
|
808
|
+
method: "POST",
|
|
809
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
810
|
+
body: JSON.stringify({ password: TEST_PASSWORD }),
|
|
811
|
+
}),
|
|
812
|
+
makeEnv(createTestDb()),
|
|
813
|
+
);
|
|
814
|
+
expect(res.status).toBe(200);
|
|
815
|
+
return res.headers.get("Set-Cookie") ?? "";
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
it("sets Secure for an https request URL", async () => {
|
|
819
|
+
expect(await loginCookie("https://x.test/auth/login", {}, false)).toContain(
|
|
820
|
+
"; Secure",
|
|
821
|
+
);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it("sets Secure for X-Forwarded-Proto: https when the proxy is trusted", async () => {
|
|
825
|
+
const cookie = await loginCookie(
|
|
826
|
+
"http://x.test/auth/login",
|
|
827
|
+
{ "x-forwarded-proto": "https" },
|
|
828
|
+
true,
|
|
829
|
+
);
|
|
830
|
+
expect(cookie).toContain("; Secure");
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("ignores X-Forwarded-Proto when the proxy is not trusted, and on plain http", async () => {
|
|
834
|
+
expect(
|
|
835
|
+
await loginCookie(
|
|
836
|
+
"http://x.test/auth/login",
|
|
837
|
+
{ "x-forwarded-proto": "https" },
|
|
838
|
+
undefined,
|
|
839
|
+
),
|
|
840
|
+
).not.toContain("Secure");
|
|
841
|
+
expect(
|
|
842
|
+
await loginCookie(
|
|
843
|
+
"http://x.test/auth/login",
|
|
844
|
+
{ "x-forwarded-proto": "http" },
|
|
845
|
+
true,
|
|
846
|
+
),
|
|
847
|
+
).not.toContain("Secure");
|
|
848
|
+
expect(
|
|
849
|
+
await loginCookie("http://x.test/auth/login", {}, true),
|
|
850
|
+
).not.toContain("Secure");
|
|
851
|
+
});
|
|
852
|
+
});
|
|
@@ -293,6 +293,21 @@ export function createDashboardAuth(config?: DashboardAuthConfig): {
|
|
|
293
293
|
: new LoginRateLimiter(config?.loginRateLimit ?? {});
|
|
294
294
|
let warnedUnkeyedClient = false;
|
|
295
295
|
|
|
296
|
+
// `Secure` when the request itself is https, or when the operator
|
|
297
|
+
// declared a trusted TLS-terminating proxy (`trustProxyHeaders`, i.e.
|
|
298
|
+
// TRUST_PROXY) that reports `X-Forwarded-Proto: https`.
|
|
299
|
+
const isSecureRequest = (
|
|
300
|
+
c: Context<{ Bindings: CollectorEnv }>,
|
|
301
|
+
limitConfig: { trustProxyHeaders?: boolean } | undefined,
|
|
302
|
+
): boolean =>
|
|
303
|
+
c.req.url.startsWith("https:") ||
|
|
304
|
+
(limitConfig?.trustProxyHeaders === true &&
|
|
305
|
+
c.req
|
|
306
|
+
.header("x-forwarded-proto")
|
|
307
|
+
?.split(",")[0]
|
|
308
|
+
?.trim()
|
|
309
|
+
.toLowerCase() === "https");
|
|
310
|
+
|
|
296
311
|
app.post("/auth/login", async (c) => {
|
|
297
312
|
const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
|
|
298
313
|
if (!configuredPassword) {
|
|
@@ -342,7 +357,7 @@ export function createDashboardAuth(config?: DashboardAuthConfig): {
|
|
|
342
357
|
const sig = await sign(payload, configuredPassword);
|
|
343
358
|
const cookieValue = `${payload}.${sig}`;
|
|
344
359
|
|
|
345
|
-
const isSecure = c
|
|
360
|
+
const isSecure = isSecureRequest(c, config?.loginRateLimit || undefined);
|
|
346
361
|
c.header(
|
|
347
362
|
"Set-Cookie",
|
|
348
363
|
`${SESSION_COOKIE}=${encodeURIComponent(cookieValue)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE}${isSecure ? "; Secure" : ""}`,
|
|
@@ -405,7 +420,7 @@ export function createDashboardAuth(config?: DashboardAuthConfig): {
|
|
|
405
420
|
});
|
|
406
421
|
|
|
407
422
|
app.post("/auth/logout", (c) => {
|
|
408
|
-
const isSecure = c
|
|
423
|
+
const isSecure = isSecureRequest(c, config?.loginRateLimit || undefined);
|
|
409
424
|
c.header(
|
|
410
425
|
"Set-Cookie",
|
|
411
426
|
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0${isSecure ? "; Secure" : ""}`,
|
package/src/framework/env.ts
CHANGED
|
@@ -89,6 +89,111 @@ export interface CollectorEnv {
|
|
|
89
89
|
TELEMETRY_QUERY_TOKEN?: string;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Optional string settings on `CollectorEnv` that host adapters (such as
|
|
94
|
+
* `apps/collector-node`) forward directly from the process environment after
|
|
95
|
+
* trimming whitespace and coercing blank strings to `undefined`.
|
|
96
|
+
*/
|
|
97
|
+
export const COLLECTOR_OPTIONAL_STRING_ENV_KEYS = [
|
|
98
|
+
"RETENTION_HOURS",
|
|
99
|
+
"ANTHROPIC_API_KEY",
|
|
100
|
+
"OPENAI_API_KEY",
|
|
101
|
+
"OPENAI_BASE_URL",
|
|
102
|
+
"NARRATIVE_MODEL",
|
|
103
|
+
"NARRATIVE_BUDGET_PER_HOUR",
|
|
104
|
+
"ALERT_WEBHOOK_ALLOW_PRIVATE",
|
|
105
|
+
"MAX_BODY_BYTES",
|
|
106
|
+
"TELEMETRY_REDACT_FIELDS",
|
|
107
|
+
"OBS_PAYLOAD_CAPTURE_DEFAULT",
|
|
108
|
+
] as const;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* String settings on `CollectorEnv` that require explicit host handling (e.g.
|
|
112
|
+
* required auth secrets, CORS origin parsing, Worker-only self-instrumentation
|
|
113
|
+
* vars, or deprecated Worker tokens) and must not be blindly forwarded by the
|
|
114
|
+
* generic optional-env loop.
|
|
115
|
+
*/
|
|
116
|
+
export const COLLECTOR_HOST_MANAGED_ENV_KEYS = [
|
|
117
|
+
"INGEST_KEY",
|
|
118
|
+
"DASHBOARD_PASSWORD",
|
|
119
|
+
"DASHBOARD_TOKEN",
|
|
120
|
+
"ALLOWED_ORIGINS",
|
|
121
|
+
"ALLOW_UNAUTHENTICATED",
|
|
122
|
+
"OBS_DASHBOARD_INGEST_KEY",
|
|
123
|
+
"OBS_COLLECTOR_SELF_URL",
|
|
124
|
+
"OBS_SELF_AWAIT_EXPORTS",
|
|
125
|
+
"TELEMETRY_INGEST_TOKEN",
|
|
126
|
+
"USAGE_INGEST_TOKEN",
|
|
127
|
+
"TELEMETRY_QUERY_TOKEN",
|
|
128
|
+
] as const;
|
|
129
|
+
|
|
130
|
+
export const COLLECTOR_STRING_ENV_MANIFEST = {
|
|
131
|
+
forwarded: COLLECTOR_OPTIONAL_STRING_ENV_KEYS,
|
|
132
|
+
hostManaged: COLLECTOR_HOST_MANAGED_ENV_KEYS,
|
|
133
|
+
} as const;
|
|
134
|
+
|
|
135
|
+
export type CollectorOptionalStringEnvKey =
|
|
136
|
+
(typeof COLLECTOR_OPTIONAL_STRING_ENV_KEYS)[number];
|
|
137
|
+
|
|
138
|
+
export type CollectorHostManagedEnvKey =
|
|
139
|
+
(typeof COLLECTOR_HOST_MANAGED_ENV_KEYS)[number];
|
|
140
|
+
|
|
141
|
+
export type CollectorManifestStringEnvKey =
|
|
142
|
+
| CollectorOptionalStringEnvKey
|
|
143
|
+
| CollectorHostManagedEnvKey;
|
|
144
|
+
|
|
145
|
+
export type CollectorObjectBindingKey =
|
|
146
|
+
| "DB"
|
|
147
|
+
| "REPLAYS_BUCKET"
|
|
148
|
+
| "PROFILES_BUCKET"
|
|
149
|
+
| "TAIL_HUB";
|
|
150
|
+
|
|
151
|
+
export type CollectorStringEnvKey = Exclude<
|
|
152
|
+
keyof CollectorEnv,
|
|
153
|
+
CollectorObjectBindingKey
|
|
154
|
+
>;
|
|
155
|
+
|
|
156
|
+
type AssertNever<T extends never> = T;
|
|
157
|
+
type _UnaccountedCollectorStringEnvKeys = AssertNever<
|
|
158
|
+
Exclude<CollectorStringEnvKey, CollectorManifestStringEnvKey>
|
|
159
|
+
>;
|
|
160
|
+
type _UnknownManifestStringEnvKeys = AssertNever<
|
|
161
|
+
Exclude<CollectorManifestStringEnvKey, CollectorStringEnvKey>
|
|
162
|
+
>;
|
|
163
|
+
type _OverlappingManifestStringEnvKeys = AssertNever<
|
|
164
|
+
Extract<CollectorOptionalStringEnvKey, CollectorHostManagedEnvKey>
|
|
165
|
+
>;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Verifies that every string key in `declaredKeys` is categorized in either
|
|
169
|
+
* `COLLECTOR_OPTIONAL_STRING_ENV_KEYS` or `COLLECTOR_HOST_MANAGED_ENV_KEYS`
|
|
170
|
+
* without overlap. Throws if any key is uncategorized or duplicated.
|
|
171
|
+
*/
|
|
172
|
+
export function assertCollectorEnvManifestCompleteness(
|
|
173
|
+
declaredKeys: readonly string[],
|
|
174
|
+
manifest: {
|
|
175
|
+
readonly forwarded: readonly string[];
|
|
176
|
+
readonly hostManaged: readonly string[];
|
|
177
|
+
} = COLLECTOR_STRING_ENV_MANIFEST,
|
|
178
|
+
): void {
|
|
179
|
+
const forwardedSet = new Set(manifest.forwarded);
|
|
180
|
+
const hostManagedSet = new Set(manifest.hostManaged);
|
|
181
|
+
const overlap = manifest.forwarded.filter((k) => hostManagedSet.has(k));
|
|
182
|
+
if (overlap.length > 0) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`CollectorEnv manifest keys cannot be both forwarded and host-managed: ${overlap.join(", ")}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const unaccounted = declaredKeys.filter(
|
|
188
|
+
(k) => !forwardedSet.has(k) && !hostManagedSet.has(k),
|
|
189
|
+
);
|
|
190
|
+
if (unaccounted.length > 0) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Uncategorized CollectorEnv string setting(s): ${unaccounted.join(", ")}. Add each key to COLLECTOR_OPTIONAL_STRING_ENV_KEYS or COLLECTOR_HOST_MANAGED_ENV_KEYS.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
92
197
|
export interface CollectorRouteContext {
|
|
93
198
|
hono: Context<{ Bindings: CollectorEnv }>;
|
|
94
199
|
env: CollectorEnv;
|
package/src/index.ts
CHANGED
|
@@ -68,10 +68,19 @@ export {
|
|
|
68
68
|
type UsageEventProcessorPlugin,
|
|
69
69
|
} from "./framework/collector";
|
|
70
70
|
|
|
71
|
-
export
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
export {
|
|
72
|
+
assertCollectorEnvManifestCompleteness,
|
|
73
|
+
COLLECTOR_HOST_MANAGED_ENV_KEYS,
|
|
74
|
+
COLLECTOR_OPTIONAL_STRING_ENV_KEYS,
|
|
75
|
+
COLLECTOR_STRING_ENV_MANIFEST,
|
|
76
|
+
type CollectorApp,
|
|
77
|
+
type CollectorEnv,
|
|
78
|
+
type CollectorHostManagedEnvKey,
|
|
79
|
+
type CollectorManifestStringEnvKey,
|
|
80
|
+
type CollectorObjectBindingKey,
|
|
81
|
+
type CollectorOptionalStringEnvKey,
|
|
82
|
+
type CollectorRouteContext,
|
|
83
|
+
type CollectorStringEnvKey,
|
|
75
84
|
} from "./framework/env";
|
|
76
85
|
|
|
77
86
|
export type { Logger } from "./framework/logger";
|