@openpalm/lib 0.10.2 → 0.11.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +105 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/channels.ts +3 -3
  7. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  8. package/src/control-plane/compose-args.test.ts +25 -24
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +103 -65
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +54 -57
  14. package/src/control-plane/docker.ts +55 -21
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +80 -0
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +187 -289
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +34 -65
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/paths.ts +82 -0
  27. package/src/control-plane/provider-config.ts +2 -2
  28. package/src/control-plane/provider-models.ts +154 -0
  29. package/src/control-plane/registry-components.test.ts +105 -27
  30. package/src/control-plane/registry.test.ts +49 -47
  31. package/src/control-plane/registry.ts +71 -50
  32. package/src/control-plane/rollback.ts +17 -16
  33. package/src/control-plane/scheduler.ts +75 -262
  34. package/src/control-plane/secret-backend.test.ts +98 -111
  35. package/src/control-plane/secret-backend.ts +221 -181
  36. package/src/control-plane/secret-mappings.ts +4 -8
  37. package/src/control-plane/secrets.ts +93 -51
  38. package/src/control-plane/setup-config.schema.json +5 -17
  39. package/src/control-plane/setup-status.ts +9 -29
  40. package/src/control-plane/setup-validation.ts +23 -23
  41. package/src/control-plane/setup.test.ts +138 -239
  42. package/src/control-plane/setup.ts +215 -130
  43. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  44. package/src/control-plane/spec-to-env.test.ts +59 -58
  45. package/src/control-plane/spec-to-env.ts +52 -142
  46. package/src/control-plane/spec-validator.ts +2 -99
  47. package/src/control-plane/stack-spec.test.ts +21 -77
  48. package/src/control-plane/stack-spec.ts +7 -83
  49. package/src/control-plane/types.ts +12 -28
  50. package/src/control-plane/ui-assets.ts +349 -0
  51. package/src/control-plane/validate.ts +44 -79
  52. package/src/index.ts +86 -48
  53. package/src/logger.test.ts +228 -0
  54. package/src/logger.ts +71 -1
  55. package/src/provider-constants.ts +22 -1
  56. package/src/control-plane/audit.ts +0 -40
  57. package/src/control-plane/env-schema-validation.test.ts +0 -118
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/redact-schema.ts +0 -50
@@ -1,8 +1,9 @@
1
1
  /** Secrets and capability key management. */
2
- import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, lstatSync, rmSync } from "node:fs";
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, lstatSync, rmSync, renameSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { createLogger } from "../logger.js";
5
5
  import { parseEnvFile, mergeEnvContent } from './env.js';
6
+ import { migrateAuth0110 } from './migrate-0110.js';
6
7
  import type { ControlPlaneState } from "./types.js";
7
8
  import { resolveConfigDir } from "./home.js";
8
9
 
@@ -54,18 +55,17 @@ function mergeVaultEnvFile(path: string, updates: Record<string, string>, uncomm
54
55
  }
55
56
 
