Skip to content

Commit fb69825

Browse files
fix(spec): datasource read redaction resolves a driver's identity the way its sibling helper does (#21963)
Fixes #21955 Clause-②: no ## What changed The per-driver half of `redactableConfigKeys` in `packages/spec/src/data/datasource-credential-redaction.ts` now resolves a driver's identity through `resolveDriverId`. That is the resolver its sibling helper `passthroughSecretPaths` already uses, and the one the write door's contract lookup (`getDriverConfigSchema`) uses. There is no second identity resolver: the fix replaces one lookup line with the sibling's two-line shape. - Every spelling the write door judges against a builtin driver's contract is now redacted as that driver. The still-writable credential key is withheld on the datasource admin item read and on the metadata read under every accepted spelling, and `redactedConfigKeys` names it. - A crafted driver id now has a defined answer. It resolves to no builtin, so it answers as a driver the platform ships no contract for: the canonical credential spellings and the former aliases are withheld, and the read succeeds. It is never served and never throws. - No `service-datasource` source line moves. `getDatasource()`, `restoreRedactedConfig` and the credential migration all reach the fix through the spec export (measured below). ## Measured reach, before the fix (BASE `76fec88b16`, spec dist built from BASE) A unit-level harness of `service-datasource`'s `DatasourceAdminService`, with an in-memory record store, ran every spelling the alias table and the resolver's folding accept. The harness is scratch and is not committed. - **Write door:** every accepted spelling was accepted by `DatasourceSchema.safeParse` and by `createDatasource`, and was judged against the builtin contract (`validateDriverConfig(...).known === true`). - **Admin item read** (`getDatasource`, behind `GET /api/v1/datasources/:name`): for each accepted spelling other than the canonical one, the still-writable credential key came back in `config` and `redactedConfigKeys` was `[]`. The canonical spelling withheld it. - **Metadata read** (the built-in `datasource` redactor, used by the `/meta` item and list reads): the same answer as the item read for every spelling. - **Admin list read** (`listDatasources`): it serves no `config` at all, by its contract (`DatasourceSummary`), so it carries no exposure for any spelling. - **Crafted driver id:** the write door accepted the row as an unknown plugin driver. Then `getDatasource`, the metadata redactor, `restoreRedactedConfig` and `planCredentialMigration` each threw an unhandled `TypeError`. So the item read failed, an edit with a config patch failed, and the migration planner failed. `listDatasources` was unaffected. After the fix, the same harness reads every accepted spelling as withheld and named, and every crafted id as read without a throw. Exposure stays admin-only: the datasource read doors require the admin capability. ## Pins - `packages/spec/src/data/datasource-credential-redaction.test.ts`, a new block. Every spelling is derived from `DRIVER_ID_ALIASES` plus the resolver's own folding (as-is, upper, capitalised, padded), never listed by hand. - Premise: each accepted spelling resolves to its builtin and is judged by the write door against that contract. - Each accepted spelling answers its canonical driver's redactable set, byte-equal. - The still-writable credential key is withheld under each accepted spelling and named as withheld. It has a population floor, so a derivation that finds nothing fails. - Control: a canonical spelling withholds it exactly as before. - A crafted driver id answers as a driver with no shipped contract, with credentials withheld and no throw. - Lockstep in `service-datasource` (declared on #6021): - `datasource-config-redaction.test.ts`: the admin item read withholds the key under each accepted spelling. An untouched Save restores the stored value under each accepted spelling, so redaction never turns a save into deletion. A crafted id is read with its credentials withheld, and an untouched Save keeps them. - `datasource-credential-migration.test.ts`: the planner reads the same list under each accepted spelling. A bindable row names the still-writable key as residue, byte-equal to the canonical spelling's plan. A row holding only that key is refused with it named. ### Reverse verification (fix committed first, then reverted with `scripts/ablation-replace.mjs`, then restored) - **Spec pin.** The subject is imported relatively from `src`, so no build leg applies. Predicted three red, with the premise and the control staying green. Observed `Tests 3 failed | 41 passed (44)`: the byte-equal set, the withheld key, and the crafted id. Restore proof: blob `b543590328ef` equals HEAD, and `git diff HEAD` is empty. - **`service-datasource` pins.** These resolve `@objectstack/spec/data` through `dist`, so each leg rebuilt spec. - Mutate leg: `ablation-dist-preflight` found the reverted line in 8 built files. Predicted five red. Observed `Tests 5 failed | 61 passed (66)`. - Restore leg: rebuild, then `--absent` reported the marker absent from all 228 built files and the tree clean against HEAD. Then `Tests 66 passed (66)`. ## Clause-② (measured) `no`. The fix narrows what the read path serves, not what `@objectstack/spec` accepts. - No schema file changed. `DatasourceSchema.safeParse` gave the same answer before and after for every spelling and every crafted id in the harness. - `check:api-surface`: `public API surface + factory signatures unchanged`. - `check:authorable-surface` and `check:export-origins` are green. The changeset is `@objectstack/spec` `patch`. `service-datasource` takes none, because no source line in it moved. ## Tests and gates (at HEAD `cbea11408e`) - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: `Test Files 619 passed (619)`, `Tests 18476 passed | 1 todo`. - `pnpm --filter @objectstack/service-datasource exec vitest run --maxWorkers=2`: `Test Files 41 passed (41)`, `Tests 748 passed (748)`. - `@objectstack/metadata-protocol`, which reaches the datasource redactor: `protocol.metadata-redaction.test.ts` and `stored-metadata-body-family.pin.test.ts` gave `Tests 56 passed (56)`. - `pnpm --filter @objectstack/service-datasource typecheck` and `pnpm --filter @objectstack/spec typecheck` (including `check:test-typecheck`) both exit 0. `tsc --listFiles` confirms that both edited `service-datasource` test files are in the program. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 87 derived, 86 run with exit 0, 1 NOT MEASURED, 0 unrun (`--ran` reconciliation). - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET (exit 3). It reads every package's built `dist/`, and this worktree built only the closure it needed. Declared to CI. - Lint is a proven narrowing, not the repo-wide run. - ① The checked population is the four touched `.ts` files. Each resolves to a config under `eslint --print-config`, so none is ignored. - ② `eslint --no-inline-config --format json` linted 4 files with 0 errors and 0 warnings. - ③ Invariance: `eslint.config.mjs` enables no type-aware linting (zero `project` / `projectService` entries, and none in the resolved `parserOptions`). So this diff cannot move any untouched file's verdict. - The repo-wide `pnpm lint` stays CI's. - `origin/main` was re-fetched before this PR opened (`80f9f7e6ba`). Nothing that landed since BASE touches these files, so there was no merge. ## Acceptance notes - `STILL_WRITABLE_CREDENTIAL_KEYS` and `PASSTHROUGH_SECRET_PATHS` are typed as string-keyed records. Typing them by the builtin id union would make a raw-string index a compile error, closing this defect class at `tsc`. That is not done here, to keep the fix to the sibling's shape. Carrier: the at-tier contract review of this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 787104b commit fb69825

