@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.1

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 (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Regex word-boundary regression tests (Task 9 from quality-refactor plan)
3
+ *
4
+ * Verifies that sanitization regexes use \b word boundaries so they don't
5
+ * create false positive matches inside compound words.
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+
10
+ import { sanitizeSystemText } from "../system-prompt/sanitize.js";
11
+
12
+ describe("sanitizeSystemText word boundaries", () => {
13
+ it("replaces the PascalCase word 'OpenCode' with 'Claude Code'", () => {
14
+ const result = sanitizeSystemText("Run OpenCode first", true);
15
+ expect(result).not.toContain("OpenCode");
16
+ expect(result).toContain("Claude Code");
17
+ });
18
+
19
+ it("replaces the lowercase word 'opencode' with 'Claude'", () => {
20
+ // Per sanitize.ts, /\bopencode\b/gi → "Claude" (not "Claude Code")
21
+ const result = sanitizeSystemText("use opencode here", true);
22
+ expect(result).not.toContain("opencode");
23
+ expect(result).toContain("Claude");
24
+ });
25
+
26
+ it("does NOT replace 'myopencode' (word boundary on left)", () => {
27
+ const result = sanitizeSystemText("the myopencode binary", true);
28
+ expect(result).toContain("myopencode");
29
+ });
30
+
31
+ it("does NOT replace 'opencoder' (word boundary on right)", () => {
32
+ const result = sanitizeSystemText("known as opencoder", true);
33
+ expect(result).toContain("opencoder");
34
+ });
35
+
36
+ it("does NOT replace 'preopencode' (word boundary on both sides)", () => {
37
+ const result = sanitizeSystemText("run preopencode first", true);
38
+ expect(result).toContain("preopencode");
39
+ });
40
+
41
+ it("handles mixed content correctly", () => {
42
+ const result = sanitizeSystemText("use opencode inside myopencode directory", true);
43
+ // Standalone "opencode" becomes "Claude"
44
+ expect(result).toContain("Claude");
45
+ // "myopencode" is preserved because of word boundary
46
+ expect(result).toContain("myopencode");
47
+ });
48
+
49
+ it("preserves text when enabled=false", () => {
50
+ const result = sanitizeSystemText("use opencode here", false);
51
+ expect(result).toContain("opencode");
52
+ });
53
+
54
+ it("replaces 'Sisyphus' with 'Claude Code Agent'", () => {
55
+ const result = sanitizeSystemText("from the Sisyphus agent", true);
56
+ expect(result).not.toContain("Sisyphus");
57
+ expect(result).toContain("Claude Code Agent");
58
+ });
59
+
60
+ it("replaces 'morph_edit' with 'edit' at word boundaries", () => {
61
+ const result = sanitizeSystemText("call morph_edit tool", true);
62
+ expect(result).not.toContain("morph_edit");
63
+ expect(result).toContain("edit");
64
+ });
65
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Unbounded state bounds tests (Task 13 from quality-refactor plan)
3
+ *
4
+ * Verifies cap helpers enforce FIFO eviction and TTL cleanup for
5
+ * long-lived in-memory collections.
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+
10
+ import { capFileAccountMap, FILE_ACCOUNT_MAP_MAX_SIZE } from "../commands/router.js";
11
+ import { pruneExpiredPendingOAuth, PENDING_OAUTH_TTL_MS, type PendingOAuthEntry } from "../commands/oauth-flow.js";
12
+
13
+ function makePendingEntry(createdAt: number): PendingOAuthEntry {
14
+ return {
15
+ mode: "login",
16
+ verifier: "test-verifier",
17
+ createdAt,
18
+ };
19
+ }
20
+
21
+ describe("capFileAccountMap FIFO eviction", () => {
22
+ it("exports FILE_ACCOUNT_MAP_MAX_SIZE = 1000", () => {
23
+ expect(FILE_ACCOUNT_MAP_MAX_SIZE).toBe(1000);
24
+ });
25
+
26
+ it("caps the map at FILE_ACCOUNT_MAP_MAX_SIZE entries", () => {
27
+ const map = new Map<string, number>();
28
+ for (let i = 0; i < FILE_ACCOUNT_MAP_MAX_SIZE + 100; i++) {
29
+ capFileAccountMap(map, `file_${i}`, i % 5);
30
+ }
31
+ expect(map.size).toBeLessThanOrEqual(FILE_ACCOUNT_MAP_MAX_SIZE);
32
+ });
33
+
34
+ it("evicts oldest entries first (FIFO)", () => {
35
+ const map = new Map<string, number>();
36
+ for (let i = 0; i < FILE_ACCOUNT_MAP_MAX_SIZE; i++) {
37
+ capFileAccountMap(map, `file_${i}`, 0);
38
+ }
39
+ capFileAccountMap(map, "file_overflow", 1);
40
+ expect(map.has("file_0")).toBe(false);
41
+ expect(map.has("file_overflow")).toBe(true);
42
+ expect(map.size).toBe(FILE_ACCOUNT_MAP_MAX_SIZE);
43
+ });
44
+
45
+ it("updates value of existing key when below cap", () => {
46
+ const map = new Map<string, number>();
47
+ capFileAccountMap(map, "file_a", 1);
48
+ capFileAccountMap(map, "file_a", 2);
49
+ expect(map.get("file_a")).toBe(2);
50
+ expect(map.size).toBe(1);
51
+ });
52
+
53
+ it("handles rapid insertion under cap without eviction", () => {
54
+ const map = new Map<string, number>();
55
+ for (let i = 0; i < 500; i++) {
56
+ capFileAccountMap(map, `file_${i}`, 0);
57
+ }
58
+ expect(map.size).toBe(500);
59
+ expect(map.has("file_0")).toBe(true);
60
+ expect(map.has("file_499")).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("pruneExpiredPendingOAuth TTL cleanup", () => {
65
+ it("exports PENDING_OAUTH_TTL_MS = 10 minutes", () => {
66
+ expect(PENDING_OAUTH_TTL_MS).toBe(10 * 60 * 1000);
67
+ });
68
+
69
+ it("removes entries older than TTL", () => {
70
+ const map = new Map<string, PendingOAuthEntry>();
71
+ const now = Date.now();
72
+ map.set("expired", makePendingEntry(now - PENDING_OAUTH_TTL_MS - 1000));
73
+ map.set("fresh", makePendingEntry(now));
74
+
75
+ pruneExpiredPendingOAuth(map);
76
+
77
+ expect(map.has("expired")).toBe(false);
78
+ expect(map.has("fresh")).toBe(true);
79
+ });
80
+
81
+ it("does not remove entries just inside the TTL boundary", () => {
82
+ const map = new Map<string, PendingOAuthEntry>();
83
+ const now = Date.now();
84
+ map.set("boundary", makePendingEntry(now - PENDING_OAUTH_TTL_MS + 1000));
85
+
86
+ pruneExpiredPendingOAuth(map);
87
+
88
+ expect(map.has("boundary")).toBe(true);
89
+ });
90
+
91
+ it("handles empty map without error", () => {
92
+ const map = new Map<string, PendingOAuthEntry>();
93
+ expect(() => pruneExpiredPendingOAuth(map)).not.toThrow();
94
+ });
95
+
96
+ it("removes only expired entries, leaves fresh ones", () => {
97
+ const map = new Map<string, PendingOAuthEntry>();
98
+ const now = Date.now();
99
+ map.set("old1", makePendingEntry(now - PENDING_OAUTH_TTL_MS - 5000));
100
+ map.set("old2", makePendingEntry(now - PENDING_OAUTH_TTL_MS - 2000));
101
+ map.set("new1", makePendingEntry(now - 1000));
102
+ map.set("new2", makePendingEntry(now));
103
+
104
+ pruneExpiredPendingOAuth(map);
105
+
106
+ expect(map.size).toBe(2);
107
+ expect(map.has("new1")).toBe(true);
108
+ expect(map.has("new2")).toBe(true);
109
+ });
110
+ });
@@ -0,0 +1,213 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { CCCredential } from "./cc-credentials.js";
3
+ import type { ManagedAccount } from "./accounts.js";
4
+ import {
5
+ findByIdentity,
6
+ identitiesMatch,
7
+ resolveIdentity,
8
+ resolveIdentityFromCCCredential,
9
+ resolveIdentityFromOAuthExchange,
10
+ serializeIdentity,
11
+ type AccountIdentity,
12
+ } from "./account-identity.js";
13
+
14
+ describe("account-identity", () => {
15
+ const oauthAccount: ManagedAccount = {
16
+ id: "oauth-1",
17
+ index: 0,
18
+ email: "alice@example.com",
19
+ refreshToken: "rt_oauth_123",
20
+ access: "at_oauth_123",
21
+ expires: Date.now() + 3600000,
22
+ tokenUpdatedAt: Date.now(),
23
+ addedAt: Date.now(),
24
+ lastUsed: 0,
25
+ enabled: true,
26
+ rateLimitResetTimes: {},
27
+ consecutiveFailures: 0,
28
+ lastFailureTime: null,
29
+ stats: {
30
+ requests: 0,
31
+ inputTokens: 0,
32
+ outputTokens: 0,
33
+ cacheReadTokens: 0,
34
+ cacheWriteTokens: 0,
35
+ lastReset: Date.now(),
36
+ },
37
+ source: "oauth",
38
+ };
39
+
40
+ const ccAccount: ManagedAccount = {
41
+ id: "cc-1",
42
+ index: 1,
43
+ email: undefined,
44
+ label: "Claude Code-credentials:alice@example.com",
45
+ refreshToken: "rt_cc_456",
46
+ access: "at_cc_456",
47
+ expires: Date.now() + 3600000,
48
+ tokenUpdatedAt: Date.now(),
49
+ addedAt: Date.now(),
50
+ lastUsed: 0,
51
+ enabled: true,
52
+ rateLimitResetTimes: {},
53
+ consecutiveFailures: 0,
54
+ lastFailureTime: null,
55
+ stats: {
56
+ requests: 0,
57
+ inputTokens: 0,
58
+ outputTokens: 0,
59
+ cacheReadTokens: 0,
60
+ cacheWriteTokens: 0,
61
+ lastReset: Date.now(),
62
+ },
63
+ source: "cc-keychain",
64
+ };
65
+
66
+ const legacyAccount: ManagedAccount = {
67
+ id: "legacy-1",
68
+ index: 2,
69
+ email: undefined,
70
+ refreshToken: "rt_legacy_789",
71
+ access: "at_legacy_789",
72
+ expires: Date.now() + 3600000,
73
+ tokenUpdatedAt: Date.now(),
74
+ addedAt: Date.now(),
75
+ lastUsed: 0,
76
+ enabled: true,
77
+ rateLimitResetTimes: {},
78
+ consecutiveFailures: 0,
79
+ lastFailureTime: null,
80
+ stats: {
81
+ requests: 0,
82
+ inputTokens: 0,
83
+ outputTokens: 0,
84
+ cacheReadTokens: 0,
85
+ cacheWriteTokens: 0,
86
+ lastReset: Date.now(),
87
+ },
88
+ source: undefined,
89
+ };
90
+
91
+ const ccCredential: CCCredential = {
92
+ accessToken: "at_cc_456",
93
+ refreshToken: "rt_cc_456",
94
+ expiresAt: Date.now() + 3600000,
95
+ source: "cc-keychain",
96
+ label: "Claude Code-credentials:alice@example.com",
97
+ };
98
+
99
+ describe("resolveIdentity", () => {
100
+ it("should resolve OAuth account identity from email", () => {
101
+ const identity = resolveIdentity(oauthAccount);
102
+
103
+ expect(identity).toEqual({
104
+ kind: "oauth",
105
+ email: "alice@example.com",
106
+ });
107
+ });
108
+
109
+ it("should resolve CC account identity from source+label", () => {
110
+ const identity = resolveIdentity(ccAccount);
111
+
112
+ expect(identity).toEqual({
113
+ kind: "cc",
114
+ source: "cc-keychain",
115
+ label: "Claude Code-credentials:alice@example.com",
116
+ });
117
+ });
118
+
119
+ it("should resolve legacy identity from refreshToken when no email or source", () => {
120
+ const identity = resolveIdentity(legacyAccount);
121
+
122
+ expect(identity).toEqual({
123
+ kind: "legacy",
124
+ refreshToken: "rt_legacy_789",
125
+ });
126
+ });
127
+ });
128
+
129
+ describe("exchange helpers", () => {
130
+ it("should resolve CC identity from a Claude Code credential", () => {
131
+ expect(resolveIdentityFromCCCredential(ccCredential)).toEqual({
132
+ kind: "cc",
133
+ source: "cc-keychain",
134
+ label: "Claude Code-credentials:alice@example.com",
135
+ });
136
+ });
137
+
138
+ it("should resolve OAuth exchange results with email as OAuth identity", () => {
139
+ expect(resolveIdentityFromOAuthExchange({ email: "alice@example.com", refresh: "rt_oauth_123" })).toEqual({
140
+ kind: "oauth",
141
+ email: "alice@example.com",
142
+ });
143
+ });
144
+
145
+ it("should resolve OAuth exchange results without email as legacy identity", () => {
146
+ expect(resolveIdentityFromOAuthExchange({ refresh: "rt_legacy_789" })).toEqual({
147
+ kind: "legacy",
148
+ refreshToken: "rt_legacy_789",
149
+ });
150
+ });
151
+ });
152
+
153
+ describe("identitiesMatch", () => {
154
+ it("should match OAuth accounts with same email", () => {
155
+ const identity1: AccountIdentity = { kind: "oauth", email: "alice@example.com" };
156
+ const identity2: AccountIdentity = { kind: "oauth", email: "alice@example.com" };
157
+
158
+ expect(identitiesMatch(identity1, identity2)).toBe(true);
159
+ });
160
+
161
+ it("should match CC accounts with same source+label", () => {
162
+ const identity1: AccountIdentity = { kind: "cc", source: "cc-keychain", label: "label1" };
163
+ const identity2: AccountIdentity = { kind: "cc", source: "cc-keychain", label: "label1" };
164
+
165
+ expect(identitiesMatch(identity1, identity2)).toBe(true);
166
+ });
167
+
168
+ it("should not match different identity types", () => {
169
+ const oauthIdentity: AccountIdentity = { kind: "oauth", email: "alice@example.com" };
170
+ const ccIdentity: AccountIdentity = { kind: "cc", source: "cc-keychain", label: "label" };
171
+ const legacyIdentity: AccountIdentity = { kind: "legacy", refreshToken: "rt" };
172
+
173
+ expect(identitiesMatch(oauthIdentity, ccIdentity)).toBe(false);
174
+ expect(identitiesMatch(oauthIdentity, legacyIdentity)).toBe(false);
175
+ expect(identitiesMatch(ccIdentity, legacyIdentity)).toBe(false);
176
+ });
177
+
178
+ it("should not match different stable key fields", () => {
179
+ expect(
180
+ identitiesMatch({ kind: "oauth", email: "alice@example.com" }, { kind: "oauth", email: "bob@example.com" }),
181
+ ).toBe(false);
182
+ expect(
183
+ identitiesMatch(
184
+ { kind: "cc", source: "cc-keychain", label: "label1" },
185
+ { kind: "cc", source: "cc-keychain", label: "label2" },
186
+ ),
187
+ ).toBe(false);
188
+ });
189
+ });
190
+
191
+ describe("findByIdentity", () => {
192
+ const accounts: ManagedAccount[] = [oauthAccount, ccAccount, legacyAccount];
193
+
194
+ it("should find matching accounts by stable identity", () => {
195
+ expect(findByIdentity(accounts, { kind: "oauth", email: "alice@example.com" })).toBe(oauthAccount);
196
+ expect(
197
+ findByIdentity(accounts, {
198
+ kind: "cc",
199
+ source: "cc-keychain",
200
+ label: "Claude Code-credentials:alice@example.com",
201
+ }),
202
+ ).toBe(ccAccount);
203
+ expect(findByIdentity(accounts, { kind: "legacy", refreshToken: "rt_legacy_789" })).toBe(legacyAccount);
204
+ expect(findByIdentity(accounts, { kind: "oauth", email: "unknown@example.com" })).toBeNull();
205
+ });
206
+
207
+ it("should serialize identities without leaking secrets", () => {
208
+ expect(serializeIdentity({ kind: "oauth", email: "alice@example.com" })).toBe("oauth:alice@example.com");
209
+ expect(serializeIdentity({ kind: "cc", source: "cc-keychain", label: "label1" })).toBe("cc:cc-keychain:label1");
210
+ expect(serializeIdentity({ kind: "legacy", refreshToken: "secret-refresh-token" })).toBe("legacy:redacted");
211
+ });
212
+ });
213
+ });
@@ -0,0 +1,108 @@
1
+ import type { CCCredential } from "./cc-credentials.js";
2
+ import type { ManagedAccount } from "./accounts.js";
3
+ import type { AccountMetadata } from "./storage.js";
4
+
5
+ type CCAccountSource = "cc-keychain" | "cc-file";
6
+
7
+ export type AccountIdentity =
8
+ | { kind: "oauth"; email: string }
9
+ | { kind: "cc"; source: CCAccountSource; label: string }
10
+ | { kind: "legacy"; refreshToken: string };
11
+
12
+ type IdentityAccount = ManagedAccount | AccountMetadata;
13
+
14
+ function isCCAccountSource(source: IdentityAccount["source"]): source is CCAccountSource {
15
+ return source === "cc-keychain" || source === "cc-file";
16
+ }
17
+
18
+ function isAccountIdentity(value: unknown): value is AccountIdentity {
19
+ if (!value || typeof value !== "object") return false;
20
+
21
+ const candidate = value as Record<string, unknown>;
22
+ switch (candidate.kind) {
23
+ case "oauth":
24
+ return typeof candidate.email === "string" && candidate.email.length > 0;
25
+ case "cc":
26
+ return isCCAccountSource(candidate.source as IdentityAccount["source"]) && typeof candidate.label === "string";
27
+ case "legacy":
28
+ return typeof candidate.refreshToken === "string" && candidate.refreshToken.length > 0;
29
+ default:
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export function resolveIdentity(account: IdentityAccount): AccountIdentity {
35
+ if (isAccountIdentity(account.identity)) {
36
+ return account.identity;
37
+ }
38
+
39
+ if (account.source === "oauth" && account.email) {
40
+ return { kind: "oauth", email: account.email };
41
+ }
42
+
43
+ if (isCCAccountSource(account.source) && account.label) {
44
+ return { kind: "cc", source: account.source, label: account.label };
45
+ }
46
+
47
+ return { kind: "legacy", refreshToken: account.refreshToken };
48
+ }
49
+
50
+ export function resolveIdentityFromCCCredential(cred: CCCredential): AccountIdentity {
51
+ return {
52
+ kind: "cc",
53
+ source: cred.source,
54
+ label: cred.label,
55
+ };
56
+ }
57
+
58
+ export function resolveIdentityFromOAuthExchange(result: { email?: string; refresh: string }): AccountIdentity {
59
+ if (result.email) {
60
+ return {
61
+ kind: "oauth",
62
+ email: result.email,
63
+ };
64
+ }
65
+
66
+ return {
67
+ kind: "legacy",
68
+ refreshToken: result.refresh,
69
+ };
70
+ }
71
+
72
+ export function identitiesMatch(a: AccountIdentity, b: AccountIdentity): boolean {
73
+ if (a.kind !== b.kind) return false;
74
+
75
+ switch (a.kind) {
76
+ case "oauth": {
77
+ return a.email === (b as Extract<AccountIdentity, { kind: "oauth" }>).email;
78
+ }
79
+ case "cc": {
80
+ const ccIdentity = b as Extract<AccountIdentity, { kind: "cc" }>;
81
+ return a.source === ccIdentity.source && a.label === ccIdentity.label;
82
+ }
83
+ case "legacy": {
84
+ return a.refreshToken === (b as Extract<AccountIdentity, { kind: "legacy" }>).refreshToken;
85
+ }
86
+ }
87
+ }
88
+
89
+ export function findByIdentity<T extends IdentityAccount>(accounts: T[], id: AccountIdentity): T | null {
90
+ for (const account of accounts) {
91
+ if (identitiesMatch(resolveIdentity(account), id)) {
92
+ return account;
93
+ }
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ export function serializeIdentity(id: AccountIdentity): string {
100
+ switch (id.kind) {
101
+ case "oauth":
102
+ return `oauth:${id.email}`;
103
+ case "cc":
104
+ return `cc:${id.source}:${id.label}`;
105
+ case "legacy":
106
+ return "legacy:redacted";
107
+ }
108
+ }