A manual OAuth app registered from within an integration's connect dialog now stamps that integration on origin_integration (kind stays manual), mirroring how DCR clients already record the integration that requested them. Thread it through the createClient wire contract (HTTP API + core tool), persist it for both manual and DCR origins, and have parseOAuthClientOrigin surface it on the manual summary so the picker can match a bring-your-own app to its integration by recorded intent rather than guessing by root domain.
Unified · Split
| @@ -103,6 +103,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 102 unmodified lines | |||
| 103 | clientId: payload.clientId, | 103 | clientId: payload.clientId, |
| 104 | clientSecret: payload.clientSecret, | 104 | clientSecret: payload.clientSecret, |
| 105 | resource: payload.resource ?? null, | 105 | resource: payload.resource ?? null, |
| 106 | origin: { kind: "manual", integration: payload.originIntegration ?? null }, | ||
| 106 | }); | 107 | }); |
| 107 | return { client }; | 108 | return { client }; |
| 108 | }), | 109 | }), |
| @@ -65,6 +65,9 @@ const CreateClientPayload = Schema.Struct({ · 64 unmodified lines | |||
| 65 | clientId: Schema.String, | 65 | clientId: Schema.String, |
| 66 | clientSecret: Schema.String, | 66 | clientSecret: Schema.String, |
| 67 | resource: Schema.optional(Schema.NullOr(Schema.String)), | 67 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| 68 | /** Integration whose connect dialog registered this manual app. Recorded so | ||
| 69 | * the picker matches it to this integration by intent, not root domain. */ | ||
| 70 | originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)), | ||
| 68 | }); | 71 | }); |
| 69 | 72 | ||
| 70 | const CreateClientResponse = Schema.Struct({ | 73 | const CreateClientResponse = Schema.Struct({ |
| @@ -243,6 +243,9 @@ const OAuthCreateClientInput = Schema.Struct({ · 242 unmodified lines | |||
| 243 | grant: OAuthGrantSchema, | 243 | grant: OAuthGrantSchema, |
| 244 | clientId: Schema.String, | 244 | clientId: Schema.String, |
| 245 | resource: Schema.optional(Schema.NullOr(Schema.String)), | 245 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| 246 | /** Integration whose connect dialog registered this manual app. Recorded so | ||
| 247 | * the picker can match it by intent (exact) instead of by root domain. */ | ||
| 248 | originIntegration: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 246 | }); | 249 | }); |
| 247 | // Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret | 250 | // Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret |
| 248 | // fields the form pre-fills. The client secret is typed by the human in the web | 251 | // fields the form pre-fills. The client secret is typed by the human in the web |
| @@ -713,6 +716,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 464 unmodified lines | |||
| 713 | // `oauth.clients.createHandoff`. | 716 | // `oauth.clients.createHandoff`. |
| 714 | clientSecret: "", | 717 | clientSecret: "", |
| 715 | resource: input.resource ?? null, | 718 | resource: input.resource ?? null, |
| 719 | origin: { | ||
| 720 | kind: "manual", | ||
| 721 | integration: | ||
| 722 | input.originIntegration == null | ||
| 723 | ? null | ||
| 724 | : IntegrationSlug.make(input.originIntegration), | ||
| 725 | }, | ||
| 716 | }), | 726 | }), |
| 717 | (client) => ({ client: String(client) }), | 727 | (client) => ({ client: String(client) }), |
| 718 | ), | 728 | ), |
| @@ -64,7 +64,15 @@ export interface OAuthClient { · 63 unmodified lines | |||
| 64 | } | 64 | } |
| 65 | 65 | ||
| 66 | export type OAuthClientOrigin = | 66 | export type OAuthClientOrigin = |
| 67 | | { readonly kind: "manual" } | 67 | | { |
| 68 | readonly kind: "manual"; | ||
| 69 | /** Integration whose connect dialog registered this manual app, when the | ||
| 70 | * registration happened from within one. Lets the picker match a BYO app | ||
| 71 | * to its integration by recorded intent (exact) instead of guessing by | ||
| 72 | * root domain. Null for apps registered outside any integration context | ||
| 73 | * (and for legacy rows predating the stamp). */ | ||
| 74 | readonly integration?: IntegrationSlug | null; | ||
| 75 | } | ||
| 68 | | { | 76 | | { |
| 69 | readonly kind: "dynamic_client_registration"; | 77 | readonly kind: "dynamic_client_registration"; |
| 70 | readonly integration?: IntegrationSlug | null; | 78 | readonly integration?: IntegrationSlug | null; |
| @@ -91,7 +91,9 @@ describe("oauth.listClients", () => { · 90 unmodified lines | |||
| 91 | tokenUrl: "https://acme.test/token", | 91 | tokenUrl: "https://acme.test/token", |
| 92 | resource: null, | 92 | resource: null, |
| 93 | clientId: "org-client-id", | 93 | clientId: "org-client-id", |
| 94 | origin: { kind: "manual" }, | 94 | // Manual apps carry a nullable recorded-intent integration; a client |
| 95 | // created outside any integration dialog stamps null. | ||
| 96 | origin: { kind: "manual", integration: null }, | ||
| 95 | }); | 97 | }); |
| 96 | expect(user!.owner).toBe("user"); | 98 | expect(user!.owner).toBe("user"); |
| 97 | expect(user!.grant).toBe("client_credentials"); | 99 | expect(user!.grant).toBe("client_credentials"); |
| @@ -367,7 +367,11 @@ const parseOAuthClientOrigin = (row: { · 366 unmodified lines | |||
| 367 | ) { | 367 | ) { |
| 368 | return { kind: "dynamic_client_registration", integration: null }; | 368 | return { kind: "dynamic_client_registration", integration: null }; |
| 369 | } | 369 | } |
| 370 | return { kind: "manual" }; | 370 | return { |
| 371 | kind: "manual", | ||
| 372 | integration: | ||
| 373 | row.origin_integration == null ? null : IntegrationSlug.make(String(row.origin_integration)), | ||
| 374 | }; | ||
| 371 | }; | 375 | }; |
| 372 | 376 | ||
| 373 | interface LoadedOAuthClient { | 377 | interface LoadedOAuthClient { |
| @@ -614,12 +618,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 240 unmodified lines | |||
| 614 | client_secret_item_id: clientSecretItemIdValue, | 618 | client_secret_item_id: clientSecretItemIdValue, |
| 615 | resource: input.resource ?? null, | 619 | resource: input.resource ?? null, |
| 616 | origin_kind: input.origin?.kind ?? "manual", | 620 | origin_kind: input.origin?.kind ?? "manual", |
| 621 | // Recorded intent, kept for BOTH origins: a manual app registered from | ||
| 622 | // an integration's dialog stamps its integration so the picker can | ||
| 623 | // match it exactly, the same way a DCR client records the integration | ||
| 624 | // that requested it. | ||
| 617 | origin_integration: | 625 | origin_integration: |
| 618 | input.origin?.kind === "dynamic_client_registration" | 626 | input.origin?.integration == null ? null : String(input.origin.integration), |
| 619 | ? input.origin.integration == null | ||
| 620 | ? null | ||
| 621 | : String(input.origin.integration) | ||
| 622 | : null, | ||
| 623 | origin_issuer: | 627 | origin_issuer: |
| 624 | input.origin?.kind === "dynamic_client_registration" | 628 | input.origin?.kind === "dynamic_client_registration" |
| 625 | ? (canonicalIssuerUrl(input.originIssuer) ?? null) | 629 | ? (canonicalIssuerUrl(input.originIssuer) ?? null) |
| @@ -110,6 +110,7 @@ export { · 109 unmodified lines | |||
| 110 | type OAuthGrant, | 110 | type OAuthGrant, |
| 111 | type OAuthAuthentication, | 111 | type OAuthAuthentication, |
| 112 | type OAuthClient, | 112 | type OAuthClient, |
| 113 | type OAuthClientOrigin, | ||
| 113 | type OAuthClientSummary, | 114 | type OAuthClientSummary, |
| 114 | type CreateOAuthClientInput, | 115 | type CreateOAuthClientInput, |
| 115 | type RegisterDynamicClientInput, | 116 | type RegisterDynamicClientInput, |