@celilo/cli 0.1.0

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 (267) hide show
  1. package/README.md +1566 -0
  2. package/bin/celilo +16 -0
  3. package/drizzle/0000_complex_puma.sql +179 -0
  4. package/drizzle/0001_dizzy_wolfpack.sql +2 -0
  5. package/drizzle/0002_web_routes.sql +16 -0
  6. package/drizzle/0003_backup_storage.sql +32 -0
  7. package/drizzle/meta/0000_snapshot.json +1151 -0
  8. package/drizzle/meta/0001_snapshot.json +1167 -0
  9. package/drizzle/meta/0002_snapshot.json +1257 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +64 -0
  12. package/schemas/system_config.json +106 -0
  13. package/src/__integration__/container-services-cli.integration.test.ts +246 -0
  14. package/src/ansible/dependencies.test.ts +309 -0
  15. package/src/ansible/dependencies.ts +896 -0
  16. package/src/ansible/inventory.test.ts +463 -0
  17. package/src/ansible/inventory.ts +445 -0
  18. package/src/ansible/secrets.ts +222 -0
  19. package/src/ansible/validation.test.ts +92 -0
  20. package/src/ansible/validation.ts +272 -0
  21. package/src/api-clients/digitalocean.ts +94 -0
  22. package/src/api-clients/proxmox.ts +655 -0
  23. package/src/capabilities/logging-wrapper.test.ts +217 -0
  24. package/src/capabilities/lookup.test.ts +149 -0
  25. package/src/capabilities/lookup.ts +89 -0
  26. package/src/capabilities/public-web-helpers.test.ts +198 -0
  27. package/src/capabilities/public-web-publish.test.ts +458 -0
  28. package/src/capabilities/registration.test.ts +395 -0
  29. package/src/capabilities/registration.ts +200 -0
  30. package/src/capabilities/route-validation.test.ts +121 -0
  31. package/src/capabilities/route-validation.ts +96 -0
  32. package/src/capabilities/secret-ref.test.ts +313 -0
  33. package/src/capabilities/secret-validation.ts +157 -0
  34. package/src/capabilities/secrets.test.ts +750 -0
  35. package/src/capabilities/secrets.ts +244 -0
  36. package/src/capabilities/validation.test.ts +613 -0
  37. package/src/capabilities/validation.ts +160 -0
  38. package/src/capabilities/well-known.test.ts +238 -0
  39. package/src/capabilities/well-known.ts +222 -0
  40. package/src/cli/cli.test.ts +654 -0
  41. package/src/cli/command-registry.ts +742 -0
  42. package/src/cli/command-tree-parser.test.ts +180 -0
  43. package/src/cli/command-tree-parser.ts +193 -0
  44. package/src/cli/commands/backup-create.ts +137 -0
  45. package/src/cli/commands/backup-delete.ts +74 -0
  46. package/src/cli/commands/backup-import.ts +97 -0
  47. package/src/cli/commands/backup-list.ts +132 -0
  48. package/src/cli/commands/backup-name.ts +73 -0
  49. package/src/cli/commands/backup-prune.ts +98 -0
  50. package/src/cli/commands/backup-restore.ts +122 -0
  51. package/src/cli/commands/capability-info.ts +121 -0
  52. package/src/cli/commands/capability-list.ts +47 -0
  53. package/src/cli/commands/completion.ts +87 -0
  54. package/src/cli/commands/hook-run.ts +176 -0
  55. package/src/cli/commands/ipam.ts +607 -0
  56. package/src/cli/commands/machine-add.ts +235 -0
  57. package/src/cli/commands/machine-earmark.ts +82 -0
  58. package/src/cli/commands/machine-list.ts +77 -0
  59. package/src/cli/commands/machine-remove.ts +90 -0
  60. package/src/cli/commands/machine-status.ts +131 -0
  61. package/src/cli/commands/module-audit.ts +51 -0
  62. package/src/cli/commands/module-build.ts +60 -0
  63. package/src/cli/commands/module-config.ts +170 -0
  64. package/src/cli/commands/module-deploy.ts +71 -0
  65. package/src/cli/commands/module-generate.ts +236 -0
  66. package/src/cli/commands/module-health.ts +108 -0
  67. package/src/cli/commands/module-import.ts +80 -0
  68. package/src/cli/commands/module-list.ts +43 -0
  69. package/src/cli/commands/module-logs.ts +73 -0
  70. package/src/cli/commands/module-remove.ts +162 -0
  71. package/src/cli/commands/module-show.ts +208 -0
  72. package/src/cli/commands/module-status.ts +131 -0
  73. package/src/cli/commands/module-types.ts +189 -0
  74. package/src/cli/commands/module-upgrade.ts +192 -0
  75. package/src/cli/commands/package.ts +68 -0
  76. package/src/cli/commands/secret-list.ts +99 -0
  77. package/src/cli/commands/secret-set.ts +134 -0
  78. package/src/cli/commands/service-add-digitalocean.ts +133 -0
  79. package/src/cli/commands/service-add-proxmox.ts +342 -0
  80. package/src/cli/commands/service-config-get.ts +83 -0
  81. package/src/cli/commands/service-config-set.ts +145 -0
  82. package/src/cli/commands/service-list.ts +74 -0
  83. package/src/cli/commands/service-reconfigure.ts +230 -0
  84. package/src/cli/commands/service-remove.ts +103 -0
  85. package/src/cli/commands/service-verify.ts +240 -0
  86. package/src/cli/commands/status.ts +216 -0
  87. package/src/cli/commands/storage-add-local.ts +106 -0
  88. package/src/cli/commands/storage-add-s3.ts +114 -0
  89. package/src/cli/commands/storage-list.ts +72 -0
  90. package/src/cli/commands/storage-remove.ts +54 -0
  91. package/src/cli/commands/storage-set-default.ts +44 -0
  92. package/src/cli/commands/storage-verify.ts +54 -0
  93. package/src/cli/commands/system-config.ts +168 -0
  94. package/src/cli/commands/system-init.ts +314 -0
  95. package/src/cli/commands/system-secret-get.ts +98 -0
  96. package/src/cli/commands/system-secret-set.ts +76 -0
  97. package/src/cli/commands/system-vault-password.ts +34 -0
  98. package/src/cli/completion.test.ts +37 -0
  99. package/src/cli/completion.ts +482 -0
  100. package/src/cli/fuel-gauge.test.ts +208 -0
  101. package/src/cli/fuel-gauge.ts +405 -0
  102. package/src/cli/generate-zsh-completion.test.ts +95 -0
  103. package/src/cli/generate-zsh-completion.ts +497 -0
  104. package/src/cli/index.ts +1583 -0
  105. package/src/cli/interactive-config.test.ts +201 -0
  106. package/src/cli/interactive-config.ts +62 -0
  107. package/src/cli/parser.test.ts +227 -0
  108. package/src/cli/parser.ts +244 -0
  109. package/src/cli/prompts.test.ts +33 -0
  110. package/src/cli/prompts.ts +121 -0
  111. package/src/cli/types.ts +38 -0
  112. package/src/cli/validators.test.ts +235 -0
  113. package/src/cli/validators.ts +188 -0
  114. package/src/config/env.ts +41 -0
  115. package/src/config/paths.test.ts +172 -0
  116. package/src/config/paths.ts +108 -0
  117. package/src/db/client.ts +190 -0
  118. package/src/db/migrate.ts +30 -0
  119. package/src/db/schema.test.ts +221 -0
  120. package/src/db/schema.ts +434 -0
  121. package/src/hooks/capability-loader-firewall.test.ts +246 -0
  122. package/src/hooks/capability-loader.test.ts +100 -0
  123. package/src/hooks/capability-loader.ts +520 -0
  124. package/src/hooks/define-hook.test.ts +488 -0
  125. package/src/hooks/executor.test.ts +462 -0
  126. package/src/hooks/executor.ts +469 -0
  127. package/src/hooks/logger.test.ts +54 -0
  128. package/src/hooks/logger.ts +95 -0
  129. package/src/hooks/test-fixtures/failing-hook.ts +13 -0
  130. package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
  131. package/src/hooks/test-fixtures/success-hook.ts +20 -0
  132. package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
  133. package/src/hooks/test-fixtures/void-hook.ts +13 -0
  134. package/src/hooks/types.ts +89 -0
  135. package/src/infrastructure/property-extractor.test.ts +194 -0
  136. package/src/infrastructure/property-extractor.ts +151 -0
  137. package/src/ipam/allocator.test.ts +442 -0
  138. package/src/ipam/allocator.ts +369 -0
  139. package/src/ipam/auto-allocator.test.ts +247 -0
  140. package/src/ipam/auto-allocator.ts +270 -0
  141. package/src/ipam/subnet-parser.test.ts +107 -0
  142. package/src/ipam/subnet-parser.ts +136 -0
  143. package/src/manifest/contracts/index.ts +61 -0
  144. package/src/manifest/contracts/v1.ts +118 -0
  145. package/src/manifest/json-schema-roundtrip.test.ts +99 -0
  146. package/src/manifest/schema.ts +367 -0
  147. package/src/manifest/template-validator.test.ts +231 -0
  148. package/src/manifest/template-validator.ts +322 -0
  149. package/src/manifest/validate.test.ts +1180 -0
  150. package/src/manifest/validate.ts +415 -0
  151. package/src/module/import.test.ts +355 -0
  152. package/src/module/import.ts +676 -0
  153. package/src/module/packaging/audit.ts +169 -0
  154. package/src/module/packaging/build.ts +228 -0
  155. package/src/module/packaging/checksum.ts +41 -0
  156. package/src/module/packaging/extract.ts +234 -0
  157. package/src/module/packaging/signature.ts +47 -0
  158. package/src/secrets/encryption.test.ts +284 -0
  159. package/src/secrets/encryption.ts +162 -0
  160. package/src/secrets/generators.test.ts +112 -0
  161. package/src/secrets/generators.ts +127 -0
  162. package/src/secrets/master-key.test.ts +159 -0
  163. package/src/secrets/master-key.ts +114 -0
  164. package/src/secrets/storage.test.ts +115 -0
  165. package/src/secrets/storage.ts +106 -0
  166. package/src/secrets/vault.test.ts +35 -0
  167. package/src/secrets/vault.ts +42 -0
  168. package/src/services/backup-create.ts +532 -0
  169. package/src/services/backup-metadata.ts +198 -0
  170. package/src/services/backup-restore.ts +229 -0
  171. package/src/services/backup-retention.ts +84 -0
  172. package/src/services/backup-storage.ts +281 -0
  173. package/src/services/build-stream.test.ts +122 -0
  174. package/src/services/build-stream.ts +201 -0
  175. package/src/services/config-interview.ts +694 -0
  176. package/src/services/container-service.test.ts +298 -0
  177. package/src/services/container-service.ts +401 -0
  178. package/src/services/cross-module-data-manager.test.ts +405 -0
  179. package/src/services/cross-module-data-manager.ts +412 -0
  180. package/src/services/deploy-ansible.ts +88 -0
  181. package/src/services/deploy-planner.ts +153 -0
  182. package/src/services/deploy-preflight.ts +274 -0
  183. package/src/services/deploy-ssh.ts +131 -0
  184. package/src/services/deploy-terraform.test.ts +55 -0
  185. package/src/services/deploy-terraform.ts +445 -0
  186. package/src/services/deploy-validation.ts +311 -0
  187. package/src/services/dns-auto-register.ts +211 -0
  188. package/src/services/health-runner.ts +184 -0
  189. package/src/services/infrastructure-selector.test.ts +485 -0
  190. package/src/services/infrastructure-selector.ts +245 -0
  191. package/src/services/infrastructure-variable-resolver.test.ts +751 -0
  192. package/src/services/infrastructure-variable-resolver.ts +234 -0
  193. package/src/services/machine-detector.ts +328 -0
  194. package/src/services/machine-pool.test.ts +405 -0
  195. package/src/services/machine-pool.ts +316 -0
  196. package/src/services/manifest-validation.ts +120 -0
  197. package/src/services/module-build.test.ts +290 -0
  198. package/src/services/module-build.ts +431 -0
  199. package/src/services/module-config.test.ts +237 -0
  200. package/src/services/module-config.ts +298 -0
  201. package/src/services/module-deploy.ts +862 -0
  202. package/src/services/module-types-drift.test.ts +73 -0
  203. package/src/services/module-types-generator.test.ts +288 -0
  204. package/src/services/module-types-generator.ts +189 -0
  205. package/src/services/proxmox-state-recovery.ts +140 -0
  206. package/src/services/schema-validation.ts +155 -0
  207. package/src/services/secret-schema-loader.test.ts +311 -0
  208. package/src/services/secret-schema-loader.ts +239 -0
  209. package/src/services/ssh-key-manager.test.ts +283 -0
  210. package/src/services/ssh-key-manager.ts +193 -0
  211. package/src/services/storage-providers/local.ts +105 -0
  212. package/src/services/storage-providers/s3.ts +182 -0
  213. package/src/services/storage-providers/types.ts +24 -0
  214. package/src/services/system-config-schema-types.ts +25 -0
  215. package/src/services/system-config-validator.test.ts +160 -0
  216. package/src/services/system-config-validator.ts +74 -0
  217. package/src/services/system-init.test.ts +153 -0
  218. package/src/services/system-init.ts +253 -0
  219. package/src/services/terraform-safety.ts +174 -0
  220. package/src/services/zone-detector.test.ts +110 -0
  221. package/src/services/zone-detector.ts +102 -0
  222. package/src/services/zone-policy.test.ts +97 -0
  223. package/src/services/zone-policy.ts +126 -0
  224. package/src/templates/generator.test.ts +645 -0
  225. package/src/templates/generator.ts +1119 -0
  226. package/src/templates/types.ts +62 -0
  227. package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
  228. package/src/test-utils/cli-context-interactive.test.ts +152 -0
  229. package/src/test-utils/cli-context-server.test.ts +66 -0
  230. package/src/test-utils/cli-context.test.ts +273 -0
  231. package/src/test-utils/cli-context.ts +677 -0
  232. package/src/test-utils/cli-result.test.ts +282 -0
  233. package/src/test-utils/cli-result.ts +241 -0
  234. package/src/test-utils/cli.ts +55 -0
  235. package/src/test-utils/completion-harness.test.ts +126 -0
  236. package/src/test-utils/completion-harness.ts +82 -0
  237. package/src/test-utils/database.test.ts +182 -0
  238. package/src/test-utils/database.ts +126 -0
  239. package/src/test-utils/filesystem.test.ts +208 -0
  240. package/src/test-utils/filesystem.ts +142 -0
  241. package/src/test-utils/fixtures.test.ts +123 -0
  242. package/src/test-utils/fixtures.ts +160 -0
  243. package/src/test-utils/golden-diff.ts +197 -0
  244. package/src/test-utils/index.ts +77 -0
  245. package/src/test-utils/integration.ts +81 -0
  246. package/src/test-utils/module-fixtures.ts +468 -0
  247. package/src/test-utils/modules.test.ts +144 -0
  248. package/src/test-utils/modules.ts +183 -0
  249. package/src/test-utils/setup-test-db.ts +90 -0
  250. package/src/test-utils/value-extractor.test.ts +231 -0
  251. package/src/test-utils/value-extractor.ts +228 -0
  252. package/src/types/infrastructure.ts +157 -0
  253. package/src/utils/shell.test.ts +365 -0
  254. package/src/utils/shell.ts +159 -0
  255. package/src/validation/schemas.ts +166 -0
  256. package/src/variables/ansible-resolver.test.ts +142 -0
  257. package/src/variables/ansible-resolver.ts +69 -0
  258. package/src/variables/capability-self-ref.test.ts +220 -0
  259. package/src/variables/context.test.ts +1265 -0
  260. package/src/variables/context.ts +624 -0
  261. package/src/variables/declarative-derivation.test.ts +743 -0
  262. package/src/variables/declarative-derivation.ts +200 -0
  263. package/src/variables/parser.test.ts +231 -0
  264. package/src/variables/parser.ts +76 -0
  265. package/src/variables/resolver.test.ts +458 -0
  266. package/src/variables/resolver.ts +282 -0
  267. package/src/variables/types.ts +59 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Schema validation service
