@indigoai-us/hq-cli 5.5.0 → 5.5.2-sentry-rc.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.
Files changed (139) hide show
  1. package/dist/bin/hq-auth-refresh.js +22 -12
  2. package/dist/bin/hq-auth-refresh.js.map +1 -1
  3. package/dist/commands/_patterns.d.ts +3 -0
  4. package/dist/commands/_patterns.d.ts.map +1 -0
  5. package/dist/commands/_patterns.js +3 -0
  6. package/dist/commands/_patterns.js.map +1 -0
  7. package/dist/commands/add.d.ts.map +1 -1
  8. package/dist/commands/add.js +2 -1
  9. package/dist/commands/add.js.map +1 -1
  10. package/dist/commands/auth.d.ts +0 -3
  11. package/dist/commands/auth.d.ts.map +1 -1
  12. package/dist/commands/auth.js +0 -3
  13. package/dist/commands/auth.js.map +1 -1
  14. package/dist/commands/cloud.d.ts +12 -2
  15. package/dist/commands/cloud.d.ts.map +1 -1
  16. package/dist/commands/cloud.js +112 -80
  17. package/dist/commands/cloud.js.map +1 -1
  18. package/dist/commands/groups.d.ts +3 -0
  19. package/dist/commands/groups.d.ts.map +1 -0
  20. package/dist/commands/groups.js +341 -0
  21. package/dist/commands/groups.js.map +1 -0
  22. package/dist/commands/list.d.ts.map +1 -1
  23. package/dist/commands/list.js +18 -1
  24. package/dist/commands/list.js.map +1 -1
  25. package/dist/commands/login.d.ts +1 -1
  26. package/dist/commands/login.d.ts.map +1 -1
  27. package/dist/commands/login.js +28 -9
  28. package/dist/commands/login.js.map +1 -1
  29. package/dist/commands/logout.d.ts +1 -1
  30. package/dist/commands/logout.d.ts.map +1 -1
  31. package/dist/commands/logout.js +8 -3
  32. package/dist/commands/logout.js.map +1 -1
  33. package/dist/commands/onboard.d.ts +23 -0
  34. package/dist/commands/onboard.d.ts.map +1 -0
  35. package/dist/commands/onboard.js +142 -0
  36. package/dist/commands/onboard.js.map +1 -0
  37. package/dist/commands/pack-install.d.ts +44 -0
  38. package/dist/commands/pack-install.d.ts.map +1 -0
  39. package/dist/commands/pack-install.js +513 -0
  40. package/dist/commands/pack-install.js.map +1 -0
  41. package/dist/commands/pkg-install.d.ts.map +1 -1
  42. package/dist/commands/pkg-install.js +20 -8
  43. package/dist/commands/pkg-install.js.map +1 -1
  44. package/dist/commands/pkg-list.js +4 -4
  45. package/dist/commands/pkg-list.js.map +1 -1
  46. package/dist/commands/pkg-update.js +3 -3
  47. package/dist/commands/pkg-update.js.map +1 -1
  48. package/dist/commands/secrets.d.ts +12 -0
  49. package/dist/commands/secrets.d.ts.map +1 -0
  50. package/dist/commands/secrets.js +756 -0
  51. package/dist/commands/secrets.js.map +1 -0
  52. package/dist/commands/sync.d.ts.map +1 -1
  53. package/dist/commands/sync.js +10 -2
  54. package/dist/commands/sync.js.map +1 -1
  55. package/dist/commands/team-sync.d.ts.map +1 -1
  56. package/dist/commands/team-sync.js +6 -6
  57. package/dist/commands/team-sync.js.map +1 -1
  58. package/dist/commands/whoami.d.ts +1 -1
  59. package/dist/commands/whoami.d.ts.map +1 -1
  60. package/dist/commands/whoami.js +23 -7
  61. package/dist/commands/whoami.js.map +1 -1
  62. package/dist/index.js +24 -2
  63. package/dist/index.js.map +1 -1
  64. package/dist/sentry-before-send.d.ts +3 -0
  65. package/dist/sentry-before-send.d.ts.map +1 -0
  66. package/dist/sentry-before-send.js +216 -0
  67. package/dist/sentry-before-send.js.map +1 -0
  68. package/dist/sentry-dsn.generated.d.ts +2 -0
  69. package/dist/sentry-dsn.generated.d.ts.map +1 -0
  70. package/dist/sentry-dsn.generated.js.map +1 -0
  71. package/dist/sentry.d.ts +4 -0
  72. package/dist/sentry.d.ts.map +1 -0
  73. package/dist/sentry.js +19 -0
  74. package/dist/sentry.js.map +1 -0
  75. package/dist/strategies/link.d.ts +2 -2
  76. package/dist/strategies/link.d.ts.map +1 -1
  77. package/dist/strategies/link.js.map +1 -1
  78. package/dist/strategies/merge.d.ts +2 -2
  79. package/dist/strategies/merge.d.ts.map +1 -1
  80. package/dist/strategies/merge.js.map +1 -1
  81. package/dist/types.d.ts +37 -3
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js +6 -0
  84. package/dist/types.js.map +1 -1
  85. package/dist/utils/cognito-session.d.ts +18 -7
  86. package/dist/utils/cognito-session.d.ts.map +1 -1
  87. package/dist/utils/cognito-session.js +35 -8
  88. package/dist/utils/cognito-session.js.map +1 -1
  89. package/dist/utils/manifest.d.ts +13 -0
  90. package/dist/utils/manifest.d.ts.map +1 -1
  91. package/dist/utils/manifest.js +24 -1
  92. package/dist/utils/manifest.js.map +1 -1
  93. package/dist/utils/secrets-cache.d.ts +7 -0
  94. package/dist/utils/secrets-cache.d.ts.map +1 -0
  95. package/dist/utils/secrets-cache.js +135 -0
  96. package/dist/utils/secrets-cache.js.map +1 -0
  97. package/package.json +17 -6
  98. package/scripts/generate-dsn.mjs +26 -0
  99. package/src/bin/hq-auth-refresh.ts +23 -16
  100. package/src/commands/_patterns.ts +2 -0
  101. package/src/commands/add.ts +7 -6
  102. package/src/commands/auth.ts +0 -3
  103. package/src/commands/cloud.ts +214 -101
  104. package/src/commands/groups.ts +357 -0
  105. package/src/commands/list.ts +20 -1
  106. package/src/commands/login.ts +28 -9
  107. package/src/commands/logout.ts +8 -3
  108. package/src/commands/onboard.ts +193 -0
  109. package/src/commands/pack-install.ts +667 -0
  110. package/src/commands/pkg-install.ts +34 -16
  111. package/src/commands/pkg-list.ts +4 -4
  112. package/src/commands/pkg-update.ts +3 -3
  113. package/src/commands/secrets.ts +950 -0
  114. package/src/commands/sync.ts +20 -4
  115. package/src/commands/team-sync.ts +7 -8
  116. package/src/commands/whoami.ts +22 -7
  117. package/src/index.ts +26 -2
  118. package/src/sentry-before-send.test.ts +359 -0
  119. package/src/sentry-before-send.ts +221 -0
  120. package/src/sentry.test.ts +24 -0
  121. package/src/sentry.ts +19 -0
  122. package/src/strategies/link.ts +2 -2
  123. package/src/strategies/merge.ts +2 -2
  124. package/src/types.ts +54 -4
  125. package/src/utils/cognito-session.ts +46 -14
  126. package/src/utils/manifest.test.ts +148 -0
  127. package/src/utils/manifest.ts +22 -1
  128. package/src/utils/secrets-cache.ts +130 -0
  129. package/tsconfig.json +2 -1
  130. package/dist/utils/auth.d.ts +0 -27
  131. package/dist/utils/auth.d.ts.map +0 -1
  132. package/dist/utils/auth.js +0 -155
  133. package/dist/utils/auth.js.map +0 -1
  134. package/dist/utils/token-store.d.ts +0 -28
  135. package/dist/utils/token-store.d.ts.map +0 -1
  136. package/dist/utils/token-store.js +0 -68
  137. package/dist/utils/token-store.js.map +0 -1
  138. package/src/utils/auth.ts +0 -193
  139. package/src/utils/token-store.ts +0 -83
