// ---------------------------------------------------------------------------// Garbage collection + backfill for legacy Dynamic Client Registration (DCR)// `oauth_client` rows (issue #1120, Part C).//// Old always-register behavior minted a fresh `oauth_client` per connect// attempt ("cloudflare-mcp", "cloudflare-mcp-2", …). Parts A+B stopped the// multiplication (per-AS reuse) and hid the rows from pickers, but the dead// rows remain and legacy rows have `origin_issuer = NULL`, so the reuse lookup// can't key on them. This module supplies the shared, testable predicates that// both host paths (local libSQL boot migration, cloud Drizzle SQL migration)// encode://// 1. GC: delete a row that is classified DCR AND has ZERO referencing// connections. The predicate is conjunctive and fail-safe — an ambiguous// row (manual, or DCR but still referenced) is always KEPT.// 2. Backfill: for a surviving DCR row with `origin_issuer IS NULL`, set it// to the registrable origin of `token_url` so the per-AS reuse lookup can// find it and mint no new duplicate.//// The predicates here are the SINGLE source of truth, imported (never// re-encoded) by every call site: `oauth-service`'s `parseOAuthClientOrigin`// calls `isDcrClassifiedRow` for the runtime reuse lookup, and BOTH GC// migrations (the local libSQL boot migration and the cloud code migration)// import `classifyOAuthClientGc` / `registrableOriginOfUrl` and decide in// process rather than rewriting the logic as SQL. There is no parallel SQL copy// to drift: changing a predicate here changes all three call sites at once.// ---------------------------------------------------------------------------import { getDomain } from "tldts";/** Parse a string into a URL, or null when it is not a valid absolute URL. */export const parseUrl = (value: string): URL | null => { if (!URL.canParse(value)) return null; return new URL(value);};/** Canonicalize a discovered issuer to `origin` + path with trailing slashes * stripped (RFC 8414 issuers are compared without a trailing slash). Null for * a blank or unparseable value. Part A's DCR-identity canonicalization. */export const canonicalIssuerUrl = (value: string | null | undefined): string | null => { if (value == null) return null; const trimmed = value.trim(); if (trimmed.length === 0) return null; const url = parseUrl(trimmed); if (url === null) return null; const path = url.pathname.replace(/\/+$/g, ""); return path.length > 0 ? `${url.origin}${path}` : url.origin;};export const hostOfUrl = (value: string): string | null => parseUrl(value)?.host.toLowerCase() ?? null;/** The registrable domain of a hostname (eTLD+1), backed by the full Public * Suffix List via `tldts` (so `api.foo.co.uk` → `foo.co.uk`, `x.co.in` → * `x.co.in`, etc., not a hardcoded suffix subset). `tldts.getDomain` returns * null for hosts with no registrable domain (`localhost`, bare IPv4/IPv6, * single-label hosts); those fall back to the raw lowercase hostname so * local-dev token hosts still key on their exact host. This mirrors how * `packages/react/src/plugins/use-effective-oauth-client.tsx` uses tldts. */export const registrableHostname = (hostname: string): string => { const host = hostname.toLowerCase(); return getDomain(host) ?? host;};/** The registrable ORIGIN of a URL: its scheme + the registrable host (dropping * any subdomain). Used to backfill a legacy DCR row's `origin_issuer` from its * `token_url`, so the per-AS reuse lookup can find it. Null when not a URL. * * Preserves scheme and port (a loopback `http://127.0.0.1:8787` token host * keeps its port). Reuses `registrableHostname` — no duplicated eTLD logic. */export const registrableOriginOfUrl = (value: string): string | null => { const url = parseUrl(value); if (url === null) return null; const registrable = registrableHostname(url.hostname); // When the hostname collapses to a registrable domain, rebuild the origin // from scheme + registrable host + original port; otherwise keep url.origin // verbatim (covers localhost / IPv4 / already-registrable hosts, incl. port). if (registrable === url.hostname.toLowerCase()) return url.origin; const port = url.port.length > 0 ? `:${url.port}` : ""; return `${url.protocol}//${registrable}${port}`;};/** The minimal shape the DCR classifier reads off an `oauth_client` row. Every * field is optional/unknown so it applies equally to a fuma row, a raw SQL * row, or a hand-built test fixture. */export interface OAuthClientGcRow { readonly grant?: unknown; readonly resource?: unknown; readonly origin_kind?: unknown;}/** * Is this `oauth_client` row a Dynamic Client Registration client? * * True when EITHER: * - it carries the explicit `origin_kind = 'dynamic_client_registration'` * stamp (rows minted after Part A), OR * - it is a legacy null-origin row with an `authorization_code` grant AND a * non-empty `resource`. * * The `resource` parameter (RFC 8707) is only ever set by the DCR/MCP connect * path, never by the manual app form: a hand-registered app has no resource. * So on a legacy (null origin_kind) row, an auth-code grant carrying a resource * is a DCR client. This was validated against the production database: of the * 394 rows that carry an explicit origin_kind stamp, `grant = * 'authorization_code' AND resource IS NOT NULL` matches all 329 stamped DCR * rows (100% recall). It also matches a handful of stamped-manual BYO rows * that point at MCP servers, but those never reach this arm: an explicit * stamp always wins. The earlier slug/resource `…mcp…` regex heuristic had * bad recall (it missed legacy rows whose slug was a bare provider name like * `linear` / `notion` / `cloudflare`), so it is gone; the resource presence * check subsumes it. * * Explicit stamps always win (arm 1): a stamped `manual` BYO row keeps its * classification even when it carries a resource, and a stamped DCR row is DCR * even with no resource. The resource heuristic only decides legacy null-origin * rows. A legacy row that does not match either arm is treated as manual and is * NEVER a GC candidate — the conservative default that protects hand-registered * apps. */export const isDcrClassifiedRow = (row: OAuthClientGcRow): boolean => { if (row.origin_kind === "dynamic_client_registration") return true; if (row.origin_kind != null) return false; if (row.grant !== "authorization_code") return false; const resource = row.resource == null ? "" : String(row.resource); return resource.length > 0;};/** The GC decision for one `oauth_client` row. */export type OAuthClientGcDecision = /** Classified DCR AND unreferenced — safe to delete. */ | { readonly action: "delete"; readonly reason: "dcr-orphaned" } /** Kept — either not DCR, or DCR but still referenced by a connection. */ | { readonly action: "keep"; readonly reason: "not-dcr" | "referenced" };/** * Decide whether a single `oauth_client` row should be garbage-collected. * * The deletion predicate is strictly conjunctive and fail-safe: * delete ⇔ isDcrClassifiedRow(row) AND referencingConnectionCount === 0 * * Anything else is kept. A manual app is kept even when orphaned (users delete * their own apps deliberately); a DCR app is kept while any connection still * references it (deleting it would break refresh/reconnect for that * connection). `referencingConnectionCount` is the number of `connection` rows * whose stored `(oauth_client_owner, oauth_client)` reference resolves to this * row's `(owner, slug)`. */export const classifyOAuthClientGc = ( row: OAuthClientGcRow, referencingConnectionCount: number,): OAuthClientGcDecision => { if (!isDcrClassifiedRow(row)) return { action: "keep", reason: "not-dcr" }; if (referencingConnectionCount > 0) return { action: "keep", reason: "referenced" }; return { action: "delete", reason: "dcr-orphaned" };};
// ---------------------------------------------------------------------------
// Garbage collection + backfill for legacy Dynamic Client Registration (DCR)
// `oauth_client` rows (issue #1120, Part C).
//
// Old always-register behavior minted a fresh `oauth_client` per connect
// attempt ("cloudflare-mcp", "cloudflare-mcp-2", …). Parts A+B stopped the
// multiplication (per-AS reuse) and hid the rows from pickers, but the dead
// rows remain and legacy rows have `origin_issuer = NULL`, so the reuse lookup
// can't key on them. This module supplies the shared, testable predicates that
// both host paths (local libSQL boot migration, cloud Drizzle SQL migration)
// encode:
//
// 1. GC: delete a row that is classified DCR AND has ZERO referencing
// connections. The predicate is conjunctive and fail-safe — an ambiguous
// row (manual, or DCR but still referenced) is always KEPT.
// 2. Backfill: for a surviving DCR row with `origin_issuer IS NULL`, set it
// to the registrable origin of `token_url` so the per-AS reuse lookup can
// find it and mint no new duplicate.
//
// The predicates here are the SINGLE source of truth, imported (never
// re-encoded) by every call site: `oauth-service`'s `parseOAuthClientOrigin`
// calls `isDcrClassifiedRow` for the runtime reuse lookup, and BOTH GC
// migrations (the local libSQL boot migration and the cloud code migration)
// import `classifyOAuthClientGc` / `registrableOriginOfUrl` and decide in
// process rather than rewriting the logic as SQL. There is no parallel SQL copy
// to drift: changing a predicate here changes all three call sites at once.
// ---------------------------------------------------------------------------
import { getDomain } from "tldts";
/** Parse a string into a URL, or null when it is not a valid absolute URL. */
export const parseUrl = (value: string): URL | null => {
if (!URL.canParse(value)) return null;
return new URL(value);
};
/** Canonicalize a discovered issuer to `origin` + path with trailing slashes
* stripped (RFC 8414 issuers are compared without a trailing slash). Null for
* a blank or unparseable value. Part A's DCR-identity canonicalization. */
export const canonicalIssuerUrl = (value: string | null | undefined): string | null => {
if (value == null) return null;
const trimmed = value.trim();
if (trimmed.length === 0) return null;
const url = parseUrl(trimmed);
if (url === null) return null;
const path = url.pathname.replace(/\/+$/g, "");
return path.length > 0 ? `${url.origin}${path}` : url.origin;
};
export const hostOfUrl = (value: string): string | null =>
parseUrl(value)?.host.toLowerCase() ?? null;
/** The registrable domain of a hostname (eTLD+1), backed by the full Public
* Suffix List via `tldts` (so `api.foo.co.uk` → `foo.co.uk`, `x.co.in` →
* `x.co.in`, etc., not a hardcoded suffix subset). `tldts.getDomain` returns
* null for hosts with no registrable domain (`localhost`, bare IPv4/IPv6,
* single-label hosts); those fall back to the raw lowercase hostname so
* local-dev token hosts still key on their exact host. This mirrors how
* `packages/react/src/plugins/use-effective-oauth-client.tsx` uses tldts. */
export const registrableHostname = (hostname: string): string => {
const host = hostname.toLowerCase();
return getDomain(host) ?? host;
};
/** The registrable ORIGIN of a URL: its scheme + the registrable host (dropping
* any subdomain). Used to backfill a legacy DCR row's `origin_issuer` from its
* `token_url`, so the per-AS reuse lookup can find it. Null when not a URL.
*
* Preserves scheme and port (a loopback `http://127.0.0.1:8787` token host
* keeps its port). Reuses `registrableHostname` — no duplicated eTLD logic. */
export const registrableOriginOfUrl = (value: string): string | null => {
const url = parseUrl(value);
if (url === null) return null;
const registrable = registrableHostname(url.hostname);
// When the hostname collapses to a registrable domain, rebuild the origin
// from scheme + registrable host + original port; otherwise keep url.origin
// verbatim (covers localhost / IPv4 / already-registrable hosts, incl. port).
if (registrable === url.hostname.toLowerCase()) return url.origin;
const port = url.port.length > 0 ? `:${url.port}` : "";
return `${url.protocol}//${registrable}${port}`;
};
/** The minimal shape the DCR classifier reads off an `oauth_client` row. Every
* field is optional/unknown so it applies equally to a fuma row, a raw SQL
* row, or a hand-built test fixture. */
export interface OAuthClientGcRow {
readonly grant?: unknown;
readonly resource?: unknown;
readonly origin_kind?: unknown;
}
/**
* Is this `oauth_client` row a Dynamic Client Registration client?
*
* True when EITHER:
* - it carries the explicit `origin_kind = 'dynamic_client_registration'`
* stamp (rows minted after Part A), OR
* - it is a legacy null-origin row with an `authorization_code` grant AND a
* non-empty `resource`.
*
* The `resource` parameter (RFC 8707) is only ever set by the DCR/MCP connect
* path, never by the manual app form: a hand-registered app has no resource.
* So on a legacy (null origin_kind) row, an auth-code grant carrying a resource
* is a DCR client. This was validated against the production database: of the
* 394 rows that carry an explicit origin_kind stamp, `grant =
* 'authorization_code' AND resource IS NOT NULL` matches all 329 stamped DCR
* rows (100% recall). It also matches a handful of stamped-manual BYO rows
* that point at MCP servers, but those never reach this arm: an explicit
* stamp always wins. The earlier slug/resource `…mcp…` regex heuristic had
* bad recall (it missed legacy rows whose slug was a bare provider name like
* `linear` / `notion` / `cloudflare`), so it is gone; the resource presence
* check subsumes it.
*
* Explicit stamps always win (arm 1): a stamped `manual` BYO row keeps its
* classification even when it carries a resource, and a stamped DCR row is DCR
* even with no resource. The resource heuristic only decides legacy null-origin
* rows. A legacy row that does not match either arm is treated as manual and is
* NEVER a GC candidate — the conservative default that protects hand-registered
* apps.
*/
export const isDcrClassifiedRow = (row: OAuthClientGcRow): boolean => {
if (row.origin_kind === "dynamic_client_registration") return true;
if (row.origin_kind != null) return false;
if (row.grant !== "authorization_code") return false;
const resource = row.resource == null ? "" : String(row.resource);
return resource.length > 0;
};
/** The GC decision for one `oauth_client` row. */
export type OAuthClientGcDecision =
/** Classified DCR AND unreferenced — safe to delete. */
| { readonly action: "delete"; readonly reason: "dcr-orphaned" }
/** Kept — either not DCR, or DCR but still referenced by a connection. */
| { readonly action: "keep"; readonly reason: "not-dcr" | "referenced" };
/**
* Decide whether a single `oauth_client` row should be garbage-collected.
*
* The deletion predicate is strictly conjunctive and fail-safe:
* delete ⇔ isDcrClassifiedRow(row) AND referencingConnectionCount === 0
*
* Anything else is kept. A manual app is kept even when orphaned (users delete
* their own apps deliberately); a DCR app is kept while any connection still
* references it (deleting it would break refresh/reconnect for that
* connection). `referencingConnectionCount` is the number of `connection` rows
* whose stored `(oauth_client_owner, oauth_client)` reference resolves to this
* row's `(owner, slug)`.
*/
export const classifyOAuthClientGc = (
row: OAuthClientGcRow,
referencingConnectionCount: number,
): OAuthClientGcDecision => {
if (!isDcrClassifiedRow(row)) return { action: "keep", reason: "not-dcr" };
if (referencingConnectionCount > 0) return { action: "keep", reason: "referenced" };
return { action: "delete", reason: "dcr-orphaned" };
};