From 1428a50d98d459462d172cb0c00966c679a1c88e Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 30 Oct 2026 23:20:56 -0400 Subject: [PATCH 1/2] Preserve OAuth clients when stored credentials cannot decrypt Treat missing or revoked credentials as absent, but propagate read/decryption faults before a dynamic registration can replace the client's shared credential. Keep malformed JSON recovery separate so parsing never leaks a stored secret. AI assistance: implemented and validated with OpenAI Codex; maintainer review remains required. --- CHANGELOG.md | 7 +++ docs/architecture.md | 2 +- server/src/plugins/plugin-store/context.ts | 50 ++++++++--------- server/tests/plugin-store.integration.test.ts | 53 +++++++++++++++++++ 4 files changed, 86 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9d3f16f4..34ba4c8f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,13 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `COMPUTER_MEMORY_HIGH_PERCENT=0` and `COMPUTER_TAB_IDLE_MS=0` (Helm: `computers.memoryHighPercent` and `computers.tabIdleMs`) keep the old behaviour. +### A client read failure does not replace a working app registration + +A server that cannot decrypt an app's stored OAuth client now fails that connection attempt without +registering a replacement. A server with the correct encryption key can keep using the original +client. Confirmed missing or revoked credentials and malformed stored clients still use the existing +registration flow. + ### A computer closes browsers nobody is using when its memory runs high A computer runs one browser for each Bot that is browsing, and the only limits were a count and half diff --git a/docs/architecture.md b/docs/architecture.md index 7650efc6fe..6f95c14968 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -390,7 +390,7 @@ Connector credentials are stored through the credential vault and referenced by - Signing in, being refused, and being granted the administrator role by configuration each write an audit row. They are the only record that somebody who can edit `INITIAL_ADMIN_EMAILS` promoted themselves, and the only evidence a revoked person was ever here, since revoking them deletes their sessions. - Removing somebody deletes their sessions and denies their address, because deleting the user row alone is not removal: the next sign-in through the provider recreates it. - With no identity provider configured, the deployment refuses to start unless `OPENBOT_SINGLE_USER=true` says every request may be one fixed administrator. That flag is the only thing that permits it; `NODE_ENV` does not. The flag alone is not enough on a public address: if `OPENBOT_PUBLIC_URL`, `OPENBOT_APP_URL` or any `TRUSTED_ORIGINS` entry resolves to an address the public internet routes to, the deployment refuses to start anyway, because no sign-in there means every visitor is the administrator. Loopback is silent, and a private address (a home LAN, a Tailnet, a VPN, a `.local` name) is allowed with a warning naming it, since that is the deployment the flag exists for. -- `KEY_ENCRYPTION_KEY` must be a base64-encoded 32-byte value. The example key is refused with `NODE_ENV=production`. +- `KEY_ENCRYPTION_KEY` must be a base64-encoded 32-byte value. The example key is refused with `NODE_ENV=production`. A vault read or decryption fault stops an OAuth client lookup without registering a replacement, so a misconfigured replica cannot replace a client still usable by the others. - Credential plaintext is encrypted at rest, never returned by APIs, and redacted from audit events. - Browser navigation allows `http` and `https`; cloud metadata addresses are refused under every configuration. - `POST /api/model-provider/v1/chat/completions` exists only when `OPENBOT_MODEL_OAUTH_FILE` names a model credential file, which the desktop app sets and nothing else does. It answers a Chat Completions request using a stored Google or xAI OAuth grant. It does not use the session guard: the caller presents a separate local bearer taken from that file and compared in constant time, and the provider's own refresh token never leaves the server. The credential file is refused unless it is a regular file under 64KB with no group or other permission bits, and the upstream host, path and headers are fixed so a caller cannot redirect the request. diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts index cec7bf727c..c71dd44e9d 100644 --- a/server/src/plugins/plugin-store/context.ts +++ b/server/src/plugins/plugin-store/context.ts @@ -1259,6 +1259,16 @@ export function createStoreContext(options: PluginStoreOptions) { keyId: `oauth-client-${serverId}`, }); + /** Parse errors can quote plaintext, so an invalid client is never rethrown with its secret. */ + function clientFromStoredJson(plaintext: string): OAuthClient | null { + try { + const parsed: unknown = JSON.parse(plaintext); + return isUsableClient(parsed) ? parsed : null; + } catch { + return null; + } + } + /** * One writer at a time for one server's OAuth client, across the whole deployment. * @@ -1321,18 +1331,10 @@ export function createStoreContext(options: PluginStoreOptions) { .limit(1); if (!held || held.revokedAt) return null; - try { - const parsed: unknown = JSON.parse( - await decryptSecret(encryptionKey, held.encryptedValue), - ); - // A value that parsed and is not a client is unreadable in the same way and for the same - // caller — see {@link isUsableClient}. Null, because that is what this reader's caller acts - // on: it goes and registers one, which is the answer to holding none. - return isUsableClient(parsed) ? parsed : null; - } catch { - // Unreadable is the same as none: there is nothing to send anybody to consent with. - return null; - } + // A decryption fault does not mean the vendor forgot this client. Re-registering would replace + // the live client for every replica, including those that can still decrypt it. + const plaintext = await decryptSecret(encryptionKey, held.encryptedValue); + return clientFromStoredJson(plaintext); } /** @@ -1460,22 +1462,20 @@ export function createStoreContext(options: PluginStoreOptions) { .limit(1); if (!row?.credentialId) return null; + let plaintext: string; try { - const parsed: unknown = JSON.parse( - await decryptCredentialForUse( - encryptionKey, - credentials, - row.credentialId, - ), + plaintext = await decryptCredentialForUse( + encryptionKey, + credentials, + row.credentialId, ); - // A client that parsed and is not one is as unusable as the revoked or missing row the catch - // below answers for, and is the same none to every caller — see {@link isUsableClient}. - return isUsableClient(parsed) ? parsed : null; - } catch { - // A revoked, missing or unreadable client is the same as none for every caller: there is - // nothing to send anybody to consent with, and the answer is to obtain one again. - return null; + } catch (error) { + // Only a confirmed missing or revoked credential permits obtaining a replacement. A failed + // read or decryption must not turn a working deployment-wide registration into a new client. + if (error instanceof CredentialUnusableError) return null; + throw error; } + return clientFromStoredJson(plaintext); } /** diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index e8b9bea292..57411e31d4 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -2878,6 +2878,59 @@ describe("a dynamic client the vendor has evicted", () => { expect(registrations).toEqual([]); }); + test.each(["before-lock", "under-lock"] as const)( + "a client decryption fault %s does not replace the deployment's client", + async (where) => { + await putClient(EVICTED); + registrations.length = 0; + issue = () => FRESH; + const [before] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, dynamicServerId)); + const wrongKeyStore = createPluginStore({ + brokerProvider: "composio", + database, + auditStore: createAuditStore(database), + // The initial read can find no row while a client is found under the lock. Neither read + // may interpret a decryption failure as permission to register over the working client. + credentials: + where === "under-lock" + ? { ...vault, readSecret: async () => null } + : vault, + encryptionKey: Buffer.alloc(32, 1).toString("base64"), + policy: () => policy, + ...seams, + redirectUri: REDIRECT_URI, + }); + try { + const outcome = await wrongKeyStore + .ensureOAuthClient(dynamicServerId, "someone@openbot.test") + .then( + () => "returned", + () => "rejected", + ); + const [after] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, dynamicServerId)); + expect({ + outcome, + registrations: registrations.length, + pointerUnchanged: before?.credentialId === after?.credentialId, + client: await dynamicStore.oauthClientFor(dynamicServerId), + }).toEqual({ + outcome: "rejected", + registrations: 0, + pointerUnchanged: true, + client: EVICTED, + }); + } finally { + await putClient(EVICTED); + } + }, + ); + test("a dynamic entry with no client gets one, kept and recorded", async () => { await clearClient(); registrations.length = 0; From 0dae797a9437fd5aae7eb649b6b377d57cd00248 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 30 Oct 2026 23:45:40 -0400 Subject: [PATCH 2/2] Audit failed client reads without replacing the shared registration Implemented and validated with OpenAI Codex assistance. --- CHANGELOG.md | 2 +- docs/architecture.md | 2 +- server/src/audit.ts | 2 + .../src/plugins/plugin-store/connections.ts | 18 ++ server/src/plugins/plugin-store/context.ts | 94 ++++++----- server/src/plugins/plugin-store/model.ts | 8 + .../src/plugins/route-groups/connections.ts | 13 ++ server/src/plugins/store.ts | 1 + server/tests/plugin-store.integration.test.ts | 158 ++++++++++++++++++ 9 files changed, 257 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34ba4c8f70..fd68b43247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,7 +83,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. A server that cannot decrypt an app's stored OAuth client now fails that connection attempt without registering a replacement. A server with the correct encryption key can keep using the original client. Confirmed missing or revoked credentials and malformed stored clients still use the existing -registration flow. +registration flow. Connect records a safe read-failure audit row for the person and app and returns a retryable error, without exposing the vault failure. ### A computer closes browsers nobody is using when its memory runs high diff --git a/docs/architecture.md b/docs/architecture.md index 6f95c14968..cff7a65410 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -390,7 +390,7 @@ Connector credentials are stored through the credential vault and referenced by - Signing in, being refused, and being granted the administrator role by configuration each write an audit row. They are the only record that somebody who can edit `INITIAL_ADMIN_EMAILS` promoted themselves, and the only evidence a revoked person was ever here, since revoking them deletes their sessions. - Removing somebody deletes their sessions and denies their address, because deleting the user row alone is not removal: the next sign-in through the provider recreates it. - With no identity provider configured, the deployment refuses to start unless `OPENBOT_SINGLE_USER=true` says every request may be one fixed administrator. That flag is the only thing that permits it; `NODE_ENV` does not. The flag alone is not enough on a public address: if `OPENBOT_PUBLIC_URL`, `OPENBOT_APP_URL` or any `TRUSTED_ORIGINS` entry resolves to an address the public internet routes to, the deployment refuses to start anyway, because no sign-in there means every visitor is the administrator. Loopback is silent, and a private address (a home LAN, a Tailnet, a VPN, a `.local` name) is allowed with a warning naming it, since that is the deployment the flag exists for. -- `KEY_ENCRYPTION_KEY` must be a base64-encoded 32-byte value. The example key is refused with `NODE_ENV=production`. A vault read or decryption fault stops an OAuth client lookup without registering a replacement, so a misconfigured replica cannot replace a client still usable by the others. +- `KEY_ENCRYPTION_KEY` must be a base64-encoded 32-byte value. The example key is refused with `NODE_ENV=production`. A vault read or decryption fault stops an OAuth client lookup without registering a replacement, so a misconfigured replica cannot replace a client still usable by the others. Connect records `mcp.oauth_client_read_failed` with the authenticated person and server before returning a safe 503; raw vault errors and causes are neither retained nor included in the audit payload. - Credential plaintext is encrypted at rest, never returned by APIs, and redacted from audit events. - Browser navigation allows `http` and `https`; cloud metadata addresses are refused under every configuration. - `POST /api/model-provider/v1/chat/completions` exists only when `OPENBOT_MODEL_OAUTH_FILE` names a model credential file, which the desktop app sets and nothing else does. It answers a Chat Completions request using a stored Google or xAI OAuth grant. It does not use the session guard: the caller presents a separate local bearer taken from that file and compared in constant time, and the provider's own refresh token never leaves the server. The credential file is refused unless it is a regular file under 64KB with no group or other permission bits, and the upstream host, path and headers are fixed so a caller cannot redirect the request. diff --git a/server/src/audit.ts b/server/src/audit.ts index 329ddbf4aa..3d70733dbf 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -179,6 +179,8 @@ export const auditEventTypes = [ * was current when it was made. The client id, never the secret. */ "mcp.oauth_client_registered", + /** A Connect attempt could not read the deployment's client. No credential or raw fault is recorded. */ + "mcp.oauth_client_read_failed", /* * One person's brokered account was exercised with a real call, to see whether it is really there. * diff --git a/server/src/plugins/plugin-store/connections.ts b/server/src/plugins/plugin-store/connections.ts index 68d09778a2..4bddf0cea3 100644 --- a/server/src/plugins/plugin-store/connections.ts +++ b/server/src/plugins/plugin-store/connections.ts @@ -187,6 +187,24 @@ export function connectionMethods(context: StoreContext) { }); }, + /** A failed Connect client lookup, attributed to the authenticated person and never to raw errors. */ + async recordOAuthClientReadFailure(input: { + serverId: string; + actorId: string; + }): Promise { + await recordAuditEvent(auditStore, { + eventType: "mcp.oauth_client_read_failed", + targetType: "mcp_server", + targetId: input.serverId, + actorUserId: input.actorId, + payload: { + server: input.serverId, + operation: "connect", + reason: "credential_read_failed", + }, + }); + }, + /** * The deployment's OAuth client for a server, or null if none is registered. * diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts index c71dd44e9d..ff2af6aa93 100644 --- a/server/src/plugins/plugin-store/context.ts +++ b/server/src/plugins/plugin-store/context.ts @@ -47,6 +47,7 @@ import { INVALID_GRANT, isUsableClient, type OAuthClient, + OAuthClientReadError, PluginInvariantError, type PluginKind, PluginRefusedError, @@ -1259,6 +1260,17 @@ export function createStoreContext(options: PluginStoreOptions) { keyId: `oauth-client-${serverId}`, }); + /** Classify only client reads, never vendor registration or subsequent vault writes. */ + async function readClient( + work: () => Promise, + ): Promise { + try { + return await work(); + } catch { + throw new OAuthClientReadError(); + } + } + /** Parse errors can quote plaintext, so an invalid client is never rethrown with its secret. */ function clientFromStoredJson(plaintext: string): OAuthClient | null { try { @@ -1314,27 +1326,29 @@ export function createStoreContext(options: PluginStoreOptions) { transaction: Transaction, serverId: string, ): Promise { - const [server] = await transaction - .select({ credentialId: mcpServers.credentialId }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .limit(1); - if (!server?.credentialId) return null; + return readClient(async () => { + const [server] = await transaction + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + if (!server?.credentialId) return null; - const [held] = await transaction - .select({ - encryptedValue: credentialRows.encryptedValue, - revokedAt: credentialRows.revokedAt, - }) - .from(credentialRows) - .where(eq(credentialRows.id, server.credentialId)) - .limit(1); - if (!held || held.revokedAt) return null; + const [held] = await transaction + .select({ + encryptedValue: credentialRows.encryptedValue, + revokedAt: credentialRows.revokedAt, + }) + .from(credentialRows) + .where(eq(credentialRows.id, server.credentialId)) + .limit(1); + if (!held || held.revokedAt) return null; - // A decryption fault does not mean the vendor forgot this client. Re-registering would replace - // the live client for every replica, including those that can still decrypt it. - const plaintext = await decryptSecret(encryptionKey, held.encryptedValue); - return clientFromStoredJson(plaintext); + // A decryption fault does not mean the vendor forgot this client. Re-registering would replace + // the live client for every replica, including those that can still decrypt it. + const plaintext = await decryptSecret(encryptionKey, held.encryptedValue); + return clientFromStoredJson(plaintext); + }); } /** @@ -1455,27 +1469,29 @@ export function createStoreContext(options: PluginStoreOptions) { async function storedOAuthClient( serverId: string, ): Promise { - const [row] = await database - .select({ credentialId: mcpServers.credentialId }) - .from(mcpServers) - .where(eq(mcpServers.id, serverId)) - .limit(1); - if (!row?.credentialId) return null; + return readClient(async () => { + const [row] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)) + .limit(1); + if (!row?.credentialId) return null; - let plaintext: string; - try { - plaintext = await decryptCredentialForUse( - encryptionKey, - credentials, - row.credentialId, - ); - } catch (error) { - // Only a confirmed missing or revoked credential permits obtaining a replacement. A failed - // read or decryption must not turn a working deployment-wide registration into a new client. - if (error instanceof CredentialUnusableError) return null; - throw error; - } - return clientFromStoredJson(plaintext); + let plaintext: string; + try { + plaintext = await decryptCredentialForUse( + encryptionKey, + credentials, + row.credentialId, + ); + } catch (error) { + // Only a confirmed missing or revoked credential permits obtaining a replacement. A failed + // read or decryption must not turn a working deployment-wide registration into a new client. + if (error instanceof CredentialUnusableError) return null; + throw error; + } + return clientFromStoredJson(plaintext); + }); } /** diff --git a/server/src/plugins/plugin-store/model.ts b/server/src/plugins/plugin-store/model.ts index 1231729e7d..6079ddb6a9 100644 --- a/server/src/plugins/plugin-store/model.ts +++ b/server/src/plugins/plugin-store/model.ts @@ -417,6 +417,14 @@ export type _ProbeOutcomeLeaves = Decides< RecordedCheck | "untouched" >; +/** A stored-client read failed. Never retains a vault error, parser error, or secret-bearing cause. */ +export class OAuthClientReadError extends Error { + constructor() { + super("The stored OAuth client could not be read."); + this.name = "OAuthClientReadError"; + } +} + export class PluginRefusedError extends Error { constructor( message: string, diff --git a/server/src/plugins/route-groups/connections.ts b/server/src/plugins/route-groups/connections.ts index 72fd2f323e..359ea4a79f 100644 --- a/server/src/plugins/route-groups/connections.ts +++ b/server/src/plugins/route-groups/connections.ts @@ -27,6 +27,7 @@ import { type AccountRef, asAccountRef } from "../shared-accounts"; import { CatalogueEntryUnknownError, type OAuthClient, + OAuthClientReadError, PluginRefusedError, } from "../store"; import { @@ -880,6 +881,18 @@ export function mountConnectionRoutes({ 409, ); } + if (error instanceof OAuthClientReadError) { + await store.recordOAuthClientReadFailure({ + serverId, + actorId: context.var.actor.id, + }); + return context.json( + { + error: `Couldn't connect ${entry.title}. Try again, or ask an admin if it keeps happening.`, + }, + 503, + ); + } throw error; } if (!client) { diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 5591ff7f4e..7e3d2c67bc 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -35,6 +35,7 @@ export { exchangeRefreshTokenOverHttp, INVALID_CLIENT, isDeploymentFault, + OAuthClientReadError, PluginInvariantError, PluginRefusedError, refFromToolName, diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 57411e31d4..8147dd6da6 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -47,6 +47,7 @@ import { } from "../src/plugins/composio"; import { actionOf } from "../src/plugins/composio-vendor"; import { redirectUriFor } from "../src/plugins/oauth"; +import { createPluginRoutes } from "../src/plugins/routes"; import type { AccountRef } from "../src/plugins/shared-accounts"; import { type AccessToken, @@ -2878,6 +2879,163 @@ describe("a dynamic client the vendor has evicted", () => { expect(registrations).toEqual([]); }); + test("Connect does not misclassify vendor registration faults as client read failures", async () => { + await clearClient(); + registrations.length = 0; + const previousIssue = issue; + const vendorFailure = new Error("vendor registration unavailable"); + issue = () => { + throw vendorFailure; + }; + const readFailures = () => + database + .select() + .from(auditEvents) + .where(eq(auditEvents.eventType, "mcp.oauth_client_read_failed")); + const before = await readFailures(); + const routes = createPluginRoutes( + dynamicStore, + async (context, next) => { + context.set("actor", { + id: dynamicUserId, + email: "person@openbot.test", + role: "user", + }); + await next(); + }, + async () => false, + { + encryptionKey: DYNAMIC_KEY, + publicUrl: "https://openbot.test", + appUrl: "https://openbot.test", + personHasAccess: async () => true, + }, + ); + const escaped: unknown[] = []; + routes.onError((error) => { + escaped.push(error); + return new Response("escaped", { status: 500 }); + }); + try { + const response = await routes.request( + `http://openbot.test/servers/${dynamicServerId}/connect`, + { method: "POST" }, + ); + expect(response.status).toBe(500); + expect(escaped).toEqual([vendorFailure]); + expect(registrations).toHaveLength(1); + expect(await readFailures()).toHaveLength(before.length); + } finally { + issue = previousIssue; + await putClient(EVICTED); + } + }); + + test.each(["wrong-key", "reader-fault"] as const)( + "Connect audits a client read fault (%s) without replacing or exposing it", + async (fault) => { + await putClient(EVICTED); + registrations.length = 0; + const sentinel = "private-client-secret-must-not-escape"; + const audit = createAuditStore(database); + const beforeEvents = await database + .select() + .from(auditEvents) + .where(eq(auditEvents.eventType, "mcp.oauth_client_read_failed")); + const [before] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, dynamicServerId)); + const failingStore = createPluginStore({ + brokerProvider: "composio", + database, + auditStore: audit, + credentials: + fault === "reader-fault" + ? { + ...vault, + readSecret: async () => { + throw new Error(sentinel); + }, + } + : vault, + encryptionKey: + fault === "wrong-key" + ? Buffer.alloc(32, 1).toString("base64") + : DYNAMIC_KEY, + policy: () => policy, + ...seams, + redirectUri: REDIRECT_URI, + }); + const routes = createPluginRoutes( + failingStore, + async (context, next) => { + context.set("actor", { + id: dynamicUserId, + email: "person@openbot.test", + role: "user", + }); + await next(); + }, + async () => false, + { + encryptionKey: DYNAMIC_KEY, + publicUrl: "https://openbot.test", + appUrl: "https://openbot.test", + personHasAccess: async () => true, + }, + ); + const escaped: unknown[] = []; + routes.onError((error) => { + escaped.push(error); + return new Response("escaped", { status: 500 }); + }); + try { + const response = await routes.request( + `http://openbot.test/servers/${dynamicServerId}/connect`, + { method: "POST" }, + ); + const body = await response.text(); + const events = await database + .select() + .from(auditEvents) + .where(eq(auditEvents.eventType, "mcp.oauth_client_read_failed")); + const written = events.filter( + (event) => !beforeEvents.some((before) => before.id === event.id), + ); + const [after] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, dynamicServerId)); + expect(response.status).toBe(503); + expect(JSON.parse(body)).toEqual({ + error: + "Couldn't connect Notion. Try again, or ask an admin if it keeps happening.", + }); + expect(escaped).toEqual([]); + expect(written).toHaveLength(1); + expect(written[0]).toMatchObject({ + actorUserId: dynamicUserId, + targetType: "mcp_server", + targetId: dynamicServerId, + payload: { + server: dynamicServerId, + operation: "connect", + reason: "credential_read_failed", + }, + }); + expect(body + JSON.stringify(written)).not.toContain(sentinel); + expect(registrations).toEqual([]); + expect(after?.credentialId).toBe(before?.credentialId); + expect(await dynamicStore.oauthClientFor(dynamicServerId)).toEqual( + EVICTED, + ); + } finally { + await putClient(EVICTED); + } + }, + ); + test.each(["before-lock", "under-lock"] as const)( "a client decryption fault %s does not replace the deployment's client", async (where) => {