From ebcec3f9a777d8b456e1479f935172bda202c221 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 15:03:39 +0000 Subject: [PATCH] fix(connectors): deploy connectors with workspace API keys `base44 deploy` with a `BASE44_API_KEY` workspace key failed with "Error listing connectors: Forbidden" whenever the project had any connector. The per-connector `external-auth/*` routes (list, set, remove) and the Stripe `payments/stripe/*` routes require a platform user and reject workspace keys, while the backend already exposes a key-capable `PUT /api/apps/{id}/deployment/connectors` that reconciles the desired connector set server-side. Under a workspace key, `pushConnectors` now sends the local OAuth connectors to that deployment route (mirroring the auth-config fix in #565) and never touches the user-bound routes. Reconciliation with an empty list is no longer skipped for keys, matching OAuth deploys. A local Stripe connector is reported as an explicit unsupported error instead of failing on the status call. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LeLErzhtT98SP1DVGM1UuX --- docs/api-patterns.md | 7 ++ docs/resources.md | 2 +- packages/cli/src/core/project/deploy.ts | 10 +- .../cli/src/core/resources/connector/api.ts | 41 +++++++ .../cli/src/core/resources/connector/push.ts | 45 +++++++- .../src/core/resources/connector/schema.ts | 20 ++++ packages/cli/tests/cli/env-token-auth.spec.ts | 108 ++++++++++++++++-- .../cli/tests/cli/testkit/TestAPIServer.ts | 14 +++ packages/cli/tests/core/connectors.spec.ts | 84 +++++++++++++- 9 files changed, 312 insertions(+), 19 deletions(-) diff --git a/docs/api-patterns.md b/docs/api-patterns.md index 26c88fc72..95c612a82 100644 --- a/docs/api-patterns.md +++ b/docs/api-patterns.md @@ -61,6 +61,13 @@ BASE44_API_KEY=b44k_... BASE44_APP_ID= base44 deploy --yes Use this for CI or other non-interactive deployers that should act as a workspace-owned machine principal rather than a human user. +Some builder routes require a platform user and reject workspace keys with a +403 (`external-auth/*`, `payments/stripe/*`). Resources that must work in CI +have a key-capable `deployment/*` route instead, selected at call time with +`hasWorkspaceApiKeyAuth()` (see `auth-config/api.ts` and +`connector/push.ts`). When adding a resource to `deploy`, use or add such a +route rather than calling the user-bound one. + ### OAuth access/refresh tokens For non-interactive flows (CI, agents, provisioning tools) that hand off an diff --git a/docs/resources.md b/docs/resources.md index 29013452b..9ae22aa4c 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -141,7 +141,7 @@ What it deploys (in order): 2. Functions (via `functionResource.push()`) 3. Agent skills (via `agentSkillResource.push()`) 4. Agents (via `agentResource.push()`) -5. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs +5. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs. With a workspace API key this syncs through `PUT /api/apps/{id}/deployment/connectors` instead (the per-connector `external-auth` and Stripe routes need a platform user); new connectors are created disconnected and must be authorized from the dashboard, and a local Stripe connector is reported as an error 6. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The deployments-API transport is not reachable from here; see [deployments.md](deployments.md). ```bash diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 99ff0ef95..fd69ee53e 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -1,5 +1,4 @@ import { resolve } from "node:path"; -import { hasWorkspaceApiKeyAuth } from "@/core/auth/config.js"; import { setAppVisibility } from "@/core/project/api.js"; import type { Visibility } from "@/core/project/schema.js"; import type { ProjectData } from "@/core/project/types.js"; @@ -108,13 +107,8 @@ export async function deployAll( await agentResource.push(agents); await authConfigResource.push(authConfig); // pushConnectors also reconciles: with an empty list it removes remote - // connectors that are no longer configured locally. Only skip that when a - // workspace API key is in use, since those principals get a 403 on the - // connectors-list endpoint. OAuth users must still reconcile removals. - const skipConnectorSync = connectors.length === 0 && hasWorkspaceApiKeyAuth(); - const connectorResults = skipConnectorSync - ? [] - : (await pushConnectors(connectors)).results; + // connectors that are no longer configured locally. + const connectorResults = (await pushConnectors(connectors)).results; if (project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); diff --git a/packages/cli/src/core/resources/connector/api.ts b/packages/cli/src/core/resources/connector/api.ts index b946f0a58..403dd39bf 100644 --- a/packages/cli/src/core/resources/connector/api.ts +++ b/packages/cli/src/core/resources/connector/api.ts @@ -11,6 +11,7 @@ import type { RemoveStripeResponse, SetConnectorResponse, StripeStatusResponse, + SyncDeploymentConnectorsResponse, } from "./schema.js"; import { InstallStripeResponseSchema, @@ -21,6 +22,7 @@ import { RemoveStripeResponseSchema, SetConnectorResponseSchema, StripeStatusResponseSchema, + SyncDeploymentConnectorsResponseSchema, } from "./schema.js"; export async function listConnectors(): Promise { @@ -45,6 +47,45 @@ export async function listConnectors(): Promise { return result.data; } +/** + * Declaratively syncs the app's shared OAuth connectors through the deployment + * endpoint. Unlike the per-connector external-auth routes this one accepts a + * workspace API key and reconciles removals server-side; connectors it creates + * stay disconnected until someone authorizes them from the dashboard. + */ +export async function syncDeploymentConnectors( + connectors: { integrationType: IntegrationType; scopes: string[] }[], +): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.put("deployment/connectors", { + json: { + connectors: connectors.map((c) => ({ + integration_type: c.integrationType, + scopes: c.scopes, + })), + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "syncing connectors"); + } + + const result = SyncDeploymentConnectorsResponseSchema.safeParse( + await response.json(), + ); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + return result.data; +} + export async function setConnector( integrationType: IntegrationType, scopes: string[], diff --git a/packages/cli/src/core/resources/connector/push.ts b/packages/cli/src/core/resources/connector/push.ts index f806b0599..a0ec6abb7 100644 --- a/packages/cli/src/core/resources/connector/push.ts +++ b/packages/cli/src/core/resources/connector/push.ts @@ -1,4 +1,10 @@ -import { listConnectors, removeConnector, setConnector } from "./api.js"; +import { hasWorkspaceApiKeyAuth } from "@/core/auth/config.js"; +import { + listConnectors, + removeConnector, + setConnector, + syncDeploymentConnectors, +} from "./api.js"; import type { ConnectorResource, IntegrationType, @@ -44,6 +50,15 @@ export async function pushConnectors( (c) => c.type !== STRIPE_CONNECTOR_TYPE, ); + if (hasWorkspaceApiKeyAuth()) { + return { + results: await syncConnectorsWithWorkspaceApiKey( + oauthConnectors, + stripeConnector, + ), + }; + } + const oauthResults = await syncOAuthConnectors(oauthConnectors); const stripeResult = await syncStripeConnector(stripeConnector); @@ -55,6 +70,34 @@ export async function pushConnectors( return { results }; } +// Workspace API keys are rejected by the per-connector external-auth and +// Stripe routes (they need a platform user), so sync through the deployment +// endpoint, which also reconciles removals server-side. +async function syncConnectorsWithWorkspaceApiKey( + oauthConnectors: ConnectorResource[], + stripeConnector: ConnectorResource | undefined, +): Promise { + const { connectors } = await syncDeploymentConnectors( + oauthConnectors.map((c) => ({ + integrationType: c.type, + scopes: c.scopes ?? [], + })), + ); + const results: ConnectorSyncResult[] = connectors.map((c) => ({ + type: c.integrationType, + action: "synced", + })); + if (stripeConnector) { + results.push({ + type: STRIPE_CONNECTOR_TYPE, + action: "error", + error: + "Stripe connector sync is not supported with a workspace API key. Run 'base44 connectors push' as a logged-in user.", + }); + } + return results; +} + async function syncOAuthConnectors( connectors: ConnectorResource[], ): Promise { diff --git a/packages/cli/src/core/resources/connector/schema.ts b/packages/cli/src/core/resources/connector/schema.ts index db83597f6..a0436a61d 100644 --- a/packages/cli/src/core/resources/connector/schema.ts +++ b/packages/cli/src/core/resources/connector/schema.ts @@ -210,6 +210,26 @@ export type RemoveConnectorResponse = z.infer< typeof RemoveConnectorResponseSchema >; +export const SyncDeploymentConnectorsResponseSchema = z + .object({ + connectors: z.array( + z.object({ + integration_type: IntegrationTypeSchema, + scopes: z.array(z.string()), + }), + ), + }) + .transform((data) => ({ + connectors: data.connectors.map((c) => ({ + integrationType: c.integration_type, + scopes: c.scopes, + })), + })); + +export type SyncDeploymentConnectorsResponse = z.infer< + typeof SyncDeploymentConnectorsResponseSchema +>; + // ─── STRIPE-SPECIFIC SCHEMAS ───────────────────────────────── export const STRIPE_CONNECTOR_TYPE = "stripe" as const; diff --git a/packages/cli/tests/cli/env-token-auth.spec.ts b/packages/cli/tests/cli/env-token-auth.spec.ts index 0b8f0c2db..663af43cc 100644 --- a/packages/cli/tests/cli/env-token-auth.spec.ts +++ b/packages/cli/tests/cli/env-token-auth.spec.ts @@ -128,15 +128,15 @@ describe("env credential seeding", () => { t.expectResult(result).toNotContain("base44 login"); }); - it("skips the connectors-list call on deploy when no connectors are configured", async () => { - // Workspace keys are forbidden from the connectors-list endpoint, so with - // no connectors configured the reconcile pass must be skipped entirely. The - // 403 mock proves the call never happens — deploy still succeeds. + it("reconciles connectors through the deployment endpoint with a workspace API key", async () => { + // The connectors-list endpoint rejects workspace keys, so deploy must go + // through the deployment sync route instead. With no local connectors it + // still sends an empty list so stale remote connectors are removed, the + // same as an OAuth deploy. await t.givenProject(fixture("with-entities")); - t.givenEnv({ - BASE44_API_KEY: - "b44k_dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - }); + const workspaceApiKey = + "b44k_dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + t.givenEnv({ BASE44_API_KEY: workspaceApiKey }); t.api.mockEntitiesPush({ created: ["Customer", "Product"], updated: [], @@ -148,10 +148,101 @@ describe("env credential seeding", () => { body: { error: "Forbidden", detail: "Workspace keys cannot list" }, }); + let syncBody: unknown; + let syncApiKeyHeader: string | undefined; + t.api.mockRoute( + "PUT", + `/api/apps/${APP_ID}/deployment/connectors`, + (req, res) => { + syncBody = req.body; + syncApiKeyHeader = req.headers.api_key as string | undefined; + res.status(200).json({ connectors: [] }); + }, + ); + const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("App deployed successfully"); + expect(syncApiKeyHeader).toBe(workspaceApiKey); + expect(syncBody).toEqual({ connectors: [] }); + }); + + it("deploys local connectors through the deployment endpoint with a workspace API key", async () => { + await t.givenProject(fixture("with-connectors")); + t.givenEnv({ + BASE44_API_KEY: + "b44k_ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + }); + t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsListError({ + status: 403, + body: { error: "Forbidden", detail: "Workspace keys cannot list" }, + }); + t.api.mockConnectorSetError({ + status: 403, + body: { error: "Forbidden", detail: "Workspace keys cannot set" }, + }); + + let syncBody: unknown; + t.api.mockRoute( + "PUT", + `/api/apps/${APP_ID}/deployment/connectors`, + (req, res) => { + syncBody = req.body; + res.status(200).json(req.body); + }, + ); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("3 connectors"); + expect(syncBody).toEqual({ + connectors: expect.arrayContaining([ + { + integration_type: "googlecalendar", + scopes: [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", + ], + }, + { integration_type: "notion", scopes: [] }, + { integration_type: "slack", scopes: ["chat:write", "channels:read"] }, + ]), + }); + }); + + it("reports the Stripe connector as unsupported with a workspace API key", async () => { + // Stripe routes only accept platform-user auth. Rather than surfacing the + // status call's auth failure, name the limitation and sync the rest. + await t.givenProject(fixture("with-stripe-connector")); + t.givenEnv({ + BASE44_API_KEY: + "b44k_gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + }); + let stripeStatusCalled = false; + t.api.mockRoute( + "GET", + `/api/apps/${APP_ID}/payments/stripe/status`, + (_req, res) => { + stripeStatusCalled = true; + res.status(401).json({ error: "Unauthorized" }); + }, + ); + t.api.mockDeploymentConnectorsSync({ + connectors: [{ integration_type: "slack", scopes: ["chat:write"] }], + }); + + const result = await t.run("connectors", "push", "--yes"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Synced: slack"); + t.expectResult(result).toContain( + "Stripe connector sync is not supported with a workspace API key", + ); + expect(stripeStatusCalled).toBe(false); }); it("pushes auth config through the deployment endpoint with a workspace API key", async () => { @@ -183,6 +274,7 @@ describe("env credential seeding", () => { deleted: [], }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockDeploymentConnectorsSync({ connectors: [] }); let deploymentAuthConfigBody: unknown; let deploymentApiKeyHeader: string | undefined; diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 0fc704cce..1f0f35cb5 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -192,6 +192,10 @@ interface ConnectorSetResponse { other_user_email?: string; } +interface DeploymentConnectorsSyncResponse { + connectors: Array<{ integration_type: string; scopes: string[] }>; +} + interface ConnectorRemoveResponse { status: "removed"; integration_type: string; @@ -649,6 +653,16 @@ export class TestAPIServer { ); } + mockDeploymentConnectorsSync( + response: DeploymentConnectorsSyncResponse, + ): this { + return this.addRoute( + "PUT", + `/api/apps/${this.appId}/deployment/connectors`, + response, + ); + } + mockAvailableIntegrationsList( response: AvailableIntegrationsListResponse, ): this { diff --git a/packages/cli/tests/core/connectors.spec.ts b/packages/cli/tests/core/connectors.spec.ts index 6da37e0a8..438b058ad 100644 --- a/packages/cli/tests/core/connectors.spec.ts +++ b/packages/cli/tests/core/connectors.spec.ts @@ -1,7 +1,7 @@ import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { InvalidInputError } from "../../src/core/errors.js"; import * as api from "../../src/core/resources/connector/api.js"; import { @@ -294,6 +294,7 @@ const mockRemoveConnector = vi.mocked(api.removeConnector); const mockGetStripeStatus = vi.mocked(api.getStripeStatus); const mockInstallStripe = vi.mocked(api.installStripe); const mockRemoveStripe = vi.mocked(api.removeStripe); +const mockSyncDeploymentConnectors = vi.mocked(api.syncDeploymentConnectors); describe("pushConnectors", () => { beforeEach(() => { @@ -840,3 +841,84 @@ describe("pullAllConnectors", () => { await expect(pullAllConnectors()).rejects.toThrow("List API error"); }); }); + +describe("pushConnectors with a workspace API key", () => { + const previousApiKey = process.env.BASE44_API_KEY; + + beforeEach(() => { + vi.resetAllMocks(); + process.env.BASE44_API_KEY = + "b44k_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + }); + + afterEach(() => { + if (previousApiKey === undefined) { + delete process.env.BASE44_API_KEY; + } else { + process.env.BASE44_API_KEY = previousApiKey; + } + }); + + it("syncs OAuth connectors through the deployment endpoint only", async () => { + const local: ConnectorResource[] = [ + { type: "gmail", scopes: ["https://mail.google.com/"] }, + { type: "notion", scopes: [] }, + ]; + mockSyncDeploymentConnectors.mockResolvedValue({ + connectors: [ + { integrationType: "gmail", scopes: ["https://mail.google.com/"] }, + { integrationType: "notion", scopes: [] }, + ], + }); + + const result = await pushConnectors(local); + + expect(mockSyncDeploymentConnectors).toHaveBeenCalledWith([ + { integrationType: "gmail", scopes: ["https://mail.google.com/"] }, + { integrationType: "notion", scopes: [] }, + ]); + expect(result.results).toEqual([ + { type: "gmail", action: "synced" }, + { type: "notion", action: "synced" }, + ]); + expect(mockListConnectors).not.toHaveBeenCalled(); + expect(mockSetConnector).not.toHaveBeenCalled(); + expect(mockRemoveConnector).not.toHaveBeenCalled(); + expect(mockGetStripeStatus).not.toHaveBeenCalled(); + }); + + it("sends an empty list so remote connectors are reconciled", async () => { + mockSyncDeploymentConnectors.mockResolvedValue({ connectors: [] }); + + const result = await pushConnectors([]); + + expect(mockSyncDeploymentConnectors).toHaveBeenCalledWith([]); + expect(result.results).toEqual([]); + }); + + it("reports a local Stripe connector as an error without calling Stripe routes", async () => { + const local: ConnectorResource[] = [ + { type: "stripe", scopes: [] }, + { type: "slack", scopes: ["chat:write"] }, + ]; + mockSyncDeploymentConnectors.mockResolvedValue({ + connectors: [{ integrationType: "slack", scopes: ["chat:write"] }], + }); + + const result = await pushConnectors(local); + + expect(result.results).toEqual([ + { type: "slack", action: "synced" }, + { + type: "stripe", + action: "error", + error: expect.stringContaining( + "not supported with a workspace API key", + ), + }, + ]); + expect(mockGetStripeStatus).not.toHaveBeenCalled(); + expect(mockInstallStripe).not.toHaveBeenCalled(); + expect(mockRemoveStripe).not.toHaveBeenCalled(); + }); +});