FN-8651: add named provider credential instances
Add provider-instance identity and auth.json storage for multiple credentials per provider. - Export provider-instance key parsing, validation, and reserved metadata helpers. - Resolve, list, mutate, and select named credential instances atomically in auth storage. - Preserve legacy bare-key hydration and document the auth.json instance contract. - Cover provider instance parsing, storage behavior, and concurrent writes. Files changed: .changeset/fn-8651-provider-instances.md | 7 + docs/secrets.md | 8 + .../src/__tests__/oauth-credential-interop.test.ts | 16 ++ packages/core/src/index.ts | 16 ++ packages/core/src/oauth-credential-interop.ts | 15 +- packages/core/src/provider-instance.test.ts | 24 +++ packages/core/src/provider-instance.ts | 65 +++++++ .../src/__tests__/auth-storage-concurrency.test.ts | 13 ++ .../src/__tests__/auth-storage-instances.test.ts | 75 ++++++++ packages/engine/src/auth-storage.ts | 214 +++++++++++++-------- 10 files changed, 367 insertions(+), 86 deletions(-) Fusion-Task-Id: FN-8651 Fusion-Task-Lineage: cfabe496-60f9-4166-a09f-87ea2028cfd9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8651-provider-instances.md
Normal file
7
.changeset/fn-8651-provider-instances.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Support multiple named credential instances per AI provider.
|
||||
category: feature
|
||||
dev: Adds core provider-instance exports and provider[instance] auth.json storage keys.
|
||||
@@ -204,6 +204,14 @@ Track follow-up: **FN-5031** (missing `packages/core/src/__tests__/secrets-env.t
|
||||
|
||||
**Plaintext prohibition:** audit payload metadata must never include plaintext, decrypted values, ciphertext, or nonce fields. Use `assertNoSecretPlaintext(...)` as the canonical enforcement helper before emitting secret audit events.
|
||||
|
||||
## Provider credential instances (`auth.json`)
|
||||
|
||||
Fusion keeps provider credentials in `~/.fusion/agent/auth.json`. A legacy bare key such as `"openrouter"` is the default instance; a named key is `"openrouter[work]"`. Provider and instance ids are non-empty, no-whitespace, no-bracket strings up to 64 characters. Existing bare entries remain readable and are not rewritten merely by reading them.
|
||||
|
||||
`__fusionDefaultInstances` is reserved metadata, never a credential. Its per-provider pointer wins only when it names an existing valid credential; resolution otherwise falls back to the bare key, then the lexicographically first named instance, then no instance (`getDefaultInstance` returns `undefined`). The metadata is untrusted: malformed records or stale entries are ignored without a read-time repair. Deleting a pointed-to instance removes that pointer in the same locked write.
|
||||
|
||||
Legacy string APIs parse this grammar rather than accepting raw keys. `set("provider", credential)` updates the resolved default, or creates the bare key when none exists; `remove`, `logout`, `removeInstance`, and `modify` are no-ops when no instance exists. `setDefaultInstance` never creates a credential and rejects a missing target. All mutators, including deletes, reject the reserved metadata key. `list()` and `getAll()` remain logical-provider keyed (one resolved credential per provider); use `listInstances()` for individual instances. On-disk records are untrusted and values are type-filtered as credentials, so metadata and malformed values are never returned. External Claude/Codex hydration intentionally consumes bare keys only and ignores named instance keys.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Backups: preserve PostgreSQL project/central schemas and the master-key material/provider source used by the deployment. Retain legacy SQLite backups only as controlled migration/recovery inputs; they are not runtime authority.
|
||||
|
||||
@@ -143,6 +143,22 @@ describe("oauth credential interop", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps only bare provider credentials from Fusion multi-instance auth files", () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-"));
|
||||
try {
|
||||
const authPath = join(tempDir, "auth.json");
|
||||
writeFileSync(authPath, JSON.stringify({
|
||||
provider: { type: "api_key", key: "bare" },
|
||||
"provider[work]": { type: "api_key", key: "named" },
|
||||
"provider[": { type: "api_key", key: "malformed" },
|
||||
__fusionDefaultInstances: { provider: "work" },
|
||||
}));
|
||||
expect(readStoredCredentialsFromAuthFile(authPath)).toEqual({ provider: { type: "api_key", key: "bare" } });
|
||||
writeFileSync(authPath, JSON.stringify({ "provider[work]": { type: "api_key", key: "named" } }));
|
||||
expect(readStoredCredentialsFromAuthFile(authPath)).toEqual({});
|
||||
} finally { rmSync(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("gracefully ignores malformed auth files", () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-"));
|
||||
|
||||
|
||||
@@ -2389,8 +2389,24 @@ export {
|
||||
getCodexCliAuthPath,
|
||||
readStoredCredentialsFromAuthFile,
|
||||
shouldHydrateStoredCredential,
|
||||
isStoredAuthCredential,
|
||||
} from "./oauth-credential-interop.js";
|
||||
export type { StoredAuthCredential } from "./oauth-credential-interop.js";
|
||||
export {
|
||||
ANTHROPIC_SUBSCRIPTION_PROVIDER_ID,
|
||||
DEFAULT_PROVIDER_INSTANCE_ID,
|
||||
PROVIDER_INSTANCE_ID_MAX_LENGTH,
|
||||
RESERVED_AUTH_STORAGE_KEYS,
|
||||
assertValidProviderId,
|
||||
assertValidProviderInstanceId,
|
||||
formatProviderInstanceKey,
|
||||
isDefaultProviderInstance,
|
||||
isReservedAuthStorageKey,
|
||||
isValidProviderId,
|
||||
isValidProviderInstanceId,
|
||||
parseProviderInstanceKey,
|
||||
} from "./provider-instance.js";
|
||||
export type { ProviderInstanceRef } from "./provider-instance.js";
|
||||
|
||||
// ── Error helpers ─────────────────────────────────────────
|
||||
export { getErrorMessage } from "./error-message.js";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { isDefaultProviderInstance, parseProviderInstanceKey } from "./provider-instance.js";
|
||||
|
||||
export type StoredAuthCredential = {
|
||||
type?: string;
|
||||
@@ -82,7 +83,7 @@ function getLastRefreshFallbackExpiryMs(lastRefresh: unknown): number | undefine
|
||||
return parsed + CODEX_REFRESH_FALLBACK_WINDOW_MS;
|
||||
}
|
||||
|
||||
function isStoredAuthCredential(value: unknown): value is StoredAuthCredential {
|
||||
export function isStoredAuthCredential(value: unknown): value is StoredAuthCredential {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
@@ -275,11 +276,17 @@ export function readStoredCredentialsFromAuthFile(authPath: string): Record<stri
|
||||
}
|
||||
|
||||
const credentials: Record<string, StoredAuthCredential> = {};
|
||||
for (const [providerId, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (!isStoredAuthCredential(value)) {
|
||||
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
const ref = parseProviderInstanceKey(key);
|
||||
if (!ref || !isDefaultProviderInstance(ref.instanceId) || !isStoredAuthCredential(value)) {
|
||||
continue;
|
||||
}
|
||||
credentials[providerId] = value;
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-01-04:36:
|
||||
External Claude/Codex hydration accepts only bare provider keys. A named instance is
|
||||
Fusion-internal selection state and must not masquerade as a provider id downstream.
|
||||
*/
|
||||
credentials[ref.providerId] = value;
|
||||
}
|
||||
return credentials;
|
||||
} catch {
|
||||
|
||||
24
packages/core/src/provider-instance.test.ts
Normal file
24
packages/core/src/provider-instance.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_PROVIDER_INSTANCE_ID,
|
||||
PROVIDER_INSTANCE_ID_MAX_LENGTH,
|
||||
formatProviderInstanceKey,
|
||||
isReservedAuthStorageKey,
|
||||
parseProviderInstanceKey,
|
||||
} from "./provider-instance.js";
|
||||
|
||||
describe("provider instance keys", () => {
|
||||
it("round trips bare defaults and named instances", () => {
|
||||
expect(parseProviderInstanceKey(formatProviderInstanceKey({ providerId: "anthropic", instanceId: DEFAULT_PROVIDER_INSTANCE_ID }))).toEqual({ providerId: "anthropic", instanceId: DEFAULT_PROVIDER_INSTANCE_ID });
|
||||
expect(parseProviderInstanceKey(formatProviderInstanceKey({ providerId: "anthropic", instanceId: "work" }))).toEqual({ providerId: "anthropic", instanceId: "work" });
|
||||
});
|
||||
|
||||
it("rejects non-invertible provider and instance names", () => {
|
||||
for (const value of ["", " ", "a b", "a[b", "a]b", "a".repeat(PROVIDER_INSTANCE_ID_MAX_LENGTH + 1)]) {
|
||||
expect(() => formatProviderInstanceKey({ providerId: value, instanceId: "work" })).toThrow();
|
||||
expect(() => formatProviderInstanceKey({ providerId: "provider", instanceId: value })).toThrow();
|
||||
}
|
||||
for (const key of ["p[", "p]", "p[]", "p[a[b]", "p[default]", ""]) expect(parseProviderInstanceKey(key)).toBeUndefined();
|
||||
expect(isReservedAuthStorageKey("__fusionDefaultInstances")).toBe(true);
|
||||
});
|
||||
});
|
||||
65
packages/core/src/provider-instance.ts
Normal file
65
packages/core/src/provider-instance.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export const DEFAULT_PROVIDER_INSTANCE_ID = "default";
|
||||
export const PROVIDER_INSTANCE_ID_MAX_LENGTH = 64;
|
||||
export const RESERVED_AUTH_STORAGE_KEYS = ["__fusionDefaultInstances"] as const;
|
||||
export const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
|
||||
|
||||
export type ProviderInstanceRef = { providerId: string; instanceId: string };
|
||||
|
||||
export function isReservedAuthStorageKey(key: string): boolean {
|
||||
return (RESERVED_AUTH_STORAGE_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
export function isDefaultProviderInstance(instanceId: string): boolean {
|
||||
return instanceId === DEFAULT_PROVIDER_INSTANCE_ID;
|
||||
}
|
||||
|
||||
export function isValidProviderInstanceId(instanceId: unknown): instanceId is string {
|
||||
return typeof instanceId === "string"
|
||||
&& instanceId.length > 0
|
||||
&& instanceId.length <= PROVIDER_INSTANCE_ID_MAX_LENGTH
|
||||
&& !/\s/.test(instanceId)
|
||||
&& !instanceId.includes("[")
|
||||
&& !instanceId.includes("]");
|
||||
}
|
||||
|
||||
export function assertValidProviderInstanceId(instanceId: unknown): asserts instanceId is string {
|
||||
if (!isValidProviderInstanceId(instanceId)) {
|
||||
throw new Error(`Invalid provider instance id: ${String(instanceId)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidProviderId(providerId: unknown): providerId is string {
|
||||
return isValidProviderInstanceId(providerId) && !isReservedAuthStorageKey(providerId);
|
||||
}
|
||||
|
||||
export function assertValidProviderId(providerId: unknown): asserts providerId is string {
|
||||
if (!isValidProviderId(providerId)) {
|
||||
throw new Error(`Invalid or reserved provider id: ${String(providerId)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-01-04:36:
|
||||
FN-8651 keeps auth.json compatible by spelling the default instance as a bare provider id and named instances as provider[instance]. Validation makes format and parse exact inverses so legacy APIs cannot write ambiguous raw keys. The reserved defaults record is never a provider because deleting or overwriting metadata would strand credentials.
|
||||
*/
|
||||
export function formatProviderInstanceKey(ref: ProviderInstanceRef): string {
|
||||
assertValidProviderId(ref.providerId);
|
||||
assertValidProviderInstanceId(ref.instanceId);
|
||||
return isDefaultProviderInstance(ref.instanceId) ? ref.providerId : `${ref.providerId}[${ref.instanceId}]`;
|
||||
}
|
||||
|
||||
export function parseProviderInstanceKey(key: string): ProviderInstanceRef | undefined {
|
||||
if (typeof key !== "string" || key.length === 0) return undefined;
|
||||
const open = key.indexOf("[");
|
||||
const close = key.indexOf("]");
|
||||
const isBare = open === -1 && close === -1;
|
||||
const isNamed = open > 0 && close === key.length - 1 && key.indexOf("[", open + 1) === -1 && key.indexOf("]", close + 1) === -1;
|
||||
if (!isBare && !isNamed) return undefined;
|
||||
const providerId = isBare ? key : key.slice(0, open);
|
||||
const instanceId = isBare ? DEFAULT_PROVIDER_INSTANCE_ID : key.slice(open + 1, -1);
|
||||
return isValidProviderId(providerId)
|
||||
&& isValidProviderInstanceId(instanceId)
|
||||
&& (!isNamed || !isDefaultProviderInstance(instanceId))
|
||||
? { providerId, instanceId }
|
||||
: undefined;
|
||||
}
|
||||
@@ -280,6 +280,19 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
|
||||
expect(await instanceC.getApiKey("anthropic")).not.toBe("refreshed-from-stale-old-token");
|
||||
});
|
||||
|
||||
it("merges concurrent named and bare instance writes for one provider", async () => {
|
||||
const instanceA = createFusionAuthStorage();
|
||||
const instanceB = createFusionAuthStorage();
|
||||
await Promise.all([
|
||||
instanceA.setInstance({ providerId: "openrouter", instanceId: "work" }, { type: "api_key", key: "work-key" }),
|
||||
instanceB.setInstance({ providerId: "openrouter", instanceId: "backup" }, { type: "api_key", key: "backup-key" }),
|
||||
]);
|
||||
const instanceC = createFusionAuthStorage();
|
||||
expect(instanceC.getInstance({ providerId: "openrouter", instanceId: "work" })).toMatchObject({ key: "work-key" });
|
||||
expect(instanceC.getInstance({ providerId: "openrouter", instanceId: "backup" })).toMatchObject({ key: "backup-key" });
|
||||
expect((readAuthFile(homeDir)["openrouter[work]"])).toBeDefined();
|
||||
});
|
||||
|
||||
it("single-flights a rotating Anthropic refresh token across auth storage instances", async () => {
|
||||
const now = Date.now();
|
||||
const instanceA = createFusionAuthStorage();
|
||||
|
||||
75
packages/engine/src/__tests__/auth-storage-instances.test.ts
Normal file
75
packages/engine/src/__tests__/auth-storage-instances.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createFusionAuthStorage, getFusionAuthPath } from "../auth-storage.js";
|
||||
|
||||
const credential = (key: string) => ({ type: "api_key", key });
|
||||
|
||||
describe("instance-aware Fusion auth storage", () => {
|
||||
const originalHome = process.env.HOME;
|
||||
let home: string;
|
||||
beforeEach(async () => { home = await mkdtemp(join(tmpdir(), "fusion-auth-instances-")); process.env.HOME = home; });
|
||||
afterEach(async () => { await rm(home, { recursive: true, force: true }); if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; });
|
||||
const authPath = () => getFusionAuthPath(home);
|
||||
const seed = (data: Record<string, unknown>) => { mkdirSync(join(home, ".fusion", "agent"), { recursive: true }); writeFileSync(authPath(), JSON.stringify(data)); };
|
||||
|
||||
it("stores coexisting instances and resolves a pointer before a bare legacy key", async () => {
|
||||
const store = createFusionAuthStorage();
|
||||
await store.setInstance({ providerId: "p", instanceId: "work" }, credential("work"));
|
||||
await store.setInstance({ providerId: "p", instanceId: "backup" }, credential("backup"));
|
||||
await store.set("p", credential("updated-work"));
|
||||
expect(JSON.parse(readFileSync(authPath(), "utf8"))["p[backup]"]).toEqual(credential("updated-work"));
|
||||
await store.setInstance({ providerId: "p", instanceId: "default" }, credential("bare"));
|
||||
await store.setDefaultInstance({ providerId: "p", instanceId: "work" });
|
||||
expect(store.get("p")).toEqual(credential("work"));
|
||||
expect(await store.getApiKey("p")).toBe("work");
|
||||
expect(store.getAll().p).toEqual(credential("work"));
|
||||
expect(store.listInstances("p")[0]).toEqual({ providerId: "p", instanceId: "work" });
|
||||
await store.setDefaultInstance({ providerId: "p", instanceId: "default" });
|
||||
expect(store.get("p")).toEqual(credential("bare"));
|
||||
});
|
||||
|
||||
it("keeps legacy bare credentials readable without a read rewrite", () => {
|
||||
seed({ provider: credential("legacy") });
|
||||
const before = readFileSync(authPath(), "utf8"); const mtime = statSync(authPath()).mtimeMs;
|
||||
const store = createFusionAuthStorage();
|
||||
expect(store.getDefaultInstance("provider")).toEqual({ providerId: "provider", instanceId: "default" });
|
||||
expect(store.get("provider")).toEqual(credential("legacy"));
|
||||
expect(readFileSync(authPath(), "utf8")).toBe(before); expect(statSync(authPath()).mtimeMs).toBe(mtime);
|
||||
});
|
||||
|
||||
it("uses legacy bare creation but non-creating calls do not write absent providers", async () => {
|
||||
const store = createFusionAuthStorage();
|
||||
await store.set("brand-new", credential("new"));
|
||||
expect(JSON.parse(readFileSync(authPath(), "utf8"))).toEqual({ "brand-new": credential("new") });
|
||||
const before = readFileSync(authPath(), "utf8"); const mtime = statSync(authPath()).mtimeMs;
|
||||
let invoked = false;
|
||||
await store.remove("absent"); await store.logout("absent"); await store.removeInstance({ providerId: "absent", instanceId: "x" });
|
||||
await store.modify("absent", async () => { invoked = true; return credential("bad"); });
|
||||
expect(invoked).toBe(false); expect(readFileSync(authPath(), "utf8")).toBe(before); expect(statSync(authPath()).mtimeMs).toBe(mtime);
|
||||
});
|
||||
|
||||
it("rejects malformed and reserved mutator keys without touching defaults metadata", async () => {
|
||||
seed({ __fusionDefaultInstances: { p: "work" } }); const store = createFusionAuthStorage(); const before = readFileSync(authPath(), "utf8");
|
||||
for (const bad of ["p[", "p]", "p[]", "p[a[b]", "", "__fusionDefaultInstances"]) {
|
||||
await expect(store.set(bad, credential("bad"))).rejects.toThrow();
|
||||
await expect(store.remove(bad)).rejects.toThrow();
|
||||
await expect(store.logout(bad)).rejects.toThrow();
|
||||
await expect(store.modify(bad, async () => credential("bad"))).rejects.toThrow();
|
||||
expect(store.get(bad)).toBeUndefined(); expect(store.has(bad)).toBe(false);
|
||||
}
|
||||
expect(readFileSync(authPath(), "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("filters metadata and malformed credential values and falls back after default deletion", async () => {
|
||||
seed({ p: credential("bare"), "p[work]": credential("work"), "p[bad]": "not-a-credential", __fusionDefaultInstances: { p: "work" } });
|
||||
const store = createFusionAuthStorage();
|
||||
expect(store.list()).toEqual(["p"]); expect(store.getAll()).toEqual({ p: credential("work") });
|
||||
await store.removeInstance({ providerId: "p", instanceId: "work" });
|
||||
expect(store.get("p")).toEqual(credential("bare"));
|
||||
expect(JSON.parse(readFileSync(authPath(), "utf8")).__fusionDefaultInstances).toEqual({});
|
||||
await expect(store.setDefaultInstance({ providerId: "p", instanceId: "missing" })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,15 @@ import {
|
||||
readStoredCredentialsFromAuthFile,
|
||||
shouldHydrateStoredCredential,
|
||||
type StoredAuthCredential,
|
||||
type ProviderInstanceRef,
|
||||
ANTHROPIC_SUBSCRIPTION_PROVIDER_ID,
|
||||
DEFAULT_PROVIDER_INSTANCE_ID,
|
||||
formatProviderInstanceKey,
|
||||
isDefaultProviderInstance,
|
||||
isReservedAuthStorageKey,
|
||||
isStoredAuthCredential,
|
||||
isValidProviderId,
|
||||
parseProviderInstanceKey,
|
||||
} from "@fusion/core";
|
||||
import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import type { AuthInteraction, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
|
||||
@@ -22,6 +31,12 @@ export interface FusionAuthStorage {
|
||||
list(): string[];
|
||||
has(provider: string): boolean;
|
||||
hasAuth(provider: string): boolean;
|
||||
listInstances(providerId: string): ProviderInstanceRef[];
|
||||
getInstance(ref: ProviderInstanceRef): StoredCredential | undefined;
|
||||
setInstance(ref: ProviderInstanceRef, credential: StoredCredential): Promise<void>;
|
||||
removeInstance(ref: ProviderInstanceRef): Promise<void>;
|
||||
getDefaultInstance(providerId: string): ProviderInstanceRef | undefined;
|
||||
setDefaultInstance(ref: ProviderInstanceRef): Promise<void>;
|
||||
set(provider: string, credential: StoredCredential): Promise<void>;
|
||||
remove(provider: string): Promise<void>;
|
||||
logout(provider: string): Promise<void>;
|
||||
@@ -85,109 +100,140 @@ async function withOAuthRefreshLock<T>(
|
||||
}
|
||||
}
|
||||
|
||||
type AuthFileData = Record<string, unknown>;
|
||||
type DefaultInstanceMap = Record<string, string>;
|
||||
|
||||
function readDefaultInstanceMap(data: AuthFileData): DefaultInstanceMap {
|
||||
const candidate = data.__fusionDefaultInstances;
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return {};
|
||||
return candidate as DefaultInstanceMap;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-01-04:36:
|
||||
FN-8651 treats auth.json as an untrusted metadata-capable record, not a credential map.
|
||||
The resolver is the sole default precedence authority: valid pointer, bare legacy key,
|
||||
sorted named key, then undefined. A pointer deliberately wins over a bare key so changing
|
||||
the default is observable through every legacy string API; absence returns no fabricated ref.
|
||||
*/
|
||||
class FusionFileAuthStorage implements FusionAuthStorage {
|
||||
private data: Record<string, StoredCredential> = {};
|
||||
private data: AuthFileData = {};
|
||||
private modelRuntime: ModelRuntime | undefined;
|
||||
|
||||
constructor(private readonly authPath: string) {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
constructor(private readonly authPath: string) { this.reload(); }
|
||||
private ensureFile(): void {
|
||||
const parent = dirname(this.authPath);
|
||||
if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o700 });
|
||||
if (!existsSync(this.authPath)) {
|
||||
writeFileSync(this.authPath, "{}", { encoding: "utf-8", mode: 0o600 });
|
||||
chmodSync(this.authPath, 0o600);
|
||||
}
|
||||
if (!existsSync(this.authPath)) { writeFileSync(this.authPath, "{}", { encoding: "utf-8", mode: 0o600 }); chmodSync(this.authPath, 0o600); }
|
||||
}
|
||||
|
||||
private readCurrent(): Record<string, StoredCredential> {
|
||||
private readCurrent(): AuthFileData {
|
||||
try {
|
||||
return JSON.parse(readFileSync(this.authPath, "utf-8")) as Record<string, StoredCredential>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const parsed: unknown = JSON.parse(readFileSync(this.authPath, "utf-8"));
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as AuthFileData : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
private async withLock<T>(fn: (current: Record<string, StoredCredential>) => Promise<T>): Promise<T> {
|
||||
private async withLock<T>(fn: (current: AuthFileData) => Promise<{ result: T; changed: boolean }>): Promise<T> {
|
||||
return enqueueAuthWrite(this.authPath, async () => {
|
||||
this.ensureFile();
|
||||
const release = await lockfile.lock(this.authPath, AUTH_LOCK_OPTIONS);
|
||||
this.ensureFile(); const release = await lockfile.lock(this.authPath, AUTH_LOCK_OPTIONS);
|
||||
try {
|
||||
// Always merge the on-disk state observed after acquiring the lock, never this.data.
|
||||
const current = this.readCurrent();
|
||||
const result = await fn(current);
|
||||
writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 });
|
||||
chmodSync(this.authPath, 0o600);
|
||||
this.data = current;
|
||||
const current = this.readCurrent(); const { result, changed } = await fn(current);
|
||||
if (changed) { writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 }); chmodSync(this.authPath, 0o600); this.data = current; }
|
||||
return result;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} finally { await release(); }
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.ensureFile();
|
||||
this.data = this.readCurrent();
|
||||
reload(): void { this.ensureFile(); this.data = this.readCurrent(); }
|
||||
private parseReadKey(key: string): ProviderInstanceRef | undefined { return parseProviderInstanceKey(key); }
|
||||
private assertRef(ref: ProviderInstanceRef): ProviderInstanceRef {
|
||||
// format validates both ids and rejects reserved provider names before any mutator locks.
|
||||
formatProviderInstanceKey(ref); return ref;
|
||||
}
|
||||
|
||||
get(provider: string): StoredCredential | undefined { return this.data[provider]; }
|
||||
getAll(): Record<string, StoredCredential> { return { ...this.data }; }
|
||||
list(): string[] { return Object.keys(this.data); }
|
||||
has(provider: string): boolean { return Boolean(this.data[provider]); }
|
||||
private resolveDefaultInstance(providerId: string, data: AuthFileData): ProviderInstanceRef | undefined {
|
||||
if (!isValidProviderId(providerId) || isReservedAuthStorageKey(providerId)) return undefined;
|
||||
const pointer = readDefaultInstanceMap(data)[providerId];
|
||||
if (typeof pointer === "string") {
|
||||
const ref = { providerId, instanceId: pointer };
|
||||
try { if (isStoredAuthCredential(data[formatProviderInstanceKey(ref)])) return ref; } catch { /* invalid untrusted pointer falls through */ }
|
||||
}
|
||||
const bare = { providerId, instanceId: DEFAULT_PROVIDER_INSTANCE_ID };
|
||||
if (isStoredAuthCredential(data[providerId])) return bare;
|
||||
const named = Object.keys(data).map(parseProviderInstanceKey).filter((ref): ref is ProviderInstanceRef => Boolean(ref) && ref!.providerId === providerId && !isDefaultProviderInstance(ref!.instanceId) && isStoredAuthCredential(data[formatProviderInstanceKey(ref!)]));
|
||||
return named.sort((a, b) => a.instanceId.localeCompare(b.instanceId))[0];
|
||||
}
|
||||
private resolveReadTarget(providerKey: string, data: AuthFileData): ProviderInstanceRef | undefined {
|
||||
const ref = this.parseReadKey(providerKey); if (!ref) return undefined;
|
||||
return isDefaultProviderInstance(ref.instanceId) ? this.resolveDefaultInstance(ref.providerId, data) : ref;
|
||||
}
|
||||
private resolveWriteTarget(providerKey: string, data: AuthFileData, creating: boolean): ProviderInstanceRef | undefined {
|
||||
const ref = this.assertRefFromKey(providerKey);
|
||||
if (!isDefaultProviderInstance(ref.instanceId)) return ref;
|
||||
return this.resolveDefaultInstance(ref.providerId, data) ?? (creating ? ref : undefined);
|
||||
}
|
||||
private assertRefFromKey(key: string): ProviderInstanceRef {
|
||||
const ref = parseProviderInstanceKey(key); if (!ref) throw new Error(`Invalid provider key: ${key}`); return this.assertRef(ref);
|
||||
}
|
||||
private credential(ref: ProviderInstanceRef | undefined, data = this.data): StoredCredential | undefined {
|
||||
if (!ref) return undefined;
|
||||
const candidate = data[formatProviderInstanceKey(ref)];
|
||||
return isStoredAuthCredential(candidate) ? candidate : undefined;
|
||||
}
|
||||
get(provider: string): StoredCredential | undefined { return this.credential(this.resolveReadTarget(provider, this.data)); }
|
||||
getInstance(ref: ProviderInstanceRef): StoredCredential | undefined { try { return this.credential(this.assertRef(ref)); } catch { return undefined; } }
|
||||
getDefaultInstance(providerId: string): ProviderInstanceRef | undefined { return this.resolveDefaultInstance(providerId, this.data); }
|
||||
listInstances(providerId: string): ProviderInstanceRef[] {
|
||||
if (!isValidProviderId(providerId) || isReservedAuthStorageKey(providerId)) return [];
|
||||
const refs = Object.keys(this.data).map(parseProviderInstanceKey).filter((ref): ref is ProviderInstanceRef => Boolean(ref) && ref!.providerId === providerId && Boolean(this.credential(ref!, this.data)));
|
||||
const defaultRef = this.resolveDefaultInstance(providerId, this.data);
|
||||
const defaultKey = defaultRef && formatProviderInstanceKey(defaultRef);
|
||||
return refs.sort((a, b) => {
|
||||
const aIsDefault = formatProviderInstanceKey(a) === defaultKey;
|
||||
const bIsDefault = formatProviderInstanceKey(b) === defaultKey;
|
||||
if (aIsDefault !== bIsDefault) return aIsDefault ? -1 : 1;
|
||||
return a.instanceId.localeCompare(b.instanceId);
|
||||
});
|
||||
}
|
||||
getAll(): Record<string, StoredCredential> { const result: Record<string, StoredCredential> = {}; for (const provider of this.list()) { const credential = this.get(provider); if (credential) result[provider] = credential; } return result; }
|
||||
list(): string[] { return [...new Set(Object.keys(this.data).map(parseProviderInstanceKey).filter((ref): ref is ProviderInstanceRef => Boolean(ref) && Boolean(this.credential(ref!, this.data))).map((ref) => ref.providerId))].sort(); }
|
||||
has(provider: string): boolean { return Boolean(this.get(provider)); }
|
||||
hasAuth(provider: string): boolean { return this.has(provider); }
|
||||
async set(provider: string, credential: StoredCredential): Promise<void> {
|
||||
await this.withLock(async (current) => { current[provider] = credential; });
|
||||
this.assertRefFromKey(provider);
|
||||
await this.withLock(async current => {
|
||||
const target = this.resolveWriteTarget(provider, current, true)!;
|
||||
current[formatProviderInstanceKey(target)] = credential;
|
||||
return { result: undefined, changed: true };
|
||||
});
|
||||
}
|
||||
async setInstance(ref: ProviderInstanceRef, credential: StoredCredential): Promise<void> { this.assertRef(ref); await this.withLock(async current => { current[formatProviderInstanceKey(ref)] = credential; return { result: undefined, changed: true }; }); }
|
||||
private async removeRef(ref: ProviderInstanceRef): Promise<void> { await this.withLock(async current => { const key = formatProviderInstanceKey(ref); if (!isStoredAuthCredential(current[key])) return { result: undefined, changed: false }; delete current[key]; const defaults = readDefaultInstanceMap(current); if (defaults[ref.providerId] === ref.instanceId) { const next = { ...defaults }; delete next[ref.providerId]; current.__fusionDefaultInstances = next; } return { result: undefined, changed: true }; }); }
|
||||
async remove(provider: string): Promise<void> {
|
||||
await this.withLock(async (current) => { delete current[provider]; });
|
||||
this.assertRefFromKey(provider);
|
||||
await this.withLock(async current => { const target = this.resolveWriteTarget(provider, current, false); if (!target || !isStoredAuthCredential(current[formatProviderInstanceKey(target)])) return { result: undefined, changed: false }; const key = formatProviderInstanceKey(target); delete current[key]; const defaults = readDefaultInstanceMap(current); if (defaults[target.providerId] === target.instanceId) { const next = { ...defaults }; delete next[target.providerId]; current.__fusionDefaultInstances = next; } return { result: undefined, changed: true }; });
|
||||
}
|
||||
async removeInstance(ref: ProviderInstanceRef): Promise<void> { this.assertRef(ref); await this.removeRef(ref); }
|
||||
async logout(provider: string): Promise<void> { await this.remove(provider); }
|
||||
async getApiKey(provider: string): Promise<string | undefined> {
|
||||
return resolveStoredCredentialApiKey(provider, this.get(provider));
|
||||
}
|
||||
getOAuthProviders(): Array<{ id: string; name: string }> {
|
||||
return [
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
{ id: "openai-codex", name: "OpenAI Codex" },
|
||||
{ id: "github-copilot", name: "GitHub Copilot" },
|
||||
];
|
||||
}
|
||||
setModelRuntime(modelRuntime: ModelRuntime): void {
|
||||
this.modelRuntime = modelRuntime;
|
||||
async setDefaultInstance(ref: ProviderInstanceRef): Promise<void> { this.assertRef(ref); await this.withLock(async current => { if (!this.credential(ref, current)) throw new Error("Cannot set default for a missing credential instance"); const defaults = { ...readDefaultInstanceMap(current), [ref.providerId]: ref.instanceId }; current.__fusionDefaultInstances = defaults; return { result: undefined, changed: true }; }); }
|
||||
async getApiKey(provider: string): Promise<string | undefined> { return resolveStoredCredentialApiKey(provider, this.get(provider)); }
|
||||
async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined> {
|
||||
this.assertRefFromKey(provider);
|
||||
return this.withLock(async current => {
|
||||
const target = this.resolveWriteTarget(provider, current, false);
|
||||
if (!target || !this.credential(target, current)) return { result: undefined, changed: false };
|
||||
const next = await fn(this.credential(target, current));
|
||||
if (next !== undefined) {
|
||||
current[formatProviderInstanceKey(target)] = next;
|
||||
return { result: next, changed: true };
|
||||
}
|
||||
return { result: this.credential(target, current), changed: false };
|
||||
});
|
||||
}
|
||||
getOAuthProviders(): Array<{ id: string; name: string }> { return [{ id: "anthropic", name: "Anthropic" }, { id: "openai-codex", name: "OpenAI Codex" }, { id: "github-copilot", name: "GitHub Copilot" }]; }
|
||||
setModelRuntime(modelRuntime: ModelRuntime): void { this.modelRuntime = modelRuntime; }
|
||||
async login(provider: string, callbacks: unknown): Promise<void> {
|
||||
if (!this.modelRuntime) throw new Error("OAuth login requires a ModelRuntime-backed Fusion auth storage");
|
||||
const legacy = callbacks as {
|
||||
onAuth?: (info: { url: string; instructions?: string }) => void;
|
||||
onDeviceCode?: (info: { userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number }) => void;
|
||||
onPrompt?: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
const interaction: AuthInteraction = {
|
||||
signal: legacy.signal,
|
||||
prompt: async (prompt) => legacy.onPrompt?.({
|
||||
message: prompt.message,
|
||||
placeholder: "placeholder" in prompt ? prompt.placeholder : undefined,
|
||||
}) ?? "",
|
||||
notify: (event) => {
|
||||
if (event.type === "auth_url") legacy.onAuth?.({ url: event.url, instructions: event.instructions });
|
||||
else if (event.type === "device_code") legacy.onDeviceCode?.(event);
|
||||
else if (event.type === "progress") legacy.onProgress?.(event.message);
|
||||
},
|
||||
};
|
||||
await this.modelRuntime.login(provider, "oauth", interaction);
|
||||
this.reload();
|
||||
}
|
||||
async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined> {
|
||||
return this.withLock(async (current) => {
|
||||
const next = await fn(current[provider]);
|
||||
if (next !== undefined) current[provider] = next;
|
||||
return current[provider];
|
||||
});
|
||||
const legacy = callbacks as { onAuth?: (info: { url: string; instructions?: string }) => void; onDeviceCode?: (info: { userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number }) => void; onPrompt?: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>; onProgress?: (message: string) => void; signal?: AbortSignal; };
|
||||
const interaction: AuthInteraction = { signal: legacy.signal, prompt: async prompt => legacy.onPrompt?.({ message: prompt.message, placeholder: "placeholder" in prompt ? prompt.placeholder : undefined }) ?? "", notify: event => { if (event.type === "auth_url") legacy.onAuth?.({ url: event.url, instructions: event.instructions }); else if (event.type === "device_code") legacy.onDeviceCode?.(event); else if (event.type === "progress") legacy.onProgress?.(event.message); } };
|
||||
await this.modelRuntime.login(provider, "oauth", interaction); this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +295,6 @@ apply so a single stuck token doesn't get hammered).
|
||||
*/
|
||||
const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000;
|
||||
const ANTHROPIC_PROVIDER_ID = "anthropic";
|
||||
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
|
||||
const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000;
|
||||
|
||||
export function getHomeDir(): string {
|
||||
@@ -976,11 +1021,14 @@ export function createFusionAuthStorage(): FusionAuthStorage {
|
||||
}
|
||||
|
||||
if (prop === "get") {
|
||||
return (provider: string) => selectVisibleStoredCredential(provider);
|
||||
return (provider: string) => parseProviderInstanceKey(provider)
|
||||
? selectVisibleStoredCredential(provider)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (prop === "has") {
|
||||
return (provider: string) => {
|
||||
if (!parseProviderInstanceKey(provider)) return false;
|
||||
if (provider === ANTHROPIC_PROVIDER_ID) {
|
||||
return hasVisibleAnthropicCredential();
|
||||
}
|
||||
@@ -996,6 +1044,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
|
||||
|
||||
if (prop === "hasAuth") {
|
||||
return (provider: string) => {
|
||||
if (!parseProviderInstanceKey(provider)) return false;
|
||||
if (provider === ANTHROPIC_PROVIDER_ID) {
|
||||
return hasVisibleAnthropicCredential();
|
||||
}
|
||||
@@ -1056,12 +1105,13 @@ export function createFusionAuthStorage(): FusionAuthStorage {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}).sort();
|
||||
};
|
||||
}
|
||||
|
||||
if (prop === "getApiKey") {
|
||||
return async (provider: string) => {
|
||||
if (!parseProviderInstanceKey(provider)) return undefined;
|
||||
await supplementalHydration;
|
||||
if (provider === ANTHROPIC_PROVIDER_ID) {
|
||||
return resolveAnthropicRuntimeApiKey();
|
||||
|
||||
Reference in New Issue
Block a user