@celilo/cli 0.3.30-alpha.0 → 0.4.0-alpha.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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +3 -3
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -173,6 +173,75 @@ export function validateCapabilityNames(manifest: ModuleManifest): ValidationErr
173
173
  return null;
174
174
  }
175
175
 
176
+ /**
177
+ * Privileged capabilities — framework-granted privileges that only specific
178
+ * modules may declare in `requires.capabilities`. Allow-list is intentionally
179
+ * tiny: adding a module here is a deliberate trust decision, not an
180
+ * automatic side effect of how capabilities work elsewhere.
181
+ *
182
+ * Why this gate lives in `validate.ts` (not in the capability resolver):
183
+ * the privilege is gated at MANIFEST IMPORT time so the operator sees the
184
+ * rejection immediately when they try to `celilo module import` a module
185
+ * that's claiming a privilege it shouldn't have. Catching it later (at
186
+ * hook-invocation time) would let a bad module sit in IMPORTED state.
187
+ *
188
+ * Keys are capability names; values are arrays of module IDs allowed to
189
+ * require that capability. Empty value = no module may require it (acts as
190
+ * an internal-only privilege flag — currently unused, but the shape leaves
191
+ * room for that pattern).
192
+ */
193
+ const PRIVILEGED_CAPABILITY_ALLOW_LIST: Record<string, readonly string[]> = {
194
+ cross_module_read: ['celilo-mgmt'],
195
+ };
196
+
197
+ /**
198
+ * Whether `name` is a framework-granted privilege rather than a normal
199
+ * provider-backed capability. Privileges are satisfied by the framework
200
+ * (gated by the allow-list above), so the capability-provider resolver
201
+ * must NOT expect a module to "provide" them.
202
+ */
203
+ export function isPrivilegedCapability(name: string): boolean {
204
+ return name in PRIVILEGED_CAPABILITY_ALLOW_LIST;
205
+ }
206
+
207
+ /**
208
+ * Reject `requires.capabilities` declarations for privileged capabilities
209
+ * by modules not in the allow-list. Surfaces a clear actionable error
210
+ * identifying the privilege and the allowed modules.
211
+ *
212
+ * `optional.capabilities` is checked too — a module shouldn't be able to
213
+ * "soft-require" a privilege either (otherwise the privilege flag could be
214
+ * smuggled in via the optional path and granted at hook time).
215
+ */
216
+ export function validatePrivilegedCapabilities(manifest: ModuleManifest): ValidationError | null {
217
+ const errors: Array<{ path: string; message: string }> = [];
218
+
219
+ for (const required of manifest.requires.capabilities) {
220
+ const allowed = PRIVILEGED_CAPABILITY_ALLOW_LIST[required.name];
221
+ if (allowed && !allowed.includes(manifest.id)) {
222
+ errors.push({
223
+ path: `requires.capabilities.${required.name}`,
224
+ message: `Capability '${required.name}' is a framework-granted privilege; only these modules may require it: ${allowed.join(', ')}. Module '${manifest.id}' is not on the allow-list.`,
225
+ });
226
+ }
227
+ }
228
+
229
+ for (const opt of manifest.optional?.capabilities ?? []) {
230
+ const allowed = PRIVILEGED_CAPABILITY_ALLOW_LIST[opt.name];
231
+ if (allowed && !allowed.includes(manifest.id)) {
232
+ errors.push({
233
+ path: `optional.capabilities.${opt.name}`,
234
+ message: `Capability '${opt.name}' is a framework-granted privilege; only these modules may declare it (including under optional): ${allowed.join(', ')}. Module '${manifest.id}' is not on the allow-list.`,
235
+ });
236
+ }
237
+ }
238
+
239
+ if (errors.length > 0) {
240
+ return { success: false, errors };
241
+ }
242
+ return null;
243
+ }
244
+
176
245
  /**
177
246
  * Validate that variable sources are valid
178
247
  *
@@ -16,6 +16,7 @@ import {
16
16
  validateDeriveFromSources,
17
17
  validateHookContract,
18
18
  validateManifest,
19
+ validatePrivilegedCapabilities,
19
20
  validateProvidesNoCrossCapabilityRefs,
20
21
  validateVariableSources,
21
22
  validateZoneRequirements,
@@ -184,6 +185,17 @@ export async function readModuleManifest(
184
185
  };
185
186
  }
186
187
 
188
+ const privilegedCapCheck = validatePrivilegedCapabilities(validationResult.data);
189
+ if (privilegedCapCheck) {
190
+ const errorMessages = privilegedCapCheck.errors
191
+ .map((e) => `${e.path}: ${e.message}`)
192
+ .join('\n');
193
+ return {
194
+ success: false,
195
+ error: `Privileged capability validation failed:\n${errorMessages}`,
196
+ };
197
+ }
198
+
187
199
  const variableSourceCheck = validateVariableSources(validationResult.data);
188
200
  if (variableSourceCheck) {
189
201
  const errorMessages = variableSourceCheck.errors
@@ -0,0 +1,231 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { closeDb, getDb } from '../db/client';
6
+ import { runMigrations } from '../db/migrate';
7
+ import { modules } from '../db/schema';
8
+ import type { BaseModuleAspect } from '../manifest/schema';
9
+ import {
10
+ checkAspectApproval,
11
+ computeAspectScopeHash,
12
+ findAspectApproval,
13
+ recordAspectApproval,
14
+ } from './aspect-approvals';
15
+
16
+ const baseAspect: BaseModuleAspect = {
17
+ ansible_role: 'dns-client-config',
18
+ applicable_zones: ['dmz', 'app', 'secure'],
19
+ triggers: ['on_install'],
20
+ };
21
+
22
+ function insertModule(id: string, version: string) {
23
+ getDb()
24
+ .insert(modules)
25
+ .values({
26
+ id,
27
+ name: id,
28
+ version,
29
+ manifestData: { id, name: id, version, celilo_contract: '1.0' },
30
+ sourcePath: `/tmp/${id}`,
31
+ })
32
+ .run();
33
+ }
34
+
35
+ describe('aspect-approvals', () => {
36
+ let dir: string;
37
+
38
+ beforeEach(async () => {
39
+ dir = mkdtempSync(join(tmpdir(), 'celilo-aspect-approvals-test-'));
40
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
41
+ await runMigrations(process.env.CELILO_DB_PATH);
42
+ });
43
+
44
+ afterEach(() => {
45
+ closeDb();
46
+ process.env.CELILO_DB_PATH = undefined;
47
+ try {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ } catch {
50
+ /* ignore */
51
+ }
52
+ });
53
+
54
+ describe('computeAspectScopeHash', () => {
55
+ it('is stable for the same scope', () => {
56
+ const h1 = computeAspectScopeHash(baseAspect);
57
+ const h2 = computeAspectScopeHash(baseAspect);
58
+ expect(h1).toBe(h2);
59
+ });
60
+
61
+ it('is the same regardless of zone or trigger order', () => {
62
+ const a: BaseModuleAspect = {
63
+ ansible_role: 'dns-client-config',
64
+ applicable_zones: ['app', 'dmz', 'secure'],
65
+ triggers: ['on_install', 'on_new_system_in_zone'],
66
+ };
67
+ const b: BaseModuleAspect = {
68
+ ansible_role: 'dns-client-config',
69
+ applicable_zones: ['secure', 'app', 'dmz'],
70
+ triggers: ['on_new_system_in_zone', 'on_install'],
71
+ };
72
+ expect(computeAspectScopeHash(a)).toBe(computeAspectScopeHash(b));
73
+ });
74
+
75
+ it('changes when applicable_zones changes', () => {
76
+ const a: BaseModuleAspect = { ...baseAspect, applicable_zones: ['dmz', 'app', 'secure'] };
77
+ const b: BaseModuleAspect = {
78
+ ...baseAspect,
79
+ applicable_zones: ['dmz', 'app', 'secure', 'internal'],
80
+ };
81
+ expect(computeAspectScopeHash(a)).not.toBe(computeAspectScopeHash(b));
82
+ });
83
+
84
+ it('changes when triggers change', () => {
85
+ const a: BaseModuleAspect = { ...baseAspect, triggers: ['on_install'] };
86
+ const b: BaseModuleAspect = {
87
+ ...baseAspect,
88
+ triggers: ['on_install', 'on_new_system_in_zone'],
89
+ };
90
+ expect(computeAspectScopeHash(a)).not.toBe(computeAspectScopeHash(b));
91
+ });
92
+
93
+ it('is unaffected by ansible_role changes', () => {
94
+ // ansible_role is intentionally NOT part of the scope hash —
95
+ // module authors evolve their role contents within an approved
96
+ // scope all the time.
97
+ const a: BaseModuleAspect = { ...baseAspect, ansible_role: 'old-role' };
98
+ const b: BaseModuleAspect = { ...baseAspect, ansible_role: 'new-role' };
99
+ expect(computeAspectScopeHash(a)).toBe(computeAspectScopeHash(b));
100
+ });
101
+ });
102
+
103
+ describe('recordAspectApproval + findAspectApproval', () => {
104
+ it('persists and reads back an approval', () => {
105
+ insertModule('knot-unbound-internal', '1.0.0');
106
+ const db = getDb();
107
+ const written = recordAspectApproval({
108
+ moduleId: 'knot-unbound-internal',
109
+ version: '1.0.0',
110
+ scopeHash: computeAspectScopeHash(baseAspect),
111
+ approver: 'testuser',
112
+ db,
113
+ });
114
+ expect(written.moduleId).toBe('knot-unbound-internal');
115
+ expect(written.version).toBe('1.0.0');
116
+ expect(written.approver).toBe('testuser');
117
+
118
+ const found = findAspectApproval('knot-unbound-internal', '1.0.0', db);
119
+ expect(found?.id).toBe(written.id);
120
+ expect(found?.scopeHash).toBe(written.scopeHash);
121
+ });
122
+
123
+ it('rejects duplicate (moduleId, version) via unique constraint', () => {
124
+ insertModule('knot-unbound-internal', '1.0.0');
125
+ const db = getDb();
126
+ recordAspectApproval({
127
+ moduleId: 'knot-unbound-internal',
128
+ version: '1.0.0',
129
+ scopeHash: 'abc',
130
+ approver: null,
131
+ db,
132
+ });
133
+ expect(() =>
134
+ recordAspectApproval({
135
+ moduleId: 'knot-unbound-internal',
136
+ version: '1.0.0',
137
+ scopeHash: 'def',
138
+ approver: null,
139
+ db,
140
+ }),
141
+ ).toThrow();
142
+ });
143
+
144
+ it('allows two approvals for the same module at different versions', () => {
145
+ insertModule('knot-unbound-internal', '1.0.0');
146
+ insertModule('knot-other', '1.0.0'); // unrelated; just keeping the test surface narrow
147
+ const db = getDb();
148
+ // Same module, but the test isolates versions by inserting a
149
+ // second row in `modules` first. We just want to verify the
150
+ // approvals table doesn't reject two rows with different
151
+ // versions.
152
+ db.insert(modules)
153
+ .values({
154
+ id: 'knot-unbound-internal-v2',
155
+ name: 'knot',
156
+ version: '2.0.0',
157
+ manifestData: { id: 'knot', name: 'knot', version: '2.0.0', celilo_contract: '1.0' },
158
+ sourcePath: '/tmp/knot',
159
+ })
160
+ .run();
161
+ recordAspectApproval({
162
+ moduleId: 'knot-unbound-internal',
163
+ version: '1.0.0',
164
+ scopeHash: 'abc',
165
+ approver: null,
166
+ db,
167
+ });
168
+ recordAspectApproval({
169
+ moduleId: 'knot-unbound-internal-v2',
170
+ version: '2.0.0',
171
+ scopeHash: 'def',
172
+ approver: null,
173
+ db,
174
+ });
175
+ expect(findAspectApproval('knot-unbound-internal', '1.0.0', db)?.scopeHash).toBe('abc');
176
+ expect(findAspectApproval('knot-unbound-internal-v2', '2.0.0', db)?.scopeHash).toBe('def');
177
+ });
178
+
179
+ it('returns undefined when there is no approval', () => {
180
+ const found = findAspectApproval('never-imported', '1.0.0', getDb());
181
+ expect(found).toBeUndefined();
182
+ });
183
+ });
184
+
185
+ describe('checkAspectApproval', () => {
186
+ it('returns "no_approval" when nothing is recorded', () => {
187
+ const status = checkAspectApproval('never-imported', '1.0.0', baseAspect, getDb());
188
+ expect(status).toBe('no_approval');
189
+ });
190
+
191
+ it('returns "approved" when an approval matches the current scope hash', () => {
192
+ insertModule('knot-unbound-internal', '1.0.0');
193
+ const db = getDb();
194
+ recordAspectApproval({
195
+ moduleId: 'knot-unbound-internal',
196
+ version: '1.0.0',
197
+ scopeHash: computeAspectScopeHash(baseAspect),
198
+ approver: null,
199
+ db,
200
+ });
201
+ const status = checkAspectApproval('knot-unbound-internal', '1.0.0', baseAspect, db);
202
+ expect(status).toBe('approved');
203
+ });
204
+
205
+ it('returns "scope_changed" when scope diverges from a prior approval', () => {
206
+ insertModule('knot-unbound-internal', '1.0.0');
207
+ const db = getDb();
208
+ const oldAspect: BaseModuleAspect = {
209
+ ansible_role: 'dns-client-config',
210
+ applicable_zones: ['app'],
211
+ triggers: ['on_install'],
212
+ };
213
+ recordAspectApproval({
214
+ moduleId: 'knot-unbound-internal',
215
+ version: '1.0.0',
216
+ scopeHash: computeAspectScopeHash(oldAspect),
217
+ approver: null,
218
+ db,
219
+ });
220
+ // Same version row, but the manifest's scope broadened — this is
221
+ // the D7 upgrade-changes-scope case.
222
+ const newAspect: BaseModuleAspect = {
223
+ ansible_role: 'dns-client-config',
224
+ applicable_zones: ['app', 'dmz', 'secure', 'internal'],
225
+ triggers: ['on_install'],
226
+ };
227
+ const status = checkAspectApproval('knot-unbound-internal', '1.0.0', newAspect, db);
228
+ expect(status).toBe('scope_changed');
229
+ });
230
+ });
231
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Aspect approvals — operator consent for a module's base-module aspect.
3
+ *
4
+ * Per v2/CELILO_BASE.md D2: when an operator imports a module that
5
+ * declares a `base_module_aspect`, they consent to that aspect's
6
+ * scope (applicable_zones + triggers) ONCE at import time. The
7
+ * consent is recorded in the `aspect_approvals` table and consulted
8
+ * before any aspect fan-out.
9
+ *
10
+ * D7 adds upgrade-scope detection: if a module version upgrade
11
+ * changes `applicable_zones` or `triggers`, the new version's
12
+ * scope_hash will differ from the prior approval's, signaling the
13
+ * framework that re-approval is required. Aspect-content-only
14
+ * changes (e.g., the Ansible role file got updated) DON'T change
15
+ * the scope_hash; they're allowed without re-prompting.
16
+ *
17
+ * The scope hash is a stable SHA-256 over the sorted JSON of the
18
+ * scope fields. Sorting matters: `[a, b]` and `[b, a]` should yield
19
+ * the same hash because zone order and trigger order don't carry
20
+ * semantic meaning.
21
+ */
22
+
23
+ import { createHash, randomUUID } from 'node:crypto';
24
+ import { and, eq } from 'drizzle-orm';
25
+ import type { getDb } from '../db/client';
26
+ import { aspectApprovals } from '../db/schema';
27
+ import type { BaseModuleAspect } from '../manifest/schema';
28
+
29
+ type DbClient = ReturnType<typeof getDb>;
30
+
31
+ /**
32
+ * Compute a stable SHA-256 hash of the aspect's scope fields.
33
+ *
34
+ * The hash covers `applicable_zones` and `triggers` only — these
35
+ * are the fields the operator consents to. `ansible_role` is NOT
36
+ * part of the scope (a module author updating the role content
37
+ * within the approved zone set is normal evolution, captured by
38
+ * `on_aspect_change` rather than re-approval).
39
+ *
40
+ * Sorted so order doesn't affect the hash.
41
+ */
42
+ export function computeAspectScopeHash(aspect: BaseModuleAspect): string {
43
+ const sortedZones = [...aspect.applicable_zones].sort();
44
+ const sortedTriggers = [...aspect.triggers].sort();
45
+ const canonical = JSON.stringify({
46
+ applicable_zones: sortedZones,
47
+ triggers: sortedTriggers,
48
+ });
49
+ return createHash('sha256').update(canonical).digest('hex');
50
+ }
51
+
52
+ /**
53
+ * Look up an existing approval for a (moduleId, version) pair.
54
+ * Returns the row if present, undefined otherwise.
55
+ */
56
+ export function findAspectApproval(
57
+ moduleId: string,
58
+ version: string,
59
+ db: DbClient,
60
+ ): typeof aspectApprovals.$inferSelect | undefined {
61
+ return db
62
+ .select()
63
+ .from(aspectApprovals)
64
+ .where(and(eq(aspectApprovals.moduleId, moduleId), eq(aspectApprovals.version, version)))
65
+ .get();
66
+ }
67
+
68
+ /**
69
+ * Record operator approval for a module version's base-module
70
+ * aspect. Caller is responsible for verifying that an approval
71
+ * doesn't already exist (the unique constraint will reject
72
+ * duplicates, but callers should check first to provide a clear
73
+ * error path).
74
+ *
75
+ * @param approver - operator identifier (e.g., $USER). Null when
76
+ * --accept-aspects is used in a context with no USER, or when
77
+ * approval is automated.
78
+ */
79
+ export function recordAspectApproval(args: {
80
+ moduleId: string;
81
+ version: string;
82
+ scopeHash: string;
83
+ approver: string | null;
84
+ db: DbClient;
85
+ }): typeof aspectApprovals.$inferSelect {
86
+ const row = {
87
+ id: randomUUID(),
88
+ moduleId: args.moduleId,
89
+ version: args.version,
90
+ scopeHash: args.scopeHash,
91
+ approver: args.approver,
92
+ };
93
+ args.db.insert(aspectApprovals).values(row).run();
94
+ return args.db
95
+ .select()
96
+ .from(aspectApprovals)
97
+ .where(eq(aspectApprovals.id, row.id))
98
+ .get() as typeof aspectApprovals.$inferSelect;
99
+ }
100
+
101
+ /**
102
+ * Check whether the current (moduleId, version) has an approval
103
+ * whose scope matches the manifest's declared aspect. Returns:
104
+ * - 'approved': there's an approval and its scope_hash matches
105
+ * - 'scope_changed': there's an approval but the manifest's
106
+ * scope is different (D7 — re-approval required before aspects
107
+ * fan out under the new scope)
108
+ * - 'no_approval': no approval row for this version
109
+ */
110
+ export function checkAspectApproval(
111
+ moduleId: string,
112
+ version: string,
113
+ aspect: BaseModuleAspect,
114
+ db: DbClient,
115
+ ): 'approved' | 'scope_changed' | 'no_approval' {
116
+ const existing = findAspectApproval(moduleId, version, db);
117
+ if (!existing) return 'no_approval';
118
+ const currentHash = computeAspectScopeHash(aspect);
119
+ return existing.scopeHash === currentHash ? 'approved' : 'scope_changed';
120
+ }