Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/first-party-oauth-listing-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/sdk": patch
---

Allow hosts to control first-party OAuth app listings for each acting user without disrupting existing connections.
89 changes: 89 additions & 0 deletions apps/cloud/src/analytics/google-oauth-listing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import { GOOGLE_OAUTH_REVIEW_FLAG, makeGoogleOAuthListing } from "./google-oauth-listing";

const context = { userId: "review-user", organizationId: "review-org" };

describe("Google OAuth review listing", () => {
it.effect("targets the authenticated user and re-evaluates after revocation", () =>
Effect.gen(function* () {
let enabled = true;
const policy = makeGoogleOAuthListing({
projectKey: "public-project-key",
host: "https://flags.example.com",
fetch: async (url, init) => {
expect(url).toBe("https://flags.example.com/flags?v=2");
expect(await new Response(String(init?.body)).json()).toMatchObject({
distinct_id: context.userId,
person_properties: { executor_user_id: context.userId },
flag_keys_to_evaluate: [GOOGLE_OAUTH_REVIEW_FLAG],
});
expect(init?.signal).toBeInstanceOf(AbortSignal);
return Response.json({ flags: { [GOOGLE_OAUTH_REVIEW_FLAG]: { enabled } } });
},
});
expect(yield* policy(context)).toBe(true);
enabled = false;
expect(yield* policy(context)).toBe(false);
}),
);

for (const body of [
{ flags: {} },
{ flags: { [GOOGLE_OAUTH_REVIEW_FLAG]: { enabled: "true" } } },
{},
]) {
it.effect(`withholds an absent or invalid flag: ${JSON.stringify(body)}`, () =>
Effect.gen(function* () {
const policy = makeGoogleOAuthListing({
projectKey: "key",
host: "https://flags.example.com",
fetch: async () => Response.json(body),
});
expect(yield* policy(context)).toBe(false);
}),
);
}

it.effect("withholds on HTTP and transport failures", () =>
Effect.gen(function* () {
const unavailable = makeGoogleOAuthListing({
projectKey: "key",
host: "https://flags.example.com",
fetch: async () => new Response(null, { status: 503 }),
});
const failed = makeGoogleOAuthListing({
projectKey: "key",
host: "https://flags.example.com",
fetch: () =>
Effect.runPromise(Effect.fail(new DOMException("Network unavailable", "NetworkError"))),
});
expect(yield* unavailable(context)).toBe(false);
expect(yield* failed(context)).toBe(false);
}),
);

it.effect("does not evaluate without a user or project key", () =>
Effect.gen(function* () {
let requests = 0;
const fetch: typeof globalThis.fetch = async () => {
requests += 1;
return Response.json({ flags: {} });
};
const configured = makeGoogleOAuthListing({
projectKey: "key",
host: "https://flags.example.com",
fetch,
});
const unconfigured = makeGoogleOAuthListing({
projectKey: undefined,
host: "https://flags.example.com",
fetch,
});
expect(yield* configured({ ...context, userId: null })).toBe(false);
expect(yield* unconfigured(context)).toBe(false);
expect(requests).toBe(0);
}),
);
});
44 changes: 44 additions & 0 deletions apps/cloud/src/analytics/google-oauth-listing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Effect, Schema } from "effect";

import type { FirstPartyOAuthClientConfig } from "@executor-js/sdk";

/** Server-side rollout for offering the built-in Google OAuth app. Configure
* the PostHog flag's runtime as "all": direct HTTP clients are not recognized
* as server SDKs. The caller identity still comes only from the server. */
export const GOOGLE_OAUTH_REVIEW_FLAG = "google-oauth-review";

const FlagsResponse = Schema.Struct({
flags: Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })),
});