3
+ * Validates module configuration values against JSON Schema definitions
4
+ */
5
+
6
+ import { existsSync } from 'node:fs';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import Ajv, { type ValidateFunction } from 'ajv';
10
+ import { eq } from 'drizzle-orm';
11
+ import { type DbClient, getDb } from '../db/client';
12
+ import { modules } from '../db/schema';
13
+
14
+ const ajv = new Ajv({ allErrors: true, strict: false });
15
+
16
+ interface ValidationResult {
17
+ valid: boolean;
18
+ errors?: string[];
19
+ }
20
+
21
+ /**
22
+ * Get module source path from database
23
+ */
24
+ function getModuleSourcePath(moduleId: string, db: DbClient = getDb()): string | null {
25
+ try {
26
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
27
+ return module?.sourcePath || null;
28
+ } catch (error) {
29
+ // If database access fails, validation will be skipped (schema not found)
30
+ console.error(`Failed to get module source path for ${moduleId}:`, error);
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Load JSON Schema for a module
37
+ * Looks for schema/user_config.json in the module directory
38
+ */
39
+ async function loadModuleSchema(
40
+ moduleId: string,
41
+ db: DbClient = getDb(),
42
+ ): Promise<Record<string, unknown> | null> {
43
+ const sourcePath = getModuleSourcePath(moduleId, db);
44
+ if (!sourcePath) {
45
+ return null;
46
+ }
47
+
48
+ const schemaPath = join(sourcePath, 'schema', 'user_config.json');
49
+ if (!existsSync(schemaPath)) {
50
+ return null;
51
+ }
52
+
53
+ try {
54
+ const schemaContent = await readFile(schemaPath, 'utf-8');
55
+ try {
56
+ return JSON.parse(schemaContent) as Record<string, unknown>;
57
+ } catch (parseError) {
58
+ console.error(
59
+ `Failed to parse user_config.json schema for ${moduleId}:`,
60
+ parseError instanceof Error ? parseError.message : parseError,
61
+ );
62
+ return null;
63
+ }
64
+ } catch (error) {
65
+ console.error(`Failed to load schema for ${moduleId}:`, error);
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Get JSON Schema for a specific property
72
+ * Extracts the property schema from the module's user_config.json
73
+ */
74
+ async function getPropertySchema(
75
+ moduleId: string,
76
+ propertyName: string,
77
+ db: DbClient = getDb(),
78
+ ): Promise<Record<string, unknown> | null> {
79
+ const schema = await loadModuleSchema(moduleId, db);
80
+ if (!schema) {
81
+ return null;
82
+ }
83
+
84
+ const properties = schema.properties as Record<string, unknown> | undefined;
85
+ if (!properties) {
86
+ return null;
87
+ }
88
+
89
+ return (properties[propertyName] as Record<string, unknown>) || null;
90
+ }
91
+
92
+ /**
93
+ * Validate a configuration value against its schema
94
+ *
95
+ * @param moduleId - Module ID
96
+ * @param key - Configuration key
97
+ * @param value - Value to validate (already parsed)
98
+ * @param db - Database client (defaults to singleton)
99
+ * @returns Validation result with errors if invalid
100
+ */
101
+ export async function validateConfigValue(
102
+ moduleId: string,
103
+ key: string,
104
+ value: unknown,
105
+ db: DbClient = getDb(),
106
+ ): Promise<ValidationResult> {
107
+ // Load property schema
108
+ const propertySchema = await getPropertySchema(moduleId, key, db);
109
+
110
+ // If no schema exists, validation passes (schema is optional)
111
+ if (!propertySchema) {
112
+ return { valid: true };
113
+ }
114
+
115
+ // Compile and validate
116
+ let validate: ValidateFunction;
117
+ try {
118
+ validate = ajv.compile(propertySchema);
119
+ } catch (error) {
120
+ // Schema compilation error - invalid schema definition
121
+ return {
122
+ valid: false,
123
+ errors: [
124
+ `Invalid schema for property ${key}: ${error instanceof Error ? error.message : String(error)}`,
125
+ ],
126
+ };
127
+ }
128
+
129
+ const valid = validate(value);
130
+
131
+ if (!valid && validate.errors) {
132
+ const errors = validate.errors.map((err) => {
133
+ const path = err.instancePath ? `${err.instancePath} ` : '';
134
+ return `${path}${err.message}`;
135
+ });
136
+ return { valid: false, errors };
137
+ }
138
+
139
+ return { valid: true };
140
+ }
141
+
142
+ /**
143
+ * Format validation errors for display
144
+ */
145
+ export function formatValidationErrors(errors: string[]): string {
146
+ if (errors.length === 0) {
147
+ return 'Validation failed';
148
+ }
149
+
150
+ if (errors.length === 1) {
151
+ return `Validation error: ${errors[0]}`;
152
+ }
153
+
154
+ return `Validation errors:\n${errors.map((err) => ` - ${err}`).join('\n')}`;
155
+ }
@@ -0,0 +1,311 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { type DbClient, getDb } from '../db/client';
6
+ import { modules } from '../db/schema';
7
+ import {
8
+ type SecretsSchema,
9
+ getAllSecretMetadata,
10
+ getSecretMetadata,
11
+ loadSecretsSchema,
12
+ validateDerivationGraph,
13
+ } from './secret-schema-loader';
14
+
15
+ describe('secret-schema-loader', () => {
16
+ let tempDir: string;
17
+ let testDb: DbClient;
18
+
19
+ beforeEach(() => {
20
+ // Create temporary directory for test module
21
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-test-'));
22
+
23
+ // Set up test database
24
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
25
+ testDb = getDb();
26
+
27
+ // Create test module in database
28
+ testDb
29
+ .insert(modules)
30
+ .values({
31
+ id: 'test-module',
32
+ name: 'Test Module',
33
+ sourcePath: tempDir,
34
+ version: '1.0.0',
35
+ manifestData: {},
36
+ })
37
+ .run();
38
+ });
39
+
40
+ afterEach(() => {
41
+ // Clean up temp directory
42
+ rmSync(tempDir, { recursive: true, force: true });
43
+ process.env.CELILO_DB_PATH = undefined;
44
+ });
45
+
46
+ describe('loadSecretsSchema', () => {
47
+ test('loads valid secrets schema', async () => {
48
+ // Create schema directory and file
49
+ const schemaDir = join(tempDir, 'schema');
50
+ mkdirSync(schemaDir, { recursive: true });
51
+
52
+ const schema = {
53
+ type: 'object',
54
+ properties: {
55
+ api_key: {
56
+ type: 'string',
57
+ source: 'user_provided',
58
+ description: 'API key for service',
59
+ },
60
+ tsig_secret: {
61
+ type: 'string',
62
+ source: 'generated',
63
+ format: 'tsig-key',
64
+ },
65
+ },
66
+ };
67
+
68
+ writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
69
+
70
+ const result = await loadSecretsSchema('test-module', testDb);
71
+
72
+ expect(result).not.toBeNull();
73
+ expect(result?.properties.api_key).toBeDefined();
74
+ expect(result?.properties.api_key.source).toBe('user_provided');
75
+ expect(result?.properties.tsig_secret.source).toBe('generated');
76
+ });
77
+
78
+ test('returns null when schema file does not exist', async () => {
79
+ const result = await loadSecretsSchema('test-module', testDb);
80
+ expect(result).toBeNull();
81
+ });
82
+
83
+ test('returns null for invalid schema structure', async () => {
84
+ const schemaDir = join(tempDir, 'schema');
85
+ mkdirSync(schemaDir, { recursive: true });
86
+
87
+ // Invalid schema (missing properties)
88
+ const schema = {
89
+ type: 'object',
90
+ };
91
+
92
+ writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
93
+
94
+ const result = await loadSecretsSchema('test-module', testDb);
95
+ expect(result).toBeNull();
96
+ });
97
+
98
+ test('returns null for malformed JSON', async () => {
99
+ const schemaDir = join(tempDir, 'schema');
100
+ mkdirSync(schemaDir, { recursive: true });
101
+
102
+ writeFileSync(join(schemaDir, 'secrets.json'), 'not valid json{');
103
+
104
+ const result = await loadSecretsSchema('test-module', testDb);
105
+ expect(result).toBeNull();
106
+ });
107
+ });
108
+
109
+ describe('getSecretMetadata', () => {
110
+ test('returns metadata for specific secret', async () => {
111
+ const schemaDir = join(tempDir, 'schema');
112
+ mkdirSync(schemaDir, { recursive: true });
113
+
114
+ const schema = {
115
+ type: 'object',
116
+ properties: {
117
+ wireguard_private_key: {
118
+ type: 'string',
119
+ source: 'generated',
120
+ format: 'wireguard-key',
121
+ length: 32,
122
+ title: 'WireGuard Private Key',
123
+ description: 'Private key for WireGuard tunnel',
124
+ },
125
+ },
126
+ };
127
+
128
+ writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
129
+
130
+ const metadata = await getSecretMetadata('test-module', 'wireguard_private_key', testDb);
131
+
132
+ expect(metadata).not.toBeNull();
133
+ expect(metadata?.name).toBe('wireguard_private_key');
134
+ expect(metadata?.source).toBe('generated');
135
+ expect(metadata?.format).toBe('wireguard-key');
136
+ expect(metadata?.length).toBe(32);
137
+ expect(metadata?.title).toBe('WireGuard Private Key');
138
+ });
139
+
140
+ test('returns null for non-existent secret', async () => {
141
+ const schemaDir = join(tempDir, 'schema');
142
+ mkdirSync(schemaDir, { recursive: true });
143
+
144
+ const schema = {
145
+ type: 'object',
146
+ properties: {
147
+ api_key: {
148
+ type: 'string',
149
+ source: 'user_provided',
150
+ },
151
+ },
152
+ };
153
+
154
+ writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
155
+
156
+ const metadata = await getSecretMetadata('test-module', 'non_existent', testDb);
157
+ expect(metadata).toBeNull();
158
+ });
159
+
160
+ test('defaults to user_provided when source not specified', async () => {
161
+ const schemaDir = join(tempDir, 'schema');
162
+ mkdirSync(schemaDir, { recursive: true });
163
+
164
+ const schema = {
165
+ type: 'object',
166
+ properties: {
167
+ api_key: {
168
+ type: 'string',
169
+ description: 'API key',
170
+ },
171
+ },
172
+ };
173
+
174
+ writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
175
+
176
+ const metadata = await getSecretMetadata('test-module', 'api_key', testDb);
177
+
178
+ expect(metadata?.source).toBe('user_provided');
179
+ });
180
+ });
181
+
182
+ describe('getAllSecretMetadata', () => {
183
+ test('returns all secret metadata', async () => {
184
+ const schemaDir = join(tempDir, 'schema');
185
+ mkdirSync(schemaDir, { recursive: true });
186
+
187
+ const schema = {
188
+ type: 'object',
189
+ properties: {
190
+ api_key: {
191
+ type: 'string',
192
+ source: 'user_provided',
193
+ },
194
+ tsig_secret: {
195
+ type: 'string',
196
+ source: 'generated',
197
+ format: 'tsig-key',
198
+ },
199
+ admin_password: {
200
+ type: 'string',
201
+ source: 'generated_optional',
202
+ format: 'base64',
203
+ },
204
+ },
205
+ };
206
+
207
+ writeFileSync(join(schemaDir, 'secrets.json'), JSON.stringify(schema, null, 2));
208
+
209
+ const allMetadata = await getAllSecretMetadata('test-module', testDb);
210
+
211
+ expect(allMetadata).toHaveLength(3);
212
+ expect(allMetadata.map((m) => m.name)).toContain('api_key');
213
+ expect(allMetadata.map((m) => m.name)).toContain('tsig_secret');
214
+ expect(allMetadata.map((m) => m.name)).toContain('admin_password');
215
+ });
216
+
217
+ test('returns empty array when no schema exists', async () => {
218
+ const allMetadata = await getAllSecretMetadata('test-module', testDb);
219
+ expect(allMetadata).toEqual([]);
220
+ });
221
+ });
222
+
223
+ describe('validateDerivationGraph', () => {
224
+ test('validates schema without derivation', () => {
225
+ const schema: SecretsSchema = {
226
+ type: 'object',
227
+ properties: {
228
+ api_key: {
229
+ type: 'string',
230
+ source: 'user_provided',
231
+ },
232
+ },
233
+ };
234
+
235
+ const error = validateDerivationGraph(schema);
236
+ expect(error).toBeNull();
237
+ });
238
+
239
+ test('validates schema with linear derivation', () => {
240
+ const schema: SecretsSchema = {
241
+ type: 'object',
242
+ properties: {
243
+ private_key: {
244
+ type: 'string',
245
+ source: 'generated',
246
+ format: 'wireguard-key',
247
+ },
248
+ public_key: {
249
+ type: 'string',
250
+ source: 'generated',
251
+ format: 'wireguard-key',
252
+ derive_from: 'private_key',
253
+ derive_method: 'wireguard-pubkey',
254
+ },
255
+ },
256
+ };
257
+
258
+ const error = validateDerivationGraph(schema);
259
+ expect(error).toBeNull();
260
+ });
261
+
262
+ test('detects circular derivation (direct cycle)', () => {
263
+ const schema: SecretsSchema = {
264
+ type: 'object',
265
+ properties: {
266
+ secret_a: {
267
+ type: 'string',
268
+ derive_from: 'secret_b',
269
+ derive_method: 'test',
270
+ },
271
+ secret_b: {
272
+ type: 'string',
273
+ derive_from: 'secret_a',
274
+ derive_method: 'test',
275
+ },
276
+ },
277
+ };
278
+
279
+ const error = validateDerivationGraph(schema);
280
+ expect(error).not.toBeNull();
281
+ expect(error).toContain('Circular derivation detected');
282
+ });
283
+
284
+ test('detects circular derivation (indirect cycle)', () => {
285
+ const schema: SecretsSchema = {
286
+ type: 'object',
287
+ properties: {
288
+ secret_a: {
289
+ type: 'string',
290
+ derive_from: 'secret_b',
291
+ derive_method: 'test',
292
+ },
293
+ secret_b: {
294
+ type: 'string',
295
+ derive_from: 'secret_c',
296
+ derive_method: 'test',
297
+ },
298
+ secret_c: {
299
+ type: 'string',
300
+ derive_from: 'secret_a',
301
+ derive_method: 'test',
302
+ },
303
+ },
304
+ };
305
+
306
+ const error = validateDerivationGraph(schema);
307
+ expect(error).not.toBeNull();
308
+ expect(error).toContain('Circular derivation detected');
309
+ });
310
+ });
311
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Secret Schema Loader
3
+ *
4
+ * Loads and parses schema/secrets.json files for modules
5
+ * Provides metadata about secret generation behavior
6
+ */
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { eq } from 'drizzle-orm';
12
+ import { type DbClient, getDb } from '../db/client';
13
+ import { modules } from '../db/schema';
14
+
15
+ /**
16
+ * Secret metadata from schema
17
+ */
18
+ export interface SecretMetadata {
19
+ name: string;
20
+ source: 'generated' | 'user_provided' | 'user_password' | 'generated_optional';
21
+ format?: string;
22
+ length?: number;
23
+ deriveFrom?: string;
24
+ deriveMethod?: string;
25
+ title?: string;
26
+ description?: string;
27
+ }
28
+
29
+ /**
30
+ * Secrets schema structure
31
+ */
32
+ export interface SecretsSchema {
33
+ $schema?: string;
34
+ type: 'object';
35
+ title?: string;
36
+ description?: string;
37
+ required?: string[];
38
+ properties: Record<string, SecretPropertySchema>;
39
+ }
40
+
41
+ /**
42
+ * Individual secret property schema
43
+ */
44
+ export interface SecretPropertySchema {
45
+ type: string;
46
+ title?: string;
47
+ description?: string;
48
+ source?: 'generated' | 'user_provided' | 'user_password' | 'generated_optional';
49
+ format?: string;
50
+ length?: number;
51
+ derive_from?: string;
52
+ derive_method?: string;
53
+ minLength?: number;
54
+ maxLength?: number;
55
+ }
56
+
57
+ /**
58
+ * Get module source path from database
59
+ */
60
+ function getModuleSourcePath(moduleId: string, db: DbClient = getDb()): string | null {
61
+ try {
62
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
63
+ return module?.sourcePath || null;
64
+ } catch (error) {
65
+ console.error(`Failed to get module source path for ${moduleId}:`, error);
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Load secrets schema for a module
72
+ *
73
+ * Looks for schema/secrets.json in the module directory
74
+ *
75
+ * @param moduleId - Module identifier
76
+ * @param db - Database client
77
+ * @returns Secrets schema or null if not found
78
+ */
79
+ export async function loadSecretsSchema(
80
+ moduleId: string,
81
+ db: DbClient = getDb(),
82
+ ): Promise<SecretsSchema | null> {
83
+ const sourcePath = getModuleSourcePath(moduleId, db);
84
+ if (!sourcePath) {
85
+ return null;
86
+ }
87
+
88
+ const schemaPath = join(sourcePath, 'schema', 'secrets.json');
89
+ if (!existsSync(schemaPath)) {
90
+ return null;
91
+ }
92
+
93
+ try {
94
+ const schemaContent = await readFile(schemaPath, 'utf-8');
95
+ let schema: SecretsSchema;
96
+ try {
97
+ schema = JSON.parse(schemaContent) as SecretsSchema;
98
+ } catch (parseError) {
99
+ console.error(
100
+ `Failed to parse secrets schema JSON for ${moduleId}:`,
101
+ parseError instanceof Error ? parseError.message : parseError,
102
+ );
103
+ return null;
104
+ }
105
+
106
+ // Validate basic structure
107
+ if (!schema.properties || typeof schema.properties !== 'object') {
108
+ console.error(`Invalid secrets schema for ${moduleId}: missing properties`);
109
+ return null;
110
+ }
111
+
112
+ return schema;
113
+ } catch (error) {
114
+ console.error(`Failed to load secrets schema for ${moduleId}:`, error);
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Get metadata for a specific secret
121
+ *
122
+ * @param moduleId - Module identifier
123
+ * @param secretName - Secret name
124
+ * @param db - Database client
125
+ * @returns Secret metadata or null if not found
126
+ */
127
+ export async function getSecretMetadata(
128
+ moduleId: string,
129
+ secretName: string,
130
+ db: DbClient = getDb(),
131
+ ): Promise<SecretMetadata | null> {
132
+ const schema = await loadSecretsSchema(moduleId, db);
133
+ if (!schema) {
134
+ return null;
135
+ }
136
+
137
+ const propertySchema = schema.properties[secretName];
138
+ if (!propertySchema) {
139
+ return null;
140
+ }
141
+
142
+ return {
143
+ name: secretName,
144
+ source: propertySchema.source || 'user_provided', // Safe default
145
+ format: propertySchema.format,
146
+ length: propertySchema.length,
147
+ deriveFrom: propertySchema.derive_from,
148
+ deriveMethod: propertySchema.derive_method,
149
+ title: propertySchema.title,
150
+ description: propertySchema.description,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Get all secret metadata for a module
156
+ *
157
+ * @param moduleId - Module identifier
158
+ * @param db - Database client
159
+ * @returns Array of secret metadata
160
+ */
161
+ export async function getAllSecretMetadata(
162
+ moduleId: string,
163
+ db: DbClient = getDb(),
164
+ ): Promise<SecretMetadata[]> {
165
+ const schema = await loadSecretsSchema(moduleId, db);
166
+ if (!schema) {
167
+ return [];
168
+ }
169
+
170
+ const metadata: SecretMetadata[] = [];
171
+
172
+ for (const [secretName, propertySchema] of Object.entries(schema.properties)) {
173
+ metadata.push({
174
+ name: secretName,
175
+ source: propertySchema.source || 'user_provided', // Safe default
176
+ format: propertySchema.format,
177
+ length: propertySchema.length,
178
+ deriveFrom: propertySchema.derive_from,
179
+ deriveMethod: propertySchema.derive_method,
180
+ title: propertySchema.title,
181
+ description: propertySchema.description,
182
+ });
183
+ }
184
+
185
+ return metadata;
186
+ }
187
+
188
+ /**
189
+ * Validate secrets schema for circular derivation
190
+ *
191
+ * Builds derivation graph and checks for cycles using DFS
192
+ *
193
+ * @param schema - Secrets schema
194
+ * @returns Error message if circular derivation detected, null otherwise
195
+ */
196
+ export function validateDerivationGraph(schema: SecretsSchema): string | null {
197
+ const graph: Record<string, string> = {};
198
+
199
+ // Build derivation graph (secret -> derive_from)
200
+ for (const [secretName, propertySchema] of Object.entries(schema.properties)) {
201
+ if (propertySchema.derive_from) {
202
+ graph[secretName] = propertySchema.derive_from;
203
+ }
204
+ }
205
+
206
+ // Check for cycles using DFS
207
+ const visited = new Set<string>();
208
+ const recursionStack = new Set<string>();
209
+
210
+ function hasCycle(node: string): boolean {
211
+ if (recursionStack.has(node)) {
212
+ return true; // Cycle detected
213
+ }
214
+
215
+ if (visited.has(node)) {
216
+ return false; // Already processed
217
+ }
218
+
219
+ visited.add(node);
220
+ recursionStack.add(node);
221
+
222
+ const parent = graph[node];
223
+ if (parent && hasCycle(parent)) {
224
+ return true;
225
+ }
226
+
227
+ recursionStack.delete(node);
228
+ return false;
229
+ }
230
+
231
+ // Check each node in the graph
232
+ for (const secretName of Object.keys(graph)) {
233
+ if (hasCycle(secretName)) {
234
+ return `Circular derivation detected involving: ${secretName}`;
235
+ }
236
+ }
237
+
238
+ return null;
239
+ }