5 files changed

Lines changed: 236 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The datasource read redaction resolves a driver's identity the way its sibling helper does. The per-driver half of `redactableConfigKeys` now looks a driver up through `resolveDriverId`, the resolver `passthroughSecretPaths` and the write door's contract lookup already use. So every spelling the write door accepts as a builtin driver is redacted as that driver.
6+
7+
Clause-②: no
8+
9+
- The still-writable credential key is withheld on the datasource admin read (`GET /api/v1/datasources/:name`) and on the metadata read under every accepted spelling of its driver, and `redactedConfigKeys` names it.
10+
- A crafted driver id that made the read throw now answers as a driver the platform ships no contract for: the canonical credential spellings and the former aliases are withheld, and the read succeeds.
11+
- `restoreRedactedConfig` and the credential migration read the same list, so an untouched Save still restores the stored value under every accepted spelling, and the migration names the still-writable key as residue there too.
12+
- Unchanged: what the write door accepts, every export and its type, and the answer for a canonical spelling.

‎packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
*/
4040

4141
import { describe, it, expect } from 'vitest';
42-
import { validateDriverConfig, getDriverConfigSchema, BUILTIN_DRIVER_IDS } from '@objectstack/spec/data';
42+
import { validateDriverConfig, getDriverConfigSchema, BUILTIN_DRIVER_IDS, DRIVER_ID_ALIASES } from '@objectstack/spec/data';
4343
import {
4444
redactDatasourceConfig,
4545
restoreRedactedConfig,
@@ -603,3 +603,77 @@ describe('nested credential positions OFF the passthrough table — the class co
603603
expect((records[0].config!.replication as any).url).toBe('postgresql://svc@other-replica/db');
604604
});
605605
});
606+
607+
// Lockstep with the spec fix that makes the per-driver half of
608+
// `redactableConfigKeys` resolve a driver's identity through `resolveDriverId`
609+
// (#21955). This door reaches it with no source change: `getDatasource()` and
610+
// `restoreRedactedConfig` both go through `redactDatasourceConfig`. Spellings
611+
// are DERIVED from the alias table and the resolver's own folding, never
612+
// listed by hand.
613+
describe('every accepted spelling of a builtin driver, at the service door', () => {
614+
const variantsOf = (spelling: string): string[] => [
615+
spelling,
616+
spelling.toUpperCase(),
617+
`${spelling.charAt(0).toUpperCase()}${spelling.slice(1)}`,
618+
` ${spelling} `,
619+
];
620+
/** The driver-specific keys beyond the contract derivation and the contract-less fallback. */
621+
const stillWritableOf = (id: string): string[] => {
622+
const fallback = new Set(redactableConfigKeys('a-driver-with-no-contract'));
623+
const refused = new Set(refusedCredentialKeys(id));
624+
return redactableConfigKeys(id).filter((key) => !fallback.has(key) && !refused.has(key));
625+
};
626+
const CASES = Object.entries(DRIVER_ID_ALIASES).flatMap(([spelling, id]) =>
627+
variantsOf(spelling).flatMap((variant) => stillWritableOf(id).map((key) => ({ variant, key }))),
628+
);
629+
const spelledRow = (variant: string, key: string): StoredDatasource => ({
630+
name: 'spelled_ds',
631+
driver: variant,
632+
origin: 'runtime',
633+
config: { url: 'file:./fixture.db', [key]: 'fixture-secret' },
634+
});
635+
636+
it('getDatasource() withholds the still-writable credential key under each accepted spelling, and names it', async () => {
637+
// Population floor: the class is not empty today, so a derivation that
638+
// silently found nothing fails here instead of passing over zero rows.
639+
expect(CASES.length).toBeGreaterThan(0);
640+
for (const { variant, key } of CASES) {
641+
const { service } = makeService([spelledRow(variant, key)]);
642+
const ds = await service.getDatasource('spelled_ds');
643+
expect(ds!.config, JSON.stringify(variant)).toEqual({ url: 'file:./fixture.db' });
644+
expect(ds!.redactedConfigKeys, JSON.stringify(variant)).toEqual([key]);
645+
}
646+
});
647+
648+
it('an untouched Save restores the stored value under each accepted spelling, so redaction never turns a save into deletion', async () => {
649+
for (const { variant, key } of CASES) {
650+
const { service, records } = makeService([spelledRow(variant, key)]);
651+
// Exactly what the edit form does: GET, then PATCH the config it was given.
652+
const read = await service.getDatasource('spelled_ds');
653+
expect(read!.config, JSON.stringify(variant)).not.toHaveProperty(key);
654+
await service.updateDatasource('spelled_ds', { config: read!.config, label: 'Renamed' });
655+
expect(records[0].label).toBe('Renamed');
656+
expect(records[0].config, JSON.stringify(variant)).toEqual({ url: 'file:./fixture.db', [key]: 'fixture-secret' });
657+
}
658+
});
659+
660+
it('a crafted driver id is read with its credentials withheld, and an untouched Save keeps them', async () => {
661+
// Every name an object literal inherits: none is a builtin spelling, so
662+
// each answers as a driver with no shipped contract, never with a throw.
663+
const crafted = Object.getOwnPropertyNames(Object.prototype);
664+
expect(crafted.length).toBeGreaterThan(0);
665+
for (const name of crafted) {
666+
const { service, records } = makeService([{
667+
name: 'crafted_ds',
668+
driver: name,
669+
origin: 'runtime',
670+
config: { host: 'fixture-host', password: 'fixture-secret' },
671+
}]);
672+
const ds = await service.getDatasource('crafted_ds');
673+
expect(ds!.config, name).toEqual({ host: 'fixture-host' });
674+
expect(ds!.redactedConfigKeys, name).toEqual(['password']);
675+
await service.updateDatasource('crafted_ds', { config: ds!.config });
676+
expect(records[0].config, name).toEqual({ host: 'fixture-host', password: 'fixture-secret' });
677+
}
678+
});
679+
});

