@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obsunified/collector",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "Plugin-based observability collector runtime with pluggable SQL/blob storage.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,14 +32,13 @@
32
32
  "files": [
33
33
  "dist",
34
34
  "src",
35
- "migrations",
36
35
  "README.md"
37
36
  ],
38
37
  "sideEffects": false,
39
38
  "dependencies": {
40
39
  "@bufbuild/protobuf": "^2.11.0",
41
40
  "@obsunified/pprof-decoder": "2.0.2",
42
- "@obsunified/types": "2.2.0"
41
+ "@obsunified/types": "2.2.1"
43
42
  },
44
43
  "peerDependencies": {
45
44
  "hono": "^4.7.0",
@@ -1397,6 +1397,7 @@ describe("AutoSRE governance hardening", () => {
1397
1397
  endTime: end,
1398
1398
  durationMs: 40,
1399
1399
  serviceVersion: "v2.4.1",
1400
+ errorSignature: "checkout 500",
1400
1401
  });
1401
1402
  }
1402
1403
  await recordChangeEvent(db, "default", {
@@ -2219,4 +2220,593 @@ describe("AutoSRE governance hardening", () => {
2219
2220
  );
2220
2221
  db.close();
2221
2222
  });
2223
+
2224
+ const json = (body: unknown) => ({
2225
+ method: "POST",
2226
+ headers: { "Content-Type": "application/json" },
2227
+ body: JSON.stringify(body),
2228
+ });
2229
+ const approveInit = (body: Record<string, unknown>) => ({
2230
+ method: "POST",
2231
+ headers: {
2232
+ "Content-Type": "application/json",
2233
+ Cookie: "obs_session=sess_lifecycle",
2234
+ },
2235
+ body: JSON.stringify(body),
2236
+ });
2237
+
2238
+ it("J10: span-id fault loci on one service/route share a fingerprint, so the breaker is not fragmented", async () => {
2239
+ const db = createMigratedSqliteDb();
2240
+ await seedIsolatedCohort(db);
2241
+ const incident = await newIncident(db);
2242
+ const spanFinding = (n: number) =>
2243
+ newFinding(db, incident.id, {
2244
+ title: `Checkout regression attempt ${n}`,
2245
+ summary: `Investigation ${n} found errors after the deploy`,
2246
+ contributingChangeId: "chg_gov",
2247
+ faultLocusEntityKind: "span",
2248
+ faultLocusEntityId: `g_new_sp_${n}`,
2249
+ });
2250
+
2251
+ const findings = [];
2252
+ for (let n = 0; n < 4; n++) findings.push(await spanFinding(n));
2253
+ expect(new Set(findings.map((f) => f.fingerprint)).size).toBe(1);
2254
+ expect(findings[0].fingerprintInput.originatingNodeId).toBe(SERVICE_NODE);
2255
+ expect(findings[0].fingerprintInput.normalizedOperation).toContain(
2256
+ "/checkout",
2257
+ );
2258
+
2259
+ for (let n = 0; n < 3; n++) {
2260
+ const granted = await propose(
2261
+ db,
2262
+ incident.id,
2263
+ findings[n].id,
2264
+ `j10_${n}`,
2265
+ );
2266
+ expect(granted.status).toBe("grant_issued");
2267
+ }
2268
+ const blocked = await propose(db, incident.id, findings[3].id, "j10_3");
2269
+ expect(blocked.status).toBe("rejected");
2270
+ expect(blocked.policyDecisionReason).toMatch(/Circuit breaker/);
2271
+
2272
+ // A span that is not in telemetry keeps the legacy behaviour.
2273
+ const unknown = await spanFinding(99);
2274
+ expect(unknown.fingerprint).not.toBe(findings[0].fingerprint);
2275
+ db.close();
2276
+ });
2277
+
2278
+ it("J10: traceId:spanId span loci resolve the service, and empty error signatures fall back to the summary", async () => {
2279
+ const db = createMigratedSqliteDb();
2280
+ const t0 = Date.parse("2026-10-15T06:00:00.000Z");
2281
+ const start = new Date(t0).toISOString();
2282
+ const end = new Date(t0 + 50).toISOString();
2283
+ const traceId = "a".repeat(32);
2284
+ insertSpan(db, {
2285
+ traceId,
2286
+ spanId: "b".repeat(16),
2287
+ serviceName: "pay-api",
2288
+ spanName: "POST /pay",
2289
+ statusCode: 2,
2290
+ statusMessage: "card declined",
2291
+ startTime: start,
2292
+ endTime: end,
2293
+ durationMs: 50,
2294
+ });
2295
+ insertSpan(db, {
2296
+ traceId,
2297
+ spanId: "c".repeat(16),
2298
+ serviceName: "pay-api",
2299
+ spanName: "POST /refund",
2300
+ statusCode: 2,
2301
+ statusMessage: "",
2302
+ errorSignature: "",
2303
+ startTime: start,
2304
+ endTime: end,
2305
+ durationMs: 50,
2306
+ });
2307
+ const incident = await newIncident(db);
2308
+ const mk = (id: string, summary = "Declines after deploy") =>
2309
+ newFinding(db, incident.id, {
2310
+ title: "Pay declines",
2311
+ summary,
2312
+ faultLocusEntityKind: "span",
2313
+ faultLocusEntityId: id,
2314
+ });
2315
+ const bare = await mk("b".repeat(16));
2316
+ const qualified = await mk(`${traceId}:${"b".repeat(16)}`);
2317
+ expect(qualified.fingerprintInput.originatingNodeId).toBe(
2318
+ "service:prod:default:pay-api",
2319
+ );
2320
+ expect(qualified.fingerprint).toBe(bare.fingerprint);
2321
+
2322
+ // No error signature or status message: the summary is the signature.
2323
+ const noSig = await mk("c".repeat(16), "Refunds time out");
2324
+ expect(noSig.fingerprintInput.errorSignature).toBe("Refunds time out");
2325
+ expect(noSig.fingerprintInput.normalizedOperation).toBe("POST /refund");
2326
+ db.close();
2327
+ });
2328
+
2329
+ it("J10: a recurring failure on a different trace surfaces the prior finding by fingerprint", async () => {
2330
+ const db = createMigratedSqliteDb();
2331
+ const { app, env } = createTestApp(db);
2332
+ const t0 = Date.parse("2026-10-15T05:00:00.000Z");
2333
+ for (const [trace, offset] of [
2334
+ ["tr_prior", 0],
2335
+ ["tr_recur", 60_000],
2336
+ ] as const) {
2337
+ const start = new Date(t0 + offset).toISOString();
2338
+ const end = new Date(t0 + offset + 80).toISOString();
2339
+ insertSpan(db, {
2340
+ traceId: trace,
2341
+ spanId: `${trace}_root`,
2342
+ serviceName: "pay-api",
2343
+ spanName: "POST /pay",
2344
+ statusCode: 2,
2345
+ startTime: start,
2346
+ endTime: end,
2347
+ durationMs: 80,
2348
+ });
2349
+ insertSpan(db, {
2350
+ traceId: trace,
2351
+ spanId: `${trace}_err`,
2352
+ parentSpanId: `${trace}_root`,
2353
+ serviceName: "pay-api",
2354
+ spanName: "POST /pay",
2355
+ statusCode: 2,
2356
+ statusMessage: "card declined",
2357
+ startTime: start,
2358
+ endTime: end,
2359
+ durationMs: 70,
2360
+ });
2361
+ }
2362
+ const incident = await newIncident(db);
2363
+ const prior = await newFinding(db, incident.id, {
2364
+ title: "Pay declines",
2365
+ summary: "Provider returns declines",
2366
+ symptomEntityId: "tr_prior",
2367
+ faultLocusEntityKind: "span",
2368
+ faultLocusEntityId: "tr_prior_err",
2369
+ });
2370
+ const bundleFor = async (trace: string) => {
2371
+ const res = await app.request(
2372
+ "/internal/evidence/bundle",
2373
+ json({
2374
+ intent: "triage_incident",
2375
+ anchor: { entityKind: "trace", entityId: trace },
2376
+ }),
2377
+ env,
2378
+ );
2379
+ expect(res.status).toBe(200);
2380
+ return (await res.json()) as {
2381
+ autosre?: { priorFindings?: Array<{ findingId: string }> };
2382
+ };
2383
+ };
2384
+ const recur = await bundleFor("tr_recur");
2385
+ expect(
2386
+ recur.autosre?.priorFindings?.some((f) => f.findingId === prior.id),
2387
+ ).toBe(true);
2388
+ db.close();
2389
+ });
2390
+
2391
+ it("J9: a finding idempotencyKey returns the stored finding on retry and survives route round-trips", async () => {
2392
+ const db = createMigratedSqliteDb();
2393
+ const { app, env } = createTestApp(db);
2394
+ const body = {
2395
+ environment: "prod",
2396
+ idempotencyKey: "retry-me",
2397
+ title: "Idempotent finding",
2398
+ summary: "Same call twice",
2399
+ symptomEntityKind: "trace",
2400
+ symptomEntityId: "t1",
2401
+ faultLocusEntityKind: "service",
2402
+ faultLocusEntityId: SERVICE_NODE,
2403
+ causalChain: [],
2404
+ citedEvidenceIds: [],
2405
+ };
2406
+ const first = await app.request("/internal/findings", json(body), env);
2407
+ const second = await app.request("/internal/findings", json(body), env);
2408
+ expect(first.status).toBe(201);
2409
+ const a = (await first.json()) as { id: string };
2410
+ const b = (await second.json()) as { id: string };
2411
+ expect(b.id).toBe(a.id);
2412
+ const n = db.raw
2413
+ .prepare(`SELECT COUNT(*) AS n FROM investigation_findings`)
2414
+ .get() as { n: number };
2415
+ expect(n.n).toBe(1);
2416
+ // A different key is a different finding.
2417
+ const third = await app.request(
2418
+ "/internal/findings",
2419
+ json({ ...body, idempotencyKey: "other" }),
2420
+ env,
2421
+ );
2422
+ expect(((await third.json()) as { id: string }).id).not.toBe(a.id);
2423
+ db.close();
2424
+ });
2425
+
2426
+ it("J9: GET /internal/evals/runs/:id reports existence so counterfactual prerequisites can be checked first", async () => {
2427
+ const db = createMigratedSqliteDb();
2428
+ const { app, env } = createTestApp(db);
2429
+ const missing = await app.request("/internal/evals/runs/nope", {}, env);
2430
+ expect(missing.status).toBe(404);
2431
+ db.raw
2432
+ .prepare(
2433
+ `INSERT INTO eval_runs (project_id, id, eval_case_id, status, pass_count, fail_count, created_at)
2434
+ VALUES ('default', 'run_exists', 'case_1', 'completed', 0, 1, ?)`,
2435
+ )
2436
+ .run(new Date().toISOString());
2437
+ const found = await app.request("/internal/evals/runs/run_exists", {}, env);
2438
+ expect(found.status).toBe(200);
2439
+ expect(((await found.json()) as { run: { id: string } }).run.id).toBe(
2440
+ "run_exists",
2441
+ );
2442
+ db.close();
2443
+ });
2444
+
2445
+ it("J17: callback stored at propose time -> approve without callback -> webhook receives the token -> claim succeeds", async () => {
2446
+ const db = createMigratedSqliteDb();
2447
+ const { app, env } = createTestApp(db);
2448
+ const incident = await newIncident(db);
2449
+ const finding = await newFinding(db, incident.id); // corroborated: needs a human
2450
+ const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
2451
+
2452
+ const bodies: Array<Record<string, unknown>> = [];
2453
+ const headers: Array<Record<string, string>> = [];
2454
+ vi.stubGlobal(
2455
+ "fetch",
2456
+ vi.fn(async (_url: string, init: RequestInit) => {
2457
+ bodies.push(JSON.parse(String(init.body)));
2458
+ headers.push(init.headers as Record<string, string>);
2459
+ return new Response("ok", { status: 200 });
2460
+ }),
2461
+ );
2462
+
2463
+ const proposeRes = await app.request(
2464
+ "/internal/remediations/propose",
2465
+ json({
2466
+ environment: "prod",
2467
+ incidentId: incident.id,
2468
+ findingId: finding.id,
2469
+ idempotencyKey: "j17",
2470
+ actionKind: "rollback_deploy",
2471
+ targetNodeId: SERVICE_NODE,
2472
+ expectedCurrentState: {},
2473
+ desiredTargetState: { version: "v2.4.0" },
2474
+ webhookUrl: "https://runner.example.com/grants?token=urlsecret",
2475
+ secret: "hmac-secret-j17",
2476
+ }),
2477
+ callbackEnv,
2478
+ );
2479
+ expect(proposeRes.status).toBe(201);
2480
+ const proposed = (await proposeRes.json()) as {
2481
+ proposal: { id: string; status: string; callbackHost: string };
2482
+ };
2483
+ expect(proposed.proposal.status).toBe("pending_human_approval");
2484
+ expect(proposed.proposal.callbackHost).toBe("runner.example.com");
2485
+ expect(bodies).toHaveLength(0);
2486
+
2487
+ // Neither the URL path/query nor the secret is ever returned by a GET.
2488
+ const getRes = await app.request(
2489
+ `/internal/remediations/${proposed.proposal.id}?environment=prod`,
2490
+ {},
2491
+ callbackEnv,
2492
+ );
2493
+ const getText = await getRes.text();
2494
+ expect(getText).toContain("runner.example.com");
2495
+ expect(getText).not.toContain("urlsecret");
2496
+ expect(getText).not.toContain("hmac-secret-j17");
2497
+ expect(getText).not.toContain("/grants");
2498
+ const list = await (
2499
+ await app.request(
2500
+ "/internal/remediations?environment=prod",
2501
+ {},
2502
+ callbackEnv,
2503
+ )
2504
+ ).text();
2505
+ expect(list).not.toContain("hmac-secret-j17");
2506
+
2507
+ const approve = await app.request(
2508
+ `/internal/remediations/${proposed.proposal.id}/approve`,
2509
+ approveInit({ environment: "prod" }),
2510
+ callbackEnv,
2511
+ );
2512
+ expect(approve.status).toBe(200);
2513
+ const approved = (await approve.json()) as {
2514
+ grantToken: string;
2515
+ webhookDelivery?: { delivered: boolean };
2516
+ proposal: { callbackHost: string | null };
2517
+ };
2518
+ expect(approved.webhookDelivery?.delivered).toBe(true);
2519
+ expect(approved.proposal.callbackHost).toBeNull();
2520
+ expect(bodies).toHaveLength(1);
2521
+ expect(bodies[0]).toMatchObject({
2522
+ event: "autosre.remediation.grant_issued",
2523
+ proposalId: proposed.proposal.id,
2524
+ grantToken: approved.grantToken,
2525
+ });
2526
+ expect(headers[0]["X-Obs-Signature-256"]).toMatch(/^sha256=[0-9a-f]{64}$/);
2527
+
2528
+ const claim = await app.request(
2529
+ `/internal/remediations/${proposed.proposal.id}/claim`,
2530
+ json({
2531
+ environment: "prod",
2532
+ grantToken: approved.grantToken,
2533
+ runnerId: "runner-j17",
2534
+ }),
2535
+ callbackEnv,
2536
+ );
2537
+ expect(claim.status).toBe(200);
2538
+
2539
+ // The stored callback is gone from the row once consumed.
2540
+ const row = db.raw
2541
+ .prepare(
2542
+ `SELECT callback_url, callback_secret FROM remediation_proposals WHERE id = ?`,
2543
+ )
2544
+ .get(proposed.proposal.id) as {
2545
+ callback_url: string | null;
2546
+ callback_secret: string | null;
2547
+ };
2548
+ expect(row.callback_url).toBeNull();
2549
+ expect(row.callback_secret).toBeNull();
2550
+ db.close();
2551
+ });
2552
+
2553
+ it("J17: a callback on the approve request wins, a bad propose callback is a 400, and reject clears the stored one", async () => {
2554
+ const db = createMigratedSqliteDb();
2555
+ const { app, env } = createTestApp(db);
2556
+ const incident = await newIncident(db);
2557
+ const finding = await newFinding(db, incident.id);
2558
+ const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
2559
+ const urls: string[] = [];
2560
+ vi.stubGlobal(
2561
+ "fetch",
2562
+ vi.fn(async (url: string) => {
2563
+ urls.push(url);
2564
+ return new Response("ok", { status: 200 });
2565
+ }),
2566
+ );
2567
+ const mk = (key: string, webhookUrl: string) =>
2568
+ app.request(
2569
+ "/internal/remediations/propose",
2570
+ json({
2571
+ environment: "prod",
2572
+ incidentId: incident.id,
2573
+ findingId: finding.id,
2574
+ idempotencyKey: key,
2575
+ actionKind: "rollback_deploy",
2576
+ targetNodeId: SERVICE_NODE,
2577
+ expectedCurrentState: {},
2578
+ desiredTargetState: {},
2579
+ webhookUrl,
2580
+ }),
2581
+ callbackEnv,
2582
+ );
2583
+ expect((await mk("j17_bad", "ftp://example.com/x")).status).toBe(400);
2584
+
2585
+ const one = (await (
2586
+ await mk("j17_a", "https://stored.example.com/a")
2587
+ ).json()) as {
2588
+ proposal: { id: string };
2589
+ };
2590
+ const approve = await app.request(
2591
+ `/internal/remediations/${one.proposal.id}/approve`,
2592
+ approveInit({
2593
+ environment: "prod",
2594
+ webhookUrl: "https://request.example.com/b",
2595
+ }),
2596
+ callbackEnv,
2597
+ );
2598
+ expect(approve.status).toBe(200);
2599
+ expect(urls).toEqual(["https://request.example.com/b"]);
2600
+
2601
+ const two = (await (
2602
+ await mk("j17_b", "https://stored.example.com/c")
2603
+ ).json()) as {
2604
+ proposal: { id: string };
2605
+ };
2606
+ await rejectRemediationProposal(db, "default", two.proposal.id, {
2607
+ environment: "prod",
2608
+ rejectedByUserId: "tester",
2609
+ });
2610
+ const row = db.raw
2611
+ .prepare(`SELECT callback_url FROM remediation_proposals WHERE id = ?`)
2612
+ .get(two.proposal.id) as { callback_url: string | null };
2613
+ expect(row.callback_url).toBeNull();
2614
+ db.close();
2615
+ });
2616
+
2617
+ describe("grant delivery retries", () => {
2618
+ type Delivery = {
2619
+ attempted: boolean;
2620
+ delivered: boolean;
2621
+ attempts: number;
2622
+ host: string | null;
2623
+ error?: string;
2624
+ };
2625
+ const setup = async (key: string, webhookUrl: string, secret?: string) => {
2626
+ const db = createMigratedSqliteDb();
2627
+ const { app, env } = createTestApp(db);
2628
+ const incident = await newIncident(db);
2629
+ const finding = await newFinding(db, incident.id);
2630
+ const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
2631
+ const proposed = (await (
2632
+ await app.request(
2633
+ "/internal/remediations/propose",
2634
+ json({
2635
+ environment: "prod",
2636
+ incidentId: incident.id,
2637
+ findingId: finding.id,
2638
+ idempotencyKey: key,
2639
+ actionKind: "rollback_deploy",
2640
+ targetNodeId: SERVICE_NODE,
2641
+ expectedCurrentState: {},
2642
+ desiredTargetState: { version: "v2.4.0" },
2643
+ webhookUrl,
2644
+ ...(secret ? { secret } : {}),
2645
+ }),
2646
+ callbackEnv,
2647
+ )
2648
+ ).json()) as { proposal: { id: string } };
2649
+ const approve = async () =>
2650
+ (await (
2651
+ await app.request(
2652
+ `/internal/remediations/${proposed.proposal.id}/approve`,
2653
+ approveInit({ environment: "prod" }),
2654
+ callbackEnv,
2655
+ )
2656
+ ).json()) as {
2657
+ grantToken: string;
2658
+ grantDelivery: Delivery;
2659
+ proposal: { callbackHost: string | null };
2660
+ };
2661
+ const get = async () =>
2662
+ (await (
2663
+ await app.request(
2664
+ `/internal/remediations/${proposed.proposal.id}?environment=prod`,
2665
+ {},
2666
+ callbackEnv,
2667
+ )
2668
+ ).json()) as {
2669
+ proposal: {
2670
+ callbackDelivery: {
2671
+ status: string;
2672
+ attempts: number;
2673
+ lastError: string | null;
2674
+ at: string;
2675
+ } | null;
2676
+ };
2677
+ };
2678
+ return { db, proposed, approve, get };
2679
+ };
2680
+
2681
+ it("delivers on the 2nd attempt after a 503", async () => {
2682
+ const statuses = [503, 200];
2683
+ const fetchMock = vi.fn(
2684
+ async () => new Response("x", { status: statuses.shift() ?? 200 }),
2685
+ );
2686
+ vi.stubGlobal("fetch", fetchMock);
2687
+ const { db, approve, get } = await setup(
2688
+ "retry-503",
2689
+ "https://runner.example.com/g?token=urlsecret",
2690
+ "hmac-retry",
2691
+ );
2692
+ const approved = await approve();
2693
+ expect(fetchMock).toHaveBeenCalledTimes(2);
2694
+ expect(approved.grantDelivery).toEqual({
2695
+ attempted: true,
2696
+ delivered: true,
2697
+ attempts: 2,
2698
+ host: "runner.example.com",
2699
+ });
2700
+ expect(JSON.stringify(approved.grantDelivery)).not.toContain("urlsecret");
2701
+ const got = await get();
2702
+ expect(got.proposal.callbackDelivery).toMatchObject({
2703
+ status: "delivered",
2704
+ attempts: 2,
2705
+ lastError: null,
2706
+ });
2707
+ db.close();
2708
+ }, 15_000);
2709
+
2710
+ it("fails after 3 attempts on a closed port but still returns the token and clears the callback", async () => {
2711
+ vi.unstubAllGlobals();
2712
+ const net = await import("node:net");
2713
+ const port = await new Promise<number>((resolve) => {
2714
+ const srv = net.createServer();
2715
+ srv.listen(0, "127.0.0.1", () => {
2716
+ const { port: p } = srv.address() as { port: number };
2717
+ srv.close(() => resolve(p));
2718
+ });
2719
+ });
2720
+ const { db, proposed, approve, get } = await setup(
2721
+ "retry-closed",
2722
+ `http://127.0.0.1:${port}/grant?token=urlsecret`,
2723
+ "hmac-closed",
2724
+ );
2725
+ const approved = await approve();
2726
+ expect(approved.grantToken).toMatch(/^rgt_/);
2727
+ expect(approved.grantDelivery).toMatchObject({
2728
+ attempted: true,
2729
+ delivered: false,
2730
+ attempts: 3,
2731
+ host: `127.0.0.1:${port}`,
2732
+ });
2733
+ expect(approved.grantDelivery.error).toBeTruthy();
2734
+ expect(JSON.stringify(approved.grantDelivery)).not.toContain("urlsecret");
2735
+ expect(approved.proposal.callbackHost).toBeNull();
2736
+ const row = db.raw
2737
+ .prepare(
2738
+ `SELECT callback_url, callback_secret FROM remediation_proposals WHERE id = ?`,
2739
+ )
2740
+ .get(proposed.proposal.id) as {
2741
+ callback_url: string | null;
2742
+ callback_secret: string | null;
2743
+ };
2744
+ expect(row.callback_url).toBeNull();
2745
+ expect(row.callback_secret).toBeNull();
2746
+ const got = await get();
2747
+ expect(got.proposal.callbackDelivery).toMatchObject({
2748
+ status: "failed",
2749
+ attempts: 3,
2750
+ });
2751
+ expect(got.proposal.callbackDelivery?.lastError).toBeTruthy();
2752
+ db.close();
2753
+ }, 20_000);
2754
+
2755
+ it("does not retry a 4xx", async () => {
2756
+ const fetchMock = vi.fn(async () => new Response("no", { status: 403 }));
2757
+ vi.stubGlobal("fetch", fetchMock);
2758
+ const { db, approve, get } = await setup(
2759
+ "retry-4xx",
2760
+ "https://runner.example.com/g",
2761
+ );
2762
+ const approved = await approve();
2763
+ expect(fetchMock).toHaveBeenCalledTimes(1);
2764
+ expect(approved.grantToken).toMatch(/^rgt_/);
2765
+ expect(approved.grantDelivery).toMatchObject({
2766
+ attempted: true,
2767
+ delivered: false,
2768
+ attempts: 1,
2769
+ error: "HTTP 403",
2770
+ });
2771
+ expect((await get()).proposal.callbackDelivery?.status).toBe("failed");
2772
+ db.close();
2773
+ });
2774
+
2775
+ it("reports attempted=false when no callback exists", async () => {
2776
+ const db = createMigratedSqliteDb();
2777
+ const { app, env } = createTestApp(db);
2778
+ const incident = await newIncident(db);
2779
+ const finding = await newFinding(db, incident.id);
2780
+ const proposed = (await (
2781
+ await app.request(
2782
+ "/internal/remediations/propose",
2783
+ json({
2784
+ environment: "prod",
2785
+ incidentId: incident.id,
2786
+ findingId: finding.id,
2787
+ idempotencyKey: "no-cb",
2788
+ actionKind: "rollback_deploy",
2789
+ targetNodeId: SERVICE_NODE,
2790
+ expectedCurrentState: {},
2791
+ desiredTargetState: {},
2792
+ }),
2793
+ env,
2794
+ )
2795
+ ).json()) as { proposal: { id: string } };
2796
+ const approved = (await (
2797
+ await app.request(
2798
+ `/internal/remediations/${proposed.proposal.id}/approve`,
2799
+ approveInit({ environment: "prod" }),
2800
+ env,
2801
+ )
2802
+ ).json()) as { grantDelivery: Delivery };
2803
+ expect(approved.grantDelivery).toEqual({
2804
+ attempted: false,
2805
+ delivered: false,
2806
+ attempts: 0,
2807
+ host: null,
2808
+ });
2809
+ db.close();
2810
+ });
2811
+ });
2222
2812
  });
@@ -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
+ });