diff --git a/docs/resources.md b/docs/resources.md index 8cf65b73..1308c5f2 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -2,7 +2,7 @@ **Keywords:** resource, entity, function, agent, connector, push, readAll, deploy, site, tar.gz, deployAll, ProjectData -Resources are project-specific collections (entities, functions, agents, connectors) that can be read from the filesystem and pushed to the Base44 API. +Resources are project-specific collections (entities, functions, agents, connectors, user credential definitions) that can be read from the filesystem and pushed to the Base44 API. ## Resource Interface @@ -102,8 +102,24 @@ What it deploys (in order): 1. Entities (via `entityResource.push()`) 2. Functions (via `functionResource.push()`) 3. Agents (via `agentResource.push()`) -4. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs -5. Site (if `site.outputDirectory` is configured) +4. User credential definitions from `base44/user-secrets/*.jsonc` +5. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs +6. Site (if `site.outputDirectory` is configured) + +User credential definition files contain metadata only: + +```jsonc +{ + "name": "provider_api_key", + "label": "Provider API key", + "description": "Create an API key in your provider account.", + "allowedFunctions": ["call-provider"] +} +``` + +Secret values are never stored in project files. Deploy fully synchronizes this +directory; deleting a file disables that definition remotely without purging +the encrypted values held by app users. ```bash base44 deploy # With confirmation prompt diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index bb3ff5c2..61ece56f 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -28,7 +28,8 @@ import { type BackendFunction, functionResource, } from "@/core/resources/function/index.js"; -import { readJsonFile } from "@/core/utils/fs.js"; +import { userSecretResource } from "@/core/resources/user-secret/index.js"; +import { pathExists, readJsonFile } from "@/core/utils/fs.js"; type ProjectResources = Omit; @@ -63,6 +64,8 @@ class ProjectConfigReader { agents: localResources.agents, connectors: localResources.connectors, authConfig: localResources.authConfig, + userSecrets: localResources.userSecrets, + userSecretsDirPresent: localResources.userSecretsDirPresent, }; } @@ -105,16 +108,34 @@ class ProjectConfigReader { project: ProjectConfig, ): Promise { const configDir = dirname(configPath); - const [entities, functions, agents, connectors, authConfig] = - await Promise.all([ - entityResource.readAll(join(configDir, project.entitiesDir)), - functionResource.readAll(join(configDir, project.functionsDir)), - agentResource.readAll(join(configDir, project.agentsDir)), - connectorResource.readAll(join(configDir, project.connectorsDir)), - authConfigResource.readAll(join(configDir, project.authDir)), - ]); - - return { entities, functions, agents, connectors, authConfig }; + const userSecretsDir = join(configDir, project.userSecretsDir); + const [ + entities, + functions, + agents, + connectors, + authConfig, + userSecrets, + userSecretsDirPresent, + ] = await Promise.all([ + entityResource.readAll(join(configDir, project.entitiesDir)), + functionResource.readAll(join(configDir, project.functionsDir)), + agentResource.readAll(join(configDir, project.agentsDir)), + connectorResource.readAll(join(configDir, project.connectorsDir)), + authConfigResource.readAll(join(configDir, project.authDir)), + userSecretResource.readAll(userSecretsDir), + pathExists(userSecretsDir), + ]); + + return { + entities, + functions, + agents, + connectors, + authConfig, + userSecrets, + userSecretsDirPresent, + }; } private assertPluginProjectDoesNotLoadPlugins( @@ -187,6 +208,8 @@ class ProjectConfigReader { agents: [], connectors: [], authConfig: [], + userSecrets: [], + userSecretsDirPresent: false, }; } @@ -243,6 +266,8 @@ class ProjectConfigReader { agents: [], connectors: [], authConfig: [], + userSecrets: [], + userSecretsDirPresent: false, }; } diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 68089173..af50f25e 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -11,6 +11,7 @@ import { deployFunctionsSequentially, type SingleFunctionDeployResult, } from "@/core/resources/function/deploy.js"; +import { userSecretResource } from "@/core/resources/user-secret/index.js"; import { deploySite } from "@/core/site/index.js"; /** @@ -20,8 +21,15 @@ import { deploySite } from "@/core/site/index.js"; * @returns true if there are entities, functions, agents, connectors, or a configured site to deploy */ export function hasResourcesToDeploy(projectData: ProjectData): boolean { - const { project, entities, functions, agents, connectors, authConfig } = - projectData; + const { + project, + entities, + functions, + agents, + connectors, + authConfig, + userSecretsDirPresent, + } = projectData; const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; @@ -35,6 +43,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { hasAgents || hasConnectors || hasAuthConfig || + userSecretsDirPresent || hasSite ); } @@ -69,8 +78,16 @@ export async function deployAll( projectData: ProjectData, options?: DeployAllOptions, ): Promise { - const { project, entities, functions, agents, connectors, authConfig } = - projectData; + const { + project, + entities, + functions, + agents, + connectors, + authConfig, + userSecrets, + userSecretsDirPresent, + } = projectData; await entityResource.push(entities); await deployFunctionsSequentially(functions, { @@ -78,6 +95,7 @@ export async function deployAll( onResult: options?.onFunctionResult, }); await agentResource.push(agents); + if (userSecretsDirPresent) await userSecretResource.push(userSecrets); await authConfigResource.push(authConfig); const { results: connectorResults } = await pushConnectors(connectors); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 22938a99..ad963edb 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -47,6 +47,7 @@ export const ProjectConfigSchema = z.object({ functionsDir: z.string().optional().default("functions"), agentsDir: z.string().optional().default("agents"), connectorsDir: z.string().optional().default("connectors"), + userSecretsDir: z.string().optional().default("user-secrets"), authDir: z.string().optional().default("auth"), plugin: PluginMetadataSchema.optional(), plugins: z.array(PluginReferenceSchema).optional().default([]), diff --git a/packages/cli/src/core/project/types.ts b/packages/cli/src/core/project/types.ts index 7b581b3c..752195d5 100644 --- a/packages/cli/src/core/project/types.ts +++ b/packages/cli/src/core/project/types.ts @@ -4,6 +4,7 @@ import type { AuthConfig } from "@/core/resources/auth-config/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; import type { BackendFunction } from "@/core/resources/function/index.js"; +import type { UserSecretDefinition } from "@/core/resources/user-secret/index.js"; interface ProjectWithPaths extends ProjectConfig { root: string; @@ -22,4 +23,6 @@ export interface ProjectData { agents: AgentConfig[]; connectors: ConnectorResource[]; authConfig: AuthConfig[]; + userSecrets: UserSecretDefinition[]; + userSecretsDirPresent: boolean; } diff --git a/packages/cli/src/core/resources/index.ts b/packages/cli/src/core/resources/index.ts index a8b80eaf..399011cd 100644 --- a/packages/cli/src/core/resources/index.ts +++ b/packages/cli/src/core/resources/index.ts @@ -4,3 +4,4 @@ export * from "./connector/index.js"; export * from "./entity/index.js"; export * from "./function/index.js"; export * from "./secret/index.js"; +export * from "./user-secret/index.js"; diff --git a/packages/cli/src/core/resources/user-secret/api.ts b/packages/cli/src/core/resources/user-secret/api.ts new file mode 100644 index 00000000..e94bf991 --- /dev/null +++ b/packages/cli/src/core/resources/user-secret/api.ts @@ -0,0 +1,33 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { + SyncUserSecretsResponseSchema, + type UserSecretDefinition, +} from "./schema.js"; + +export async function pushUserSecrets(definitions: UserSecretDefinition[]) { + let response: KyResponse; + try { + response = await getAppClient().put("app-user-secret-definitions", { + json: definitions.map((definition) => ({ + key: definition.name, + label: definition.label, + description: definition.description, + allowed_backend_functions: definition.allowedFunctions, + })), + }); + } catch (error) { + throw await ApiError.fromHttpError( + error, + "syncing user credential definitions", + ); + } + const result = SyncUserSecretsResponseSchema.safeParse(await response.json()); + if (!result.success) + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + return result.data; +} diff --git a/packages/cli/src/core/resources/user-secret/config.ts b/packages/cli/src/core/resources/user-secret/config.ts new file mode 100644 index 00000000..4348fe12 --- /dev/null +++ b/packages/cli/src/core/resources/user-secret/config.ts @@ -0,0 +1,51 @@ +import { basename } from "node:path"; +import { globby } from "globby"; +import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js"; +import { InvalidInputError, SchemaValidationError } from "@/core/errors.js"; +import { pathExists, readJsonFile } from "@/core/utils/fs.js"; +import { + type UserSecretDefinition, + UserSecretDefinitionSchema, +} from "./schema.js"; + +export async function readAllUserSecrets( + dir: string, +): Promise { + if (!(await pathExists(dir))) return []; + const files = await globby(`*.${CONFIG_FILE_EXTENSION_GLOB}`, { + cwd: dir, + absolute: true, + }); + const definitions = await Promise.all( + files.map(async (filePath) => { + const result = UserSecretDefinitionSchema.safeParse( + await readJsonFile(filePath), + ); + if (!result.success) + throw new SchemaValidationError( + "Invalid user credential definition", + result.error, + filePath, + ); + const fileName = basename(filePath).replace(/\.jsonc?$/, ""); + if (fileName !== result.data.name) { + throw new InvalidInputError( + `User credential name "${result.data.name}" must match filename "${fileName}"`, + { + hints: [{ message: `Rename ${filePath} or update its name field` }], + }, + ); + } + return result.data; + }), + ); + const names = new Set(); + for (const definition of definitions) { + if (names.has(definition.name)) + throw new InvalidInputError( + `Duplicate user credential name "${definition.name}"`, + ); + names.add(definition.name); + } + return definitions; +} diff --git a/packages/cli/src/core/resources/user-secret/index.ts b/packages/cli/src/core/resources/user-secret/index.ts new file mode 100644 index 00000000..8182de95 --- /dev/null +++ b/packages/cli/src/core/resources/user-secret/index.ts @@ -0,0 +1,4 @@ +export * from "./api.js"; +export * from "./config.js"; +export * from "./resource.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/resources/user-secret/resource.ts b/packages/cli/src/core/resources/user-secret/resource.ts new file mode 100644 index 00000000..e5ca099f --- /dev/null +++ b/packages/cli/src/core/resources/user-secret/resource.ts @@ -0,0 +1,9 @@ +import type { Resource } from "../types.js"; +import { pushUserSecrets } from "./api.js"; +import { readAllUserSecrets } from "./config.js"; +import type { UserSecretDefinition } from "./schema.js"; + +export const userSecretResource: Resource = { + readAll: readAllUserSecrets, + push: pushUserSecrets, +}; diff --git a/packages/cli/src/core/resources/user-secret/schema.ts b/packages/cli/src/core/resources/user-secret/schema.ts new file mode 100644 index 00000000..6aa1089d --- /dev/null +++ b/packages/cli/src/core/resources/user-secret/schema.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +const NameSchema = z + .string() + .regex( + /^[a-z][a-z0-9_]{0,63}$/, + "Name must use lowercase letters, numbers, and underscores", + ); + +export const UserSecretDefinitionSchema = z.object({ + name: NameSchema, + label: z.string().trim().min(1).max(100), + description: z.string().max(500).optional().default(""), + allowedFunctions: z.array(z.string().min(1)).min(1).max(50), +}); + +export type UserSecretDefinition = z.infer; + +export const UserSecretDefinitionResponseSchema = z.object({ + id: z.string(), + key: NameSchema, + label: z.string(), + description: z.string(), + allowed_backend_functions: z.array(z.string()), + version: z.number(), + is_active: z.boolean(), +}); + +export const SyncUserSecretsResponseSchema = z.array( + UserSecretDefinitionResponseSchema, +); diff --git a/packages/cli/tests/core/user-secrets.spec.ts b/packages/cli/tests/core/user-secrets.spec.ts new file mode 100644 index 00000000..9b5e8d51 --- /dev/null +++ b/packages/cli/tests/core/user-secrets.spec.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { pushUserSecrets } from "../../src/core/resources/user-secret/api.js"; + +const mockPut = vi.fn(); +vi.mock("../../src/core/clients/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, getAppClient: () => ({ put: mockPut }) }; +}); + +describe("pushUserSecrets", () => { + beforeEach(() => vi.clearAllMocks()); + + it("sends metadata-only definitions using the platform API shape", async () => { + mockPut.mockResolvedValue({ + json: () => + Promise.resolve([ + { + id: "definition-1", + key: "provider_api_key", + label: "Provider API key", + description: "", + allowed_backend_functions: ["call-provider"], + version: 1, + is_active: true, + }, + ]), + }); + + await pushUserSecrets([ + { + name: "provider_api_key", + label: "Provider API key", + description: "", + allowedFunctions: ["call-provider"], + }, + ]); + + expect(mockPut).toHaveBeenCalledWith("app-user-secret-definitions", { + json: [ + { + key: "provider_api_key", + label: "Provider API key", + description: "", + allowed_backend_functions: ["call-provider"], + }, + ], + }); + }); + + it("syncs an empty directory so removed definitions are disabled", async () => { + mockPut.mockResolvedValue({ json: () => Promise.resolve([]) }); + + await pushUserSecrets([]); + + expect(mockPut).toHaveBeenCalledWith("app-user-secret-definitions", { + json: [], + }); + }); +});