History

Add first-party OAuth clients (host-operated GitHub/Google apps)

Rhys Sullivan committed 3 months ago

Commit 8d46cc51 parent 4263f3317 files · +826 −41Browse files

Unified · Split

17 files
  1. apps/cloud/src/engine/execution-stack.ts +31 −1
  2. apps/cloud/src/env-augment.d.ts +10 −0
  3. e2e/scenarios/first-party-oauth.test.ts +135 −0
  4. e2e/setup/cloud.boot.ts +5 −0
  5. packages/core/api/src/oauth/api.ts +7 −0
  6. packages/core/api/src/server/scoped-executor.ts +10 −0
  7. packages/core/sdk/src/executor.ts +67 −19
  8. packages/core/sdk/src/index.ts +5 −0
  9. packages/core/sdk/src/oauth-client.ts +48 −1
  10. packages/core/sdk/src/oauth-first-party.test.ts +290 −0
  11. packages/core/sdk/src/oauth-service.ts +94 −5
  12. packages/core/sdk/src/shared.ts +4 −0
  13. packages/core/sdk/src/test-config.ts +2 −0
  14. packages/plugins/openapi/src/sdk/presets.ts +16 −0
  15. packages/react/src/components/add-account-modal.tsx +12 −3
  16. packages/react/src/plugins/use-effective-oauth-client.test.ts +59 −0
  17. packages/react/src/plugins/use-effective-oauth-client.tsx +31 −12
