From 4b96bfee245464fa9921d7f01eda27e6987b3ae1 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Fri, 26 Jun 2026 21:05:47 +0300 Subject: [PATCH 1/2] feat: add app user secrets module --- src/client.ts | 21 ++++++++ src/client.types.ts | 3 ++ src/index.ts | 5 ++ src/modules/app-user-secrets.ts | 63 ++++++++++++++++++++++++ src/modules/app-user-secrets.types.ts | 33 +++++++++++++ tests/unit/app-user-secrets.test.ts | 70 +++++++++++++++++++++++++++ 6 files changed, 195 insertions(+) create mode 100644 src/modules/app-user-secrets.ts create mode 100644 src/modules/app-user-secrets.types.ts create mode 100644 tests/unit/app-user-secrets.test.ts diff --git a/src/client.ts b/src/client.ts index a028f337..bd20d3cc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 }; @@ -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}` } : {}), @@ -198,6 +211,12 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, userAuthModule, }), + appUserSecrets: createAppUserSecretsModule( + axiosClient, + appUserSecretsReadAxiosClient, + appId, + serviceToken + ), cleanup: () => { userModules.analytics.cleanup(); if (socket) { @@ -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, diff --git a/src/client.types.ts b/src/client.types.ts index 6b4c9c57..df66f264 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 { AppUserSecretsModule } from "./modules/app-user-secrets.types.js"; /** * Options for creating a Base44 client. @@ -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. */ diff --git a/src/index.ts b/src/index.ts index bc531d89..9559a22e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { diff --git a/src/modules/app-user-secrets.ts b/src/modules/app-user-secrets.ts new file mode 100644 index 00000000..cf8a9dcf --- /dev/null +++ b/src/modules/app-user-secrets.ts @@ -0,0 +1,63 @@ +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 { + return await userAxios.get(basePath); + }, + + async set(key: string, value: string): Promise { + 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 consent(key: string): Promise { + assertSecretKey(key); + await userAxios.post(`${basePath}/${key}/consent`); + }, + + async delete(key: string): Promise { + assertSecretKey(key); + await userAxios.delete(`${basePath}/${key}`); + }, + + async get(key: string): Promise { + 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; + }, + }; +} diff --git a/src/modules/app-user-secrets.types.ts b/src/modules/app-user-secrets.types.ts new file mode 100644 index 00000000..d58b63b2 --- /dev/null +++ b/src/modules/app-user-secrets.types.ts @@ -0,0 +1,33 @@ +export interface AppUserSecretStatus { + id: string; + key: string; + label: string; + description: string; + allowed_backend_functions: string[]; + version: number; + is_active: boolean; + configured: boolean; + requires_consent: boolean; +} + +export interface AppUserSecretsModule { + /** Lists the secret definitions available to the current app user. */ + list(): Promise; + + /** Stores or replaces the current app user's value for a declared secret. */ + set(key: string, value: string): Promise; + + /** Consents to the latest access policy for an already configured secret. */ + consent(key: string): Promise; + + /** Deletes the current app user's value for a declared secret. */ + delete(key: string): Promise; + + /** + * 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; +} diff --git a/tests/unit/app-user-secrets.test.ts b/tests/unit/app-user-secrets.test.ts new file mode 100644 index 00000000..07db87b4 --- /dev/null +++ b/tests/unit/app-user-secrets.test.ts @@ -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"); + }); +}); From a21a6ce8b06ffbcca8e86e51db42d1e06dae35d3 Mon Sep 17 00:00:00 2001 From: Avner Rosenan Date: Fri, 26 Jun 2026 22:09:30 +0300 Subject: [PATCH 2/2] fix: require credential re-entry for policy changes --- src/modules/app-user-secrets.ts | 5 ----- src/modules/app-user-secrets.types.ts | 5 +---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/modules/app-user-secrets.ts b/src/modules/app-user-secrets.ts index cf8a9dcf..f92dd8d5 100644 --- a/src/modules/app-user-secrets.ts +++ b/src/modules/app-user-secrets.ts @@ -36,11 +36,6 @@ export function createAppUserSecretsModule( await userAxios.put(`${basePath}/${key}`, { value }); }, - async consent(key: string): Promise { - assertSecretKey(key); - await userAxios.post(`${basePath}/${key}/consent`); - }, - async delete(key: string): Promise { assertSecretKey(key); await userAxios.delete(`${basePath}/${key}`); diff --git a/src/modules/app-user-secrets.types.ts b/src/modules/app-user-secrets.types.ts index d58b63b2..7b8a87bb 100644 --- a/src/modules/app-user-secrets.types.ts +++ b/src/modules/app-user-secrets.types.ts @@ -7,7 +7,7 @@ export interface AppUserSecretStatus { version: number; is_active: boolean; configured: boolean; - requires_consent: boolean; + requires_reentry: boolean; } export interface AppUserSecretsModule { @@ -17,9 +17,6 @@ export interface AppUserSecretsModule { /** Stores or replaces the current app user's value for a declared secret. */ set(key: string, value: string): Promise; - /** Consents to the latest access policy for an already configured secret. */ - consent(key: string): Promise; - /** Deletes the current app user's value for a declared secret. */ delete(key: string): Promise;