‎packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import { describe, it, expect } from 'vitest';
19-
import { BUILTIN_DRIVER_IDS, refusedCredentialKeys } from '@objectstack/spec/data';
19+
import { BUILTIN_DRIVER_IDS, DRIVER_ID_ALIASES, redactableConfigKeys, refusedCredentialKeys } from '@objectstack/spec/data';
2020
import { planCredentialMigration, urlCredentialKeys } from '../datasource-credential-migration.js';
2121
import type { StoredDatasource } from '../datasource-admin-service.js';
2222

@@ -289,3 +289,53 @@ describe('#9040 — the passthrough spelling at the planner door', () => {
289289
expect(plan).toEqual({ action: 'none', status: 'nothing-to-migrate', remaining: [] });
290290
});
291291
});
292+
293+
// Lockstep with the spec fix that makes the per-driver half of
294+
// `redactableConfigKeys` resolve a driver's identity through `resolveDriverId`
295+
// (#21955): the planner's unbindable residue is derived from that same list,
296+
// so a row whose driver is written in any accepted spelling must be planned
297+
// exactly as the canonical spelling's row is. Spellings are DERIVED from the
298+
// alias table and the resolver's own folding, never listed by hand.
299+
describe('planCredentialMigration reads the same list under every accepted spelling of a builtin driver', () => {
300+
const variantsOf = (spelling: string): string[] => [
301+
spelling,
302+
spelling.toUpperCase(),
303+
`${spelling.charAt(0).toUpperCase()}${spelling.slice(1)}`,
304+
` ${spelling} `,
305+
];
306+
/** The driver-specific keys beyond the contract derivation and the contract-less fallback. */
307+
const stillWritableOf = (id: string): string[] => {
308+
const fallback = new Set(redactableConfigKeys('a-driver-with-no-contract'));
309+
const refused = new Set(refusedCredentialKeys(id));
310+
return redactableConfigKeys(id).filter((key) => !fallback.has(key) && !refused.has(key));
311+
};
312+
const CASES = Object.entries(DRIVER_ID_ALIASES).flatMap(([spelling, id]) =>
313+
variantsOf(spelling).flatMap((variant) => stillWritableOf(id).map((key) => ({ variant, id, key }))),
314+
);
315+
316+
it('a bindable row names the still-writable credential key as its residue, byte-equal to the canonical spelling', () => {
317+
// Population floor: the class is not empty today.
318+
expect(CASES.length).toBeGreaterThan(0);
319+
for (const { variant, id, key } of CASES) {
320+
const [slot] = refusedCredentialKeys(id);
321+
expect(slot, id).toBeDefined();
322+
const config = { url: 'file:./fixture.db', [slot as string]: 'fixture-token', [key]: 'fixture-secret' };
323+
const canonical = planCredentialMigration(row({ driver: id, config }));
324+
expect(canonical).toEqual({ action: 'bind', key: slot, value: 'fixture-token', remaining: [key] });
325+
expect(planCredentialMigration(row({ driver: variant, config })), JSON.stringify(variant)).toEqual(canonical);
326+
}
327+
});
328+
329+
it('a row holding only the still-writable credential key is refused with that key named, as under the canonical spelling', () => {
330+
for (const { variant, id, key } of CASES) {
331+
const config = { url: 'file:./fixture.db', [key]: 'fixture-secret' };
332+
const canonical = planCredentialMigration(row({ driver: id, config }));
333+
const spelled = planCredentialMigration(row({ driver: variant, config }));
334+
expect(canonical.action).toBe('refuse');
335+
expect(spelled.action, JSON.stringify(variant)).toBe('refuse');
336+
if (spelled.action !== 'refuse') throw new Error('unreachable');
337+
expect(spelled.reason, JSON.stringify(variant)).toContain(`config.${key}`);
338+
expect(spelled.remedy.length).toBeGreaterThan(0);
339+
}
340+
});
341+
});

