Unified · Split
| @@ -43,7 +43,7 @@ import { · 42 unmodified lines | ||
| 43 | 43 | collectTables, |
| 44 | 44 | } from "@executor-js/api/server"; |
| 45 | 45 | 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"; | |
| 47 | 47 | |
| 48 | 48 | import executorConfig from "../../executor.config"; |
| 49 | 49 | import { DbService } from "../db/db"; |
| @@ -88,6 +88,35 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 38 unmodified lines | ||
| 88 | 88 | */ |
| 89 | 89 | export const CLOUD_MOUNT_PREFIX = "/api" as const; |
| 90 | 90 | |
| 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 | + | |
| 91 | 120 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ |
| 92 | 121 | // SSRF / private-network egress guard. Config-driven, NOT a test flag: |
| 93 | 122 | // 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 | ||
| 99 | 128 | // WorkOS Vault is cloud's credential storage implementation detail, not a |
| 100 | 129 | // user-selectable provider surface. |
| 101 | 130 | exposeCredentialProviders: false, |
| 131 | + firstPartyOAuthClients: cloudFirstPartyOAuthClients(), | |
| 102 | 132 | })); |
| 103 | 133 | |
| 104 | 134 | export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync( |
| @@ -53,6 +53,16 @@ declare global { · 52 unmodified lines | ||
| 53 | 53 | // number to drive the backstop. Production leaves it unset. |
| 54 | 54 | EXECUTION_RATE_LIMIT_PER_HOUR?: string; |
| 55 | 55 | |
| 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 | + | |
| 56 | 66 | // Billing |
| 57 | 67 | AUTUMN_SECRET_KEY?: string; |
| 58 | 68 | /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */ |
| @@ -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 | +); | |
| @@ -94,6 +94,11 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 93 unmodified lines | ||
| 94 | 94 | MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS, |
| 95 | 95 | MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS, |
| 96 | 96 | 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", | |
| 97 | 102 | // Shrink the per-org hourly execution cap (prod default 1000) to a number |
| 98 | 103 | // the rate-limit-backstop scenario can actually exhaust with real |
| 99 | 104 | // executions — but see execution-limits.ts: it must stay above every other |
| @@ -121,6 +121,13 @@ const OAuthClientSummaryResponse = Schema.Struct({ · 120 unmodified lines | ||
| 121 | 121 | kind: Schema.Literal("dynamic_client_registration"), |
| 122 | 122 | integration: Schema.optional(Schema.NullOr(IntegrationSlug)), |
| 123 | 123 | }), |
| 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 | + }), | |
| 124 | 131 | ]), |
| 125 | 132 | }); |
| 126 | 133 | |
| @@ -41,6 +41,7 @@ import { · 40 unmodified lines | ||
| 41 | 41 | type AnyPlugin, |
| 42 | 42 | type Executor, |
| 43 | 43 | type ExecutorConfig, |
| 44 | + type FirstPartyOAuthClientConfig, | |
| 44 | 45 | type StorageFailure, |
| 45 | 46 | } from "@executor-js/sdk"; |
| 46 | 47 | import { |
| @@ -97,6 +98,14 @@ export interface HostConfigShape { · 50 unmodified lines | ||
| 97 | 98 | * Hosts that record product analytics supply it; omitted -> no observation. |
| 98 | 99 | */ |
| 99 | 100 | 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[]; | |
| 100 | 109 | } |
| 101 | 110 | |
| 102 | 111 | export class HostConfig extends Context.Service<HostConfig, HostConfigShape>()( |
| @@ -281,6 +290,7 @@ export const makeScopedExecutor = < · 178 unmodified lines | ||
| 281 | 290 | onElicitation: "accept-all", |
| 282 | 291 | redirectUri, |
| 283 | 292 | oauthCallbackStateOrgSlug: orgSlug, |
| 293 | + firstPartyOAuthClients: config.firstPartyOAuthClients, | |
| 284 | 294 | coreTools: { |
| 285 | 295 | webBaseUrl, |
| 286 | 296 | orgSlug, |
| @@ -108,7 +108,8 @@ import { · 107 unmodified lines | ||
| 108 | 108 | type MintOAuthConnectionInput, |
| 109 | 109 | type OAuthScopePolicy, |
| 110 | 110 | } 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"; | |
| 112 | 113 | import { |
| 113 | 114 | comparePolicyRow, |
| 114 | 115 | isValidPattern, |
| @@ -625,6 +626,14 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 510 unmodified lines | ||
| 625 | 626 | /** Optional URL selected organization slug to carry inside OAuth `state`. */ |
| 626 | 627 | readonly oauthCallbackStateOrgSlug?: string; |
| 627 | 628 | 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[]; | |
| 628 | 637 | /** |
| 629 | 638 | * Enable the built-in `core-tools` plugin which contributes agent-facing |
| 630 | 639 | * 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 | ||
| 1753 | 1762 | where: (b: AnyCb) => b.and(byOwner(owner)(b), b("slug", "=", slug)), |
| 1754 | 1763 | }); |
| 1755 | 1764 | |
| 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 | + | |
| 1756 | 1782 | /** What drove a refresh: the pre-call expiry check (`proactive`), or an |
| 1757 | 1783 | * upstream 401 on a token we believed was still valid (`reactive`). */ |
| 1758 | 1784 | type RefreshTrigger = "proactive" | "reactive"; |
| @@ -1839,19 +1865,42 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 80 unmodified lines | ||
| 1839 | 1865 | return yield* reauth(detail); |
| 1840 | 1866 | } |
| 1841 | 1867 | |
| 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 | + }); | |
| 1846 | 1900 | if (!clientRow) { |
| 1847 | 1901 | return yield* reauth(`OAuth client "${row.oauth_client}" is no longer registered.`); |
| 1848 | 1902 | } |
| 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; | |
| 1855 | 1904 | // Re-request the scopes this connection was GRANTED (RFC 6749 §6: a |
| 1856 | 1905 | // refresh must not exceed the originally-granted scope). Empty → omit |
| 1857 | 1906 | // 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 | ||
| 1862 | 1911 | // Refresh against the region the code was redeemed at when one was |
| 1863 | 1912 | // recorded at connect time (multi-site providers like Datadog), else |
| 1864 | 1913 | // 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; | |
| 1868 | 1915 | |
| 1869 | 1916 | // client_credentials (machine-to-machine) has NO refresh token — the |
| 1870 | 1917 | // 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 | ||
| 1872 | 1919 | // what keeps a client_credentials connection (e.g. DealCloud) from |
| 1873 | 1920 | // demanding a re-auth on a credential that has no human to re-auth. |
| 1874 | 1921 | const token = |
| 1875 | − String(clientRow.grant) === "client_credentials" | |
| 1922 | + clientRow.grant === "client_credentials" | |
| 1876 | 1923 | ? yield* exchangeClientCredentials({ |
| 1877 | 1924 | tokenUrl, |
| 1878 | − clientId: String(clientRow.client_id), | |
| 1925 | + clientId: clientRow.clientId, | |
| 1879 | 1926 | clientSecret, |
| 1880 | 1927 | scopes: grantedScopes, |
| 1881 | − resource: clientRow.resource ? String(clientRow.resource) : undefined, | |
| 1928 | + resource: clientRow.resource ?? undefined, | |
| 1882 | 1929 | endpointUrlPolicy: config.oauthEndpointUrlPolicy, |
| 1883 | 1930 | fetch: config.fetch, |
| 1884 | 1931 | }).pipe( |
| @@ -1905,13 +1952,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines | ||
| 1905 | 1952 | } |
| 1906 | 1953 | return yield* refreshAccessToken({ |
| 1907 | 1954 | tokenUrl, |
| 1908 | − clientId: String(clientRow.client_id), | |
| 1955 | + clientId: clientRow.clientId, | |
| 1909 | 1956 | clientSecret, |
| 1910 | 1957 | refreshToken, |
| 1911 | 1958 | scopes: grantedScopes, |
| 1912 | 1959 | // RFC 8707: keep the re-minted token bound to the same resource |
| 1913 | 1960 | // (MCP servers require this on refresh). |
| 1914 | − resource: clientRow.resource ? String(clientRow.resource) : undefined, | |
| 1961 | + resource: clientRow.resource ?? undefined, | |
| 1915 | 1962 | endpointUrlPolicy: config.oauthEndpointUrlPolicy, |
| 1916 | 1963 | fetch: config.fetch, |
| 1917 | 1964 | }).pipe( |
| @@ -4566,6 +4613,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2648 unmodified lines | ||
| 4566 | 4613 | // OAuth (cloud, self-host) derive a real `${webBaseUrl}/oauth/callback`. |
| 4567 | 4614 | redirectUri: config.redirectUri ?? null, |
| 4568 | 4615 | callbackStateOrgSlug: config.oauthCallbackStateOrgSlug ?? null, |
| 4616 | + firstPartyClients: config.firstPartyOAuthClients, | |
| 4569 | 4617 | }); |
| 4570 | 4618 | |
| 4571 | 4619 | // ------------------------------------------------------------------ |
| @@ -291,6 +291,11 @@ export { · 290 unmodified lines | ||
| 291 | 291 | OAuthProbeError, |
| 292 | 292 | OAuthRegisterDynamicError, |
| 293 | 293 | OAuthSessionNotFoundError, |
| 294 | + FIRST_PARTY_OAUTH_CLIENT_PREFIX, | |
| 295 | + firstPartyOAuthClientSlug, | |
| 296 | + isFirstPartyOAuthClientSlug, | |
| 297 | + type FirstPartyOAuthClientConfig, | |
| 298 | + type OAuthClientOrigin, | |
| 294 | 299 | type OAuthGrant, |
| 295 | 300 | type OAuthAuthentication, |
| 296 | 301 | type OAuthClient, |
| @@ -77,10 +77,57 @@ export type OAuthClientOrigin = · 76 unmodified lines | ||
| 77 | 77 | | { |
| 78 | 78 | readonly kind: "dynamic_client_registration"; |
| 79 | 79 | 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[]; | |
| 80 | 91 | }; |
| 81 | 92 | |
| 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 | + | |
| 82 | 127 | 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" }>; | |
| 84 | 131 | readonly originIssuer?: string | null; |
| 85 | 132 | /** The redirect URI a DCR registration sent as the client's `redirect_uris` |
| 86 | 133 | * entry. Persisted so reuse can detect a changed callback (strict servers |
| @@ -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 | +}); | |
| @@ -36,8 +36,11 @@ import { · 35 unmodified lines | ||
| 36 | 36 | OAuthRegisterDynamicError, |
| 37 | 37 | OAuthSessionNotFoundError, |
| 38 | 38 | OAuthStartError, |
| 39 | + firstPartyOAuthClientSlug, | |
| 40 | + isFirstPartyOAuthClientSlug, | |
| 39 | 41 | type ConnectResult, |
| 40 | 42 | type CreateOAuthClientInput, |
| 43 | + type FirstPartyOAuthClientConfig, | |
| 41 | 44 | type OAuthClientOrigin, |
| 42 | 45 | type OAuthClientSummary, |
| 43 | 46 | type OAuthCompleteInput, |
| @@ -174,6 +177,12 @@ export interface OAuthServiceDeps { · 130 unmodified lines | ||
| 174 | 177 | readonly redirectUri: string | null; |
| 175 | 178 | /** URL selected organization slug to round-trip through OAuth `state`. */ |
| 176 | 179 | 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[]; | |
| 177 | 186 | } |
| 178 | 187 | |
| 179 | 188 | type LooseDb = { |
| @@ -490,9 +499,42 @@ const validateClientEndpoints = ( · 310 unmodified lines | ||
| 490 | 499 | } |
| 491 | 500 | }); |
| 492 | 501 | |
| 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 | + | |
| 493 | 526 | export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { |
| 494 | 527 | const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer; |
| 495 | 528 | 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 | + ); | |
| 496 | 538 | // EXPLICIT — no localhost default. `null` means this executor has no OAuth |
| 497 | 539 | // callback; redirect-requiring flows fail loudly via `requireRedirectUri`. |
| 498 | 540 | const redirectUri = deps.redirectUri; |
| @@ -597,6 +639,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 98 unmodified lines | ||
| 597 | 639 | input: CreateOAuthClientInput, |
| 598 | 640 | ): Effect.Effect<OAuthClientSlug, StorageFailure> => |
| 599 | 641 | 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 | + } | |
| 600 | 651 | yield* validateClientEndpoints(input, deps.endpointUrlPolicy); |
| 601 | 652 | const keys = yield* Effect.try({ |
| 602 | 653 | try: () => deps.ownedKeys(input.owner), |
| @@ -682,6 +733,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 79 unmodified lines | ||
| 682 | 733 | // ----------------------------------------------------------------------- |
| 683 | 734 | const removeClient = (owner: Owner, slug: OAuthClientSlug): Effect.Effect<void, StorageFailure> => |
| 684 | 735 | 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 | + } | |
| 685 | 745 | yield* deps.fuma |
| 686 | 746 | .use("oauth_client.delete", (db) => |
| 687 | 747 | looseDb(db).deleteMany("oauth_client", { |
| @@ -953,8 +1013,24 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 265 unmodified lines | ||
| 953 | 1013 | // tenant's org rows + this subject's own user rows, so no explicit filter is |
| 954 | 1014 | // needed. The `client_secret` column is deliberately never projected. |
| 955 | 1015 | // ----------------------------------------------------------------------- |
| 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 | |
| 958 | 1034 | .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {})) |
| 959 | 1035 | .pipe( |
| 960 | 1036 | Effect.flatMap((rows) => |
| @@ -982,7 +1058,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 21 unmodified lines | ||
| 982 | 1058 | } satisfies OAuthClientSummary); |
| 983 | 1059 | }), |
| 984 | 1060 | ), |
| 1061 | + Effect.map((stored) => [...firstPartySummaries, ...stored]), | |
| 985 | 1062 | ); |
| 1063 | + }; | |
| 986 | 1064 | |
| 987 | 1065 | // ----------------------------------------------------------------------- |
| 988 | 1066 | // Load an oauth_client row by (owner, slug). |
| @@ -990,8 +1068,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1 unmodified line | ||
| 990 | 1068 | const loadClient = ( |
| 991 | 1069 | owner: Owner, |
| 992 | 1070 | 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 | |
| 995 | 1080 | .use("oauth_client.findFirst", (db) => |
| 996 | 1081 | looseDb(db).findFirst("oauth_client", { |
| 997 | 1082 | 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 | ||
| 1038 | 1123 | }); |
| 1039 | 1124 | }), |
| 1040 | 1125 | ); |
| 1126 | + }; | |
| 1041 | 1127 | |
| 1042 | 1128 | // ----------------------------------------------------------------------- |
| 1043 | 1129 | // start — begin a flow through a client to mint a connection. |
| @@ -1058,7 +1144,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 14 unmodified lines | ||
| 1058 | 1144 | // cannot be backed by a member's private (user) app. The connection owner |
| 1059 | 1145 | // and the app owner are otherwise independent — a Personal connection |
| 1060 | 1146 | // 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") { | |
| 1062 | 1151 | return yield* new OAuthStartError({ |
| 1063 | 1152 | message: "A Workspace connection must use a Workspace app.", |
| 1064 | 1153 | }); |
| @@ -144,6 +144,10 @@ export { · 143 unmodified lines | ||
| 144 | 144 | |
| 145 | 145 | // OAuth wire contracts (data + tagged errors; the flow impl is server-only). |
| 146 | 146 | export { |
| 147 | + FIRST_PARTY_OAUTH_CLIENT_PREFIX, | |
| 148 | + firstPartyOAuthClientSlug, | |
| 149 | + isFirstPartyOAuthClientSlug, | |
| 150 | + type FirstPartyOAuthClientConfig, | |
| 147 | 151 | type OAuthGrant, |
| 148 | 152 | type OAuthAuthentication, |
| 149 | 153 | type OAuthClient, |
| @@ -123,6 +123,7 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [ · 122 unmodified lines | ||
| 123 | 123 | readonly redirectUri?: string | null; |
| 124 | 124 | readonly oauthCallbackStateOrgSlug?: string; |
| 125 | 125 | readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"]; |
| 126 | + readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"]; | |
| 126 | 127 | }; |
| 127 | 128 | |
| 128 | 129 | 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 | ||
| 163 | 164 | onIntegrationChange: options?.onIntegrationChange, |
| 164 | 165 | ...(redirectUri != null ? { redirectUri } : {}), |
| 165 | 166 | oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug, |
| 167 | + firstPartyOAuthClients: options?.firstPartyOAuthClients, | |
| 166 | 168 | }; |
| 167 | 169 | }; |
| 168 | 170 | |
| @@ -16,6 +16,21 @@ export interface OpenApiPreset { · 15 unmodified lines | ||
| 16 | 16 | readonly healthCheck?: HealthCheckSpec; |
| 17 | 17 | } |
| 18 | 18 | |
| 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 | + | |
| 19 | 34 | export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ |
| 20 | 35 | "current_user:read", |
| 21 | 36 | "file_comments:read", |
| @@ -68,6 +83,7 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 46 unmodified lines | ||
| 68 | 83 | url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", |
| 69 | 84 | icon: "https://svgl.app/library/github_dark.svg", |
| 70 | 85 | featured: true, |
| 86 | + authTemplate: [GITHUB_OAUTH_TEMPLATE], | |
| 71 | 87 | }, |
| 72 | 88 | { |
| 73 | 89 | id: "vercel", |
| @@ -876,11 +876,18 @@ function OAuthAppRadioRow(props: { · 875 unmodified lines | ||
| 876 | 876 | <span className="min-w-0 flex-1"> |
| 877 | 877 | <span className="block text-sm font-medium">{clientDisplayName(String(app.slug))}</span> |
| 878 | 878 | <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 | + }`} | |
| 881 | 884 | </span> |
| 882 | 885 | </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} | |
| 884 | 891 | </Label> |
| 885 | 892 | {onManage ? ( |
| 886 | 893 | <DropdownMenu> |
| @@ -1595,6 +1602,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 708 unmodified lines | ||
| 1595 | 1602 | const manageHandlersFor = ( |
| 1596 | 1603 | appOption: OAuthClientOption, |
| 1597 | 1604 | ): { 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; | |
| 1598 | 1607 | const summary = clientSummaries.find( |
| 1599 | 1608 | (c: OAuthClientSummary) => |
| 1600 | 1609 | c.owner === appOption.owner && String(c.slug) === String(appOption.slug), |
| @@ -267,6 +267,65 @@ describe("selectClientsForEndpoints", () => { · 266 unmodified lines | ||
| 267 | 267 | "spotify-app-2", |
| 268 | 268 | ]); |
| 269 | 269 | }); |
| 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 | + }); | |
| 270 | 329 | }); |
| 271 | 330 | |
| 272 | 331 | describe("selectDcrClientsForIntegration", () => { |
| @@ -47,6 +47,12 @@ export interface OAuthClientOption { · 46 unmodified lines | ||
| 47 | 47 | export const isDcrClient = (app: OAuthClientOption): boolean => |
| 48 | 48 | app.origin.kind === "dynamic_client_registration"; |
| 49 | 49 | |
| 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 | + | |
| 50 | 56 | const hostOf = (url: string): string | undefined => { |
| 51 | 57 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" |
| 52 | 58 | try { |
| @@ -115,12 +121,14 @@ const EMPTY_CLIENTS: readonly OAuthClientOption[] = []; · 62 unmodified lines | ||
| 115 | 121 | const hostEq = (a: string | undefined, b: string | undefined): boolean => |
| 116 | 122 | a !== undefined && b !== undefined && a === b; |
| 117 | 123 | |
| 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. */ | |
| 120 | 126 | 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 | + }); | |
| 124 | 132 | |
| 125 | 133 | /** |
| 126 | 134 | * Pure matcher (no React/atoms) — split owner-visible apps into three honest |
| @@ -170,11 +178,19 @@ export function selectClientsForEndpoints( · 43 unmodified lines | ||
| 170 | 178 | const manual = all.filter((app) => !isDcrClient(app)); |
| 171 | 179 | |
| 172 | 180 | 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 | + }; | |
| 178 | 194 | |
| 179 | 195 | const wantedTokenHost = endpoints.tokenUrl ? hostOf(endpoints.tokenUrl) : undefined; |
| 180 | 196 | const wantedAuthorizationHost = endpoints.authorizationUrl |
| @@ -377,9 +393,12 @@ export function optimisticDcrClientSlug(issuerOrEndpoint: string): OAuthClientSl · 196 unmodified lines | ||
| 377 | 393 | return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`); |
| 378 | 394 | } |
| 379 | 395 | |
| 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. */ | |
| 381 | 399 | 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(); | |
| 383 | 402 | return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : slug; |
| 384 | 403 | } |
| 385 | 404 | |