(owner: Owner, slug: OAuthClientSlug)
| 940 | // op never cascades into connections). |
| 941 | // ----------------------------------------------------------------------- |
| 942 | const removeClient = (owner: Owner, slug: OAuthClientSlug): Effect.Effect<void, StorageFailure> => |
| 943 | Effect.gen(function* () { |
| 944 | // Config-declared apps have no row to remove; removing one is an env |
| 945 | // change on the host, not a storage operation. Fail loudly rather than |
| 946 | // returning a success that changed nothing. |
| 947 | if (isFirstPartyOAuthClientSlug(String(slug))) { |
| 948 | return yield* new StorageError({ |
| 949 | message: `OAuth client "${String(slug)}" is a first-party app declared in host config; it cannot be removed through this surface.`, |
| 950 | cause: undefined, |
| 951 | }); |
| 952 | } |
| 953 | // "Is there an app at (owner, slug) right now?" — asked twice, for two |
| 954 | // different reasons. Before the delete it says whether this call removes |
| 955 | // anything at all; after the commit it says whether the secret key still |
| 956 | // belongs to the app this call removed. |
| 957 | const findClientRow = deps.fuma.use("oauth_client.findFirst", (db) => |
| 958 | looseDb(db).findFirst("oauth_client", { |
| 959 | where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), |
| 960 | }), |
| 961 | ); |
| 962 | |
| 963 | const removedRow = yield* findClientRow; |
| 964 | yield* deps.fuma |
| 965 | .use("oauth_client.delete", (db) => |
| 966 | looseDb(db).deleteMany("oauth_client", { |
| 967 | where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), |
| 968 | }), |
| 969 | ) |
| 970 | .pipe(Effect.asVoid); |
| 971 | // Nothing matched, so this call removed nothing and owns no secret. The |
| 972 | // idempotent no-op and the cross-subject miss both land here, and both |
| 973 | // used to queue a delete of a key they never had a claim on. |
| 974 | if (!removedRow) return; |
| 975 | |
| 976 | // Best-effort: drop the secret from the provider so it isn't orphaned. |
| 977 | // |
| 978 | // Deferred to the outermost commit. This function opens no transaction of |
| 979 | // its own, but a caller can wrap it in one — and `provider.delete` reaches |
| 980 | // a store that does not roll back with it. An abort would then restore the |
| 981 | // client row while its secret stayed destroyed, leaving a client that |
| 982 | // looks configured and can never authenticate again. Orphaning a secret is |
| 983 | // recoverable; deleting one that is still referenced is not, so the |
| 984 | // deletion waits until the row's removal is durable. With no transaction |
| 985 | // active `afterCommit` runs it immediately, which is the behaviour this |
| 986 | // path already had. |
| 987 | const provider = deps.defaultWritableProvider(); |
| 988 | const dropSecret = provider?.delete; |
| 989 | if (provider && dropSecret) { |
| 990 | yield* afterCommit( |
| 991 | Effect.gen(function* () { |
| 992 | // Deferral alone is not enough: the secret is keyed by (owner, slug) |
| 993 | // ALONE, so the key outlives the row it belonged to. If the same |
| 994 | // slug is registered again before this hook runs, the key now holds |
| 995 | // the NEW app's secret, and deleting it recreates exactly the state |
| 996 | // the deferral exists to prevent — a client that looks configured |
| 997 | // and can never authenticate. Re-check that the app is still gone |
| 998 | // and stand down when it is not. A re-check that FAILS is caught |
| 999 | // below and also stands down, which is the deliberate direction: |
nothing calls this directly
no test coverage detected