‎packages/spec/src/data/datasource-credential-redaction.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@ import {
3232
BUILTIN_DRIVER_IDS,
3333
CREDENTIAL_KEY_SPELLINGS,
3434
CREDENTIAL_URL_QUERY_PARAM_NAMES,
35+
DRIVER_ID_ALIASES,
3536
getDriverConfigSchema,
37+
resolveDriverId,
3638
urlCredentialQueryParams,
3739
urlUserinfoPassword,
3840
urlUserinfoUsername,
41+
validateDriverConfig,
3942
} from './driver/index';
4043
import {
4144
passthroughSecretPaths,
@@ -617,3 +620,91 @@ describe('refusedCredentialPaths — the schema derivation, walked at depth', ()
617620
}
618621
});
619622
});
623+
624+
// The per-driver half of `redactableConfigKeys` resolves a driver's identity
625+
// through `resolveDriverId`, the resolver its sibling helper
626+
// `passthroughSecretPaths` and the write door's contract lookup already use
627+
// (#21955). Every spelling below is DERIVED from the alias table and the
628+
// resolver's own folding (trim + lower-case), never listed by hand, so a
629+
// spelling added to the vocabulary is pinned the day it lands.
630+
describe('driver identity: every accepted spelling of a builtin driver is redacted as that driver', () => {
631+
/** The spellings the resolver folds onto one alias: as-is, upper, capitalised, padded. */
632+
const variantsOf = (spelling: string): string[] => [
633+
spelling,
634+
spelling.toUpperCase(),
635+
`${spelling.charAt(0).toUpperCase()}${spelling.slice(1)}`,
636+
` ${spelling} `,
637+
];
638+
const ACCEPTED = Object.entries(DRIVER_ID_ALIASES).flatMap(([spelling, id]) =>
639+
variantsOf(spelling).map((variant) => [variant, id] as const),
640+
);
641+
/** The driver-specific keys beyond the contract derivation and the contract-less fallback. */
642+
const stillWritableOf = (id: string): string[] => {
643+
const fallback = new Set(redactableConfigKeys('a-driver-with-no-contract'));
644+
const refused = new Set(refusedCredentialKeys(id));
645+
return redactableConfigKeys(id).filter((key) => !fallback.has(key) && !refused.has(key));
646+
};
647+
648+
it('each accepted spelling is judged by the write door against its builtin driver contract', () => {
649+
// The premise of the pins below: the write door does not treat these as
650+
// unknown plugin drivers, so the read door must not either.
651+
expect(ACCEPTED.length).toBe(Object.keys(DRIVER_ID_ALIASES).length * 4);
652+
for (const [variant, id] of ACCEPTED) {
653+
expect(resolveDriverId(variant), JSON.stringify(variant)).toBe(id);
654+
expect(validateDriverConfig(variant, {}).known, JSON.stringify(variant)).toBe(true);
655+
}
656+
});
657+
658+
it('each accepted spelling answers its canonical driver\'s redactable set, byte-equal', () => {
659+
for (const [variant, id] of ACCEPTED) {
660+
expect(redactableConfigKeys(variant), JSON.stringify(variant)).toEqual(redactableConfigKeys(id));
661+
}
662+
});
663+
664+
it('the still-writable credential key is withheld under each accepted spelling and named as withheld', () => {
665+
let judged = 0;
666+
for (const [variant, id] of ACCEPTED) {
667+
for (const key of stillWritableOf(id)) {
668+
const stored = { url: 'file:./fixture.db', [key]: 'fixture-secret' };
669+
const { config, redactedKeys } = redactDatasourceConfig(variant, stored);
670+
expect(config, JSON.stringify(variant)).toEqual({ url: 'file:./fixture.db' });
671+
expect(redactedKeys, JSON.stringify(variant)).toEqual([key]);
672+
judged += 1;
673+
}
674+
}
675+
// Population floor: the still-writable class is not empty today, so a
676+
// derivation that silently found nothing would fail here instead of
677+
// passing over zero spellings.
678+
expect(judged).toBeGreaterThan(0);
679+
});
680+
681+
it('control: a canonical spelling withholds the still-writable credential key exactly as before', () => {
682+
let judged = 0;
683+
for (const id of BUILTIN_DRIVER_IDS as readonly string[]) {
684+
for (const key of stillWritableOf(id)) {
685+
const { config, redactedKeys } = redactDatasourceConfig(id, { url: 'file:./fixture.db', [key]: 'fixture-secret' });
686+
expect(config, id).toEqual({ url: 'file:./fixture.db' });
687+
expect(redactedKeys, id).toEqual([key]);
688+
judged += 1;
689+
}
690+
}
691+
expect(judged).toBeGreaterThan(0);
692+
});
693+
694+
it('a crafted driver id answers as a driver with no shipped contract: credentials withheld, never a throw', () => {
695+
// Every name an object literal inherits. None is a builtin spelling, so
696+
// each must resolve to no driver and take the contract-less fallback, not
697+
// index an inherited member of the still-writable table.
698+
const crafted = Object.getOwnPropertyNames(Object.prototype);
699+
expect(crafted.length).toBeGreaterThan(0);
700+
const fallback = redactableConfigKeys('a-driver-with-no-contract');
701+
for (const name of crafted) {
702+
expect(resolveDriverId(name), name).toBeUndefined();
703+
expect(() => redactableConfigKeys(name), name).not.toThrow();
704+
expect(redactableConfigKeys(name), name).toEqual(fallback);
705+
const { config, redactedKeys } = redactDatasourceConfig(name, { host: 'fixture-host', password: 'fixture-secret' });
706+
expect(config, name).toEqual({ host: 'fixture-host' });
707+
expect(redactedKeys, name).toEqual(['password']);
708+
}
709+
});
710+
});