apps/cloud/src/engine/execution-stack.ts+31−1
@@ -43,7 +43,7 @@ import { · 42 unmodified lines
4343 collectTables,
4444 } from "@executor-js/api/server";
4545 import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
46−import type { AnyPlugin } from "@executor-js/sdk";
46+import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk";
4747
4848 import executorConfig from "../../executor.config";
4949 import { DbService } from "../db/db";
@@ -88,6 +88,35 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 38 unmodified lines
8888 */
8989 export const CLOUD_MOUNT_PREFIX = "/api" as const;
9090
91+// Executor-owned provider apps, enabled per provider by setting BOTH env vars
92+// (id + secret). Each provider-side registration must list
93+// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
94+// travels inside OAuth `state`, so the single static callback serves every org.
95+const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [
96+ ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET
97+ ? [
98+ {
99+ name: "github",
100+ authorizationUrl: "https://github.com/login/oauth/authorize",
101+ tokenUrl: "https://github.com/login/oauth/access_token",
102+ clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID,
103+ clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET,
104+ },
105+ ]
106+ : []),
107+ ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET
108+ ? [
109+ {
110+ name: "google",
111+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
112+ tokenUrl: "https://oauth2.googleapis.com/token",
113+ clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID,
114+ clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET,
115+ },
116+ ]
117+ : []),
118+];
119+
91120 export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
92121 // SSRF / private-network egress guard. Config-driven, NOT a test flag:
93122 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
@@ -99,6 +128,7 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 5 unmodified lines
99128 // WorkOS Vault is cloud's credential storage implementation detail, not a
100129 // user-selectable provider surface.
101130 exposeCredentialProviders: false,
131+ firstPartyOAuthClients: cloudFirstPartyOAuthClients(),
102132 }));
103133
104134 export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(
apps/cloud/src/env-augment.d.ts+10−0
@@ -53,6 +53,16 @@ declare global { · 52 unmodified lines
5353 // number to drive the backstop. Production leaves it unset.
5454 EXECUTION_RATE_LIMIT_PER_HOUR?: string;
5555
56+ // First-party OAuth apps (executor-owned provider registrations). Each
57+ // pair enables one-click connect through `first-party:<provider>`; an
58+ // unset pair simply ships no first-party app for that provider. The
59+ // registered callback on the provider side must be
60+ // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`.
61+ FIRST_PARTY_GITHUB_CLIENT_ID?: string;
62+ FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;
63+ FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
64+ FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;
65+
5666 // Billing
5767 AUTUMN_SECRET_KEY?: string;
5868 /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */
e2e/scenarios/first-party-oauth.test.ts+135−0
@@ -0,0 +1,135 @@
1+// First-party OAuth clients: the cloud host declares executor-owned apps via
2+// env (`FIRST_PARTY_GITHUB_CLIENT_ID/SECRET`, set by the e2e cloud boot), and
3+// every org can connect through them with nothing to paste. Three guarantees:
4+//
5+// 1. Listing: `oauth.listClients` surfaces `first-party:github` with a
6+// `first_party` origin and its public client id — no create call ever ran.
7+// 2. Flow: `oauth.start` through the first-party slug redirects to the
8+// provider's authorize endpoint carrying the env-configured client id and
9+// this platform's `/api/oauth/callback` — proof the config-resolved
10+// identity (not a stored row) drives the flow. The redirect is asserted,
11+// never followed: github.com is not visited.
12+// 3. Guardrails: the reserved `first-party:` namespace is rejected by
13+// createClient, so no org can shadow the host's app with its own row.
14+import { randomBytes } from "node:crypto";
15+
16+import { expect } from "@effect/vitest";
17+import { Effect } from "effect";
18+import { composePluginApi } from "@executor-js/api/server";
19+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
20+import {
21+ AuthTemplateSlug,
22+ ConnectionName,
23+ IntegrationSlug,
24+ OAuthClientSlug,
25+} from "@executor-js/sdk/shared";
26+
27+import { scenario } from "../src/scenario";
28+import { Api, Target } from "../src/services";
29+
30+const api = composePluginApi([openApiHttpPlugin()] as const);
31+
32+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
33+
34+/** A minimal integration whose OAuth template points at GitHub's endpoints, so
35+ * the first-party `first-party:github` app is the matching client for it. */
36+const githubShapedIntegrationSpec = {
37+ spec: {
38+ kind: "blob" as const,
39+ value: JSON.stringify({
40+ openapi: "3.0.3",
41+ info: { title: "GitHub-shaped API", version: "1.0.0" },
42+ paths: {
43+ "/user": {
44+ get: {
45+ operationId: "getUser",
46+ tags: ["default"],
47+ responses: { "200": { description: "the caller" } },
48+ },
49+ },
50+ },
51+ }),
52+ },
53+ baseUrl: "https://api.github.com",
54+ authenticationTemplate: [
55+ {
56+ slug: "oauth",
57+ kind: "oauth2" as const,
58+ authorizationUrl: "https://github.com/login/oauth/authorize",
59+ tokenUrl: "https://github.com/login/oauth/access_token",
60+ scopes: ["repo", "read:org"],
61+ },
62+ ],
63+} as const;
64+
65+scenario(
66+ "First-party OAuth · the host-declared GitHub app is listed and drives the authorize redirect",
67+ {},
68+ Effect.scoped(
69+ Effect.gen(function* () {
70+ const target = yield* Target;
71+ const { client: makeApiClient } = yield* Api;
72+ const identity = yield* target.newIdentity();
73+ const client = yield* makeApiClient(api, identity);
74+
75+ // 1. The config-declared app appears in listings with its public id.
76+ const clients = yield* client.oauth.listClients();
77+ const firstParty = clients.find((c) => String(c.slug) === "first-party:github");
78+ expect(firstParty, "the env-declared first-party GitHub app is listed").toBeDefined();
79+ expect(firstParty?.origin.kind).toBe("first_party");
80+ expect(firstParty?.clientId).toBe("e2e-first-party-github");
81+
82+ // 2. A start through the first-party slug builds GitHub's authorize URL
83+ // from the config identity and this platform's served callback.
84+ const integration = IntegrationSlug.make(unique("fpgh"));
85+ yield* client.openapi.addSpec({
86+ payload: { ...githubShapedIntegrationSpec, slug: integration },
87+ });
88+ const started = yield* client.oauth.start({
89+ payload: {
90+ client: OAuthClientSlug.make("first-party:github"),
91+ clientOwner: "org",
92+ owner: "org",
93+ name: ConnectionName.make("main"),
94+ integration,
95+ template: AuthTemplateSlug.make("oauth"),
96+ },
97+ });
98+ expect(started.status, "oauth.start redirects to the provider").toBe("redirect");
99+ const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : "";
100+ const authorize = new URL(authorizationUrl);
101+ expect(authorize.origin + authorize.pathname).toBe(
102+ "https://github.com/login/oauth/authorize",
103+ );
104+ expect(authorize.searchParams.get("client_id")).toBe("e2e-first-party-github");
105+ expect(authorize.searchParams.get("redirect_uri")).toBe(
106+ new URL("/api/oauth/callback", target.baseUrl).toString(),
107+ );
108+
109+ // 3. The reserved namespace cannot be shadowed by a stored row. The
110+ // server rejects with a StorageError, which the HTTP edge scrubs to an
111+ // opaque InternalError — assert the rejection, then prove the listed
112+ // app is still the config-declared one (same public id, same origin).
113+ yield* client.oauth
114+ .createClient({
115+ payload: {
116+ owner: "org",
117+ slug: OAuthClientSlug.make("first-party:github"),
118+ authorizationUrl: "https://github.com/login/oauth/authorize",
119+ tokenUrl: "https://github.com/login/oauth/access_token",
120+ grant: "authorization_code",
121+ clientId: "impostor",
122+ clientSecret: "impostor-secret",
123+ },
124+ })
125+ .pipe(Effect.flip);
126+ const after = yield* client.oauth.listClients();
127+ const survivors = after.filter((c) => String(c.slug) === "first-party:github");
128+ expect(survivors, "exactly one first-party:github remains listed").toHaveLength(1);
129+ expect(survivors[0]?.origin.kind).toBe("first_party");
130+ expect(survivors[0]?.clientId, "the impostor never shadowed the host's app").toBe(
131+ "e2e-first-party-github",
132+ );
133+ }),
134+ ),
135+);
e2e/setup/cloud.boot.ts+5−0
@@ -94,6 +94,11 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 93 unmodified lines
9494 MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS,
9595 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS,
9696 ALLOW_LOCAL_NETWORK: "true",
97+ // A first-party GitHub app for the first-party-oauth scenario: proves the
98+ // env → HostConfig → executor plumbing end to end. The scenario asserts the
99+ // authorize REDIRECT only (client id + callback), never visits github.com.
100+ FIRST_PARTY_GITHUB_CLIENT_ID: "e2e-first-party-github",
101+ FIRST_PARTY_GITHUB_CLIENT_SECRET: "e2e-first-party-github-secret",
97102 // Shrink the per-org hourly execution cap (prod default 1000) to a number
98103 // the rate-limit-backstop scenario can actually exhaust with real
99104 // executions — but see execution-limits.ts: it must stay above every other
packages/core/api/src/oauth/api.ts+7−0
@@ -121,6 +121,13 @@ const OAuthClientSummaryResponse = Schema.Struct({ · 120 unmodified lines
121121 kind: Schema.Literal("dynamic_client_registration"),
122122 integration: Schema.optional(Schema.NullOr(IntegrationSlug)),
123123 }),
124+ /** Host-operated app declared in executor config — every org connects
125+ * through it; nothing to paste. `integrations` ranks it as the default
126+ * for those integrations in the picker. */
127+ Schema.Struct({
128+ kind: Schema.Literal("first_party"),
129+ integrations: Schema.optional(Schema.Array(IntegrationSlug)),
130+ }),
124131 ]),
125132 });
126133
packages/core/api/src/server/scoped-executor.ts+10−0
@@ -41,6 +41,7 @@ import { · 40 unmodified lines
4141 type AnyPlugin,
4242 type Executor,
4343 type ExecutorConfig,
44+ type FirstPartyOAuthClientConfig,
4445 type StorageFailure,
4546 } from "@executor-js/sdk";
4647 import {
@@ -97,6 +98,14 @@ export interface HostConfigShape { · 50 unmodified lines
9798 * Hosts that record product analytics supply it; omitted -> no observation.
9899 */
99100 readonly onIntegrationChange?: ExecutorConfig["onIntegrationChange"];
101+ /**
102+ * Host-operated OAuth apps (`first-party:<name>`), threaded verbatim into
103+ * `createExecutor`. Declared here — not per-request — because the registered
104+ * redirect URI on the provider side is fixed per deployment, and both request
105+ * planes (HTTP API, MCP session DO) must resolve the same apps. Hosts that
106+ * ship none simply omit it.
107+ */
108+ readonly firstPartyOAuthClients?: readonly FirstPartyOAuthClientConfig[];
100109 }
101110
102111 export class HostConfig extends Context.Service<HostConfig, HostConfigShape>()(
@@ -281,6 +290,7 @@ export const makeScopedExecutor = < · 178 unmodified lines
281290 onElicitation: "accept-all",
282291 redirectUri,
283292 oauthCallbackStateOrgSlug: orgSlug,
293+ firstPartyOAuthClients: config.firstPartyOAuthClients,
284294 coreTools: {
285295 webBaseUrl,
286296 orgSlug,
packages/core/sdk/src/executor.ts+67−19
@@ -108,7 +108,8 @@ import { · 107 unmodified lines
108108 type MintOAuthConnectionInput,
109109 type OAuthScopePolicy,
110110 } from "./oauth-service";
111−import type { OAuthService } from "./oauth-client";
111+import { isFirstPartyOAuthClientSlug, type OAuthService } from "./oauth-client";
112+import type { FirstPartyOAuthClientConfig } from "./oauth-client";
112113 import {
113114 comparePolicyRow,
114115 isValidPattern,
@@ -625,6 +626,14 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 510 unmodified lines
625626 /** Optional URL selected organization slug to carry inside OAuth `state`. */
626627 readonly oauthCallbackStateOrgSlug?: string;
627628 readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy;
629+ /**
630+ * Host-operated OAuth apps (the deployment's own registered GitHub/Google/…
631+ * apps), addressed as `first-party:<name>`. Users connect through them with
632+ * nothing to paste. Config-resolved — never persisted; secrets stay in host
633+ * env and are never written to a credential provider or returned over any
634+ * read surface. Minted connections and their tokens remain per-owner.
635+ */
636+ readonly firstPartyOAuthClients?: readonly FirstPartyOAuthClientConfig[];
628637 /**
629638 * Enable the built-in `core-tools` plugin which contributes agent-facing
630639 * static tools over the v2 surface (integrations / connections / policies).
@@ -1753,6 +1762,23 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1122 unmodified lines
17531762 where: (b: AnyCb) => b.and(byOwner(owner)(b), b("slug", "=", slug)),
17541763 });
17551764
1765+ // Config-declared first-party apps, keyed by prefixed slug — the refresh
1766+ // path's counterpart to the OAuth service's config-first resolution.
1767+ const firstPartyOAuthBySlug = new Map(
1768+ (config.firstPartyOAuthClients ?? []).map((client) => [`first-party:${client.name}`, client]),
1769+ );
1770+
1771+ /** The app identity a refresh runs against, uniformly resolved: a stored
1772+ * row's secret comes out of the credential provider by item id; a
1773+ * first-party app's comes from host config and never touches a provider. */
1774+ interface RefreshClient {
1775+ readonly clientId: string;
1776+ readonly clientSecret: string;
1777+ readonly tokenUrl: string;
1778+ readonly grant: string;
1779+ readonly resource: string | null;
1780+ }
1781+
17561782 /** What drove a refresh: the pre-call expiry check (`proactive`), or an
17571783 * upstream 401 on a token we believed was still valid (`reactive`). */
17581784 type RefreshTrigger = "proactive" | "reactive";
@@ -1839,19 +1865,42 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 80 unmodified lines
18391865 return yield* reauth(detail);
18401866 }
18411867
1842− // Load the backing app by the owner STORED on the connection (a Personal
1843− // connection may be backed by a shared Workspace app) — no derivation.
1844− const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;
1845− const clientRow = yield* loadOAuthClientRow(clientOwner, String(row.oauth_client));
1868+ // Load the backing app. A `first-party:` slug resolves from host config
1869+ // (deployment-owned identity, in-memory secret); a stored slug loads by
1870+ // the owner STORED on the connection (a Personal connection may be
1871+ // backed by a shared Workspace app) — no derivation — with its secret
1872+ // resolved out of the credential provider by item id.
1873+ const clientSlug = String(row.oauth_client);
1874+ const clientRow: RefreshClient | null = yield* Effect.gen(function* () {
1875+ if (isFirstPartyOAuthClientSlug(clientSlug)) {
1876+ const firstParty = firstPartyOAuthBySlug.get(clientSlug);
1877+ if (!firstParty) return null;
1878+ return {
1879+ clientId: firstParty.clientId,
1880+ clientSecret: firstParty.clientSecret,
1881+ tokenUrl: firstParty.tokenUrl,
1882+ grant: "authorization_code",
1883+ resource: null,
1884+ } satisfies RefreshClient;
1885+ }
1886+ const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;
1887+ const stored = yield* loadOAuthClientRow(clientOwner, clientSlug);
1888+ if (!stored) return null;
1889+ return {
1890+ clientId: String(stored.client_id),
1891+ clientSecret: stored.client_secret_item_id
1892+ ? ((yield* provider.get(ProviderItemId.make(String(stored.client_secret_item_id)))) ??
1893+ "")
1894+ : "",
1895+ tokenUrl: String(stored.token_url),
1896+ grant: String(stored.grant),
1897+ resource: stored.resource ? String(stored.resource) : null,
1898+ } satisfies RefreshClient;
1899+ });
18461900 if (!clientRow) {
18471901 return yield* reauth(`OAuth client "${row.oauth_client}" is no longer registered.`);
18481902 }
1849−
1850− // The secret is stored in the provider (a vault item id), not inline.
1851− const clientSecret = clientRow.client_secret_item_id
1852− ? ((yield* provider.get(ProviderItemId.make(String(clientRow.client_secret_item_id)))) ??
1853− "")
1854− : "";
1903+ const clientSecret = clientRow.clientSecret;
18551904 // Re-request the scopes this connection was GRANTED (RFC 6749 §6: a
18561905 // refresh must not exceed the originally-granted scope). Empty → omit
18571906 // the param, which the AS treats as "same scopes as granted".
@@ -1862,9 +1911,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines
18621911 // Refresh against the region the code was redeemed at when one was
18631912 // recorded at connect time (multi-site providers like Datadog), else
18641913 // the oauth_client's configured token endpoint.
1865− const tokenUrl = row.oauth_token_url
1866− ? String(row.oauth_token_url)
1867− : String(clientRow.token_url);
1914+ const tokenUrl = row.oauth_token_url ? String(row.oauth_token_url) : clientRow.tokenUrl;
18681915
18691916 // client_credentials (machine-to-machine) has NO refresh token — the
18701917 // token is RE-MINTED from the client id/secret. The authorization_code
@@ -1872,13 +1919,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
18721919 // what keeps a client_credentials connection (e.g. DealCloud) from
18731920 // demanding a re-auth on a credential that has no human to re-auth.
18741921 const token =
1875− String(clientRow.grant) === "client_credentials"
1922+ clientRow.grant === "client_credentials"
18761923 ? yield* exchangeClientCredentials({
18771924 tokenUrl,
1878− clientId: String(clientRow.client_id),
1925+ clientId: clientRow.clientId,
18791926 clientSecret,
18801927 scopes: grantedScopes,
1881− resource: clientRow.resource ? String(clientRow.resource) : undefined,
1928+ resource: clientRow.resource ?? undefined,
18821929 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
18831930 fetch: config.fetch,
18841931 }).pipe(
@@ -1905,13 +1952,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
19051952 }
19061953 return yield* refreshAccessToken({
19071954 tokenUrl,
1908− clientId: String(clientRow.client_id),
1955+ clientId: clientRow.clientId,
19091956 clientSecret,
19101957 refreshToken,
19111958 scopes: grantedScopes,
19121959 // RFC 8707: keep the re-minted token bound to the same resource
19131960 // (MCP servers require this on refresh).
1914− resource: clientRow.resource ? String(clientRow.resource) : undefined,
1961+ resource: clientRow.resource ?? undefined,
19151962 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
19161963 fetch: config.fetch,
19171964 }).pipe(
@@ -4566,6 +4613,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2648 unmodified lines
45664613 // OAuth (cloud, self-host) derive a real `${webBaseUrl}/oauth/callback`.
45674614 redirectUri: config.redirectUri ?? null,
45684615 callbackStateOrgSlug: config.oauthCallbackStateOrgSlug ?? null,
4616+ firstPartyClients: config.firstPartyOAuthClients,
45694617 });
45704618
45714619 // ------------------------------------------------------------------
packages/core/sdk/src/index.ts+5−0
@@ -291,6 +291,11 @@ export { · 290 unmodified lines
291291 OAuthProbeError,
292292 OAuthRegisterDynamicError,
293293 OAuthSessionNotFoundError,
294+ FIRST_PARTY_OAUTH_CLIENT_PREFIX,
295+ firstPartyOAuthClientSlug,
296+ isFirstPartyOAuthClientSlug,
297+ type FirstPartyOAuthClientConfig,
298+ type OAuthClientOrigin,
294299 type OAuthGrant,
295300 type OAuthAuthentication,
296301 type OAuthClient,
packages/core/sdk/src/oauth-client.ts+48−1
@@ -77,10 +77,57 @@ export type OAuthClientOrigin = · 76 unmodified lines
7777 | {
7878 readonly kind: "dynamic_client_registration";
7979 readonly integration?: IntegrationSlug | null;
80+ }
81+ | {
82+ /** A host-operated app declared in executor config (`firstPartyOAuthClients`),
83+ * not a stored row: the deployment operator registered ONE app with the
84+ * provider (GitHub, Google, …) and every org connects through it, so
85+ * users paste nothing. Config-resolved — never persisted, never
86+ * creatable or removable through the client CRUD surface. Plural
87+ * `integrations` (unlike the stored origins' single recorded intent):
88+ * one Google app deliberately backs gmail, calendar, drive, …. */
89+ readonly kind: "first_party";
90+ readonly integrations?: readonly IntegrationSlug[];
8091 };
8192
93+/** Slug namespace separating config-declared first-party apps from stored
94+ * owner-scoped rows. A first-party client declared as `github` is addressed
95+ * everywhere (start input, connection rows' `oauth_client` column, listings)
96+ * as `first-party:github`, so it can never collide with a BYO row and every
97+ * load path can intercept before touching storage. The client CRUD surface
98+ * rejects the prefix on stored rows. */
99+export const FIRST_PARTY_OAUTH_CLIENT_PREFIX = "first-party:";
100+
101+export const isFirstPartyOAuthClientSlug = (slug: string): boolean =>
102+ slug.startsWith(FIRST_PARTY_OAUTH_CLIENT_PREFIX);
103+
104+export const firstPartyOAuthClientSlug = (name: string): OAuthClientSlug =>
105+ OAuthClientSlug.make(`${FIRST_PARTY_OAUTH_CLIENT_PREFIX}${name}`);
106+
107+/** A first-party OAuth app the HOST declares at composition time — the
108+ * deployment operator's own registered app for a provider. The secret comes
109+ * from host env/config and stays in memory: it is never written to a
110+ * credential provider and never surfaced over any read surface. Minted
111+ * connections (and their tokens) remain per-owner exactly as with BYO apps;
112+ * only the app identity is shared. */
113+export interface FirstPartyOAuthClientConfig {
114+ /** Unprefixed name, e.g. `"github"`; addressed as `first-party:github`. */
115+ readonly name: string;
116+ readonly authorizationUrl: string;
117+ readonly tokenUrl: string;
118+ readonly clientId: string;
119+ /** Literal secret from host env. Empty string for a public/PKCE client. */
120+ readonly clientSecret: string;
121+ /** Integrations this app is intended for, used by pickers to rank it as the
122+ * exact-match default for those integrations. Endpoint-host matching still
123+ * applies when omitted. */
124+ readonly integrations?: readonly IntegrationSlug[];
125+}
126+
82127 export type CreateOAuthClientInput = OAuthClient & {
83− readonly origin?: OAuthClientOrigin;
128+ /** Stored-row origins only — `first_party` is config-declared, never created
129+ * through this surface (the service also rejects the slug namespace). */
130+ readonly origin?: Exclude<OAuthClientOrigin, { kind: "first_party" }>;
84131 readonly originIssuer?: string | null;
85132 /** The redirect URI a DCR registration sent as the client's `redirect_uris`
86133 * entry. Persisted so reuse can detect a changed callback (strict servers
packages/core/sdk/src/oauth-first-party.test.ts+290−0
@@ -0,0 +1,290 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Predicate } from "effect";
3+
4+import {
5+ AuthTemplateSlug,
6+ ConnectionName,
7+ IntegrationSlug,
8+ OAuthClientSlug,
9+ ToolAddress,
10+ ToolName,
11+} from "./ids";
12+import {
13+ firstPartyOAuthClientSlug,
14+ type FirstPartyOAuthClientConfig,
15+ type OAuthStartError,
16+} from "./oauth-client";
17+import { definePlugin } from "./plugin";
18+import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
19+import { serveOAuthTestServer } from "./testing/oauth-test-server";
20+
21+// First-party OAuth clients: host-operated apps declared in executor config
22+// (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from
23+// config, never storage — these tests prove the whole lifecycle (start →
24+// complete → execute → refresh) runs off the config-declared identity, that the
25+// client CRUD surface rejects the reserved namespace, and that listings project
26+// the app without ever having written its secret to a credential provider.
27+
28+const INTEG = IntegrationSlug.make("acme");
29+const TEMPLATE = AuthTemplateSlug.make("oauth");
30+const FIRST_PARTY = firstPartyOAuthClientSlug("acme");
31+
32+const oauthPlugin = definePlugin(() => ({
33+ id: "acme" as const,
34+ storage: () => ({}),
35+ resolveTools: () =>
36+ Effect.succeed({
37+ tools: [{ name: ToolName.make("whoami"), description: "whoami" }],
38+ }),
39+ describeAuthMethods: (record) => {
40+ const config = record.config as { readonly scopes?: readonly string[] } | null;
41+ return [
42+ {
43+ id: "oauth",
44+ label: "OAuth2",
45+ kind: "oauth" as const,
46+ template: String(TEMPLATE),
47+ oauth: { scopes: config?.scopes ?? [] },
48+ },
49+ ];
50+ },
51+ invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),
52+ checkHealth: ({ credential }) =>
53+ Effect.succeed({
54+ status: credential.value === null ? "expired" : "healthy",
55+ checkedAt: Date.now(),
56+ }),
57+ extension: (ctx) => ({
58+ seed: (scopes: readonly string[] = []) =>
59+ ctx.core.integrations.register({
60+ slug: INTEG,
61+ description: "Acme",
62+ config: { scopes },
63+ }),
64+ }),
65+}))();
66+
67+const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const;
68+
69+const firstPartyClientFor = (server: {
70+ readonly authorizationEndpoint: string;
71+ readonly tokenEndpoint: string;
72+}): FirstPartyOAuthClientConfig => ({
73+ name: "acme",
74+ authorizationUrl: server.authorizationEndpoint,
75+ tokenUrl: server.tokenEndpoint,
76+ clientId: "test-client",
77+ clientSecret: "test-secret",
78+ integrations: [INTEG],
79+});
80+
81+describe("first-party oauth clients", () => {
82+ it.effect(
83+ "start → complete through a config-declared client mints an executable connection",
84+ () =>
85+ Effect.scoped(
86+ Effect.gen(function* () {
87+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
88+ const { executor } = yield* makeTestWorkspaceHarness({
89+ plugins,
90+ firstPartyOAuthClients: [firstPartyClientFor(server)],
91+ });
92+ yield* executor.acme.seed();
93+
94+ // No createClient call — the app exists purely in config.
95+ const started = yield* executor.oauth.start({
96+ owner: "org",
97+ client: FIRST_PARTY,
98+ clientOwner: "org",
99+ name: ConnectionName.make("main-account"),
100+ integration: INTEG,
101+ template: TEMPLATE,
102+ });
103+ expect(started.status).toBe("redirect");
104+ if (started.status !== "redirect") return;
105+
106+ const callback = yield* server.completeAuthorizationCodeFlow({
107+ authorizationUrl: started.authorizationUrl,
108+ });
109+ const connection = yield* executor.oauth.complete({
110+ state: started.state,
111+ code: callback.code,
112+ });
113+ expect(String(connection.address)).toBe("tools.acme.org.mainAccount");
114+
115+ const out = (yield* executor.execute(
116+ ToolAddress.make("tools.acme.org.mainAccount.whoami"),
117+ {},
118+ )) as { token: string };
119+ expect(out.token).toMatch(/^at_/);
120+ expect(yield* server.acceptsAccessToken(out.token)).toBe(true);
121+ }),
122+ ),
123+ );
124+
125+ it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>
126+ Effect.scoped(
127+ Effect.gen(function* () {
128+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
129+ const harness = yield* makeTestWorkspaceHarness({
130+ plugins,
131+ firstPartyOAuthClients: [firstPartyClientFor(server)],
132+ });
133+ const { executor, config } = harness;
134+ yield* executor.acme.seed();
135+
136+ const started = yield* executor.oauth.start({
137+ owner: "org",
138+ client: FIRST_PARTY,
139+ clientOwner: "org",
140+ name: ConnectionName.make("main"),
141+ integration: INTEG,
142+ template: TEMPLATE,
143+ });
144+ if (started.status !== "redirect") return;
145+ const callback = yield* server.completeAuthorizationCodeFlow({
146+ authorizationUrl: started.authorizationUrl,
147+ });
148+ yield* executor.oauth.complete({ state: started.state, code: callback.code });
149+
150+ const firstToken = (yield* executor.execute(
151+ ToolAddress.make("tools.acme.org.main.whoami"),
152+ {},
153+ )) as { token: string };
154+
155+ // Force expiry so the next resolve refreshes through the config client.
156+ yield* Effect.promise(() =>
157+ config.db.updateMany("connection", {
158+ where: (b) => b("name", "=", "main"),
159+ set: { expires_at: Date.now() - 60_000 },
160+ }),
161+ );
162+
163+ const refreshedToken = (yield* executor.execute(
164+ ToolAddress.make("tools.acme.org.main.whoami"),
165+ {},
166+ )) as { token: string };
167+ expect(refreshedToken.token).not.toBe(firstToken.token);
168+ expect(yield* server.acceptsAccessToken(refreshedToken.token)).toBe(true);
169+ }),
170+ ),
171+ );
172+
173+ it.effect("listClients projects the first-party app ahead of stored rows, secretless", () =>
174+ Effect.scoped(
175+ Effect.gen(function* () {
176+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
177+ const { executor } = yield* makeTestWorkspaceHarness({
178+ plugins,
179+ firstPartyOAuthClients: [firstPartyClientFor(server)],
180+ });
181+ yield* executor.acme.seed();
182+
183+ yield* executor.oauth.createClient({
184+ owner: "org",
185+ slug: OAuthClientSlug.make("byo-app"),
186+ authorizationUrl: server.authorizationEndpoint,
187+ tokenUrl: server.tokenEndpoint,
188+ grant: "authorization_code",
189+ clientId: "byo-client",
190+ clientSecret: "byo-secret",
191+ });
192+
193+ const clients = yield* executor.oauth.listClients();
194+ expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]);
195+ const firstParty = clients[0]!;
196+ expect(firstParty.origin).toEqual({ kind: "first_party", integrations: [INTEG] });
197+ expect(firstParty.clientId).toBe("test-client");
198+ expect("clientSecret" in firstParty).toBe(false);
199+ }),
200+ ),
201+ );
202+
203+ it.effect("createClient and removeClient reject the reserved first-party namespace", () =>
204+ Effect.scoped(
205+ Effect.gen(function* () {
206+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
207+ const { executor } = yield* makeTestWorkspaceHarness({
208+ plugins,
209+ firstPartyOAuthClients: [firstPartyClientFor(server)],
210+ });
211+
212+ const createError = yield* executor.oauth
213+ .createClient({
214+ owner: "org",
215+ slug: FIRST_PARTY,
216+ authorizationUrl: server.authorizationEndpoint,
217+ tokenUrl: server.tokenEndpoint,
218+ grant: "authorization_code",
219+ clientId: "impostor",
220+ clientSecret: "impostor-secret",
221+ })
222+ .pipe(Effect.flip);
223+ expect(createError.message).toContain("reserved first-party namespace");
224+
225+ const removeError = yield* executor.oauth
226+ .removeClient("org", FIRST_PARTY)
227+ .pipe(Effect.flip);
228+ expect(removeError.message).toContain("cannot be removed");
229+ }),
230+ ),
231+ );
232+
233+ it.effect("start with an undeclared first-party slug fails as client-not-found", () =>
234+ Effect.scoped(
235+ Effect.gen(function* () {
236+ yield* serveOAuthTestServer({ scopes: ["read"] });
237+ const { executor } = yield* makeTestWorkspaceHarness({ plugins });
238+ yield* executor.acme.seed();
239+
240+ const error = yield* executor.oauth
241+ .start({
242+ owner: "org",
243+ client: firstPartyOAuthClientSlug("nope"),
244+ clientOwner: "org",
245+ name: ConnectionName.make("main"),
246+ integration: INTEG,
247+ template: TEMPLATE,
248+ })
249+ .pipe(Effect.flip);
250+ // `OAuthStartError` carries a typed `message`; the `Predicate.isTagged`
251+ // guard narrows the union so this read is on a typed failure.
252+ expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true);
253+ const startError = error as OAuthStartError;
254+ expect(startError.message).toContain("not found");
255+ }),
256+ ),
257+ );
258+
259+ it.effect("a Personal connection can mint through a first-party app", () =>
260+ Effect.scoped(
261+ Effect.gen(function* () {
262+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
263+ const { executor } = yield* makeTestWorkspaceHarness({
264+ plugins,
265+ firstPartyOAuthClients: [firstPartyClientFor(server)],
266+ });
267+ yield* executor.acme.seed();
268+
269+ const started = yield* executor.oauth.start({
270+ owner: "user",
271+ client: FIRST_PARTY,
272+ clientOwner: "org",
273+ name: ConnectionName.make("mine"),
274+ integration: INTEG,
275+ template: TEMPLATE,
276+ });
277+ expect(started.status).toBe("redirect");
278+ if (started.status !== "redirect") return;
279+ const callback = yield* server.completeAuthorizationCodeFlow({
280+ authorizationUrl: started.authorizationUrl,
281+ });
282+ const connection = yield* executor.oauth.complete({
283+ state: started.state,
284+ code: callback.code,
285+ });
286+ expect(String(connection.address)).toBe("tools.acme.user.mine");
287+ }),
288+ ),
289+ );
290+});
packages/core/sdk/src/oauth-service.ts+94−5
@@ -36,8 +36,11 @@ import { · 35 unmodified lines
3636 OAuthRegisterDynamicError,
3737 OAuthSessionNotFoundError,
3838 OAuthStartError,
39+ firstPartyOAuthClientSlug,
40+ isFirstPartyOAuthClientSlug,
3941 type ConnectResult,
4042 type CreateOAuthClientInput,
43+ type FirstPartyOAuthClientConfig,
4144 type OAuthClientOrigin,
4245 type OAuthClientSummary,
4346 type OAuthCompleteInput,
@@ -174,6 +177,12 @@ export interface OAuthServiceDeps { · 130 unmodified lines
174177 readonly redirectUri: string | null;
175178 /** URL selected organization slug to round-trip through OAuth `state`. */
176179 readonly callbackStateOrgSlug?: string | null;
180+ /** Host-operated apps declared at composition time (`first-party:<name>`
181+ * slugs). Resolved from config, never from storage: `loadClient` intercepts
182+ * the prefix ahead of the DB, `listClients` appends their summaries, and the
183+ * client CRUD surface rejects the namespace. Empty/omitted on hosts that
184+ * ship no first-party apps. */
185+ readonly firstPartyClients?: readonly FirstPartyOAuthClientConfig[];
177186 }
178187
179188 type LooseDb = {
@@ -490,9 +499,42 @@ const validateClientEndpoints = ( · 310 unmodified lines
490499 }
491500 });
492501
502+/** Resolve a config-declared first-party app to the loaded-client shape the
503+ * flow/refresh paths consume. First-party apps are authorization_code only:
504+ * client_credentials mints machine tokens under the OPERATOR's app identity,
505+ * which must never be shared across tenants. */
506+export const loadedFirstPartyClient = (
507+ config: FirstPartyOAuthClientConfig,
508+): {
509+ readonly slug: string;
510+ readonly authorizationUrl: string;
511+ readonly tokenUrl: string;
512+ readonly grant: OAuthGrant;
513+ readonly clientId: string;
514+ readonly clientSecret: string;
515+ readonly resource: null;
516+} => ({
517+ slug: String(firstPartyOAuthClientSlug(config.name)),
518+ authorizationUrl: config.authorizationUrl,
519+ tokenUrl: config.tokenUrl,
520+ grant: "authorization_code",
521+ clientId: config.clientId,
522+ clientSecret: config.clientSecret,
523+ resource: null,
524+});
525+
493526 export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
494527 const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer;
495528 const fetch = deps.fetch;
529+ // Config-declared first-party apps, keyed by their prefixed slug. Config is
530+ // the source of truth — no row exists, so every stored-row path (CRUD, GC)
531+ // is bypassed by construction, and rotating a secret is an env change.
532+ const firstPartyBySlug = new Map(
533+ (deps.firstPartyClients ?? []).map((client) => [
534+ String(firstPartyOAuthClientSlug(client.name)),
535+ client,
536+ ]),
537+ );
496538 // EXPLICIT — no localhost default. `null` means this executor has no OAuth
497539 // callback; redirect-requiring flows fail loudly via `requireRedirectUri`.
498540 const redirectUri = deps.redirectUri;
@@ -597,6 +639,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 98 unmodified lines
597639 input: CreateOAuthClientInput,
598640 ): Effect.Effect<OAuthClientSlug, StorageFailure> =>
599641 Effect.gen(function* () {
642+ // The `first-party:` namespace is reserved for config-declared apps — a
643+ // stored row under it would be shadowed by (or worse, impersonate) the
644+ // host's own app.
645+ if (isFirstPartyOAuthClientSlug(String(input.slug))) {
646+ return yield* new StorageError({
647+ message: `OAuth client slug "${String(input.slug)}" uses the reserved first-party namespace.`,
648+ cause: undefined,
649+ });
650+ }
600651 yield* validateClientEndpoints(input, deps.endpointUrlPolicy);
601652 const keys = yield* Effect.try({
602653 try: () => deps.ownedKeys(input.owner),
@@ -682,6 +733,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 79 unmodified lines
682733 // -----------------------------------------------------------------------
683734 const removeClient = (owner: Owner, slug: OAuthClientSlug): Effect.Effect<void, StorageFailure> =>
684735 Effect.gen(function* () {
736+ // Config-declared apps have no row to remove; removing one is an env
737+ // change on the host, not a storage operation. Fail loudly rather than
738+ // returning a success that changed nothing.
739+ if (isFirstPartyOAuthClientSlug(String(slug))) {
740+ return yield* new StorageError({
741+ message: `OAuth client "${String(slug)}" is a first-party app declared in host config; it cannot be removed through this surface.`,
742+ cause: undefined,
743+ });
744+ }
685745 yield* deps.fuma
686746 .use("oauth_client.delete", (db) =>
687747 looseDb(db).deleteMany("oauth_client", {
@@ -953,8 +1013,24 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 265 unmodified lines
9531013 // tenant's org rows + this subject's own user rows, so no explicit filter is
9541014 // needed. The `client_secret` column is deliberately never projected.
9551015 // -----------------------------------------------------------------------
956− const listClients = (): Effect.Effect<readonly OAuthClientSummary[], StorageFailure> =>
957− deps.fuma
1016+ const listClients = (): Effect.Effect<readonly OAuthClientSummary[], StorageFailure> => {
1017+ // First-party apps lead the list: config-resolved, visible to every caller,
1018+ // and projected exactly like stored rows — clientId only, never the secret.
1019+ // Owner is reported as "org" (the widest visibility the summary shape can
1020+ // express); the flow itself ignores owner for first-party slugs.
1021+ const firstPartySummaries: readonly OAuthClientSummary[] = [...firstPartyBySlug.values()].map(
1022+ (config) => ({
1023+ owner: "org",
1024+ slug: firstPartyOAuthClientSlug(config.name),
1025+ grant: "authorization_code",
1026+ authorizationUrl: config.authorizationUrl,
1027+ tokenUrl: config.tokenUrl,
1028+ resource: null,
1029+ clientId: config.clientId,
1030+ origin: { kind: "first_party", integrations: config.integrations ?? [] },
1031+ }),
1032+ );
1033+ return deps.fuma
9581034 .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {}))
9591035 .pipe(
9601036 Effect.flatMap((rows) =>
@@ -982,7 +1058,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 21 unmodified lines
9821058 } satisfies OAuthClientSummary);
9831059 }),
9841060 ),
1061+ Effect.map((stored) => [...firstPartySummaries, ...stored]),
9851062 );
1063+ };
9861064
9871065 // -----------------------------------------------------------------------
9881066 // Load an oauth_client row by (owner, slug).
@@ -990,8 +1068,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1 unmodified line
9901068 const loadClient = (
9911069 owner: Owner,
9921070 slug: OAuthClientSlug,
993− ): Effect.Effect<LoadedOAuthClient | null, StorageFailure> =>
994− deps.fuma
1071+ ): Effect.Effect<LoadedOAuthClient | null, StorageFailure> => {
1072+ // First-party apps resolve from config, never storage. Owner is irrelevant:
1073+ // the app belongs to the DEPLOYMENT, and visibility policy has nothing to
1074+ // narrow — only the minted connection (and its tokens) is owner-scoped.
1075+ if (isFirstPartyOAuthClientSlug(String(slug))) {
1076+ const config = firstPartyBySlug.get(String(slug));
1077+ return Effect.succeed(config ? loadedFirstPartyClient(config) : null);
1078+ }
1079+ return deps.fuma
9951080 .use("oauth_client.findFirst", (db) =>
9961081 looseDb(db).findFirst("oauth_client", {
9971082 where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))),
@@ -1038,6 +1123,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 40 unmodified lines
10381123 });
10391124 }),
10401125 );
1126+ };
10411127
10421128 // -----------------------------------------------------------------------
10431129 // start — begin a flow through a client to mint a connection.
@@ -1058,7 +1144,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 14 unmodified lines
10581144 // cannot be backed by a member's private (user) app. The connection owner
10591145 // and the app owner are otherwise independent — a Personal connection
10601146 // through a shared Workspace app is the supported cross-owner case.
1061− if (input.owner === "org" && input.clientOwner === "user") {
1147+ // First-party apps are deployment-owned, outside the owner lattice
1148+ // entirely, so the rule does not apply to them.
1149+ const firstPartyFlow = isFirstPartyOAuthClientSlug(String(input.client));
1150+ if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") {
10621151 return yield* new OAuthStartError({
10631152 message: "A Workspace connection must use a Workspace app.",
10641153 });
packages/core/sdk/src/shared.ts+4−0
@@ -144,6 +144,10 @@ export { · 143 unmodified lines
144144
145145 // OAuth wire contracts (data + tagged errors; the flow impl is server-only).
146146 export {
147+ FIRST_PARTY_OAUTH_CLIENT_PREFIX,
148+ firstPartyOAuthClientSlug,
149+ isFirstPartyOAuthClientSlug,
150+ type FirstPartyOAuthClientConfig,
147151 type OAuthGrant,
148152 type OAuthAuthentication,
149153 type OAuthClient,
packages/core/sdk/src/test-config.ts+2−0
@@ -123,6 +123,7 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [ · 122 unmodified lines
123123 readonly redirectUri?: string | null;
124124 readonly oauthCallbackStateOrgSlug?: string;
125125 readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"];
126+ readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"];
126127 };
127128
128129 export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
@@ -163,6 +164,7 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea · 34 unmodified lines
163164 onIntegrationChange: options?.onIntegrationChange,
164165 ...(redirectUri != null ? { redirectUri } : {}),
165166 oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,
167+ firstPartyOAuthClients: options?.firstPartyOAuthClients,
166168 };
167169 };
168170
packages/plugins/openapi/src/sdk/presets.ts+16−0
@@ -16,6 +16,21 @@ export interface OpenApiPreset { · 15 unmodified lines
1616 readonly healthCheck?: HealthCheckSpec;
1717 }
1818
19+/** GitHub OAuth-app flow (classic apps, not GitHub Apps): scopes are requested
20+ * at authorize time, tokens don't expire, and the token endpoint returns
21+ * form-encoded unless the request sends `Accept: application/json` — which the
22+ * SDK's token exchange always does (oauth4webapi sets it). `repo` + `read:org`
23+ * + `user:email` covers the common repo/issue/PR surface without admin grants. */
24+export const GITHUB_SUPPORTED_OAUTH_SCOPES = ["repo", "read:org", "user:email"] as const;
25+
26+export const GITHUB_OAUTH_TEMPLATE: IntegrationPresetAuthentication = {
27+ slug: "oauth2",
28+ kind: "oauth2",
29+ authorizationUrl: "https://github.com/login/oauth/authorize",
30+ tokenUrl: "https://github.com/login/oauth/access_token",
31+ scopes: GITHUB_SUPPORTED_OAUTH_SCOPES,
32+};
33+
1934 export const FIGMA_SUPPORTED_OAUTH_SCOPES = [
2035 "current_user:read",
2136 "file_comments:read",
@@ -68,6 +83,7 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 46 unmodified lines
6883 url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",
6984 icon: "https://svgl.app/library/github_dark.svg",
7085 featured: true,
86+ authTemplate: [GITHUB_OAUTH_TEMPLATE],
7187 },
7288 {
7389 id: "vercel",
packages/react/src/components/add-account-modal.tsx+12−3
@@ -876,11 +876,18 @@ function OAuthAppRadioRow(props: { · 875 unmodified lines
876876 <span className="min-w-0 flex-1">
877877 <span className="block text-sm font-medium">{clientDisplayName(String(app.slug))}</span>
878878 <span className="block truncate text-xs text-muted-foreground">
879− {clientHost(app.tokenUrl)} ·{" "}
880− {app.grant === "client_credentials" ? "app-to-app" : "you'll sign in"}
879+ {app.origin.kind === "first_party"
880+ ? "No setup needed · you'll sign in"
881+ : `${clientHost(app.tokenUrl)} · ${
882+ app.grant === "client_credentials" ? "app-to-app" : "you'll sign in"
883+ }`}
881884 </span>
882885 </span>
883− {showOwnerLabel ? <Badge variant="outline">{ownerLabel(app.owner)}</Badge> : null}
886+ {app.origin.kind === "first_party" ? (
887+ <Badge variant="outline">Built-in</Badge>
888+ ) : showOwnerLabel ? (
889+ <Badge variant="outline">{ownerLabel(app.owner)}</Badge>
890+ ) : null}
884891 </Label>
885892 {onManage ? (
886893 <DropdownMenu>
@@ -1595,6 +1602,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 708 unmodified lines
15951602 const manageHandlersFor = (
15961603 appOption: OAuthClientOption,
15971604 ): { readonly onEdit: () => void; readonly onRemove: () => void } | undefined => {
1605+ // First-party apps are host config, not rows: nothing to edit or remove.
1606+ if (appOption.origin.kind === "first_party") return undefined;
15981607 const summary = clientSummaries.find(
15991608 (c: OAuthClientSummary) =>
16001609 c.owner === appOption.owner && String(c.slug) === String(appOption.slug),
packages/react/src/plugins/use-effective-oauth-client.test.ts+59−0
@@ -267,6 +267,65 @@ describe("selectClientsForEndpoints", () => { · 266 unmodified lines
267267 "spotify-app-2",
268268 ]);
269269 });
270+
271+ it("ranks a first-party app above BYO apps when both match", () => {
272+ const integration = IntegrationSlug.make("github_rest");
273+ const byo = app("my-github-app", {
274+ authorizationUrl: "https://github.com/login/oauth/authorize",
275+ tokenUrl: "https://github.com/login/oauth/access_token",
276+ });
277+ const firstParty = app("first-party:github", {
278+ owner: "org",
279+ authorizationUrl: "https://github.com/login/oauth/authorize",
280+ tokenUrl: "https://github.com/login/oauth/access_token",
281+ origin: { kind: "first_party", integrations: [integration] },
282+ });
283+ const result = selectClientsForEndpoints([byo, firstParty], {
284+ authorizationUrl: "https://github.com/login/oauth/authorize",
285+ tokenUrl: "https://github.com/login/oauth/access_token",
286+ integration,
287+ });
288+ expect(result.endpointMatched).toBe(true);
289+ // First-party leads despite being org-owned (user-owned normally sorts first).
290+ expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([
291+ "first-party:github",
292+ "my-github-app",
293+ ]);
294+ });
295+
296+ it("intent-matches a first-party app to its declared integrations even without endpoints", () => {
297+ const integration = IntegrationSlug.make("github_rest");
298+ const firstParty = app("first-party:github", {
299+ owner: "org",
300+ authorizationUrl: "https://github.com/login/oauth/authorize",
301+ tokenUrl: "https://github.com/login/oauth/access_token",
302+ origin: { kind: "first_party", integrations: [integration] },
303+ });
304+ const result = selectClientsForEndpoints([firstParty], {
305+ requireEndpointMatch: true,
306+ integration,
307+ });
308+ expect(result.endpointMatched).toBe(true);
309+ expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([
310+ "first-party:github",
311+ ]);
312+ });
313+
314+ it("does not surface a first-party app for an unrelated integration", () => {
315+ const firstParty = app("first-party:github", {
316+ owner: "org",
317+ authorizationUrl: "https://github.com/login/oauth/authorize",
318+ tokenUrl: "https://github.com/login/oauth/access_token",
319+ origin: { kind: "first_party", integrations: [IntegrationSlug.make("github_rest")] },
320+ });
321+ const result = selectClientsForEndpoints([firstParty], {
322+ authorizationUrl: "https://accounts.spotify.com/authorize",
323+ tokenUrl: "https://accounts.spotify.com/api/token",
324+ integration: IntegrationSlug.make("spotify"),
325+ });
326+ expect(result.endpointMatched).toBe(false);
327+ expect(result.matched).toEqual([]);
328+ });
270329 });
271330
272331 describe("selectDcrClientsForIntegration", () => {
packages/react/src/plugins/use-effective-oauth-client.tsx+31−12
@@ -47,6 +47,12 @@ export interface OAuthClientOption { · 46 unmodified lines
4747 export const isDcrClient = (app: OAuthClientOption): boolean =>
4848 app.origin.kind === "dynamic_client_registration";
4949
50+/** True for host-operated first-party apps (config-declared, `first-party:`
51+ * slugs). They rank ABOVE user/workspace apps when they match an integration:
52+ * the one-click "nothing to paste" path is the default, BYO the escape hatch. */
53+export const isFirstPartyClient = (app: OAuthClientOption): boolean =>
54+ app.origin.kind === "first_party";
55+
5056 const hostOf = (url: string): string | undefined => {
5157 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"
5258 try {
@@ -115,12 +121,14 @@ const EMPTY_CLIENTS: readonly OAuthClientOption[] = []; · 62 unmodified lines
115121 const hostEq = (a: string | undefined, b: string | undefined): boolean =>
116122 a !== undefined && b !== undefined && a === b;
117123
118−/** Sort apps user-owned first (so the user's own apps surface before shared
119− * workspace apps). */
124+/** Sort first-party apps first (the one-click default), then user-owned before
125+ * shared workspace apps. */
120126 const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] =>
121− [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) =>
122− a.owner === b.owner ? 0 : a.owner === "user" ? -1 : 1,
123− );
127+ [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) => {
128+ const aFirstParty = isFirstPartyClient(a);
129+ if (aFirstParty !== isFirstPartyClient(b)) return aFirstParty ? -1 : 1;
130+ return a.owner === b.owner ? 0 : a.owner === "user" ? -1 : 1;
131+ });
124132
125133 /**
126134 * Pure matcher (no React/atoms) — split owner-visible apps into three honest
@@ -170,11 +178,19 @@ export function selectClientsForEndpoints( · 43 unmodified lines
170178 const manual = all.filter((app) => !isDcrClient(app));
171179
172180 const intent = endpoints.integration;
173− const matchesIntent = (app: OAuthClientOption): boolean =>
174− intent != null &&
175− app.origin.kind === "manual" &&
176− app.origin.integration != null &&
177− app.origin.integration === intent;
181+ const matchesIntent = (app: OAuthClientOption): boolean => {
182+ if (intent == null) return false;
183+ // A first-party app declaring this integration is intent-matched the same
184+ // way a BYO app registered from this dialog is.
185+ if (app.origin.kind === "first_party") {
186+ return (app.origin.integrations ?? []).includes(intent);
187+ }
188+ return (
189+ app.origin.kind === "manual" &&
190+ app.origin.integration != null &&
191+ app.origin.integration === intent
192+ );
193+ };
178194
179195 const wantedTokenHost = endpoints.tokenUrl ? hostOf(endpoints.tokenUrl) : undefined;
180196 const wantedAuthorizationHost = endpoints.authorizationUrl
@@ -377,9 +393,12 @@ export function optimisticDcrClientSlug(issuerOrEndpoint: string): OAuthClientSl · 196 unmodified lines
377393 return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`);
378394 }
379395
380−/** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */
396+/** Humanize a client slug for display ("spotify-prod" → "Spotify prod").
397+ * First-party slugs drop their namespace prefix ("first-party:github" →
398+ * "Github") — the row's badge already says it's the built-in app. */
381399 export function clientDisplayName(slug: string): string {
382− const text = slug.replace(/[-_]/g, " ").trim();
400+ const bare = slug.startsWith("first-party:") ? slug.slice("first-party:".length) : slug;
401+ const text = bare.replace(/[-_]/g, " ").trim();
383402 return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : slug;
384403 }
385404