@@ -23,10 +23,10 @@ import {
23
23
  } from '../utils/git.js';
24
24
  import { linkSync } from '../strategies/link.js';
25
25
  import { mergeSync } from '../strategies/merge.js';
26
- import type { ModuleDefinition, ModuleLock, SyncResult } from '../types.js';
26
+ import type { LegacyModuleDefinition, ModuleLock, SyncResult } from '../types.js';
27
27
 
28
28
  async function syncModule(
29
- module: ModuleDefinition,
29
+ module: LegacyModuleDefinition,
30
30
  moduleDir: string,
31
31
  hqRoot: string,
32
32
  locked: boolean,
@@ -101,9 +101,25 @@ export function registerSyncCommand(program: Command): void {
101
101
  const results: SyncResult[] = [];
102
102
  const newLock: ModuleLock = { version: '1', locked: {} };
103
103
 
104
- console.log(`Syncing ${manifest.modules.length} module(s)...\n`);
104
+ // strategy: package entries are wired by scripts/scan-packages.sh, not
105
+ // the git-repo sync flow. Partition and report separately.
106
+ const legacyModules = manifest.modules.filter(
107
+ (m): m is LegacyModuleDefinition => m.strategy !== 'package'
108
+ );
109
+ const packModules = manifest.modules.filter(
110
+ (m) => m.strategy === 'package'
111
+ );
112
+
113
+ if (packModules.length > 0) {
114
+ console.log(
115
+ `Skipping ${packModules.length} package module(s) (wired via scan-packages.sh): ` +
116
+ packModules.map((m) => m.name).join(', ')
117
+ );
118
+ }
119
+
120
+ console.log(`Syncing ${legacyModules.length} module(s)...\n`);
105
121
 
106
- for (const module of manifest.modules) {
122
+ for (const module of legacyModules) {
107
123
  console.log(`[${module.name}]`);
108
124
  const moduleDir = path.join(modulesDir, module.name);
109
125
 
@@ -17,8 +17,7 @@ import { Command } from 'commander';
17
17
  import chalk from 'chalk';
18
18
  import simpleGit from 'simple-git';
19
19
  import { findHqRoot } from '../utils/hq-root.js';
20
- import { getAuthToken } from '../utils/auth.js';
21
- import type { AuthToken } from '../utils/token-store.js';
20
+ import { ensureCognitoToken } from '../utils/cognito-session.js';
22
21
 
23
22
  // ─── Types ──────────────────────────────────────────────────────────────────
24
23
 
@@ -57,7 +56,7 @@ interface TeamSyncResult {
57
56
 
58
57
  // ─── API helpers ────────────────────────────────────────────────────────────
59
58
 
60
- const API_BASE = 'https://hq.indigoai.com/api';
59
+ const API_BASE = 'https://example.com/api';
61
60
 
62
61
  async function apiGet<T>(urlPath: string, token: string): Promise<T> {
63
62
  const res = await fetch(`${API_BASE}${urlPath}`, {
@@ -255,7 +254,7 @@ function discoverTeamDirs(hqRoot: string): { dir: string; meta: TeamMetadata }[]
255
254
  async function syncTeam(
256
255
  teamDir: string,
257
256
  meta: TeamMetadata,
258
- authToken: AuthToken
257
+ accessToken: string
259
258
  ): Promise<TeamSyncResult> {
260
259
  const result: TeamSyncResult = {
261
260
  slug: meta.team_slug,
@@ -286,7 +285,7 @@ async function syncTeam(
286
285
  try {
287
286
  const entitlements = await fetchTeamEntitlements(
288
287
  meta.team_id,
289
- authToken.clerk_session_token
288
+ accessToken
290
289
  );
291
290
  const entitledPaths = entitlements.flatMap((e) => e.paths);
292
291
 
@@ -339,7 +338,7 @@ async function syncTeam(
339
338
  try {
340
339
  repoConfig = await fetchRepoConfig(
341
340
  meta.team_id,
342
- authToken.clerk_session_token
341
+ accessToken
343
342
  );
344
343
  } catch (err) {
345
344
  result.message = `Failed to fetch repo credentials: ${err instanceof Error ? err.message : 'Unknown error'}`;
@@ -467,9 +466,9 @@ export function registerTeamSyncCommand(program: Command): void {
467
466
  }
468
467
 
469
468
  // 2. Get valid auth token (handles refresh / re-auth prompt)
470
- let authToken: AuthToken;
469
+ let authToken: string;
471
470
  try {
472
- authToken = await getAuthToken();
471
+ authToken = await ensureCognitoToken();
473
472
  } catch (err) {
474
473
  console.error(
475
474
  chalk.red(
@@ -1,10 +1,23 @@
1
1
  /**
2
- * hq whoami — displays current user or 'not logged in' (US-004)
2
+ * hq whoami — displays current user or 'not logged in'
3
3
  */
4
4
 
5
5
  import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
- import { loadToken, isTokenExpired } from '../utils/token-store.js';
7
+ import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
8
+
9
+ function peekIdToken(idToken: string): { email?: string; sub?: string } {
10
+ try {
11
+ const payload = idToken.split('.')[1];
12
+ if (!payload) return {};
13
+ const pad = payload.length % 4 === 0 ? '' : '='.repeat(4 - (payload.length % 4));
14
+ const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + pad;
15
+ const decoded = JSON.parse(Buffer.from(normalized, 'base64').toString('utf-8'));
16
+ return { email: decoded.email, sub: decoded.sub };
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
8
21
 
9
22
  export function registerWhoamiCommand(program: Command): void {
10
23
  program
@@ -12,21 +25,23 @@ export function registerWhoamiCommand(program: Command): void {
12
25
  .description('Show the currently authenticated user')
13
26
  .action(async () => {
14
27
  try {
15
- const token = await loadToken();
28
+ const cached = loadCachedTokens();
16
29
 
17
- if (!token) {
30
+ if (!cached) {
18
31
  console.log("Not logged in. Run 'hq login' to authenticate.");
19
32
  return;
20
33
  }
21
34
 
22
- if (isTokenExpired(token)) {
35
+ if (isExpiring(cached, 0)) {
36
+ const who = peekIdToken(cached.idToken).email ?? 'unknown';
23
37
  console.log(
24
- chalk.yellow(`Session expired for ${token.email}. Run 'hq login' to re-authenticate.`)
38
+ chalk.yellow(`Session expired for ${who}. Run 'hq login' to re-authenticate.`)
25
39
  );
26
40
  return;
27
41
  }
28
42
 
29
- console.log(`Logged in as ${token.email} (${token.user_id})`);
43
+ const { email, sub } = peekIdToken(cached.idToken);
44
+ console.log(`Logged in as ${email ?? 'unknown'}${sub ? ` (${sub})` : ''}`);
30
45
  } catch (error) {
31
46
  console.error(
32
47
  chalk.red('Error:'),
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Command } from "commander";
8
+ import { initSentry, Sentry } from "./sentry.js";
8
9
  import { registerAddCommand } from "./commands/add.js";
9
10
  import { registerSyncCommand } from "./commands/sync.js";
10
11
  import { registerListCommand } from "./commands/list.js";
@@ -13,12 +14,17 @@ import { registerCloudCommands } from "./commands/cloud.js";
13
14
  import { registerLoginCommand } from "./commands/login.js";
14
15
  import { registerLogoutCommand } from "./commands/logout.js";
15
16
  import { registerWhoamiCommand } from "./commands/whoami.js";
17
+ import { registerOnboardCommand } from "./commands/onboard.js";
16
18
  import { registerPackageInstallCommand } from "./commands/pkg-install.js";
17
19
  import { registerPackageRemoveCommand } from "./commands/pkg-remove.js";
18
20
  import { registerPackageUpdateCommand } from "./commands/pkg-update.js";
19
21
  import { registerPackageListCommand } from "./commands/pkg-list.js";
20
22
  import { registerTeamSyncCommand } from "./commands/team-sync.js";
21
23
  import { registerAuthCommands } from "./commands/auth.js";
24
+ import { registerSecretsCommand } from "./commands/secrets.js";
25
+ import { registerGroupsCommand } from "./commands/groups.js";
26
+
27
+ initSentry();
22
28
 
23
29
  const program = new Command();
24
30
 
@@ -63,10 +69,28 @@ registerCloudCommands(syncCmd);
63
69
  // Team commands (top-level)
64
70
  registerTeamSyncCommand(program);
65
71
 
66
- // Auth commands (top-level)
72
+ // Auth commands (top-level — Cognito OAuth)
67
73
  registerLoginCommand(program);
68
74
  registerLogoutCommand(program);
69
75
  registerWhoamiCommand(program);
70
76
  registerAuthCommands(program);
71
77
 
72
- program.parse();
78
+ // Secrets management (subcommand group — hq secrets set|get|list|delete|exec|generate-link|cache)
79
+ registerSecretsCommand(program);
80
+
81
+ // Groups management (subcommand group — hq groups create|delete|add|remove|list|members)
82
+ registerGroupsCommand(program);
83
+
84
+ // Onboarding (top-level — Cognito + vault-service provisioning)
85
+ registerOnboardCommand(program);
86
+
87
+ (async () => {
88
+ try {
89
+ await program.parseAsync();
90
+ } catch (err) {
91
+ Sentry.captureException(err);
92
+ process.exitCode = 1;
93
+ } finally {
94
+ await Sentry.flush(2000);
95
+ }
96
+ })();
@@ -0,0 +1,359 @@
1
+ // src/sentry-before-send.test.ts — canonical fleet Vitest suite.
2
+ // This file is byte-identical across every consumer repo (no per-repo
3
+ // line-1 variation, unlike sentry-before-send.ts). The body-identity
4
+ // gate each consumer runs is `diff hq-console/src/sentry-before-send.test.ts
5
+ // <consumer>/src/sentry-before-send.test.ts` returns empty (no tail -n +2).
6
+ import { describe, expect, it } from "vitest";
7
+ import type { ErrorEvent, EventHint } from "@sentry/nextjs";
8
+ import { beforeSend } from "./sentry-before-send";
9
+
10
+ const REDACTED = "[Filtered]";
11
+
12
+ function run(event: ErrorEvent): ErrorEvent {
13
+ const result = beforeSend(event, {} as EventHint);
14
+ if (!result) throw new Error("beforeSend dropped event unexpectedly");
15
+ return result;
16
+ }
17
+
18
+ describe("scrubHeaders — SENSITIVE_HEADER_NAMES case-insensitive redaction", () => {
19
+ const sensitiveHeaders = [
20
+ "authorization",
21
+ "proxy-authorization",
22
+ "cookie",
23
+ "set-cookie",
24
+ "x-api-key",
25
+ "x-auth-token",
26
+ "x-access-token",
27
+ "x-session-token",
28
+ ];
29
+
30
+ it.each(sensitiveHeaders)("redacts %s header (and case variants)", (name) => {
31
+ const upper = name.toUpperCase();
32
+ const out = run({
33
+ request: {
34
+ headers: { [upper]: "leaky-value", "x-trace-id": "keep-me" },
35
+ },
36
+ } as unknown as ErrorEvent);
37
+ expect((out.request!.headers as Record<string, unknown>)[upper]).toBe(REDACTED);
38
+ expect((out.request!.headers as Record<string, unknown>)["x-trace-id"]).toBe("keep-me");
39
+ });
40
+
41
+ it("redacts a raw Cookie header that lands on request.headers.cookie", () => {
42
+ const out = run({
43
+ request: {
44
+ headers: {
45
+ Cookie: "authjs.session-token=abc; other=keep",
46
+ "x-trace-id": "keep-me",
47
+ },
48
+ },
49
+ } as unknown as ErrorEvent);
50
+ expect((out.request!.headers as Record<string, unknown>).Cookie).toBe(REDACTED);
51
+ expect((out.request!.headers as Record<string, unknown>)["x-trace-id"]).toBe("keep-me");
52
+ });
53
+ });
54
+
55
+ describe("scrubCookies — SENSITIVE_COOKIE_NAMES (Auth.js v5 + v4 legacy + Clerk) + raw-string fallback", () => {
56
+ // v5 (Auth.js): session / callback-url use __Secure-; csrf-token uses
57
+ // __Host- (NOT __Secure-). OAuth-state families (pkce / state / nonce /
58
+ // challenge) each pair bare + __Secure-.
59
+ const v5Names = [
60
+ "authjs.session-token",
61
+ "__secure-authjs.session-token",
62
+ "authjs.csrf-token",
63
+ "__host-authjs.csrf-token",
64
+ "authjs.callback-url",
65
+ "__secure-authjs.callback-url",
66
+ "authjs.pkce.code_verifier",
67
+ "__secure-authjs.pkce.code_verifier",
68
+ "authjs.state",
69
+ "__secure-authjs.state",
70
+ "authjs.nonce",
71
+ "__secure-authjs.nonce",
72
+ "authjs.challenge",
73
+ "__secure-authjs.challenge",
74
+ ];
75
+
76
+ // v4 (NextAuth legacy): same prefix discipline — csrf uses __Host-, others
77
+ // use __Secure-. Kept in the set so accidental downgrades / mixed-version
78
+ // monorepos don't silently leak.
79
+ const v4Names = [
80
+ "next-auth.session-token",
81
+ "__secure-next-auth.session-token",
82
+ "next-auth.csrf-token",
83
+ "__host-next-auth.csrf-token",
84
+ "next-auth.callback-url",
85
+ "__secure-next-auth.callback-url",
86
+ "next-auth.pkce.code_verifier",
87
+ "__secure-next-auth.pkce.code_verifier",
88
+ "next-auth.state",
89
+ "__secure-next-auth.state",
90
+ ];
91
+
92
+ const clerkNames = ["__clerk_db_jwt", "__session"];
93
+
94
+ it.each([...v5Names, ...v4Names, ...clerkNames])(
95
+ "redacts cookie %s (case-insensitive)",
96
+ (name) => {
97
+ const out = run({
98
+ request: {
99
+ cookies: { [name.toUpperCase()]: "secret-session-value", keep_me: "ok" },
100
+ },
101
+ } as unknown as ErrorEvent);
102
+ const cookies = out.request!.cookies as Record<string, unknown>;
103
+ expect(cookies[name.toUpperCase()]).toBe(REDACTED);
104
+ expect(cookies.keep_me).toBe("ok");
105
+ },
106
+ );
107
+
108
+ it("does NOT include a __Secure-authjs.csrf-token assertion — Auth.js v5 never emits that name (uses __Host- instead)", () => {
109
+ // This test is a read-through spec comment: the set MUST NOT have an
110
+ // entry for __Secure-authjs.csrf-token. If a maintainer ever adds one
111
+ // mechanically-by-pattern, this test documents why it is wrong.
112
+ const out = run({
113
+ request: {
114
+ cookies: { "__Secure-authjs.csrf-token": "does-not-exist-in-prod" },
115
+ },
116
+ } as unknown as ErrorEvent);
117
+ // The value is NOT redacted because the set does not contain that name.
118
+ // This is deliberate — see SENSITIVE_COOKIE_NAMES comment in the module.
119
+ const cookies = out.request!.cookies as Record<string, unknown>;
120
+ expect(cookies["__Secure-authjs.csrf-token"]).toBe("does-not-exist-in-prod");
121
+ });
122
+
123
+ it("wholesale-redacts a raw-string Cookie header", () => {
124
+ const out = run({
125
+ request: { cookies: "anything=here; other=keep" },
126
+ } as unknown as ErrorEvent);
127
+ expect(out.request!.cookies).toBe(REDACTED);
128
+ });
129
+
130
+ it("leaves undefined cookies alone", () => {
131
+ const out = run({ request: { headers: {} } } as unknown as ErrorEvent);
132
+ expect(out.request!.cookies).toBeUndefined();
133
+ });
134
+ });
135
+
136
+ describe("scrubFields — recursive redaction with case-insensitivity, arrays, nested, cycles", () => {
137
+ it("redacts top-level sensitive field names case-insensitively", () => {
138
+ const out = run({
139
+ extra: {
140
+ token: "abc",
141
+ Password: "pw",
142
+ APIKEY: "k",
143
+ api_key: "k2",
144
+ IdToken: "id",
145
+ ACCESSTOKEN: "at",
146
+ RefreshToken: "rt",
147
+ keep: "yes",
148
+ },
149
+ } as unknown as ErrorEvent);
150
+ const extra = out.extra as Record<string, unknown>;
151
+ expect(extra.token).toBe(REDACTED);
152
+ expect(extra.Password).toBe(REDACTED);
153
+ expect(extra.APIKEY).toBe(REDACTED);
154
+ expect(extra.api_key).toBe(REDACTED);
155
+ expect(extra.IdToken).toBe(REDACTED);
156
+ expect(extra.ACCESSTOKEN).toBe(REDACTED);
157
+ expect(extra.RefreshToken).toBe(REDACTED);
158
+ expect(extra.keep).toBe("yes");
159
+ });
160
+
161
+ it("recurses into nested objects and arrays", () => {
162
+ const out = run({
163
+ extra: {
164
+ nested: { deeper: { token: "t", safe: 1 } },
165
+ items: [{ password: "pw" }, { other: "ok" }],
166
+ },
167
+ } as unknown as ErrorEvent);
168
+ const extra = out.extra as Record<string, any>;
169
+ expect(extra.nested.deeper.token).toBe(REDACTED);
170
+ expect(extra.nested.deeper.safe).toBe(1);
171
+ expect(extra.items[0].password).toBe(REDACTED);
172
+ expect(extra.items[1].other).toBe("ok");
173
+ });
174
+
175
+ it("handles circular references without stack overflow and scrubs fields reachable only through the cycle", () => {
176
+ const cyclic: Record<string, unknown> = { token: "leak" };
177
+ cyclic.self = cyclic;
178
+ const out = run({ extra: { outer: cyclic } } as unknown as ErrorEvent);
179
+ const outer = (out.extra as any).outer;
180
+ // Top-level field is scrubbed.
181
+ expect(outer.token).toBe(REDACTED);
182
+ // The Map<original, scrubbed> cycle fix means re-visits return the
183
+ // memoized scrubbed copy, not the un-scrubbed original — so
184
+ // `outer.self.token` is ALSO [Filtered], not the leaked value.
185
+ expect(outer.self.token).toBe(REDACTED);
186
+ expect(outer.self).toBe(outer);
187
+ });
188
+
189
+ it("scalar-string defense-in-depth: redacts embedded credentials in extra.note even when the key is NOT in SENSITIVE_FIELD_NAMES", () => {
190
+ const out = run({
191
+ extra: { note: "Something broke, password=hunter2 failed" },
192
+ } as unknown as ErrorEvent);
193
+ const extra = out.extra as Record<string, unknown>;
194
+ expect(extra.note).toContain(`password=${REDACTED}`);
195
+ expect(extra.note).not.toContain("hunter2");
196
+ });
197
+
198
+ it("scalar-string defense-in-depth: redacts a Bearer in user.bio (key NOT in SENSITIVE_FIELD_NAMES)", () => {
199
+ const out = run({
200
+ user: { id: "u1", email: "a@b.c", bio: "authorization: Bearer leak" },
201
+ } as unknown as ErrorEvent);
202
+ const user = out.user as Record<string, unknown>;
203
+ expect(user.id).toBe("u1");
204
+ expect(user.email).toBe("a@b.c");
205
+ expect(user.bio).toContain(`Bearer ${REDACTED}`);
206
+ expect(user.bio).not.toContain("leak");
207
+ });
208
+ });
209
+
210
+ describe("scrubString — regex passes for kv, scheme+credential, JWT triplets", () => {
211
+ it("redacts key=value and key: value pairs", () => {
212
+ const out = run({
213
+ message: "password=hunter2 and api-key: abc123 should be redacted",
214
+ } as unknown as ErrorEvent);
215
+ expect(out.message).toContain(`password=${REDACTED}`);
216
+ expect(out.message).toContain(`api-key:${REDACTED}`);
217
+ expect(out.message).not.toContain("hunter2");
218
+ expect(out.message).not.toContain("abc123");
219
+ });
220
+
221
+ it("redacts Bearer / Basic credentials", () => {
222
+ const out = run({
223
+ message: "Got header Bearer abcDEF._~+/=- and Basic Zm9vOmJhcg==",
224
+ } as unknown as ErrorEvent);
225
+ expect(out.message).toContain(`Bearer ${REDACTED}`);
226
+ expect(out.message).toContain(`Basic ${REDACTED}`);
227
+ });
228
+
229
+ it("redacts non-JWT-shaped accessToken/refreshToken/id_token values in free text", () => {
230
+ const out = run({
231
+ message:
232
+ "refreshToken=opaqueBase64NotAJwt-xyz and accessToken: anotherOpaqueValue and id_token=short",
233
+ } as unknown as ErrorEvent);
234
+ expect(out.message).toContain(`refreshToken=${REDACTED}`);
235
+ expect(out.message).toContain(`accessToken:${REDACTED}`);
236
+ expect(out.message).toContain(`id_token=${REDACTED}`);
237
+ expect(out.message).not.toContain("opaqueBase64NotAJwt-xyz");
238
+ expect(out.message).not.toContain("anotherOpaqueValue");
239
+ expect(out.message).not.toContain("=short");
240
+ });
241
+
242
+ it("redacts bare JWT triplets", () => {
243
+ const jwt = "eyJhbGciOi12345.eyJzdWIiOi67890.sig_abcdef1234";
244
+ const out = run({ message: `token was ${jwt} yeah` } as unknown as ErrorEvent);
245
+ expect(out.message).not.toContain(jwt);
246
+ expect(out.message).toContain(REDACTED);
247
+ });
248
+ });
249
+
250
+ describe("scrubQueryString — parse + fallback", () => {
251
+ it("parses well-formed query strings with a leading ? and redacts sensitive keys", () => {
252
+ const out = run({
253
+ request: {
254
+ query_string: "?foo=bar&token=leak&password=pw&keep=me",
255
+ },
256
+ } as unknown as ErrorEvent);
257
+ const qs = out.request!.query_string as string;
258
+ expect(qs.startsWith("?")).toBe(true);
259
+ expect(qs).toContain("foo=bar");
260
+ expect(qs).toContain(`token=${REDACTED}`);
261
+ expect(qs).toContain(`password=${REDACTED}`);
262
+ expect(qs).toContain("keep=me");
263
+ });
264
+
265
+ it("parses no-leading-? strings as well", () => {
266
+ const out = run({
267
+ request: { query_string: "token=leak&keep=1" },
268
+ } as unknown as ErrorEvent);
269
+ const qs = out.request!.query_string as string;
270
+ expect(qs.startsWith("?")).toBe(false);
271
+ expect(qs).toContain(`token=${REDACTED}`);
272
+ expect(qs).toContain("keep=1");
273
+ });
274
+
275
+ it("leaves non-string query_string alone", () => {
276
+ const out = run({
277
+ request: { query_string: undefined },
278
+ } as unknown as ErrorEvent);
279
+ expect(out.request!.query_string).toBeUndefined();
280
+ });
281
+ });
282
+
283
+ describe("event-level walk — exception.values, message, breadcrumbs, user, extra, contexts, request.data", () => {
284
+ it("scrubs exception.values[].value strings", () => {
285
+ const out = run({
286
+ exception: {
287
+ values: [
288
+ { type: "Error", value: "password=sekret failed" },
289
+ { type: "Error", value: "clean" },
290
+ ],
291
+ },
292
+ } as unknown as ErrorEvent);
293
+ expect(out.exception!.values![0].value).toContain(`password=${REDACTED}`);
294
+ expect(out.exception!.values![0].value).not.toContain("sekret");
295
+ expect(out.exception!.values![1].value).toBe("clean");
296
+ });
297
+
298
+ it("scrubs event.message", () => {
299
+ const out = run({ message: "Bearer leaked.jwt.token" } as unknown as ErrorEvent);
300
+ expect(out.message).toContain(`Bearer ${REDACTED}`);
301
+ });
302
+
303
+ it("scrubs breadcrumbs.message (string) and breadcrumbs.data (structured)", () => {
304
+ const out = run({
305
+ breadcrumbs: [
306
+ {
307
+ category: "fetch",
308
+ message: "GET /api?token=abc",
309
+ data: { url: "/api", password: "leak" },
310
+ },
311
+ ],
312
+ } as unknown as ErrorEvent);
313
+ const bc = out.breadcrumbs![0];
314
+ expect(bc.message).toContain(`token=${REDACTED}`);
315
+ expect((bc.data as Record<string, unknown>).password).toBe(REDACTED);
316
+ expect((bc.data as Record<string, unknown>).url).toBe("/api");
317
+ });
318
+
319
+ it("scrubs event.user fields", () => {
320
+ const out = run({
321
+ user: { id: "u1", email: "a@b.c", token: "bad", IdToken: "cognito" },
322
+ } as unknown as ErrorEvent);
323
+ const user = out.user as Record<string, unknown>;
324
+ expect(user.id).toBe("u1");
325
+ expect(user.email).toBe("a@b.c");
326
+ expect(user.token).toBe(REDACTED);
327
+ expect(user.IdToken).toBe(REDACTED);
328
+ });
329
+
330
+ it("scrubs event.extra", () => {
331
+ const out = run({ extra: { password: "x", safe: "y" } } as unknown as ErrorEvent);
332
+ expect((out.extra as Record<string, unknown>).password).toBe(REDACTED);
333
+ expect((out.extra as Record<string, unknown>).safe).toBe("y");
334
+ });
335
+
336
+ it("scrubs event.contexts", () => {
337
+ const out = run({
338
+ contexts: {
339
+ auth: { token: "x", scheme: "bearer" },
340
+ runtime: { name: "node", version: "22" },
341
+ },
342
+ } as unknown as ErrorEvent);
343
+ const auth = (out.contexts as any).auth;
344
+ const runtime = (out.contexts as any).runtime;
345
+ expect(auth.token).toBe(REDACTED);
346
+ expect(auth.scheme).toBe("bearer");
347
+ expect(runtime.name).toBe("node");
348
+ });
349
+
350
+ it("scrubs structured request body via request.data", () => {
351
+ const out = run({
352
+ request: { data: { username: "u", password: "pw", nested: { token: "t" } } },
353
+ } as unknown as ErrorEvent);
354
+ const data = out.request!.data as Record<string, any>;
355
+ expect(data.username).toBe("u");
356
+ expect(data.password).toBe(REDACTED);
357
+ expect(data.nested.token).toBe(REDACTED);
358
+ });
359
+ });