dcrCandidatesForIssuer previously matched a null-origin_issuer row by comparing registrable token hosts. Now a candidate matches only via dcrIssuerMatches on a non-null, canonicalized stored issuer. The GC migration backfills origin_issuer on every surviving DCR row, so post-migration the token-host fallback is dead code; in the brief deploy->migration window a missed lookup just mints one duplicate that the migration immediately GCs, which is safer than reusing on a fuzzy token-host guess. Drops the now-unused tokenUrl parameter and the registrableHostOfUrl import. register-dynamic tests: the old null-issuer-token-host reuse test becomes two cases: a null-issuer legacy row is NOT reused (mints fresh), and a backfilled row (origin_issuer set) IS reused with no duplicate.
Unified · Split
| @@ -104,10 +104,12 @@ export interface OAuthClientGcRow { · 103 unmodified lines | ||
| 104 | 104 | * is a DCR client. This was validated against the production database: of the |
| 105 | 105 | * 394 rows that carry an explicit origin_kind stamp, `grant = |
| 106 | 106 | * 'authorization_code' AND resource IS NOT NULL` matches all 329 stamped DCR |
| 107 | − * rows and zero stamped-manual rows (100% recall, no false positives). The | |
| 108 | − * earlier slug/resource `…mcp…` regex heuristic had bad recall (it missed | |
| 109 | − * legacy rows whose slug was a bare provider name like `linear` / `notion` / | |
| 110 | − * `cloudflare`), so it is gone; the resource presence check subsumes it. | |
| 107 | + * rows (100% recall). It also matches a handful of stamped-manual BYO rows | |
| 108 | + * that point at MCP servers, but those never reach this arm: an explicit | |
| 109 | + * stamp always wins. The earlier slug/resource `…mcp…` regex heuristic had | |
| 110 | + * bad recall (it missed legacy rows whose slug was a bare provider name like | |
| 111 | + * `linear` / `notion` / `cloudflare`), so it is gone; the resource presence | |
| 112 | + * check subsumes it. | |
| 111 | 113 | * |
| 112 | 114 | * Explicit stamps always win (arm 1): a stamped `manual` BYO row keeps its |
| 113 | 115 | * classification even when it carries a resource, and a stamped DCR row is DCR |
| @@ -252,9 +252,14 @@ describe("oauth.registerDynamicClient", () => { · 251 unmodified lines | ||
| 252 | 252 | ), |
| 253 | 253 | ); |
| 254 | 254 | |
| 255 | − it.effect("reuses legacy DCR-looking rows with no stored issuer by token host", () => | |
| 255 | + it.effect("does NOT reuse a legacy DCR row with no stored issuer (mints fresh)", () => | |
| 256 | 256 | Effect.scoped( |
| 257 | 257 | Effect.gen(function* () { |
| 258 | + // Post-migration, the reuse lookup keys strictly on a non-null | |
| 259 | + // origin_issuer: the fuzzy token-host fallback is gone. A legacy row that | |
| 260 | + // has not yet been backfilled (null origin_issuer) is therefore NOT | |
| 261 | + // reused, so a fresh DCR registration happens. The GC migration then | |
| 262 | + // backfills/GCs any duplicate this transient window mints. | |
| 258 | 263 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 259 | 264 | const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); |
| 260 | 265 | yield* executor.acme.seed(); |
| @@ -279,6 +284,64 @@ describe("oauth.registerDynamicClient", () => { · 18 unmodified lines | ||
| 279 | 284 | ); |
| 280 | 285 | yield* server.clearRequests; |
| 281 | 286 | |
| 287 | + const registered = yield* executor.oauth.registerDynamicClient({ | |
| 288 | + owner: "org", | |
| 289 | + slug: OAuthClientSlug.make("new-attempt"), | |
| 290 | + issuer: probe.issuer, | |
| 291 | + registrationEndpoint: probe.registrationEndpoint!, | |
| 292 | + authorizationUrl: probe.authorizationUrl, | |
| 293 | + tokenUrl: probe.tokenUrl, | |
| 294 | + resource: server.mcpResourceUrl, | |
| 295 | + scopes: ["read"], | |
| 296 | + tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | |
| 297 | + clientName: "Acme DCR", | |
| 298 | + redirectUri: FLOW_REDIRECT_URI, | |
| 299 | + originIntegration: INTEG, | |
| 300 | + }); | |
| 301 | + | |
| 302 | + // A fresh client is registered, not the null-issuer legacy row. | |
| 303 | + expect(registered).not.toBe(legacySlug); | |
| 304 | + const requests = yield* server.requests; | |
| 305 | + expect(registerRequestCount(requests)).toBe(1); | |
| 306 | + }), | |
| 307 | + ), | |
| 308 | + ); | |
| 309 | + | |
| 310 | + it.effect("reuses a legacy DCR row once its origin_issuer is backfilled", () => | |
| 311 | + Effect.scoped( | |
| 312 | + Effect.gen(function* () { | |
| 313 | + // The post-backfill counterpart: after the GC migration stamps a legacy | |
| 314 | + // row's origin_issuer, the reuse lookup keys on it and mints no | |
| 315 | + // duplicate. This is the steady state the migration establishes. | |
| 316 | + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | |
| 317 | + const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); | |
| 318 | + yield* executor.acme.seed(); | |
| 319 | + const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | |
| 320 | + const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); | |
| 321 | + | |
| 322 | + yield* executor.oauth.createClient({ | |
| 323 | + owner: "org", | |
| 324 | + slug: legacySlug, | |
| 325 | + authorizationUrl: probe.authorizationUrl, | |
| 326 | + tokenUrl: probe.tokenUrl, | |
| 327 | + resource: server.mcpResourceUrl, | |
| 328 | + grant: "authorization_code", | |
| 329 | + clientId: "legacy-dcr-client", | |
| 330 | + clientSecret: "", | |
| 331 | + }); | |
| 332 | + // Simulate the migration's backfill: legacy DCR stamp + issuer set. | |
| 333 | + yield* Effect.promise(() => | |
| 334 | + config.db.updateMany("oauth_client", { | |
| 335 | + where: (b) => b("slug", "=", String(legacySlug)), | |
| 336 | + set: { | |
| 337 | + origin_kind: "dynamic_client_registration", | |
| 338 | + origin_integration: null, | |
| 339 | + origin_issuer: probe.issuer, | |
| 340 | + }, | |
| 341 | + }), | |
| 342 | + ); | |
| 343 | + yield* server.clearRequests; | |
| 344 | + | |
| 282 | 345 | const reused = yield* executor.oauth.registerDynamicClient({ |
| 283 | 346 | owner: "org", |
| 284 | 347 | slug: OAuthClientSlug.make("new-attempt"), |
| @@ -70,13 +70,7 @@ import { · 69 unmodified lines | ||
| 70 | 70 | type OAuthEndpointUrlPolicy, |
| 71 | 71 | } from "./oauth-helpers"; |
| 72 | 72 | import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth"; |
| 73 | −import { | |
| 74 | − canonicalIssuerUrl, | |
| 75 | − hostOfUrl, | |
| 76 | − isDcrClassifiedRow, | |
| 77 | − parseUrl, | |
| 78 | − registrableHostOfUrl, | |
| 79 | −} from "./oauth-gc"; | |
| 73 | +import { canonicalIssuerUrl, hostOfUrl, isDcrClassifiedRow, parseUrl } from "./oauth-gc"; | |
| 80 | 74 | |
| 81 | 75 | /** Connection-minting input for the OAuth flow — extends a connection create |
| 82 | 76 | * with the OAuth lifecycle fields (client slug, refresh material, expiry, |
| @@ -677,7 +671,6 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 594 unmodified lines | ||
| 677 | 671 | const dcrCandidatesForIssuer = ( |
| 678 | 672 | owner: Owner, |
| 679 | 673 | issuer: string | null, |
| 680 | − tokenUrl: string, | |
| 681 | 674 | ): Effect.Effect<readonly DcrReuseCandidate[], StorageFailure> => |
| 682 | 675 | deps.fuma |
| 683 | 676 | .use("oauth_client.findMany", (db) => |
| @@ -687,17 +680,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 3 unmodified lines | ||
| 687 | 680 | ) |
| 688 | 681 | .pipe( |
| 689 | 682 | Effect.map((rows) => { |
| 690 | − const inputTokenHost = registrableHostOfUrl(tokenUrl); | |
| 691 | 683 | const matches = rows.flatMap( |
| 692 | 684 | (row): readonly (DcrReuseCandidate & { readonly createdAt: number })[] => { |
| 693 | 685 | if (parseOAuthClientOrigin(row).kind !== "dynamic_client_registration") return []; |
| 686 | + // A candidate matches only via a non-null, canonicalized stored | |
| 687 | + // issuer. The GC migration backfills origin_issuer on every | |
| 688 | + // surviving DCR row, so post-migration a null-issuer row is a | |
| 689 | + // transient (unmigrated) row; skipping it just mints one duplicate | |
| 690 | + // the migration then GCs, rather than reusing on a fuzzy token-host | |
| 691 | + // guess. | |
| 694 | 692 | const rowIssuer = |
| 695 | 693 | row.origin_issuer == null ? null : canonicalIssuerUrl(String(row.origin_issuer)); |
| 696 | − const issuerMatches = | |
| 697 | − rowIssuer !== null | |
| 698 | − ? dcrIssuerMatches(rowIssuer, issuer) | |
| 699 | − : inputTokenHost !== null && | |
| 700 | − registrableHostOfUrl(String(row.token_url)) === inputTokenHost; | |
| 694 | + const issuerMatches = rowIssuer !== null && dcrIssuerMatches(rowIssuer, issuer); | |
| 701 | 695 | if (!issuerMatches) return []; |
| 702 | 696 | return [ |
| 703 | 697 | { |
| @@ -733,7 +727,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 29 unmodified lines | ||
| 733 | 727 | StorageFailure |
| 734 | 728 | > => |
| 735 | 729 | Effect.gen(function* () { |
| 736 | − const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer, input.tokenUrl); | |
| 730 | + const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer); | |
| 737 | 731 | const resource = input.resource ?? null; |
| 738 | 732 | if (resource !== null) { |
| 739 | 733 | const matchingResource = candidates.find((client) => client.resource === resource); |