/** Evaluate each listing against the authenticated user. Missing configuration,
* timeouts, and invalid responses withhold the app. No verdict is cached. */
export const makeGoogleOAuthListing =
(config: {
readonly projectKey: string | undefined;
readonly host: string;
readonly fetch: typeof globalThis.fetch;
}): NonNullable<FirstPartyOAuthClientConfig["isListed"]> =>
(context) =>
Effect.gen(function* () {
if (!config.projectKey || context.userId === null) return false;
const response = yield* Effect.tryPromise(() =>
config.fetch(`${config.host}/flags?v=2`, {
method: "POST",
headers: { "content-type": "application/json", "user-agent": "executor-cloud" },
body: JSON.stringify({
api_key: config.projectKey,
distinct_id: context.userId,
// Supplied by the server so targeting needs no prior browser identify.
person_properties: { executor_user_id: context.userId },
groups: { organization: context.organizationId },
flag_keys_to_evaluate: [GOOGLE_OAUTH_REVIEW_FLAG],
}),
signal: AbortSignal.timeout(1_000),
}),
);
if (!response.ok) return false;
const body: unknown = yield* Effect.tryPromise(() => response.json());
const parsed = yield* Schema.decodeUnknownEffect(FlagsResponse)(body);
return parsed.flags[GOOGLE_OAUTH_REVIEW_FLAG]?.enabled === true;
}).pipe(Effect.catch(() => Effect.succeed(false)));
13 changes: 9 additions & 4 deletions apps/cloud/src/engine/first-party-oauth-clients.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import {
firstPartyOAuthClientsFor,
Expand Down Expand Up @@ -99,7 +100,7 @@ describe("cloud first-party OAuth clients", () => {
// The reviewed consumer scope boundary of the Executor-owned Google app.
//
// These assertions used to live in `e2e/scenarios/first-party-oauth.test.ts`,
// read off `listClients`. The app is now `unlisted`, so it has no read surface
// read off `listClients`. The app is now gated, so it has no public read surface
// to introspect — the bundle is only observable on the config it is built from,
// which is here. The e2e still owns the BEHAVIOUR the boundary produces (which
// scopes an `oauth.start` requests, and that admin scopes are refused).
Expand All @@ -109,13 +110,17 @@ describe("cloud first-party Google app", () => {
const google = () =>
firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google");

it("declares the Google app but withholds it from every listing", () => {
it("declares the Google app but withholds it without a configured rollout", async () => {
const client = google();
expect(client, "the env-declared first-party Google app is configured").toBeDefined();
// The entry MUST stay declared: `loadClient` resolves it by slug for every
// existing connection's refresh and reconnect. `unlisted` is what stops it
// existing connection's refresh and reconnect. The listing policy stops it
// being offered for new connections.
expect(client?.unlisted).toBe(true);
expect(client?.isListed).toBeDefined();
if (client?.isListed === undefined) return;
expect(
await Effect.runPromise(client.isListed({ userId: "test-user", organizationId: "test-org" })),
).toBe(false);
});

it("covers the reviewed consumer bundle", () => {
Expand Down
18 changes: 12 additions & 6 deletions apps/cloud/src/engine/first-party-oauth-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import {
import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk";

import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing";
import { POSTHOG_INGEST_HOST } from "../edge/passthrough";

/** Cloud secret bindings that enable host-operated OAuth clients. A provider
* is absent unless both values in its pair are present. */
export interface FirstPartyOAuthClientEnv {
readonly VITE_PUBLIC_POSTHOG_KEY?: string;
readonly VITE_PUBLIC_POSTHOG_HOST?: string;
readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string;
readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string;
readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string;
Expand Down Expand Up @@ -286,12 +291,13 @@ export const firstPartyOAuthClientsFor = (
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
allowedScopes: GOOGLE_ALLOWED_SCOPES,
// Withdrawn from the connect picker: no new connection is offered the
// Executor-owned Google app. The entry stays declared on purpose — every
// connection already minted against it keeps refreshing and reconnecting
// through it. Deleting this block, or unsetting the env vars, would strand
// those connections instead.
unlisted: true,
// Offer the app only to the review rollout. Resolution remains available
// for existing connections regardless of the current listing decision.
isListed: makeGoogleOAuthListing({
projectKey: env.VITE_PUBLIC_POSTHOG_KEY,
host: env.VITE_PUBLIC_POSTHOG_HOST ?? `https://${POSTHOG_INGEST_HOST}`,
fetch: (input, init) => globalThis.fetch(input, init),
}),
}),
...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, {
name: "hubspot",
Expand Down
8 changes: 8 additions & 0 deletions packages/core/sdk/src/oauth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,14 @@ export interface FirstPartyOAuthClientConfig {
* instead removes the config entry itself, which strands every existing
* connection on a client the host can no longer resolve. */
readonly unlisted?: boolean;
/** Optional host policy for offering this app to the acting user. Evaluated
* on each listing; false withholds the app without disrupting existing
* connections. `unlisted: true` always withholds it. This controls discovery,
* not authorization to resolve an already-known first-party client slug. */
readonly isListed?: (context: {
readonly userId: string | null;
readonly organizationId: string;
}) => Effect.Effect<boolean>;
/** OAuth scopes this deployment permits the app to request. Omit to allow
* every scope declared by a matching integration. For declared scopes,
* start and completion fail unless every requested scope belongs to this
Expand Down
47 changes: 46 additions & 1 deletion packages/core/sdk/src/oauth-first-party.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,58 @@ describe("first-party oauth clients", () => {
),
);

it.effect("listing policies use the acting identity and re-evaluate within an executor", () =>
Effect.scoped(
Effect.gen(function* () {
const server = yield* serveOAuthTestServer({ scopes: ["read"] });
let enabled = true;
const { executor } = yield* makeTestWorkspaceHarness({
plugins,
subject: "review-user",
tenant: "review-org",
firstPartyOAuthClients: [
{
...firstPartyClientFor(server),
isListed: (context) =>
Effect.sync(() => {
expect(context).toEqual({ userId: "review-user", organizationId: "review-org" });
return enabled;
}),
},
],
});
expect((yield* executor.oauth.listClients()).map((client) => String(client.slug))).toEqual([
"first-party:acme",
]);
enabled = false;
expect(yield* executor.oauth.listClients()).toEqual([]);
yield* executor.acme.seed();
const started = yield* executor.oauth.start({
owner: "org",
client: FIRST_PARTY,
clientOwner: "org",
name: ConnectionName.make("main"),
integration: INTEG,
template: TEMPLATE,
});
expect(started.status).toBe("redirect");
}),
),
);

it.effect("an unlisted first-party app is withheld from listings but still refreshes", () =>
Effect.scoped(
Effect.gen(function* () {
const server = yield* serveOAuthTestServer({ scopes: ["read"] });
const harness = yield* makeTestWorkspaceHarness({
plugins,
firstPartyOAuthClients: [{ ...firstPartyClientFor(server), unlisted: true }],
firstPartyOAuthClients: [
{
...firstPartyClientFor(server),
unlisted: true,
isListed: () => Effect.succeed(true),
},
],
});
const { executor, config } = harness;
yield* executor.acme.seed();
Expand Down
Loading
Loading