Skip to content

Commit 98c80bd

Browse files
felixkobcursoragent
andcommitted
feat(accounts): multi-tenancy support — accounts module + dynamic active-account header
- Add `base44.accounts` module: getActiveAccountId, switchAccount, listMine, create, update, listMembers, invite, acceptInvite, changeMemberRole, removeMember, transferOwnership, and billing.{listPlans, startCheckout} mapping to the backend /api/apps/{appId}/accounts/... routes. - Send X-Active-Account-Id per request, read from the URL path (the canonical account source) so account-scoped reads/writes stay isolated to the current tenant even after a client-side (Link/useNavigate) account switch. No-op for single-tenant apps / Node. - Wire accounts into the client + Base44Client type; re-export account types. - Add node-safe unit tests for the module's HTTP surface. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fa66d47 commit 98c80bd

8 files changed

Lines changed: 335 additions & 1 deletion

File tree

src/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createFunctionsModule } from "./modules/functions.js";
1212
import { createAgentsModule } from "./modules/agents.js";
1313
import { createAppLogsModule } from "./modules/app-logs.js";
1414
import { createUsersModule } from "./modules/users.js";
15+
import { createAccountsModule } from "./modules/accounts.js";
1516
import { RoomsSocket, RoomsSocketConfig } from "./utils/socket-utils.js";
1617
import type {
1718
Base44Client,
@@ -192,6 +193,7 @@ export function createClient(config: CreateClientConfig): Base44Client {
192193
}),
193194
appLogs: createAppLogsModule(axiosClient, appId),
194195
users: createUsersModule(axiosClient, appId),
196+
accounts: createAccountsModule(axiosClient, appId),
195197
analytics: createAnalyticsModule({
196198
axiosClient,
197199
serverUrl,

src/client.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { FunctionsModule } from "./modules/functions.types.js";
1010
import type { AgentsModule } from "./modules/agents.types.js";
1111
import type { AppLogsModule } from "./modules/app-logs.types.js";
1212
import type { AnalyticsModule } from "./modules/analytics.types.js";
13+
import type { AccountsModule } from "./modules/accounts.types.js";
1314

1415
/**
1516
* Options for creating a Base44 client.
@@ -85,6 +86,8 @@ export interface CreateClientConfig {
8586
* Provides access to all SDK modules for interacting with the app.
8687
*/
8788
export interface Base44Client {
89+
/** {@link AccountsModule | Accounts module} for multi-tenancy (accounts, members, billing). */
90+
accounts: AccountsModule;
8891
/** {@link AgentsModule | Agents module} for managing AI agent conversations. */
8992
agents: AgentsModule;
9093
/** {@link AnalyticsModule | Analytics module} for tracking custom events in your app. */

src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,19 @@ export type {
102102

103103
export type { AppLogsModule } from "./modules/app-logs.types.js";
104104

105+
export type {
106+
AccountsModule,
107+
Account,
108+
AccountMembership,
109+
AccountPlan,
110+
AccountRole,
111+
AssignableAccountRole,
112+
AccountStatus,
113+
AccountMembershipStatus,
114+
MyAccountsResponse,
115+
CheckoutSession,
116+
} from "./modules/accounts.types.js";
117+
105118
export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js";
106119

107120
export type {

src/modules/accounts.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { AxiosInstance } from "axios";
2+
3+
import { getActiveAccountIdFromPath } from "../utils/common.js";
4+
import type {
5+
Account,
6+
AccountMembership,
7+
AccountPlan,
8+
AccountsModule,
9+
AssignableAccountRole,
10+
CheckoutSession,
11+
MyAccountsResponse,
12+
} from "./accounts.types.js";
13+
14+
/**
15+
* Creates the accounts module (multi-tenancy) for the Base44 SDK.
16+
*
17+
* @param axios - Axios instance (responses are unwrapped to data).
18+
* @param appId - Application ID.
19+
* @returns The accounts module.
20+
* @internal
21+
*/
22+
export function createAccountsModule(
23+
axios: AxiosInstance,
24+
appId: string
25+
): AccountsModule {
26+
const base = `/apps/${appId}/accounts`;
27+
const enc = encodeURIComponent;
28+
29+
return {
30+
getActiveAccountId(): string | undefined {
31+
return getActiveAccountIdFromPath();
32+
},
33+
34+
switchAccount(accountId: string, subPath = ""): void {
35+
if (typeof window === "undefined") return;
36+
const clean = subPath.replace(/^\/+/, "");
37+
window.location.assign(`/${accountId}${clean ? `/${clean}` : "/"}`);
38+
},
39+
40+
async listMine(): Promise<MyAccountsResponse> {
41+
return axios.get(`${base}/me`);
42+
},
43+
44+
async create(params: {
45+
name: string;
46+
data?: Record<string, unknown>;
47+
}): Promise<Account> {
48+
return axios.post(base, params);
49+
},
50+
51+
async update(
52+
accountId: string,
53+
params: { name?: string; data?: Record<string, unknown> }
54+
): Promise<Account> {
55+
return axios.patch(`${base}/${accountId}`, params);
56+
},
57+
58+
async listMembers(accountId: string): Promise<AccountMembership[]> {
59+
return axios.get(`${base}/${accountId}/members`);
60+
},
61+
62+
async invite(
63+
accountId: string,
64+
email: string,
65+
role: AssignableAccountRole = "member"
66+
): Promise<AccountMembership> {
67+
return axios.post(`${base}/${accountId}/invites`, { email, role });
68+
},
69+
70+
async acceptInvite(accountId: string): Promise<AccountMembership> {
71+
return axios.post(`${base}/${accountId}/accept`, {});
72+
},
73+
74+
async changeMemberRole(
75+
accountId: string,
76+
email: string,
77+
role: AssignableAccountRole
78+
): Promise<AccountMembership> {
79+
return axios.patch(`${base}/${accountId}/members/${enc(email)}/role`, {
80+
role,
81+
});
82+
},
83+
84+
async removeMember(
85+
accountId: string,
86+
email: string
87+
): Promise<{ removed: boolean }> {
88+
return axios.delete(`${base}/${accountId}/members/${enc(email)}`);
89+
},
90+
91+
async transferOwnership(
92+
accountId: string,
93+
email: string
94+
): Promise<{ transferred: boolean }> {
95+
return axios.post(`${base}/${accountId}/transfer-ownership`, { email });
96+
},
97+
98+
billing: {
99+
async listPlans(accountId: string): Promise<AccountPlan[]> {
100+
return axios.get(`${base}/${accountId}/billing/plans`);
101+
},
102+
103+
async startCheckout(
104+
accountId: string,
105+
params: { plan_id: string; success_url: string; cancel_url: string }
106+
): Promise<CheckoutSession> {
107+
return axios.post(`${base}/${accountId}/billing/checkout`, params);
108+
},
109+
},
110+
};
111+
}

src/modules/accounts.types.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Types for the {@link AccountsModule | accounts} module (multi-tenancy).
3+
*
4+
* An Account groups the app's end-users into an isolated tenant (a company,
5+
* team, or organization). Users join accounts via membership and act inside one
6+
* active account at a time. Account-scoped entities are transparently isolated
7+
* to the active account (carried by the `X-Active-Account-Id` header, derived
8+
* from the `/<account_id>/...` URL path).
9+
*/
10+
11+
/** Account-management role. Distinct from the app's business roles. */
12+
export type AccountRole = "owner" | "admin" | "member";
13+
14+
/** Assignable (non-owner) role used for invites/role changes. */
15+
export type AssignableAccountRole = "admin" | "member";
16+
17+
export type AccountStatus = "active" | "suspended";
18+
export type AccountMembershipStatus = "pending" | "active";
19+
20+
/** An account (tenant) within the app. */
21+
export interface Account {
22+
id: string;
23+
app_id: string;
24+
name: string;
25+
status: AccountStatus;
26+
plan_id?: string | null;
27+
billing_status?: string;
28+
/** The current user's role in this account (present on `listMine()` results). */
29+
my_role?: AccountRole;
30+
/** Builder-defined custom fields. */
31+
data?: Record<string, unknown>;
32+
created_date?: string;
33+
}
34+
35+
/** The accounts the current user belongs to, plus the active one. */
36+
export interface MyAccountsResponse {
37+
accounts: Account[];
38+
active_account_id: string | null;
39+
}
40+
41+
/** A user's membership in an account. */
42+
export interface AccountMembership {
43+
id: string;
44+
account_id: string;
45+
email: string;
46+
role: AccountRole;
47+
status: AccountMembershipStatus;
48+
}
49+
50+
/** A subscription plan/tier offered to accounts. */
51+
export interface AccountPlan {
52+
id: string;
53+
name: string;
54+
description?: string | null;
55+
price_amount: number;
56+
currency: string;
57+
interval: "month" | "year";
58+
is_active: boolean;
59+
}
60+
61+
/** A provider checkout session. */
62+
export interface CheckoutSession {
63+
url: string;
64+
session_id: string;
65+
}
66+
67+
/**
68+
* The accounts module — manage multi-tenancy ("Accounts") from inside the app.
69+
*
70+
* Access via `base44.accounts`. Available when the app has multi-tenancy enabled.
71+
*/
72+
export interface AccountsModule {
73+
/** The active account id, read from the current URL path (or `undefined`). */
74+
getActiveAccountId(): string | undefined;
75+
/**
76+
* Switch the active account by navigating to its folder (`/<accountId>/...`).
77+
* A full navigation re-roots the app so all data follows the new account.
78+
* @param accountId - The account to switch to.
79+
* @param subPath - Optional in-account route to land on (defaults to the root).
80+
*/
81+
switchAccount(accountId: string, subPath?: string): void;
82+
/** List the accounts the current user belongs to, plus the active one. */
83+
listMine(): Promise<MyAccountsResponse>;
84+
/** Create a new account; the current user becomes its owner. */
85+
create(params: { name: string; data?: Record<string, unknown> }): Promise<Account>;
86+
/** Rename and/or update an account's custom fields (managers only). */
87+
update(
88+
accountId: string,
89+
params: { name?: string; data?: Record<string, unknown> }
90+
): Promise<Account>;
91+
/** List an account's members (any active member). */
92+
listMembers(accountId: string): Promise<AccountMembership[]>;
93+
/** Invite a user by email to an account (managers only). */
94+
invite(
95+
accountId: string,
96+
email: string,
97+
role?: AssignableAccountRole
98+
): Promise<AccountMembership>;
99+
/** Accept a pending invite to an account for the current user. */
100+
acceptInvite(accountId: string): Promise<AccountMembership>;
101+
/** Change a member's role (managers only; not for the owner). */
102+
changeMemberRole(
103+
accountId: string,
104+
email: string,
105+
role: AssignableAccountRole
106+
): Promise<AccountMembership>;
107+
/** Remove a member from an account (managers only; not the owner). */
108+
removeMember(accountId: string, email: string): Promise<{ removed: boolean }>;
109+
/** Transfer ownership to another active member (owner only). */
110+
transferOwnership(
111+
accountId: string,
112+
email: string
113+
): Promise<{ transferred: boolean }>;
114+
/** Per-account billing. */
115+
billing: {
116+
/** List the active plans available to this account. */
117+
listPlans(accountId: string): Promise<AccountPlan[]>;
118+
/** Start a subscription checkout session for a plan. */
119+
startCheckout(
120+
accountId: string,
121+
params: { plan_id: string; success_url: string; cancel_url: string }
122+
): Promise<CheckoutSession>;
123+
};
124+
}

src/utils/axios-client.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import axios from "axios";
2-
import { isInIFrame } from "./common.js";
2+
import { getActiveAccountIdFromPath, isInIFrame } from "./common.js";
33
import { v4 as uuidv4 } from "uuid";
44
import type { Base44ErrorJSON } from "./axios-client.types.js";
55

@@ -176,6 +176,15 @@ export function createAxiosClient({
176176
client.interceptors.request.use((config) => {
177177
if (typeof window !== "undefined") {
178178
config.headers.set("X-Origin-URL", window.location.href);
179+
// Multi-tenancy: forward the active account (from the URL path) per request
180+
// so account-scoped reads/writes stay isolated to the current tenant even
181+
// after a client-side account switch. The path is the canonical source, so
182+
// it overrides any stale default header (e.g. one frozen at module load);
183+
// no-op for single-tenant apps (no account segment in the path).
184+
const activeAccountId = getActiveAccountIdFromPath();
185+
if (activeAccountId) {
186+
config.headers.set("X-Active-Account-Id", activeAccountId);
187+
}
179188
}
180189
const requestId = uuidv4();
181190
(config as any).requestId = requestId;

src/utils/common.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
export const isNode = typeof window === "undefined";
22
export const isInIFrame = !isNode && window.self !== window.top;
33

4+
// Multi-tenancy: apps are served under `/<account_id>/<route>` where the first
5+
// path segment is a 24-hex Mongo ObjectId. Read it at request time so the active
6+
// account is always current — even after client-side (Link/useNavigate) account
7+
// switches that don't reload the module.
8+
const ACCOUNT_ID_RE = /^[a-f0-9]{24}$/;
9+
10+
export function getActiveAccountIdFromPath(): string | undefined {
11+
if (isNode) return undefined;
12+
const firstSegment = window.location.pathname.split("/").filter(Boolean)[0];
13+
return firstSegment && ACCOUNT_ID_RE.test(firstSegment)
14+
? firstSegment
15+
: undefined;
16+
}
17+
418
export const generateUuid = () => {
519
return (
620
Math.random().toString(36).substring(2, 15) +

tests/unit/accounts.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
2+
import nock from "nock";
3+
import { createClient } from "../../src/index.ts";
4+
5+
describe("Accounts module", () => {
6+
const appId = "test-app-id";
7+
const serverUrl = "https://base44.app";
8+
const ACCT = "a".repeat(24);
9+
let base44: ReturnType<typeof createClient>;
10+
let scope: nock.Scope;
11+
12+
beforeEach(() => {
13+
base44 = createClient({ serverUrl, appId });
14+
scope = nock(serverUrl);
15+
});
16+
17+
afterEach(() => {
18+
nock.cleanAll();
19+
});
20+
21+
test("listMine GETs /accounts/me", async () => {
22+
const payload = { accounts: [{ id: ACCT, name: "Acme", my_role: "owner" }], active_account_id: ACCT };
23+
scope.get(`/api/apps/${appId}/accounts/me`).reply(200, payload);
24+
const res = await base44.accounts.listMine();
25+
expect(res).toEqual(payload);
26+
expect(scope.isDone()).toBe(true);
27+
});
28+
29+
test("create POSTs the account name", async () => {
30+
scope.post(`/api/apps/${appId}/accounts`, { name: "Acme" }).reply(200, { id: ACCT, name: "Acme" });
31+
const res = await base44.accounts.create({ name: "Acme" });
32+
expect(res.id).toBe(ACCT);
33+
expect(scope.isDone()).toBe(true);
34+
});
35+
36+
test("invite POSTs email + role and url-encodes member email on role change", async () => {
37+
scope.post(`/api/apps/${appId}/accounts/${ACCT}/invites`, { email: "a@b.com", role: "admin" }).reply(200, {});
38+
await base44.accounts.invite(ACCT, "a@b.com", "admin");
39+
scope.patch(`/api/apps/${appId}/accounts/${ACCT}/members/a%2Bx%40b.com/role`, { role: "member" }).reply(200, {});
40+
await base44.accounts.changeMemberRole(ACCT, "a+x@b.com", "member");
41+
expect(scope.isDone()).toBe(true);
42+
});
43+
44+
test("billing.listPlans GETs the account plans", async () => {
45+
scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/plans`).reply(200, []);
46+
const res = await base44.accounts.billing.listPlans(ACCT);
47+
expect(res).toEqual([]);
48+
expect(scope.isDone()).toBe(true);
49+
});
50+
51+
// The active-account behavior (getActiveAccountId + the per-request
52+
// X-Active-Account-Id header) is browser-only — `getActiveAccountIdFromPath`
53+
// is gated on a module-load `typeof window` check, so it is exercised in the
54+
// browser/app context, not this node-environment test suite.
55+
test("getActiveAccountId returns undefined outside a browser", () => {
56+
expect(base44.accounts.getActiveAccountId()).toBeUndefined();
57+
});
58+
});

0 commit comments

Comments
 (0)