Skip to content
Open
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
7 changes: 7 additions & 0 deletions docs/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ BASE44_API_KEY=b44k_... BASE44_APP_ID=<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
Expand Down
2 changes: 1 addition & 1 deletion docs/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 2 additions & 8 deletions packages/cli/src/core/project/deploy.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/core/resources/connector/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
RemoveStripeResponse,
SetConnectorResponse,
StripeStatusResponse,
SyncDeploymentConnectorsResponse,
} from "./schema.js";
import {
InstallStripeResponseSchema,
Expand All @@ -21,6 +22,7 @@ import {
RemoveStripeResponseSchema,
SetConnectorResponseSchema,
StripeStatusResponseSchema,
SyncDeploymentConnectorsResponseSchema,
} from "./schema.js";

export async function listConnectors(): Promise<ListConnectorsResponse> {
Expand All @@ -45,6 +47,45 @@ export async function listConnectors(): Promise<ListConnectorsResponse> {
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<SyncDeploymentConnectorsResponse> {
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[],
Expand Down
45 changes: 44 additions & 1 deletion packages/cli/src/core/resources/connector/push.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);

Expand All @@ -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<ConnectorSyncResult[]> {
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<ConnectorSyncResult[]> {
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/core/resources/connector/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
108 changes: 100 additions & 8 deletions packages/cli/tests/cli/env-token-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/tests/cli/testkit/TestAPIServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading