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
22 changes: 19 additions & 3 deletions docs/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
47 changes: 36 additions & 11 deletions packages/cli/src/core/project/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectData, "project">;

Expand Down Expand Up @@ -63,6 +64,8 @@ class ProjectConfigReader {
agents: localResources.agents,
connectors: localResources.connectors,
authConfig: localResources.authConfig,
userSecrets: localResources.userSecrets,
userSecretsDirPresent: localResources.userSecretsDirPresent,
};
}

Expand Down Expand Up @@ -105,16 +108,34 @@ class ProjectConfigReader {
project: ProjectConfig,
): Promise<ProjectResources> {
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(
Expand Down Expand Up @@ -187,6 +208,8 @@ class ProjectConfigReader {
agents: [],
connectors: [],
authConfig: [],
userSecrets: [],
userSecretsDirPresent: false,
};
}

Expand Down Expand Up @@ -243,6 +266,8 @@ class ProjectConfigReader {
agents: [],
connectors: [],
authConfig: [],
userSecrets: [],
userSecretsDirPresent: false,
};
}

Expand Down
26 changes: 22 additions & 4 deletions packages/cli/src/core/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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;
Expand All @@ -35,6 +43,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean {
hasAgents ||
hasConnectors ||
hasAuthConfig ||
userSecretsDirPresent ||
hasSite
);
}
Expand Down Expand Up @@ -69,15 +78,24 @@ export async function deployAll(
projectData: ProjectData,
options?: DeployAllOptions,
): Promise<DeployAllResult> {
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, {
onStart: options?.onFunctionStart,
onResult: options?.onFunctionResult,
});
await agentResource.push(agents);
if (userSecretsDirPresent) await userSecretResource.push(userSecrets);
await authConfigResource.push(authConfig);
const { results: connectorResults } = await pushConnectors(connectors);

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/core/project/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/core/project/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,4 +23,6 @@ export interface ProjectData {
agents: AgentConfig[];
connectors: ConnectorResource[];
authConfig: AuthConfig[];
userSecrets: UserSecretDefinition[];
userSecretsDirPresent: boolean;
}
1 change: 1 addition & 0 deletions packages/cli/src/core/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
33 changes: 33 additions & 0 deletions packages/cli/src/core/resources/user-secret/api.ts
Original file line number Diff line number Diff line change
@@ -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;
}
51 changes: 51 additions & 0 deletions packages/cli/src/core/resources/user-secret/config.ts
Original file line number Diff line number Diff line change
@@ -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<UserSecretDefinition[]> {
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<string>();
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;
}
4 changes: 4 additions & 0 deletions packages/cli/src/core/resources/user-secret/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./api.js";
export * from "./config.js";
export * from "./resource.js";
export * from "./schema.js";
9 changes: 9 additions & 0 deletions packages/cli/src/core/resources/user-secret/resource.ts
Original file line number Diff line number Diff line change
@@ -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<UserSecretDefinition> = {
readAll: readAllUserSecrets,
push: pushUserSecrets,
};
31 changes: 31 additions & 0 deletions packages/cli/src/core/resources/user-secret/schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof UserSecretDefinitionSchema>;

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,
);
Loading