feat: write back claude subscription credentials

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip authored and Devin Foley committed 2026-06-24 14:49:52 -07:00
1 parent 490f921a3f
commit c2e3b9bf0f
8 files changed
+535 -10

No files matched your search

@@ -11,6 +11,7 @@ const {
restoreWorkspaceFromSshExecution,
syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
readAdapterExecutionTargetTextFile,
} = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({
exitCode: 0,
@@ -38,6 +39,7 @@ const {
},
stop: async () => {},
})),
readAdapterExecutionTargetTextFile: vi.fn(async (): Promise<string | null> => null),
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
@@ -71,6 +73,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
return {
...actual,
startAdapterExecutionTargetPaperclipBridge,
readAdapterExecutionTargetTextFile,
};
});
@@ -81,6 +84,7 @@ describe("claude remote execution", () => {
afterEach(async () => {
vi.clearAllMocks();
readAdapterExecutionTargetTextFile.mockResolvedValue(null);
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
@@ -333,4 +337,159 @@ describe("claude remote execution", () => {
expect(call?.[2]).toContain("12345678-1234-4abc-9def-123456789012");
});
it("returns refreshed Claude subscription .credentials.json after a remote run", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-auth-refresh-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-auth-refresh/workspace";
await mkdir(workspaceDir, { recursive: true });
readAdapterExecutionTargetTextFile.mockResolvedValue('{"oauthAccount":{"email":"user@example.com"}}');
const result = await execute({
runId: "run-auth-refresh",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: "claude",
},
context: {
paperclipWorkspace: {
cwd: workspaceDir,
source: "project_primary",
},
},
executionTarget: {
kind: "remote",
transport: "ssh",
remoteCwd: "/remote/workspace",
runtimeCredentialMaterialization: {
provider: "claude",
assets: {
"config-seed": {
files: [
{
relativePath: ".credentials.json",
contents: '{"oauthAccount":{"email":"stored@example.com"}}',
},
],
},
},
},
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/remote/workspace",
remoteCwd: "/remote/workspace",
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
onLog: async () => {},
});
expect(readAdapterExecutionTargetTextFile).toHaveBeenCalledWith(
"run-auth-refresh",
expect.objectContaining({ kind: "remote", transport: "ssh" }),
`${managedRemoteWorkspace}/.paperclip-runtime/claude/config/.credentials.json`,
expect.objectContaining({
maxBytes: 32_768,
}),
);
expect(result.runtimeCredentialUpdates).toEqual({
provider: "claude",
assets: {
"config-seed": {
files: [
{
relativePath: ".credentials.json",
contents: '{"oauthAccount":{"email":"user@example.com"}}',
mode: 0o600,
},
],
},
},
});
});
it("does not let subscription credentials override explicit Claude API-key mode", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-api-key-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
await mkdir(workspaceDir, { recursive: true });
const result = await execute({
runId: "run-api-key",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: "claude",
env: {
ANTHROPIC_API_KEY: "sk-ant-test",
},
},
context: {
paperclipWorkspace: {
cwd: workspaceDir,
source: "project_primary",
},
},
executionTarget: {
kind: "remote",
transport: "ssh",
remoteCwd: "/remote/workspace",
runtimeCredentialMaterialization: {
provider: "claude",
assets: {
"config-seed": {
files: [
{
relativePath: ".credentials.json",
contents: '{"oauthAccount":{"email":"subscription@example.com"}}',
},
],
},
},
},
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/remote/workspace",
remoteCwd: "/remote/workspace",
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
onLog: async () => {},
});
expect(readAdapterExecutionTargetTextFile).not.toHaveBeenCalled();
expect(result.runtimeCredentialUpdates).toBeUndefined();
});
});
@@ -18,6 +18,7 @@ import {
ensureAdapterExecutionTargetRuntimeCommandInstalled,
materializeAdapterRuntimeCredentialAsset,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTargetTextFile,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetTimeoutSec,
resolveAdapterExecutionTargetCommandForLogs,
@@ -132,11 +133,30 @@ function isBedrockAuth(env: Record<string, string>): boolean {
);
}
function hasExplicitClaudeApiOrBedrockAuth(env: Record<string, string>): boolean {
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") || isBedrockAuth(env);
}
function suppressInheritedClaudeApiOrBedrockAuth(env: Record<string, string>) {
env.ANTHROPIC_API_KEY = "";
env.CLAUDE_CODE_USE_BEDROCK = "";
env.ANTHROPIC_BEDROCK_BASE_URL = "";
}
function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" | "metered_api" {
if (isBedrockAuth(env)) return "metered_api";
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
}
function claudeCredentialsJsonHasUsableAuth(contents: string): boolean {
try {
const parsed = JSON.parse(contents);
return Boolean(parsed) && typeof parsed === "object" && !Array.isArray(parsed) && Object.keys(parsed).length > 0;
} catch {
return false;
}
}
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
const { runId, agent, config, context, runtimeCommandSpec, executionTarget, authToken } = input;
const onLog = input.onLog ?? (async () => {});
@@ -175,6 +195,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const runtimeCredentialEnv = adapterRuntimeCredentialEnv(executionTarget);
const runtimeCredentialEnvKeys = Object.keys(runtimeCredentialEnv);
const hasRuntimeCredentialMaterial =
runtimeCredentialEnvKeys.length > 0 ||
adapterRuntimeCredentialAssetFiles(executionTarget, "config-seed").length > 0;
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
workspaceCwd: effectiveWorkspaceCwd,
@@ -273,7 +296,12 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
for (const [key, value] of Object.entries(shapedEnvConfig)) {
if (typeof value === "string") env[key] = value;
}
Object.assign(env, runtimeCredentialEnv);
if (!hasExplicitClaudeApiOrBedrockAuth(env)) {
Object.assign(env, runtimeCredentialEnv);
if (hasRuntimeCredentialMaterial) {
suppressInheritedClaudeApiOrBedrockAuth(env);
}
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
@@ -378,8 +406,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const executionTargetIsSandbox = executionTarget?.kind === "remote" && executionTarget.transport === "sandbox";
const claudeConfigCredentialFiles = adapterRuntimeCredentialAssetFiles(executionTarget, "config-seed");
const hasRuntimeClaudeConfigFiles = claudeConfigCredentialFiles.length > 0;
const runtimeCredentialEnvKeys = Object.keys(adapterRuntimeCredentialEnv(executionTarget));
const promptTemplate = asString(
@@ -392,6 +418,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const configEnv = parseObject(config.env);
const hasExplicitClaudeRuntimeAuth = hasExplicitClaudeApiOrBedrockAuth(
Object.fromEntries(
Object.entries(configEnv).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
),
);
const claudeConfigCredentialFiles = hasExplicitClaudeRuntimeAuth
? []
: adapterRuntimeCredentialAssetFiles(executionTarget, "config-seed");
const hasRuntimeClaudeConfigFiles = claudeConfigCredentialFiles.length > 0;
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
@@ -632,6 +669,66 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
}
}
const readRefreshedClaudeCredentialUpdates =
async (): Promise<AdapterExecutionResult["runtimeCredentialUpdates"]> => {
const usesExplicitApiAuth = hasExplicitClaudeRuntimeAuth;
if (!hasRuntimeClaudeConfigFiles || hasExplicitClaudeConfigDir || usesExplicitApiAuth) return null;
const claudeConfigDir = remoteClaudeConfigDir ?? (!executionTargetIsRemote ? env.CLAUDE_CONFIG_DIR : null);
if (!claudeConfigDir) return null;
const credentialsPath = executionTargetIsRemote
? path.posix.join(claudeConfigDir, ".credentials.json")
: path.join(claudeConfigDir, ".credentials.json");
const contents = await readAdapterExecutionTargetTextFile(
runId,
runtimeExecutionTarget,
credentialsPath,
{
cwd: effectiveExecutionCwd,
env: Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
),
timeoutSec: 15,
graceSec: 5,
maxBytes: 32_768,
},
);
if (contents === null) {
throw new Error(
"Claude subscription .credentials.json was missing after the run; refusing to leave stored subscription credentials stale.",
);
}
if (!claudeCredentialsJsonHasUsableAuth(contents)) {
throw new Error(
"Claude subscription .credentials.json was not valid credential material after the run; refusing to overwrite stored credentials.",
);
}
return {
provider: "claude",
assets: {
"config-seed": {
files: [
{
relativePath: ".credentials.json",
contents,
mode: 0o600,
},
],
},
},
};
};
const withRuntimeCredentialUpdates = async (
result: AdapterExecutionResult,
): Promise<AdapterExecutionResult> => {
const runtimeCredentialUpdates = await readRefreshedClaudeCredentialUpdates();
return runtimeCredentialUpdates ? { ...result, runtimeCredentialUpdates } : result;
};
let effectiveEffort = effort;
if (executionTargetIsSandbox && effort) {
const supportsEffort = await claudeCommandSupportsEffortFlag({
@@ -1092,10 +1189,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
}
const retry = await runAttempt(null);
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
return await withRuntimeCredentialUpdates(
toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true }),
);
}
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
return await withRuntimeCredentialUpdates(
toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId }),
);
} finally {
if (paperclipBridge) {
await paperclipBridge.stop();
@@ -67,6 +67,7 @@ const payload = {
paperclipApiUrl: process.env.PAPERCLIP_API_URL || null,
paperclipApiKey: process.env.PAPERCLIP_API_KEY || null,
paperclipApiBridgeMode: process.env.PAPERCLIP_API_BRIDGE_MODE || null,
claudeOauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN || null,
};
if (capturePath) {
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
@@ -168,6 +169,7 @@ type CapturePayload = {
paperclipApiUrl?: string | null;
paperclipApiKey?: string | null;
paperclipApiBridgeMode?: string | null;
claudeOauthToken?: string | null;
appendedSystemPromptFilePath?: string | null;
appendedSystemPromptFileContents?: string | null;
};
@@ -726,6 +728,66 @@ describe("claude execute", () => {
}
});
it("injects Claude OAuth runtime credentials into the CLI env while redacting invocation metadata", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-oauth-"));
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root);
let loggedCommand: string | null = null;
let loggedEnv: Record<string, string> = {};
try {
const result = await execute({
runId: "run-oauth-runtime-credential",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
executionTarget: {
kind: "local",
runtimeCredentialMaterialization: {
provider: "claude",
env: {
CLAUDE_CODE_OAUTH_TOKEN: "oauth-secret",
},
},
},
authToken: "run-jwt-token",
onLog: async () => {},
onMeta: async (meta) => {
loggedCommand = meta.command;
loggedEnv = meta.env ?? {};
},
});
expect(result.exitCode).toBe(0);
expect(loggedCommand).toBe(commandPath);
expect(loggedEnv.CLAUDE_CODE_OAUTH_TOKEN).toBe("***REDACTED***");
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.claudeOauthToken).toBe("oauth-secret");
expect(capture.argv.slice(0, 4)).toEqual(["--print", "-", "--output-format", "stream-json"]);
} finally {
restore();
await fs.rm(root, { recursive: true, force: true });
}
});
it("injects bridge env into sandbox-managed remote runs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-"));
const localWorkspace = path.join(root, "workspace");
@@ -781,6 +843,19 @@ describe("claude execute", () => {
remoteCwd: remoteWorkspace,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
runtimeCredentialMaterialization: {
provider: "claude",
assets: {
"config-seed": {
files: [
{
relativePath: ".credentials.json",
contents: '{"oauthAccount":{"email":"stored@example.com"}}',
},
],
},
},
},
},
authToken: "run-jwt-token",
onLog: async () => {},
@@ -804,10 +879,25 @@ describe("claude execute", () => {
);
expect(capture.argv).not.toContain("--dangerously-skip-permissions");
expect(capture.claudeConfigDir).toBe(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "config"));
expect(capture.claudeConfigEntries).toContain(".credentials.json");
expect(capture.claudeConfigEntries).toContain("settings.json");
expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(capture.paperclipApiKey).not.toBe("run-jwt-token");
expect(capture.paperclipApiBridgeMode).toBe("queue_v1");
expect(result.runtimeCredentialUpdates).toEqual({
provider: "claude",
assets: {
"config-seed": {
files: [
{
relativePath: ".credentials.json",
contents: '{"oauthAccount":{"email":"stored@example.com"}}',
mode: 0o600,
},
],
},
},
});
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
@@ -236,6 +236,67 @@ describe("runtime credential materialization service", () => {
).toBeNull();
});
it("extracts Claude credentials JSON runtime updates without accepting other material", () => {
expect(
byoSubscriptionCredentialMaterialFromRuntimeUpdate("claude", {
provider: "claude",
assets: {
"config-seed": {
files: [
{
relativePath: ".credentials.json",
contents: '{"oauthAccount":{"email":"user@example.com"}}',
},
],
},
},
}),
).toEqual({
provider: "claude",
kind: "credentials_json",
value: '{"oauthAccount":{"email":"user@example.com"}}',
});
expect(
byoSubscriptionCredentialMaterialFromRuntimeUpdate("claude", {
provider: "codex",
assets: {
"config-seed": {
files: [
{
relativePath: ".credentials.json",
contents: '{"refresh":"wrong-provider"}',
},
],
},
},
}),
).toBeNull();
expect(
byoSubscriptionCredentialMaterialFromRuntimeUpdate("claude", {
provider: "claude",
assets: {
"config-seed": {
files: [
{
relativePath: "credentials.json",
contents: '{"refresh":"wrong-file"}',
},
],
},
},
}),
).toBeNull();
expect(
byoSubscriptionCredentialMaterialFromRuntimeUpdate("claude", {
provider: "claude",
env: {
CLAUDE_CODE_OAUTH_TOKEN: "oauth-token",
},
}),
).toBeNull();
});
it("writes refreshed Codex runtime auth material back through the user-scoped store", async () => {
const writeBackFromRuntime = vi.fn<NonNullable<ByoSubscriptionCredentialStore["writeBackFromRuntime"]>>(
async () => undefined,
@@ -283,4 +344,52 @@ describe("runtime credential materialization service", () => {
},
});
});
it("writes refreshed Claude credentials JSON material back through the user-scoped store", async () => {
const writeBackFromRuntime = vi.fn<NonNullable<ByoSubscriptionCredentialStore["writeBackFromRuntime"]>>(
async () => undefined,
);
await expect(
writeBackByoSubscriptionRuntimeCredentialMaterialization({
store: {
resolveForRuntime: vi.fn(),
writeBackFromRuntime,
},
companyId: "company-1",
userId: "user-1",
provider: "claude",
agentId: "agent-1",
issueId: "issue-1",
heartbeatRunId: "run-1",
runtimeCredentialUpdates: {
provider: "claude",
assets: {
"config-seed": {
files: [
{
relativePath: ".credentials.json",
contents: '{"oauthAccount":{"email":"user@example.com"}}',
},
],
},
},
},
}),
).resolves.toBe(true);
expect(writeBackFromRuntime).toHaveBeenCalledWith({
companyId: "company-1",
userId: "user-1",
provider: "claude",
agentId: "agent-1",
issueId: "issue-1",
heartbeatRunId: "run-1",
material: {
provider: "claude",
kind: "credentials_json",
value: '{"oauthAccount":{"email":"user@example.com"}}',
},
});
});
});
@@ -274,6 +274,30 @@ describe("subscriptionCredentialService", () => {
expect(result).not.toHaveProperty("material");
});
it("rejects runtime material when the expected credential kind does not match the stored row", async () => {
const row = makeCredentialRow({
provider: "claude",
credentialKind: "claude_oauth_token",
});
const { db } = makeFakeDb({ findFirstRows: [row] });
const svc = subscriptionCredentialService(db);
await expect(
svc.updateMaterialFromRuntime(
row.companyId as string,
row.userId as string,
"claude",
'{"oauthAccount":{"email":"user@example.com"}}',
"claude_credentials_json",
),
).rejects.toMatchObject({
status: 422,
details: { code: "credential_kind_mismatch" },
});
expect(mockSecretProvider.createSecret).not.toHaveBeenCalled();
});
it("rejects decrypted material that does not match the stored integrity hash", async () => {
const row = makeCredentialRow({ valueSha256: sha256Hex("different-token") });
const { db } = makeFakeDb({ findFirstRows: [row] });
+19 -4
View File
@@ -1731,6 +1731,16 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function hasExplicitClaudeApiOrBedrockRuntimeAuth(env: Record<string, unknown>): boolean {
const useBedrock = readNonEmptyString(env.CLAUDE_CODE_USE_BEDROCK);
return Boolean(
readNonEmptyString(env.ANTHROPIC_API_KEY) ||
useBedrock === "1" ||
useBedrock === "true" ||
readNonEmptyString(env.ANTHROPIC_BEDROCK_BASE_URL),
);
}
function readRequestedUserIdFromContext(contextSnapshot: Record<string, unknown>): string | null {
const explicitRuntimeUserId =
readNonEmptyString(contextSnapshot.runtimeCredentialUserId) ??
@@ -9162,10 +9172,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
};
const adapterRuntimeCredentialProvider = providerForSubscriptionCredentialAdapter(agent.adapterType);
const resolvedRuntimeEnv = parseObject(effectiveResolvedConfig.env);
const runtimeCredentialProvider =
adapterRuntimeCredentialProvider === "codex" && readNonEmptyString(resolvedRuntimeEnv.OPENAI_API_KEY)
? null
: adapterRuntimeCredentialProvider;
const runtimeCredentialProvider = (() => {
if (adapterRuntimeCredentialProvider === "codex" && readNonEmptyString(resolvedRuntimeEnv.OPENAI_API_KEY)) {
return null;
}
if (adapterRuntimeCredentialProvider === "claude" && hasExplicitClaudeApiOrBedrockRuntimeAuth(resolvedRuntimeEnv)) {
return null;
}
return adapterRuntimeCredentialProvider;
})();
const runtimeCredentialUserId = runtimeCredentialProvider
? await resolveRuntimeCredentialUserId({
companyId: agent.companyId,
@@ -96,16 +96,25 @@ export function createSubscriptionCredentialRuntimeStore(db: Db): ByoSubscriptio
async writeBackFromRuntime(input) {
if (input.material.provider !== input.provider) return;
switch (input.material.kind) {
case "credentials_json":
await credentials.updateMaterialFromRuntime(
input.companyId,
input.userId,
input.provider,
input.material.value,
"claude_credentials_json",
);
break;
case "auth_json":
await credentials.updateMaterialFromRuntime(
input.companyId,
input.userId,
input.provider,
input.material.value,
"codex_auth_json",
);
break;
case "oauth_token":
case "credentials_json":
break;
}
},
@@ -182,6 +191,18 @@ export function byoSubscriptionCredentialMaterialFromRuntimeUpdate(
};
}
if (provider === "claude") {
const credentialsJson = normalized.assets?.["config-seed"]?.files.find(
(file) => file.relativePath === ".credentials.json" && typeof file.contents === "string",
);
if (!credentialsJson) return null;
return {
provider: "claude",
kind: "credentials_json",
value: credentialsJson.contents,
};
}
return null;
}
@@ -198,6 +198,7 @@ export function subscriptionCredentialService(db: Db) {
userId: string,
provider: SubscriptionCredentialProvider,
material: string,
expectedCredentialKind?: SubscriptionCredentialKind,
): Promise<SubscriptionCredentialReadModel> {
const row = await findRow(companyId, userId, provider);
if (!row) throw notFound("Subscription credential not found");
@@ -205,6 +206,11 @@ export function subscriptionCredentialService(db: Db) {
throw unprocessable("Subscription credential is not active", { code: "credential_inactive" });
}
const credentialKind = row.credentialKind as SubscriptionCredentialKind;
if (expectedCredentialKind && credentialKind !== expectedCredentialKind) {
throw unprocessable("Runtime credential update kind does not match stored credential", {
code: "credential_kind_mismatch",
});
}
assertKindMatchesProvider(provider, credentialKind);
assertMaterialMatchesKind(credentialKind, material);