diff --git a/src/client.ts b/src/client.ts index a028f33..304f207 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,6 +12,7 @@ import { createFunctionsModule } from "./modules/functions.js"; import { createAgentsModule } from "./modules/agents.js"; import { createAppLogsModule } from "./modules/app-logs.js"; import { createUsersModule } from "./modules/users.js"; +import { createAccountsModule } from "./modules/accounts.js"; import { RoomsSocket, RoomsSocketConfig } from "./utils/socket-utils.js"; import type { Base44Client, @@ -111,6 +112,7 @@ export function createClient(config: CreateClientConfig): Base44Client { baseURL: `${serverUrl}/api`, headers, token, + appId: String(appId), onError: options?.onError, }); @@ -118,6 +120,7 @@ export function createClient(config: CreateClientConfig): Base44Client { baseURL: `${serverUrl}/api`, headers: functionHeaders, token, + appId: String(appId), interceptResponses: false, onError: options?.onError, }); @@ -131,6 +134,7 @@ export function createClient(config: CreateClientConfig): Base44Client { baseURL: `${serverUrl}/api`, headers: serviceRoleHeaders, token: serviceToken, + appId: String(appId), onError: options?.onError, }); @@ -138,6 +142,7 @@ export function createClient(config: CreateClientConfig): Base44Client { baseURL: `${serverUrl}/api`, headers: functionHeaders, token: serviceToken, + appId: String(appId), interceptResponses: false, }); @@ -192,6 +197,7 @@ export function createClient(config: CreateClientConfig): Base44Client { }), appLogs: createAppLogsModule(axiosClient, appId), users: createUsersModule(axiosClient, appId), + accounts: createAccountsModule(axiosClient, appId), analytics: createAnalyticsModule({ axiosClient, serverUrl, diff --git a/src/client.types.ts b/src/client.types.ts index 6b4c9c5..ad2c471 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -10,6 +10,7 @@ import type { FunctionsModule } from "./modules/functions.types.js"; import type { AgentsModule } from "./modules/agents.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; +import type { AccountsModule } from "./modules/accounts.types.js"; /** * Options for creating a Base44 client. @@ -85,6 +86,8 @@ export interface CreateClientConfig { * Provides access to all SDK modules for interacting with the app. */ export interface Base44Client { + /** {@link AccountsModule | Accounts module} for multi-tenancy (accounts, members, billing). */ + accounts: AccountsModule; /** {@link AgentsModule | Agents module} for managing AI agent conversations. */ agents: AgentsModule; /** {@link AnalyticsModule | Analytics module} for tracking custom events in your app. */ diff --git a/src/index.ts b/src/index.ts index bc531d8..ae7fe8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,19 @@ export type { export type { AppLogsModule } from "./modules/app-logs.types.js"; +export type { + AccountsModule, + Account, + AccountMembership, + AccountPlan, + AccountRole, + AssignableAccountRole, + AccountStatus, + AccountMembershipStatus, + MyAccountsResponse, + CheckoutSession, +} from "./modules/accounts.types.js"; + export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; export type { diff --git a/src/modules/accounts.ts b/src/modules/accounts.ts new file mode 100644 index 0000000..b8095f2 --- /dev/null +++ b/src/modules/accounts.ts @@ -0,0 +1,164 @@ +import { AxiosInstance } from "axios"; + +import { + getStoredActiveAccountId, + setStoredActiveAccountId, +} from "../utils/common.js"; +import type { + Account, + AccountMembership, + AccountPlan, + AccountsModule, + AccountSubscription, + AssignableAccountRole, + CheckoutParams, + CheckoutSession, + MyAccountsResponse, + PublicAccount, +} from "./accounts.types.js"; + +/** + * Creates the accounts module (multi-tenancy) for the Base44 SDK. + * + * @param axios - Axios instance (responses are unwrapped to data). + * @param appId - Application ID. + * @returns The accounts module. + * @internal + */ +export function createAccountsModule( + axios: AxiosInstance, + appId: string +): AccountsModule { + const base = `/apps/${appId}/accounts`; + const enc = encodeURIComponent; + + // Resolve the account id to operate on: an explicit id wins, then the + // explicitly-stored client selection, then the server-resolved default + // (the sole-account case). Throws a clear error when none can be found so + // callers never silently send `/accounts/undefined/...` (which 404s as + // "Account not found"). + const resolveAccountId = async (provided?: string | null): Promise => { + if (provided) return provided; + const stored = getStoredActiveAccountId(appId); + if (stored) return stored; + const mine: MyAccountsResponse = await axios.get(`${base}/me`); + if (mine.active_account_id) return mine.active_account_id; + throw new Error( + "No active account: pass an accountId, or have the user select or create an account first." + ); + }; + + return { + getActiveAccountId(): string | undefined { + return getStoredActiveAccountId(appId); + }, + + switchAccount(accountId: string): void { + setStoredActiveAccountId(appId, accountId); + if (typeof window === "undefined") return; + window.location.reload(); + }, + + setActiveAccount(accountId: string): void { + setStoredActiveAccountId(appId, accountId); + }, + + clearActiveAccount(): void { + setStoredActiveAccountId(appId, null); + }, + + async listMine(): Promise { + return axios.get(`${base}/me`); + }, + + async getPublicAccount(slug: string): Promise { + return axios.get(`${base}/public/by-slug/${enc(slug)}`); + }, + + async joinAccount(slug: string): Promise { + return axios.post(`${base}/by-slug/${enc(slug)}/join`, {}); + }, + + async create(params: { + name: string; + data?: Record; + slug?: string; + }): Promise { + return axios.post(base, params); + }, + + async update( + accountId: string, + params: { name?: string; data?: Record; slug?: string } + ): Promise { + return axios.patch(`${base}/${accountId}`, params); + }, + + async listMembers(accountId?: string): Promise { + const id = await resolveAccountId(accountId); + return axios.get(`${base}/${id}/members`); + }, + + async invite( + accountId: string, + email: string, + role: AssignableAccountRole = "member" + ): Promise { + return axios.post(`${base}/${accountId}/invites`, { email, role }); + }, + + async acceptInvite(accountId: string): Promise { + return axios.post(`${base}/${accountId}/accept`, {}); + }, + + async changeMemberRole( + accountId: string, + email: string, + role: AssignableAccountRole + ): Promise { + return axios.patch(`${base}/${accountId}/members/${enc(email)}/role`, { + role, + }); + }, + + async removeMember( + accountId: string, + email: string + ): Promise<{ removed: boolean }> { + return axios.delete(`${base}/${accountId}/members/${enc(email)}`); + }, + + async transferOwnership( + accountId: string, + email: string + ): Promise<{ transferred: boolean }> { + return axios.post(`${base}/${accountId}/transfer-ownership`, { email }); + }, + + billing: { + async listPlans(accountId?: string): Promise { + const id = await resolveAccountId(accountId); + return axios.get(`${base}/${id}/billing/plans`); + }, + + async getSubscription(accountId?: string): Promise { + const id = await resolveAccountId(accountId); + return axios.get(`${base}/${id}/billing/subscription`); + }, + + async startCheckout( + accountIdOrParams: string | CheckoutParams, + maybeParams?: CheckoutParams + ): Promise { + const explicitId = + typeof accountIdOrParams === "string" ? accountIdOrParams : undefined; + const params = + typeof accountIdOrParams === "string" + ? maybeParams + : accountIdOrParams; + const id = await resolveAccountId(explicitId); + return axios.post(`${base}/${id}/billing/checkout`, params); + }, + }, + }; +} diff --git a/src/modules/accounts.types.ts b/src/modules/accounts.types.ts new file mode 100644 index 0000000..85b9ebe --- /dev/null +++ b/src/modules/accounts.types.ts @@ -0,0 +1,204 @@ +/** + * Types for the {@link AccountsModule | accounts} module (multi-tenancy). + * + * An Account groups the app's end-users into an isolated tenant (a company, + * team, or organization). Users join accounts via membership and act inside one + * active account at a time. Account-scoped entities are transparently isolated + * to the active account (carried by the `X-Active-Account-Id` header, read from + * stored client state in localStorage, keyed per app). + */ + +/** Account-management role. Distinct from the app's business roles. */ +export type AccountRole = "owner" | "admin" | "member"; + +/** Assignable (non-owner) role used for invites/role changes. */ +export type AssignableAccountRole = "admin" | "member"; + +export type AccountStatus = "active" | "suspended"; +export type AccountMembershipStatus = "pending" | "active"; + +/** An account (tenant) within the app. */ +export interface Account { + id: string; + app_id: string; + name: string; + status: AccountStatus; + plan_id?: string | null; + billing_status?: string; + /** The current user's role in this account (present on `listMine()` results). */ + my_role?: AccountRole; + /** Builder-defined custom fields. */ + data?: Record; + created_date?: string; +} + +/** The accounts the current user belongs to, plus the active one. */ +export interface MyAccountsResponse { + accounts: Account[]; + active_account_id: string | null; +} + +/** + * Public, unauthenticated view of an account (its landing page), resolved by + * slug. Carries identity plus only the builder-designated public custom fields. + */ +export interface PublicAccount { + id: string; + name: string; + slug: string | null; + /** Builder-flagged public companion fields (e.g. logo, tagline). */ + data: Record; +} + +/** A user's membership in an account. */ +export interface AccountMembership { + id: string; + account_id: string; + email: string; + role: AccountRole; + status: AccountMembershipStatus; +} + +/** A subscription plan/tier offered to accounts. */ +export interface AccountPlan { + id: string; + name: string; + description?: string | null; + price_amount: number; + currency: string; + interval: "month" | "year"; + is_active: boolean; +} + +/** A provider checkout session. */ +export interface CheckoutSession { + url: string; + session_id: string; +} + +/** Parameters for starting a subscription checkout. */ +export interface CheckoutParams { + plan_id: string; + success_url: string; + cancel_url: string; +} + +/** The current subscription state of an account. */ +export interface AccountSubscription { + account_id: string; + /** The active plan id, or `null` when the account has no subscription. */ + plan_id: string | null; + /** Lifecycle status: "none" | "active" | "past_due" | "canceled". */ + billing_status: string; + /** The payment rail backing the subscription, or `null`. */ + billing_provider: string | null; + /** The current plan, or `null` when the account has no subscription. */ + plan: AccountPlan | null; + /** When the current paid period ends / renews (ISO 8601), or `null`. */ + current_period_end: string | null; + /** True when the subscription will not renew at period end. */ + cancel_at_period_end: boolean; + /** When the subscription was canceled (ISO 8601), or `null`. */ + canceled_at: string | null; + /** When the subscription started (ISO 8601), or `null`. */ + started_at: string | null; +} + +/** + * The accounts module — manage multi-tenancy ("Accounts") from inside the app. + * + * Access via `base44.accounts`. Available when the app has multi-tenancy enabled. + */ +export interface AccountsModule { + /** The active account id, read from stored client state (or `undefined`). */ + getActiveAccountId(): string | undefined; + /** + * Switch the active account by persisting it to stored client state and + * reloading the page so all data follows the new account. + * @param accountId - The account to switch to. + */ + switchAccount(accountId: string): void; + /** + * Persist the active account WITHOUT reloading the page. + * + * Use on the public landing page to select the account before redirecting to + * login, so the app resolves that account after the visitor returns. For + * switching accounts inside the running app, use {@link switchAccount} (which + * reloads so all data follows the new account). + */ + setActiveAccount(accountId: string): void; + /** Clear the stored active account (the backend falls back to the default). */ + clearActiveAccount(): void; + /** List the accounts the current user belongs to, plus the active one. */ + listMine(): Promise; + /** + * Resolve a public account by its slug (unauthenticated) for its landing page. + * @param slug - The account's URL slug. + */ + getPublicAccount(slug: string): Promise; + /** + * Self-join an account by slug (the current user becomes a member). Requires + * login and that the app enables public joining; otherwise rejects. + * @param slug - The account's URL slug. + */ + joinAccount(slug: string): Promise; + /** + * Create a new account; the current user becomes its owner. + * @param params.slug - Optional public landing-page URL segment; auto-derived + * from the name when omitted. + */ + create(params: { name: string; data?: Record; slug?: string }): Promise; + /** Rename, change the landing-page slug, and/or update an account's custom fields (managers only). */ + update( + accountId: string, + params: { name?: string; data?: Record; slug?: string } + ): Promise; + /** + * List an account's members (any active member). + * @param accountId - Defaults to the active account when omitted. + */ + listMembers(accountId?: string): Promise; + /** Invite a user by email to an account (managers only). */ + invite( + accountId: string, + email: string, + role?: AssignableAccountRole + ): Promise; + /** Accept a pending invite to an account for the current user. */ + acceptInvite(accountId: string): Promise; + /** Change a member's role (managers only; not for the owner). */ + changeMemberRole( + accountId: string, + email: string, + role: AssignableAccountRole + ): Promise; + /** Remove a member from an account (managers only; not the owner). */ + removeMember(accountId: string, email: string): Promise<{ removed: boolean }>; + /** Transfer ownership to another active member (owner only). */ + transferOwnership( + accountId: string, + email: string + ): Promise<{ transferred: boolean }>; + /** Per-account billing. */ + billing: { + /** + * List the active plans available to an account. + * @param accountId - Defaults to the active account when omitted. + */ + listPlans(accountId?: string): Promise; + /** + * Get the current subscription state (plan + status) of an account. + * @param accountId - Defaults to the active account when omitted. + */ + getSubscription(accountId?: string): Promise; + /** + * Start a subscription checkout session for a plan, then redirect the + * browser to the returned `url`. The account defaults to the active one. + */ + startCheckout(params: CheckoutParams): Promise; + startCheckout( + accountId: string, + params: CheckoutParams + ): Promise; + }; +} diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index 957835c..7ba43d4 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { isInIFrame } from "./common.js"; +import { getStoredActiveAccountId, isInIFrame } from "./common.js"; import { v4 as uuidv4 } from "uuid"; import type { Base44ErrorJSON } from "./axios-client.types.js"; @@ -149,12 +149,14 @@ export function createAxiosClient({ baseURL, headers = {}, token, + appId, interceptResponses = true, onError, }: { baseURL: string; headers?: Record; token?: string; + appId?: string; interceptResponses?: boolean; onError?: (error: Error) => void; }) { @@ -176,6 +178,16 @@ export function createAxiosClient({ client.interceptors.request.use((config) => { if (typeof window !== "undefined") { config.headers.set("X-Origin-URL", window.location.href); + // Multi-tenancy: forward the active account from stored client state + // (localStorage, keyed per app) on every request so account-scoped + // reads/writes stay isolated to the current tenant. No-op when unset — + // the backend then defaults to the user's sole active account. + const activeAccountId = appId + ? getStoredActiveAccountId(appId) + : undefined; + if (activeAccountId) { + config.headers.set("X-Active-Account-Id", activeAccountId); + } } const requestId = uuidv4(); (config as any).requestId = requestId; diff --git a/src/utils/common.ts b/src/utils/common.ts index 61d8d17..b7918ce 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,6 +1,57 @@ export const isNode = typeof window === "undefined"; export const isInIFrame = !isNode && window.self !== window.top; +// Multi-tenancy: the active account is explicit client state, not the URL path. +// It is persisted in localStorage keyed per app (`base44:active_account:`) +// so it survives reloads and works under any base path (e.g. the sandbox/preview +// where the app is served under a non-account base path). When unset, no header +// is sent and the backend defaults to the user's sole active account. +const ACCOUNT_ID_RE = /^[a-f0-9]{24}$/; + +const activeAccountStorageKey = (appId: string): string => + `base44:active_account:${appId}`; + +/** + * The active account id from stored client state, or undefined. + * + * Browser-only: reads `localStorage['base44:active_account:']` and returns + * it when it's a valid 24-hex account id, else undefined. Returns undefined in + * non-browser environments or if storage access throws. + */ +export function getStoredActiveAccountId(appId: string): string | undefined { + if (isNode) return undefined; + try { + const stored = window.localStorage.getItem(activeAccountStorageKey(appId)); + return stored && ACCOUNT_ID_RE.test(stored) ? stored : undefined; + } catch { + return undefined; + } +} + +/** + * Persist (or clear) the active account id in stored client state. + * + * Browser-only: writes `localStorage['base44:active_account:']` when + * `accountId` is a valid 24-hex id, and removes the key when it is null or + * invalid. No-op in non-browser environments or if storage access throws. + */ +export function setStoredActiveAccountId( + appId: string, + accountId: string | null +): void { + if (isNode) return; + try { + const key = activeAccountStorageKey(appId); + if (accountId && ACCOUNT_ID_RE.test(accountId)) { + window.localStorage.setItem(key, accountId); + } else { + window.localStorage.removeItem(key); + } + } catch { + /* storage unavailable — ignore */ + } +} + export const generateUuid = () => { return ( Math.random().toString(36).substring(2, 15) + diff --git a/tests/unit/accounts.test.ts b/tests/unit/accounts.test.ts new file mode 100644 index 0000000..06034a2 --- /dev/null +++ b/tests/unit/accounts.test.ts @@ -0,0 +1,152 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; + +describe("Accounts module", () => { + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + const ACCT = "a".repeat(24); + let base44: ReturnType; + let scope: nock.Scope; + + beforeEach(() => { + base44 = createClient({ serverUrl, appId }); + scope = nock(serverUrl); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test("listMine GETs /accounts/me", async () => { + const payload = { accounts: [{ id: ACCT, name: "Acme", my_role: "owner" }], active_account_id: ACCT }; + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, payload); + const res = await base44.accounts.listMine(); + expect(res).toEqual(payload); + expect(scope.isDone()).toBe(true); + }); + + test("create POSTs the account name", async () => { + scope.post(`/api/apps/${appId}/accounts`, { name: "Acme" }).reply(200, { id: ACCT, name: "Acme" }); + const res = await base44.accounts.create({ name: "Acme" }); + expect(res.id).toBe(ACCT); + expect(scope.isDone()).toBe(true); + }); + + test("invite POSTs email + role and url-encodes member email on role change", async () => { + scope.post(`/api/apps/${appId}/accounts/${ACCT}/invites`, { email: "a@b.com", role: "admin" }).reply(200, {}); + await base44.accounts.invite(ACCT, "a@b.com", "admin"); + scope.patch(`/api/apps/${appId}/accounts/${ACCT}/members/a%2Bx%40b.com/role`, { role: "member" }).reply(200, {}); + await base44.accounts.changeMemberRole(ACCT, "a+x@b.com", "member"); + expect(scope.isDone()).toBe(true); + }); + + test("billing.listPlans GETs the account plans", async () => { + scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/plans`).reply(200, []); + const res = await base44.accounts.billing.listPlans(ACCT); + expect(res).toEqual([]); + expect(scope.isDone()).toBe(true); + }); + + test("billing.listPlans without an id resolves the active account via /me", async () => { + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { accounts: [], active_account_id: ACCT }); + scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/plans`).reply(200, []); + const res = await base44.accounts.billing.listPlans(); + expect(res).toEqual([]); + expect(scope.isDone()).toBe(true); + }); + + test("billing.getSubscription GETs the subscription endpoint (resolving active account)", async () => { + const payload = { + account_id: ACCT, + plan_id: "p1", + billing_status: "active", + billing_provider: "stripe_connect", + plan: { id: "p1", name: "Pro", price_amount: 1000, currency: "usd", interval: "month", is_active: true }, + current_period_end: "2026-07-01T00:00:00+00:00", + cancel_at_period_end: false, + canceled_at: null, + started_at: "2026-06-01T00:00:00+00:00", + }; + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { accounts: [], active_account_id: ACCT }); + scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/subscription`).reply(200, payload); + const sub = await base44.accounts.billing.getSubscription(); + expect(sub).toEqual(payload); + expect(scope.isDone()).toBe(true); + }); + + test("billing.getSubscription(accountId) uses the explicit id (no /me)", async () => { + const payload = { + account_id: ACCT, plan_id: null, billing_status: "none", billing_provider: null, + plan: null, current_period_end: null, cancel_at_period_end: false, canceled_at: null, started_at: null, + }; + scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/subscription`).reply(200, payload); + const sub = await base44.accounts.billing.getSubscription(ACCT); + expect(sub).toEqual(payload); + expect(scope.isDone()).toBe(true); + }); + + test("billing.startCheckout(params) resolves the active account", async () => { + const params = { plan_id: "p1", success_url: "https://x/ok", cancel_url: "https://x/no" }; + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { accounts: [], active_account_id: ACCT }); + scope.post(`/api/apps/${appId}/accounts/${ACCT}/billing/checkout`, params).reply(200, { url: "https://pay", session_id: "s1" }); + const res = await base44.accounts.billing.startCheckout(params); + expect(res.url).toBe("https://pay"); + expect(scope.isDone()).toBe(true); + }); + + test("billing.startCheckout(accountId, params) uses the explicit id (no /me)", async () => { + const params = { plan_id: "p1", success_url: "https://x/ok", cancel_url: "https://x/no" }; + scope.post(`/api/apps/${appId}/accounts/${ACCT}/billing/checkout`, params).reply(200, { url: "https://pay", session_id: "s1" }); + const res = await base44.accounts.billing.startCheckout(ACCT, params); + expect(res.url).toBe("https://pay"); + expect(scope.isDone()).toBe(true); + }); + + test("listMembers without an id resolves the active account via /me", async () => { + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { accounts: [], active_account_id: ACCT }); + scope.get(`/api/apps/${appId}/accounts/${ACCT}/members`).reply(200, []); + const res = await base44.accounts.listMembers(); + expect(res).toEqual([]); + expect(scope.isDone()).toBe(true); + }); + + test("resolveAccountId throws a clear error when there is no active account", async () => { + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { accounts: [], active_account_id: null }); + await expect(base44.accounts.billing.listPlans()).rejects.toThrow(/No active account/); + expect(scope.isDone()).toBe(true); + }); + + test("getPublicAccount GETs the public by-slug endpoint (unauthenticated)", async () => { + const payload = { id: ACCT, name: "Acme", slug: "acme", data: { tagline: "Hi" } }; + scope.get(`/api/apps/${appId}/accounts/public/by-slug/acme`).reply(200, payload); + const res = await base44.accounts.getPublicAccount("acme"); + expect(res).toEqual(payload); + expect(scope.isDone()).toBe(true); + }); + + test("getPublicAccount url-encodes the slug", async () => { + scope.get(`/api/apps/${appId}/accounts/public/by-slug/a%2Fb`).reply(200, { id: ACCT, name: "x", slug: "a-b", data: {} }); + await base44.accounts.getPublicAccount("a/b"); + expect(scope.isDone()).toBe(true); + }); + + test("joinAccount POSTs to the by-slug join endpoint", async () => { + scope.post(`/api/apps/${appId}/accounts/by-slug/acme/join`, {}).reply(200, { id: "m1", account_id: ACCT, email: "a@b.com", role: "member", status: "active" }); + const res = await base44.accounts.joinAccount("acme"); + expect(res.status).toBe("active"); + expect(scope.isDone()).toBe(true); + }); + + // The active-account behavior (getActiveAccountId + the per-request + // X-Active-Account-Id header) is browser-only — `getStoredActiveAccountId` + // is gated on a module-load `typeof window` check, so it is exercised in the + // browser/app context, not this node-environment test suite. + test("getActiveAccountId returns undefined outside a browser", () => { + expect(base44.accounts.getActiveAccountId()).toBeUndefined(); + }); + + test("setActiveAccount does not throw outside a browser (no reload)", () => { + expect(() => base44.accounts.setActiveAccount(ACCT)).not.toThrow(); + }); +});