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
21 changes: 21 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
CreateClientOptions,
} from "./client.types.js";
import { createAnalyticsModule } from "./modules/analytics.js";
import { createAppUserSecretsModule } from "./modules/app-user-secrets.js";

// Re-export client types
export type { Base44Client, CreateClientConfig, CreateClientOptions };
Expand Down Expand Up @@ -122,6 +123,18 @@ export function createClient(config: CreateClientConfig): Base44Client {
onError: options?.onError,
});

const appUserSecretsReadAxiosClient = createAxiosClient({
baseURL: `${serverUrl}/api`,
headers: {
...headers,
...(serviceToken
? { "Base44-Service-Authorization": `Bearer ${serviceToken}` }
: {}),
},
token,
onError: options?.onError,
});

const serviceRoleHeaders = {
...headers,
...(token ? { "on-behalf-of": `Bearer ${token}` } : {}),
Expand Down Expand Up @@ -198,6 +211,12 @@ export function createClient(config: CreateClientConfig): Base44Client {
appId,
userAuthModule,
}),
appUserSecrets: createAppUserSecretsModule(
axiosClient,
appUserSecretsReadAxiosClient,
appId,
serviceToken
),
cleanup: () => {
userModules.analytics.cleanup();
if (socket) {
Expand Down Expand Up @@ -278,6 +297,8 @@ export function createClient(config: CreateClientConfig): Base44Client {
*/
setToken(newToken: string) {
userModules.auth.setToken(newToken);
appUserSecretsReadAxiosClient.defaults.headers.common["Authorization"] =
`Bearer ${newToken}`;
if (socket) {
socket.updateConfig({
token: newToken,
Expand Down
3 changes: 3 additions & 0 deletions src/client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { AppUserSecretsModule } from "./modules/app-user-secrets.types.js";

/**
* Options for creating a Base44 client.
Expand Down Expand Up @@ -91,6 +92,8 @@ export interface Base44Client {
analytics: AnalyticsModule;
/** {@link AppLogsModule | App logs module} for tracking app usage. */
appLogs: AppLogsModule;
/** App-user secrets module for storing and using user-owned credentials. */
appUserSecrets: AppUserSecretsModule;
/** {@link AuthModule | Auth module} for user authentication and management. */
auth: AuthModule;
/** {@link UserConnectorsModule | Connectors module} for app-user OAuth flows. */
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ export type {

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

export type {
AppUserSecretsModule,
AppUserSecretStatus,
} from "./modules/app-user-secrets.types.js";

export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js";

export type {
Expand Down
58 changes: 58 additions & 0 deletions src/modules/app-user-secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { AxiosInstance } from "axios";

import type {
AppUserSecretsModule,
AppUserSecretStatus,
} from "./app-user-secrets.types.js";

const SECRET_KEY_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;

function assertSecretKey(key: string): void {
if (typeof key !== "string" || !SECRET_KEY_PATTERN.test(key)) {
throw new Error(
"Secret key must start with a lowercase letter and contain only lowercase letters, numbers, and underscores"
);
}
}

export function createAppUserSecretsModule(
userAxios: AxiosInstance,
readAxios: AxiosInstance,
appId: string,
serviceToken?: string
): AppUserSecretsModule {
const basePath = `/apps/${appId}/app-user-secrets`;

return {
async list(): Promise<AppUserSecretStatus[]> {
return await userAxios.get(basePath);
},

async set(key: string, value: string): Promise<void> {
assertSecretKey(key);
if (typeof value !== "string" || value.length === 0) {
throw new Error("Secret value is required and must be a non-empty string");
}
await userAxios.put(`${basePath}/${key}`, { value });
},

async delete(key: string): Promise<void> {
assertSecretKey(key);
await userAxios.delete(`${basePath}/${key}`);
},

async get(key: string): Promise<string> {
assertSecretKey(key);
if (!serviceToken) {
throw new Error(
"App user secrets can only be read from a Base44 backend function created with createClientFromRequest(request)"
);
}
const response = await readAxios.get<{ value: string }>(
`${basePath}/${key}/value`
);
const data = response as unknown as { value: string };
return data.value;
},
};
}
30 changes: 30 additions & 0 deletions src/modules/app-user-secrets.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface AppUserSecretStatus {
id: string;
key: string;
label: string;
description: string;
allowed_backend_functions: string[];
version: number;
is_active: boolean;
configured: boolean;
requires_reentry: boolean;
}

export interface AppUserSecretsModule {
/** Lists the secret definitions available to the current app user. */
list(): Promise<AppUserSecretStatus[]>;

/** Stores or replaces the current app user's value for a declared secret. */
set(key: string, value: string): Promise<void>;

/** Deletes the current app user's value for a declared secret. */
delete(key: string): Promise<void>;

/**
* Reads the current app user's secret value.
*
* Available only in a user-authenticated Base44 backend-function invocation
* created with `createClientFromRequest(request)`.
*/
get(key: string): Promise<string>;
}
70 changes: 70 additions & 0 deletions tests/unit/app-user-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import nock from "nock";
import { afterEach, describe, expect, test } from "vitest";

import { createClient } from "../../src/index.ts";


const appId = "test-app-id";
const serverUrl = "https://api.base44.com";


afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});


describe("App user secrets module", () => {
test("stores a declared secret with user authentication", async () => {
const scope = nock(serverUrl, {
reqheaders: { authorization: "Bearer user-token" },
})
.put(`/api/apps/${appId}/app-user-secrets/provider_api_key`, {
value: "secret-value",
})
.reply(200, { success: true });
const client = createClient({ serverUrl, appId, token: "user-token" });

await client.appUserSecrets.set("provider_api_key", "secret-value");

expect(scope.isDone()).toBe(true);
});

test("reads with both user and backend-function service authentication", async () => {
const scope = nock(serverUrl, {
reqheaders: {
authorization: "Bearer user-token",
"base44-service-authorization": "Bearer service-token",
},
})
.get(`/api/apps/${appId}/app-user-secrets/provider_api_key/value`)
.reply(200, { value: "secret-value" });
const client = createClient({
serverUrl,
appId,
token: "user-token",
serviceToken: "service-token",
});

const value = await client.appUserSecrets.get("provider_api_key");

expect(value).toBe("secret-value");
expect(scope.isDone()).toBe(true);
});

test("rejects reads outside a backend function", async () => {
const client = createClient({ serverUrl, appId, token: "user-token" });

await expect(
client.appUserSecrets.get("provider_api_key")
).rejects.toThrow("App user secrets can only be read from a Base44 backend function");
});

test("rejects arbitrary secret keys", async () => {
const client = createClient({ serverUrl, appId, token: "user-token" });

await expect(
client.appUserSecrets.set("Not Declared", "secret-value")
).rejects.toThrow("Secret key must start with a lowercase letter");
});
});