56
57
  function ensureSystemSecrets(state: ControlPlaneState): void {
57
- const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
58
+ const systemEnvPath = `${state.stackDir}/stack.env`;
58
59
  const existing = existsSync(systemEnvPath) ? parseEnvFile(systemEnvPath) : {};
59
60
  const updates: Record<string, string> = {};
60
61
 
61
- if (!existing.OP_ADMIN_TOKEN && state.adminToken) {
62
- updates.OP_ADMIN_TOKEN = state.adminToken;
63
- }
64
- if (!existing.OP_ASSISTANT_TOKEN) {
65
- updates.OP_ASSISTANT_TOKEN = randomBytes(32).toString("hex");
66
- }
67
- if (!existing.OP_MEMORY_TOKEN) {
68
- updates.OP_MEMORY_TOKEN = randomBytes(32).toString("hex");
62
+ // OP_UI_LOGIN_PASSWORD seeds the operator login secret. ensureSecrets
63
+ // generates a random fallback the first time so the stack is never
64
+ // installed with an empty password slot; the wizard / CLI install path
65
+ // overwrites it with the operator's chosen value via
66
+ // buildSystemSecretsFromSetup().
67
+ if (!existing.OP_UI_LOGIN_PASSWORD) {
68
+ updates.OP_UI_LOGIN_PASSWORD = randomBytes(32).toString("hex");
69
69
  }
70
70
 
71
71
  if (!existsSync(systemEnvPath)) {
@@ -74,11 +74,9 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
74
74
  "# All secrets and configuration live here. Advanced users may edit directly.",
75
75
  "",
76
76
  "# ── Authentication ──────────────────────────────────────────────────",
77
- "OP_ADMIN_TOKEN=",
78
- "OP_ASSISTANT_TOKEN=",
77
+ "OP_UI_LOGIN_PASSWORD=",
79
78
  "",
80
79
  "# ── Service Auth ─────────────────────────────────────────────────────",
81
- "OP_MEMORY_TOKEN=",
82
80
  "OP_OPENCODE_PASSWORD=",
83
81
  "",
84
82
  "# ── Provider API Keys ────────────────────────────────────────────────",
@@ -88,7 +86,6 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
88
86
  "GROQ_API_KEY=",
89
87
  "MISTRAL_API_KEY=",
90
88
  "GOOGLE_API_KEY=",
91
- "OPENVIKING_API_KEY=",
92
89
  "MCP_API_KEY=",
93
90
  "EMBEDDING_API_KEY=",
94
91
  "LMSTUDIO_API_KEY=",
@@ -107,37 +104,25 @@ function ensureSystemSecrets(state: ControlPlaneState): void {
107
104
  }
108
105
 
109
106
  export function ensureSecrets(state: ControlPlaneState): void {
110
- enforceVaultDirMode(state.vaultDir);
111
- mkdirSync(`${state.vaultDir}/stack`, { recursive: true, mode: VAULT_DIR_MODE });
112
- mkdirSync(`${state.vaultDir}/user`, { recursive: true, mode: VAULT_DIR_MODE });
113
-
114
- // user.env is an empty placeholder — users can add custom vars here.
115
- // All standard config lives in stack.env.
116
- const userEnvPath = `${state.vaultDir}/user/user.env`;
117
- if (!existsSync(userEnvPath)) {
118
- writeVaultFile(userEnvPath, [
119
- "# OpenPalm — User Extensions",
120
- "# Add any custom environment variables here.",
121
- "# These are loaded by compose alongside stack.env.",
122
- "",
123
- ].join("\n"));
124
- } else {
125
- try { chmodSync(userEnvPath, VAULT_FILE_MODE); } catch { /* best-effort */ }
126
- }
107
+ enforceVaultDirMode(state.stackDir);
108
+
109
+ // Migrate pre-0.11.0 installs (OP_UI_TOKEN/OP_ASSISTANT_TOKEN OP_UI_LOGIN_PASSWORD)
110
+ // before any code path that reads OP_UI_LOGIN_PASSWORD sees an empty value.
111
+ migrateAuth0110(state);
127
112
 
128
113
  ensureSystemSecrets(state);
129
- ensureGuardianEnv(state.vaultDir);
130
- ensureAuthJson(state.vaultDir);
114
+ ensureGuardianEnv(state.stackDir);
115
+ ensureAuthJson(state.configDir);
131
116
  }
132
117
 
133
118
  /**
134
- * Ensure vault/stack/guardian.env exists.
119
+ * Ensure config/stack/guardian.env exists.
135
120
  * Channel HMAC secrets (CHANNEL_<NAME>_SECRET) live here exclusively.
136
121
  * This file is loaded by the guardian as an env_file and via GUARDIAN_SECRETS_PATH.
137
122
  */
138
- function ensureGuardianEnv(vaultDir: string): void {
139
- const guardianEnvPath = `${vaultDir}/stack/guardian.env`;
140
- mkdirSync(`${vaultDir}/stack`, { recursive: true, mode: VAULT_DIR_MODE });
123
+ function ensureGuardianEnv(stackDir: string): void {
124
+ const guardianEnvPath = `${stackDir}/guardian.env`;
125
+ mkdirSync(stackDir, { recursive: true, mode: VAULT_DIR_MODE });
141
126
  if (!existsSync(guardianEnvPath)) {
142
127
  writeVaultFile(guardianEnvPath, [
143
128
  "# Guardian channel HMAC secrets — managed by openpalm",
@@ -149,9 +134,9 @@ function ensureGuardianEnv(vaultDir: string): void {
149
134
  }
150
135
  }
151
136
 
152
- function ensureAuthJson(vaultDir: string): void {
153
- const authJsonPath = `${vaultDir}/stack/auth.json`;
154
- mkdirSync(`${vaultDir}/stack`, { recursive: true, mode: VAULT_DIR_MODE });
137
+ function ensureAuthJson(configDir: string): void {
138
+ const authJsonPath = `${configDir}/auth.json`;
139
+ mkdirSync(configDir, { recursive: true, mode: VAULT_DIR_MODE });
155
140
 
156
141
  if (existsSync(authJsonPath)) {
157
142
  try {
@@ -177,25 +162,82 @@ export function updateSecretsEnv(
177
162
  state: ControlPlaneState,
178
163
  updates: Record<string, string>
179
164
  ): void {
180
- const stackEnvPath = `${state.vaultDir}/stack/stack.env`;
165
+ const stackEnvPath = `${state.stackDir}/stack.env`;
181
166
  if (!existsSync(stackEnvPath)) {
182
- throw new Error("vault/stack/stack.env does not exist — run setup first");
167
+ throw new Error("config/stack/stack.env does not exist — run setup first");
183
168
  }
184
169
 
185
170
  mergeVaultEnvFile(stackEnvPath, updates, true);
186
171
  }
187
172
 
188
- /** Read and parse vault/stack/stack.env. Returns {} if the file does not exist. */
189
- export function readStackEnv(vaultDir: string): Record<string, string> {
190
- return parseEnvFile(`${vaultDir}/stack/stack.env`);
173
+ /**
174
+ * Merge-write provider API keys into OpenCode's auth.json at
175
+ * `${configDir}/auth.json`. Each entry uses OpenCode's schema for
176
+ * api-key auth: `{ <providerId>: { type: "api", key: "..." } }`.
177
+ *
178
+ * This file is bind-mounted into the assistant container so the chat
179
+ * assistant picks up new credentials on its next OpenCode restart —
180
+ * see core.compose.yml.
181
+ *
182
+ * Existing entries (OAuth tokens, other providers) are preserved.
183
+ * Empty values DELETE the corresponding entry.
184
+ */
185
+ export function writeAuthJsonProviderKeys(
186
+ state: ControlPlaneState,
187
+ providerKeys: Record<string, string>
188
+ ): void {
189
+ if (Object.keys(providerKeys).length === 0) return;
190
+
191
+ const authJsonPath = `${state.configDir}/auth.json`;
192
+ mkdirSync(state.configDir, { recursive: true, mode: VAULT_DIR_MODE });
193
+
194
+ let current: Record<string, unknown> = {};
195
+ if (existsSync(authJsonPath)) {
196
+ try {
197
+ const raw = readFileSync(authJsonPath, "utf-8").trim();
198
+ if (raw && raw !== "{}") current = JSON.parse(raw) as Record<string, unknown>;
199
+ } catch {
200
+ // Corrupt auth.json — rename it so the operator can recover, then start fresh.
201
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
202
+ const corruptPath = `${authJsonPath}.corrupt-${timestamp}`;
203
+ try {
204
+ renameSync(authJsonPath, corruptPath);
205
+ logger.warn("corrupt auth.json renamed for recovery", {
206
+ original: authJsonPath,
207
+ renamed: corruptPath,
208
+ });
209
+ } catch (renameErr) {
210
+ logger.warn("could not rename corrupt auth.json; starting fresh", {
211
+ path: authJsonPath,
212
+ error: renameErr instanceof Error ? renameErr.message : String(renameErr),
213
+ });
214
+ }
215
+ current = {};
216
+ }
217
+ }
218
+
219
+ for (const [providerId, key] of Object.entries(providerKeys)) {
220
+ if (key) {
221
+ current[providerId] = { type: "api", key };
222
+ } else {
223
+ delete current[providerId];
224
+ }
225
+ }
226
+
227
+ writeVaultFile(authJsonPath, JSON.stringify(current, null, 2) + "\n");
228
+ }
229
+
230
+ /** Read and parse config/stack/stack.env. Returns {} if the file does not exist. */
231
+ export function readStackEnv(stackDir: string): Record<string, string> {
232
+ return parseEnvFile(`${stackDir}/stack.env`);
191
233
  }
192
234
 
193
235
  export function updateSystemSecretsEnv(
194
236
  state: ControlPlaneState,
195
237
  updates: Record<string, string>
196
238
  ): void {
197
- const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
198
- enforceVaultDirMode(state.vaultDir);
239
+ const systemEnvPath = `${state.stackDir}/stack.env`;
240
+ enforceVaultDirMode(state.stackDir);
199
241
  if (!existsSync(systemEnvPath)) {
200
242
  ensureSystemSecrets(state);
201
243
  }
@@ -203,14 +245,14 @@ export function updateSystemSecretsEnv(
203
245
  }
204
246
 
205
247
  export function patchSecretsEnvFile(
206
- vaultDir: string,
248
+ stackDir: string,
207
249
  patches: Record<string, string>
208
250
  ): void {
209
251
  if (Object.keys(patches).length === 0) return;
210
252
 
211
- const stackEnvPath = `${vaultDir}/stack/stack.env`;
212
- enforceVaultDirMode(vaultDir);
213
- mkdirSync(`${vaultDir}/stack`, { recursive: true, mode: VAULT_DIR_MODE });
253
+ const stackEnvPath = `${stackDir}/stack.env`;
254
+ enforceVaultDirMode(stackDir);
255
+ mkdirSync(stackDir, { recursive: true, mode: VAULT_DIR_MODE });
214
256
 
215
257
  let existingContent = "";
216
258
  try {
@@ -30,12 +30,12 @@
30
30
  "security": {
31
31
  "type": "object",
32
32
  "description": "Security settings for the instance.",
33
- "required": ["adminToken"],
33
+ "required": ["uiLoginPassword"],
34
34
  "additionalProperties": false,
35
35
  "properties": {
36
- "adminToken": {
36
+ "uiLoginPassword": {
37
37
  "type": "string",
38
- "description": "Admin API authentication token. Used to authenticate CLI and admin UI requests.",
38
+ "description": "Operator login password for the OpenPalm UI. Persisted to stack.env as OP_UI_LOGIN_PASSWORD; the UI's op_session cookie value is compared against it on every authenticated request.",
39
39
  "minLength": 8
40
40
  }
41
41
  }
@@ -51,18 +51,6 @@
51
51
  "assignments": {
52
52
  "$ref": "#/$defs/SetupConfigAssignments"
53
53
  },
54
- "memory": {
55
- "type": "object",
56
- "description": "Optional memory subsystem configuration.",
57
- "additionalProperties": false,
58
- "properties": {
59
- "userId": {
60
- "type": "string",
61
- "pattern": "^[A-Za-z0-9_]+$",
62
- "description": "User ID for the memory service. Alphanumeric and underscores only. Defaults to 'default_user' if omitted."
63
- }
64
- }
65
- },
66
54
  "channels": {
67
55
  "type": "object",
68
56
  "description": "Optional channel configurations. Keys are channel identifiers (e.g. 'chat', 'discord', 'api'). Values can be a boolean to enable (true) or skip (false) installation, or a credential object.",
@@ -162,13 +150,13 @@
162
150
  },
163
151
  "smallModel": {
164
152
  "type": "string",
165
- "description": "Optional smaller/faster model for lightweight tasks (e.g. memory extraction)."
153
+ "description": "Optional smaller/faster model for lightweight akm operations."
166
154
  }
167
155
  }
168
156
  },
169
157
  "embeddings": {
170
158
  "type": "object",
171
- "description": "Embedding model assignment for the memory subsystem.",
159
+ "description": "Embedding model assignment for akm and semantic operations.",
172
160
  "required": ["capabilityId", "model"],
173
161
  "additionalProperties": false,
174
162
  "properties": {
@@ -1,38 +1,18 @@
1
- import { userInfo } from "node:os";
2
1
  import { parseEnvFile } from './env.js';
3
2
 
4
- export function readSecretsKeys(vaultDir: string): Record<string, boolean> {
5
- // System scope wins on overlap because vault/stack/stack.env is the
6
- // authoritative source for system-managed credentials and flags.
7
- const parsed = {
8
- ...parseEnvFile(`${vaultDir}/user/user.env`),
9
- ...parseEnvFile(`${vaultDir}/stack/stack.env`),
10
- };
11
- const result: Record<string, boolean> = {};
12
- for (const [key, value] of Object.entries(parsed)) {
13
- result[key] = value.length > 0;
14
- }
15
- return result;
16
- }
17
-
18
- export function detectUserId(): string {
19
- const envUser = process.env.USER ?? process.env.LOGNAME ?? "";
20
- if (envUser) return envUser;
21
- try {
22
- return userInfo().username || "default_user";
23
- } catch {
24
- return "default_user";
25
- }
26
- }
27
-
28
3
  /**
29
- * Check if setup is complete by reading vault/stack/stack.env.
4
+ * Check if setup is complete by reading config/stack/stack.env.
5
+ *
6
+ * Phase 4 of the auth/proxy refactor replaced the legacy `OP_UI_TOKEN`
7
+ * sentinel with `OP_UI_LOGIN_PASSWORD`. The presence of a non-empty value
8
+ * implies the operator (or the install wizard) has seeded the login
9
+ * secret; `OP_SETUP_COMPLETE=true` is still authoritative when present.
30
10
  */
31
- export function isSetupComplete(vaultDir: string): boolean {
32
- const parsed = parseEnvFile(`${vaultDir}/stack/stack.env`);
11
+ export function isSetupComplete(stackDir: string): boolean {
12
+ const parsed = parseEnvFile(`${stackDir}/stack.env`);
33
13
  if ("OP_SETUP_COMPLETE" in parsed) {
34
14
  return parsed.OP_SETUP_COMPLETE.toLowerCase() === "true";
35
15
  }
36
16
 
37
- return (parsed.OP_ADMIN_TOKEN ?? "").length > 0;
17
+ return (parsed.OP_UI_LOGIN_PASSWORD ?? "").length > 0;
38
18
  }
@@ -1,6 +1,5 @@
1
1
  /**
2
2
  * Validation logic for SetupSpec inputs.
3
- * Extracted from setup.ts to reduce per-file complexity.
4
3
  */
5
4
 
6
5
  const CAPABILITY_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
@@ -20,10 +19,12 @@ export function validateSetupSpec(input: unknown): { valid: boolean; errors: str
20
19
  const body = requireObj(input, "Input must be a non-null object", errors);
21
20
  if (!body) return { valid: false, errors };
22
21
 
22
+ if (body.version !== 2) errors.push("version must be 2");
23
23
  validateSecurity(body, errors);
24
24
  validateOwner(body, errors);
25
25
  validateConnectionsArray(body.connections, errors);
26
- validateSpecCapabilities(body, errors);
26
+ validateLlm(body, errors);
27
+ validateEmbedding(body, errors);
27
28
  if (body.channelCredentials !== undefined && (typeof body.channelCredentials !== "object" || body.channelCredentials === null)) {
28
29
  errors.push("channelCredentials must be an object if provided");
29
30
  }
@@ -33,35 +34,34 @@ export function validateSetupSpec(input: unknown): { valid: boolean; errors: str
33
34
  function validateSecurity(body: Record<string, unknown>, errors: string[]): void {
34
35
  const security = requireObj(body.security, "security object is required", errors);
35
36
  if (!security) return;
36
- if (!requireStr(security, "adminToken", "security.adminToken is required and must be a non-empty string", errors)) return;
37
- if ((security.adminToken as string).length < 8) errors.push("security.adminToken must be at least 8 characters");
37
+ if (!requireStr(security, "uiLoginPassword", "security.uiLoginPassword is required and must be a non-empty string", errors)) return;
38
+ if ((security.uiLoginPassword as string).length < 8) errors.push("security.uiLoginPassword must be at least 8 characters");
38
39
  }
39
40
 
40
41
  function validateOwner(body: Record<string, unknown>, errors: string[]): void {
41
42
  const owner = body.owner as Record<string, unknown> | undefined;
42
- if (!owner) return; // owner is optional
43
+ if (!owner) return;
43
44
  if (owner.name !== undefined && typeof owner.name !== "string") errors.push("owner.name must be a string");
44
45
  if (owner.email !== undefined && typeof owner.email !== "string") errors.push("owner.email must be a string");
45
46
  }
46
47
 
47
- function validateSpecCapabilities(body: Record<string, unknown>, errors: string[]): void {
48
- if (body.version !== 2) errors.push("version must be 2");
49
- const caps = requireObj(body.capabilities, "capabilities is required", errors);
50
- if (!caps) return;
51
- requireStr(caps, "llm", "capabilities.llm is required (format: 'provider/model')", errors);
52
- const emb = requireObj(caps.embeddings, "capabilities.embeddings is required", errors);
53
- if (emb) {
54
- requireStr(emb, "provider", "capabilities.embeddings.provider is required", errors);
55
- requireStr(emb, "model", "capabilities.embeddings.model is required", errors);
56
- if (emb.dims !== undefined && emb.dims !== 0 && (typeof emb.dims !== "number" || !Number.isInteger(emb.dims) || emb.dims < 1)) {
57
- errors.push("capabilities.embeddings.dims must be a positive integer or 0 (auto-resolve)");
58
- }
59
- }
60
- const mem = requireObj(caps.memory, "capabilities.memory is required", errors);
61
- if (!mem) return;
62
- if (mem.userId !== undefined && typeof mem.userId !== "string") errors.push("capabilities.memory.userId must be a string if provided");
63
- if (typeof mem.userId === "string" && mem.userId && !/^[A-Za-z0-9_]+$/.test(mem.userId)) {
64
- errors.push("capabilities.memory.userId contains invalid characters (alphanumeric and underscores only)");
48
+ function validateLlm(body: Record<string, unknown>, errors: string[]): void {
49
+ if (body.llm === undefined) return;
50
+ const llm = requireObj(body.llm, "llm must be an object if provided", errors);
51
+ if (!llm) return;
52
+ requireStr(llm, "provider", "llm.provider is required", errors);
53
+ requireStr(llm, "model", "llm.model is required", errors);
54
+ if (llm.baseUrl !== undefined && typeof llm.baseUrl !== "string") errors.push("llm.baseUrl must be a string");
55
+ }
56
+
57
+ function validateEmbedding(body: Record<string, unknown>, errors: string[]): void {
58
+ if (body.embedding === undefined) return;
59
+ const emb = requireObj(body.embedding, "embedding must be an object if provided", errors);
60
+ if (!emb) return;
61
+ requireStr(emb, "provider", "embedding.provider is required", errors);
62
+ requireStr(emb, "model", "embedding.model is required", errors);
63
+ if (emb.dims !== undefined && (typeof emb.dims !== "number" || !Number.isInteger(emb.dims) || emb.dims < 1)) {
64
+ errors.push("embedding.dims must be a positive integer");
65
65
  }
66
66
  }
67
67