‎packages/spec/src/data/datasource-credential-redaction.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ import { getDriverConfigSchema, resolveDriverId } from './driver/config-registry
127127
* left it writable because the datasource secret binder injects exactly one
128128
* secret slot and `external.credentialsRef` resolution cannot target a second
129129
* one; giving it a slot is #8081 scope item 4 and is NOT decided here.
130+
*
131+
* Keyed by CANONICAL driver id and looked up through {@link resolveDriverId},
132+
* exactly like {@link PASSTHROUGH_SECRET_PATHS} below: every spelling the
133+
* write door judges against a driver's contract is redacted as that driver,
134+
* and an id that resolves to no builtin indexes nothing (#21955).
130135
*/
131136
const STILL_WRITABLE_CREDENTIAL_KEYS: Record<string, readonly string[]> = {
132137
turso: ['encryptionKey'],
@@ -370,7 +375,8 @@ export function refusedCredentialKeys(driver: unknown): string[] {
370375
export function redactableConfigKeys(driver: unknown): string[] {
371376
const derived = refusedCredentialKeys(driver);
372377
const canonical = derived.length > 0 ? derived : [...CANONICAL_CREDENTIAL_KEYS];
373-
const stillWritable = typeof driver === 'string' ? (STILL_WRITABLE_CREDENTIAL_KEYS[driver] ?? []) : [];
378+
const id = resolveDriverId(driver);
379+
const stillWritable = id ? (STILL_WRITABLE_CREDENTIAL_KEYS[id] ?? []) : [];
374380
return [...new Set([...canonical, ...FORMER_CREDENTIAL_ALIASES, ...stillWritable])];
375381
}
376382

0 commit comments

Comments
 (0)