@celilo/cli 0.4.0-alpha.1 → 0.4.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.
- package/drizzle/0008_aspect_consent.sql +1 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -6
- package/src/cli/command-registry.ts +38 -0
- package/src/cli/commands/backup-pull.test.ts +48 -0
- package/src/cli/commands/backup-pull.ts +116 -0
- package/src/cli/commands/events.test.ts +108 -0
- package/src/cli/commands/events.ts +243 -0
- package/src/cli/commands/module-generate.ts +5 -4
- package/src/cli/commands/module-import-aspect.test.ts +116 -0
- package/src/cli/commands/module-import.ts +12 -1
- package/src/cli/commands/storage-add-s3.ts +91 -46
- package/src/cli/completion.ts +2 -1
- package/src/cli/index.ts +11 -0
- package/src/db/client.ts +4 -0
- package/src/db/schema.ts +9 -1
- package/src/hooks/capability-loader.test.ts +31 -1
- package/src/hooks/capability-loader.ts +65 -16
- package/src/manifest/contracts/v1.ts +12 -0
- package/src/manifest/schema.ts +13 -1
- package/src/manifest/template-validator.ts +1 -0
- package/src/module/packaging/build.test.ts +75 -0
- package/src/module/packaging/build.ts +9 -20
- package/src/module/packaging/package-rules.ts +44 -0
- package/src/secrets/generators.test.ts +14 -1
- package/src/secrets/generators.ts +63 -1
- package/src/services/aspect-approvals.test.ts +30 -10
- package/src/services/aspect-approvals.ts +61 -31
- package/src/services/aspect-runner.test.ts +161 -8
- package/src/services/aspect-runner.ts +156 -34
- package/src/services/backup-create.ts +11 -2
- package/src/services/bus-ensure-flow.test.ts +19 -1
- package/src/services/bus-interview.ts +56 -0
- package/src/services/bus-secret-flow.test.ts +19 -1
- package/src/services/celilo-events.test.ts +122 -0
- package/src/services/celilo-events.ts +144 -0
- package/src/services/celilo-mgmt-hooks.test.ts +9 -1
- package/src/services/config-interview.ts +38 -19
- package/src/services/deploy-planner.test.ts +66 -0
- package/src/services/deploy-planner.ts +16 -2
- package/src/services/deploy-preflight.ts +18 -1
- package/src/services/deployed-systems.ts +30 -1
- package/src/services/dns-provider-backfill.test.ts +150 -0
- package/src/services/dns-provider-backfill.ts +72 -2
- package/src/services/e2e-guard.test.ts +38 -0
- package/src/services/e2e-guard.ts +43 -0
- package/src/services/module-deploy.ts +12 -26
- package/src/services/responder-probe.test.ts +87 -0
- package/src/services/responder-probe.ts +29 -0
- package/src/services/restore-from-file.ts +16 -6
- package/src/services/storage-providers/s3.test.ts +101 -0
- package/src/templates/generator.test.ts +77 -0
- package/src/templates/generator.ts +69 -2
- package/src/variables/context.ts +34 -0
- package/src/variables/lxc-nameserver.test.ts +86 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packaging bundles the module's hook-script runtime deps in full (ISS-0046):
|
|
3
|
+
* scripts/node_modules must land in the .netapp's checksummed payload so a
|
|
4
|
+
* module's hooks resolve their third-party deps (e.g. tldts) on a target with
|
|
5
|
+
* no reachable package registry. Other node_modules trees stay
|
|
6
|
+
* @celilo/capabilities-only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { computeChecksums } from './build';
|
|
14
|
+
|
|
15
|
+
describe('computeChecksums — scripts/node_modules bundling (ISS-0046)', () => {
|
|
16
|
+
let dir: string;
|
|
17
|
+
|
|
18
|
+
function write(rel: string, content = 'x'): void {
|
|
19
|
+
const full = join(dir, rel);
|
|
20
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
21
|
+
writeFileSync(full, content);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-build-test-'));
|
|
26
|
+
write('manifest.yml', 'id: demo\n');
|
|
27
|
+
write('scripts/on-install.ts');
|
|
28
|
+
// Hook-script runtime closure — must all be bundled.
|
|
29
|
+
write('scripts/node_modules/@celilo/capabilities/src/dns-internal.ts');
|
|
30
|
+
write('scripts/node_modules/tldts/index.js');
|
|
31
|
+
write('scripts/node_modules/drizzle-orm/index.js');
|
|
32
|
+
// .bin holds symlink shims — excluded.
|
|
33
|
+
write('scripts/node_modules/.bin/tldts');
|
|
34
|
+
// A module-root node_modules: only @celilo/capabilities is kept.
|
|
35
|
+
write('node_modules/@celilo/capabilities/index.js');
|
|
36
|
+
write('node_modules/lodash/index.js');
|
|
37
|
+
// e2e/ is never part of the deployed module.
|
|
38
|
+
write('e2e/foo.test.ts');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
try {
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
} catch {
|
|
45
|
+
/* ignore */
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('bundles the full scripts/node_modules closure, drops .bin', async () => {
|
|
50
|
+
const { files } = await computeChecksums(dir);
|
|
51
|
+
const paths = Object.keys(files);
|
|
52
|
+
|
|
53
|
+
expect(paths).toContain('scripts/node_modules/@celilo/capabilities/src/dns-internal.ts');
|
|
54
|
+
expect(paths).toContain('scripts/node_modules/tldts/index.js');
|
|
55
|
+
expect(paths).toContain('scripts/node_modules/drizzle-orm/index.js');
|
|
56
|
+
expect(paths).not.toContain('scripts/node_modules/.bin/tldts');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps only @celilo/capabilities in a non-scripts node_modules', async () => {
|
|
60
|
+
const { files } = await computeChecksums(dir);
|
|
61
|
+
const paths = Object.keys(files);
|
|
62
|
+
|
|
63
|
+
expect(paths).toContain('node_modules/@celilo/capabilities/index.js');
|
|
64
|
+
expect(paths).not.toContain('node_modules/lodash/index.js');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('excludes the module e2e/ directory and the manifest stays', async () => {
|
|
68
|
+
const { files } = await computeChecksums(dir);
|
|
69
|
+
const paths = Object.keys(files);
|
|
70
|
+
|
|
71
|
+
expect(paths).toContain('manifest.yml');
|
|
72
|
+
expect(paths).toContain('scripts/on-install.ts');
|
|
73
|
+
expect(paths.some((p) => p.startsWith('e2e/'))).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -8,6 +8,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
8
8
|
import { log } from '../../cli/prompts';
|
|
9
9
|
import { validateModuleDirectory } from '../import';
|
|
10
10
|
import { computeFileChecksum } from './checksum';
|
|
11
|
+
import { includeNodeModulesPath } from './package-rules';
|
|
11
12
|
import { signChecksums } from './signature';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -48,9 +49,9 @@ export interface ModuleBuildResult {
|
|
|
48
49
|
/**
|
|
49
50
|
* Files/directories to exclude from package.
|
|
50
51
|
*
|
|
51
|
-
* `node_modules` is
|
|
52
|
-
*
|
|
53
|
-
*
|
|
52
|
+
* `node_modules` is NOT listed here — it's path-aware via the canonical
|
|
53
|
+
* `includeNodeModulesPath` rule (package-rules.ts), so the hook-script runtime
|
|
54
|
+
* closure is bundled while other node_modules is dropped.
|
|
54
55
|
*/
|
|
55
56
|
const EXCLUDE_PATTERNS = [
|
|
56
57
|
'.git',
|
|
@@ -75,12 +76,9 @@ const FRAMEWORK_OWNED_PATHS = new Set(['celilo/types.d.ts']);
|
|
|
75
76
|
* Check if a path inside the source dir should be excluded from the
|
|
76
77
|
* package.
|
|
77
78
|
*
|
|
78
|
-
* `node_modules`
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* captures the capabilities API the module was authored against, so a
|
|
82
|
-
* module's hooks aren't silently broken by a runtime that ships a
|
|
83
|
-
* different version of `@celilo/capabilities` than the one on npm.
|
|
79
|
+
* The `node_modules` decision is delegated to the canonical
|
|
80
|
+
* `includeNodeModulesPath` rule (package-rules.ts) — the single source of truth
|
|
81
|
+
* the registry-server's bootstrap packager is held to as well (ISS-0046).
|
|
84
82
|
*/
|
|
85
83
|
function shouldExclude(filePath: string): boolean {
|
|
86
84
|
if (FRAMEWORK_OWNED_PATHS.has(filePath)) return true;
|
|
@@ -91,17 +89,8 @@ function shouldExclude(filePath: string): boolean {
|
|
|
91
89
|
// not part of the deployed module.
|
|
92
90
|
if (segments[0] === 'e2e') return true;
|
|
93
91
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// `node_modules` itself: descend so we can pick out @celilo/capabilities.
|
|
97
|
-
// The capabilities scope is the only thing we ship — it's the framework
|
|
98
|
-
// SDK the module was authored against. Everything else is regular npm
|
|
99
|
-
// cruft we'd just re-install on the target (or test-only deps inside
|
|
100
|
-
// packages like `@celilo/e2e` we don't want to drag in).
|
|
101
|
-
if (nmIdx + 1 >= segments.length) return false; // node_modules dir itself
|
|
102
|
-
if (segments[nmIdx + 1] !== '@celilo') return true;
|
|
103
|
-
if (nmIdx + 2 >= segments.length) return false; // node_modules/@celilo dir itself
|
|
104
|
-
return segments[nmIdx + 2] !== 'capabilities';
|
|
92
|
+
if (segments.includes('node_modules')) {
|
|
93
|
+
return !includeNodeModulesPath(filePath);
|
|
105
94
|
}
|
|
106
95
|
|
|
107
96
|
const name = basename(filePath);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical rule for which `node_modules` paths a module package bundles —
|
|
3
|
+
* the single source of truth for module packaging (ISS-0046).
|
|
4
|
+
*
|
|
5
|
+
* Two packagers must agree on this:
|
|
6
|
+
* - apps/celilo/src/module/packaging/build.ts (`celilo package` / publish)
|
|
7
|
+
* - packages/registry-server/src/bootstrap.ts (the e2e registry-sim's
|
|
8
|
+
* on-demand packager — what the e2e actually serves)
|
|
9
|
+
*
|
|
10
|
+
* They can't share this at RUNTIME: the registry-server ships in a standalone
|
|
11
|
+
* Docker image with no `@celilo/*` deps, so it carries its own structural copy
|
|
12
|
+
* of the rule. Lockstep is instead enforced by a test
|
|
13
|
+
* (packages/registry-server/src/bootstrap-packaging.test.ts) that asserts the
|
|
14
|
+
* registry-server's packaging output conforms to THIS function — so a drift in
|
|
15
|
+
* either copy fails CI rather than shipping a broken `.netapp`.
|
|
16
|
+
*
|
|
17
|
+
* The rule itself: `scripts/node_modules` is the module's hook runtime, bundled
|
|
18
|
+
* in full (minus `.bin` symlink shims) so hooks resolve their third-party deps
|
|
19
|
+
* (tldts, drizzle-orm, …) on a target with no reachable registry; any OTHER
|
|
20
|
+
* `node_modules` ships only `@celilo/capabilities` — the authored-against SDK.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a module-relative path that contains a `node_modules` segment should
|
|
25
|
+
* be INCLUDED in the package. Paths without a `node_modules` segment are not
|
|
26
|
+
* this function's concern — it returns `true` for them (the caller applies its
|
|
27
|
+
* own non-node_modules exclusions).
|
|
28
|
+
*/
|
|
29
|
+
export function includeNodeModulesPath(relPath: string): boolean {
|
|
30
|
+
const segments = relPath.split('/');
|
|
31
|
+
const nmIdx = segments.indexOf('node_modules');
|
|
32
|
+
if (nmIdx < 0) return true;
|
|
33
|
+
|
|
34
|
+
// scripts/node_modules: the hook runtime closure — bundle everything but .bin.
|
|
35
|
+
if (nmIdx >= 1 && segments[nmIdx - 1] === 'scripts') {
|
|
36
|
+
return segments[nmIdx + 1] !== '.bin';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Other node_modules: ship only @celilo/capabilities.
|
|
40
|
+
if (nmIdx + 1 >= segments.length) return true; // node_modules dir itself
|
|
41
|
+
if (segments[nmIdx + 1] !== '@celilo') return false;
|
|
42
|
+
if (nmIdx + 2 >= segments.length) return true; // node_modules/@celilo dir itself
|
|
43
|
+
return segments[nmIdx + 2] === 'capabilities';
|
|
44
|
+
}
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { deriveSecret, generateSecret } from './generators';
|
|
2
|
+
import { deriveSecret, generateGpgPrivateKey, generateSecret } from './generators';
|
|
3
|
+
|
|
4
|
+
describe('generateGpgPrivateKey', () => {
|
|
5
|
+
test('mints a base64-encoded ASCII-armored PGP private key', () => {
|
|
6
|
+
const b64 = generateGpgPrivateKey('apt.celilo.computer');
|
|
7
|
+
const armor = Buffer.from(b64, 'base64').toString('utf-8');
|
|
8
|
+
expect(armor).toContain('BEGIN PGP PRIVATE KEY BLOCK');
|
|
9
|
+
expect(armor).toContain('END PGP PRIVATE KEY BLOCK');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('mints a distinct key each call (throwaway keyring)', () => {
|
|
13
|
+
expect(generateGpgPrivateKey('a@example.com')).not.toBe(generateGpgPrivateKey('a@example.com'));
|
|
14
|
+
});
|
|
15
|
+
});
|
|
3
16
|
|
|
4
17
|
describe('generateSecret', () => {
|
|
5
18
|
test('generates base64 secret with default length', () => {
|
|
@@ -5,13 +5,75 @@
|
|
|
5
5
|
* and derives related secrets (e.g., WireGuard public keys from private keys)
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { execSync } from 'node:child_process';
|
|
8
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
9
12
|
|
|
10
13
|
export interface SecretGenerationOptions {
|
|
11
14
|
format: string;
|
|
12
15
|
length?: number;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Mint a passphraseless GPG/PGP signing key for `identity` and return the
|
|
20
|
+
* base64'd ASCII-armored PRIVATE key — the shape consuming Ansible roles decode
|
|
21
|
+
* (e.g. celilo-apt-repo's reprepro signing key). For `generate: {method: gpg}`
|
|
22
|
+
* secrets: celilo owns the signing key as infrastructure, not the operator.
|
|
23
|
+
*
|
|
24
|
+
* The key is generated in a throwaway GNUPGHOME (never touches the host
|
|
25
|
+
* operator's keyring) and is passphraseless because celilo encrypts it at rest
|
|
26
|
+
* in the secret store. Requires `gnupg` on the celilo host. Uses execFileSync
|
|
27
|
+
* (argv, no shell) so `identity` can't inject.
|
|
28
|
+
*/
|
|
29
|
+
export function generateGpgPrivateKey(identity: string): string {
|
|
30
|
+
const home = mkdtempSync(join(tmpdir(), 'celilo-gpg-'));
|
|
31
|
+
const env = { ...process.env, GNUPGHOME: home };
|
|
32
|
+
try {
|
|
33
|
+
execFileSync(
|
|
34
|
+
'gpg',
|
|
35
|
+
[
|
|
36
|
+
'--batch',
|
|
37
|
+
'--pinentry-mode',
|
|
38
|
+
'loopback',
|
|
39
|
+
'--passphrase',
|
|
40
|
+
'',
|
|
41
|
+
'--quick-generate-key',
|
|
42
|
+
identity,
|
|
43
|
+
'default',
|
|
44
|
+
'sign',
|
|
45
|
+
'never',
|
|
46
|
+
],
|
|
47
|
+
{ env, stdio: 'pipe' },
|
|
48
|
+
);
|
|
49
|
+
const armor = execFileSync(
|
|
50
|
+
'gpg',
|
|
51
|
+
[
|
|
52
|
+
'--batch',
|
|
53
|
+
'--pinentry-mode',
|
|
54
|
+
'loopback',
|
|
55
|
+
'--passphrase',
|
|
56
|
+
'',
|
|
57
|
+
'--armor',
|
|
58
|
+
'--export-secret-keys',
|
|
59
|
+
],
|
|
60
|
+
{ env, encoding: 'utf-8' },
|
|
61
|
+
);
|
|
62
|
+
if (!armor.includes('BEGIN PGP PRIVATE KEY')) {
|
|
63
|
+
throw new Error('gpg produced no private-key armor');
|
|
64
|
+
}
|
|
65
|
+
return Buffer.from(armor, 'utf-8').toString('base64');
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Failed to generate GPG signing key for "${identity}": ${
|
|
69
|
+
err instanceof Error ? err.message : String(err)
|
|
70
|
+
}. Is gnupg installed on the celilo host?`,
|
|
71
|
+
);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(home, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
15
77
|
export interface SecretDerivationOptions {
|
|
16
78
|
sourceSecret: string;
|
|
17
79
|
deriveMethod: string;
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
computeAspectScopeHash,
|
|
12
12
|
findAspectApproval,
|
|
13
13
|
recordAspectApproval,
|
|
14
|
+
recordAspectConsent,
|
|
14
15
|
} from './aspect-approvals';
|
|
15
16
|
|
|
16
17
|
const baseAspect: BaseModuleAspect = {
|
|
@@ -120,7 +121,7 @@ describe('aspect-approvals', () => {
|
|
|
120
121
|
expect(found?.scopeHash).toBe(written.scopeHash);
|
|
121
122
|
});
|
|
122
123
|
|
|
123
|
-
it('
|
|
124
|
+
it('upserts on duplicate (moduleId, version) — the latest decision wins', () => {
|
|
124
125
|
insertModule('knot-unbound-internal', '1.0.0');
|
|
125
126
|
const db = getDb();
|
|
126
127
|
recordAspectApproval({
|
|
@@ -130,15 +131,19 @@ describe('aspect-approvals', () => {
|
|
|
130
131
|
approver: null,
|
|
131
132
|
db,
|
|
132
133
|
});
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
134
|
+
// Re-deciding the same (module, version) overwrites rather than throwing
|
|
135
|
+
// — this is how a refusal flips to approval, or a scope change re-records.
|
|
136
|
+
recordAspectConsent({
|
|
137
|
+
moduleId: 'knot-unbound-internal',
|
|
138
|
+
version: '1.0.0',
|
|
139
|
+
scopeHash: 'def',
|
|
140
|
+
approver: null,
|
|
141
|
+
consented: false,
|
|
142
|
+
db,
|
|
143
|
+
});
|
|
144
|
+
const row = findAspectApproval('knot-unbound-internal', '1.0.0', db);
|
|
145
|
+
expect(row?.scopeHash).toBe('def'); // latest scope
|
|
146
|
+
expect(row?.consented).toBe(false); // latest decision (a refusal)
|
|
142
147
|
});
|
|
143
148
|
|
|
144
149
|
it('allows two approvals for the same module at different versions', () => {
|
|
@@ -202,6 +207,21 @@ describe('aspect-approvals', () => {
|
|
|
202
207
|
expect(status).toBe('approved');
|
|
203
208
|
});
|
|
204
209
|
|
|
210
|
+
it('returns "denied" when a matching-scope row recorded a refusal', () => {
|
|
211
|
+
insertModule('knot-unbound-internal', '1.0.0');
|
|
212
|
+
const db = getDb();
|
|
213
|
+
recordAspectConsent({
|
|
214
|
+
moduleId: 'knot-unbound-internal',
|
|
215
|
+
version: '1.0.0',
|
|
216
|
+
scopeHash: computeAspectScopeHash(baseAspect),
|
|
217
|
+
approver: null,
|
|
218
|
+
consented: false,
|
|
219
|
+
db,
|
|
220
|
+
});
|
|
221
|
+
const status = checkAspectApproval('knot-unbound-internal', '1.0.0', baseAspect, db);
|
|
222
|
+
expect(status).toBe('denied');
|
|
223
|
+
});
|
|
224
|
+
|
|
205
225
|
it('returns "scope_changed" when scope diverges from a prior approval', () => {
|
|
206
226
|
insertModule('knot-unbound-internal', '1.0.0');
|
|
207
227
|
const db = getDb();
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { createHash, randomUUID } from 'node:crypto';
|
|
24
|
-
import { and, eq } from 'drizzle-orm';
|
|
24
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
25
25
|
import type { getDb } from '../db/client';
|
|
26
26
|
import { aspectApprovals } from '../db/schema';
|
|
27
27
|
import type { BaseModuleAspect } from '../manifest/schema';
|
|
@@ -66,15 +66,55 @@ export function findAspectApproval(
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Record operator
|
|
70
|
-
* aspect
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
69
|
+
* Record the operator's consent DECISION for a module version's
|
|
70
|
+
* base-module aspect — `consented: true` (approve) or `false`
|
|
71
|
+
* (refuse, ISS-0027). Upserts on (moduleId, version): a later
|
|
72
|
+
* decision (e.g. a denial flipped to approval via
|
|
73
|
+
* `import --accept-aspects`, or a re-decision after a scope change)
|
|
74
|
+
* overwrites the prior one, keeping exactly one row per
|
|
75
|
+
* (module, version) reflecting the latest scope + decision.
|
|
74
76
|
*
|
|
75
|
-
* @param approver - operator identifier (e.g., $USER). Null when
|
|
76
|
-
*
|
|
77
|
-
|
|
77
|
+
* @param approver - operator identifier (e.g., $USER). Null when the
|
|
78
|
+
* decision was made in a context with no USER, or automated.
|
|
79
|
+
*/
|
|
80
|
+
export function recordAspectConsent(args: {
|
|
81
|
+
moduleId: string;
|
|
82
|
+
version: string;
|
|
83
|
+
scopeHash: string;
|
|
84
|
+
approver: string | null;
|
|
85
|
+
consented: boolean;
|
|
86
|
+
db: DbClient;
|
|
87
|
+
}): typeof aspectApprovals.$inferSelect {
|
|
88
|
+
args.db
|
|
89
|
+
.insert(aspectApprovals)
|
|
90
|
+
.values({
|
|
91
|
+
id: randomUUID(),
|
|
92
|
+
moduleId: args.moduleId,
|
|
93
|
+
version: args.version,
|
|
94
|
+
scopeHash: args.scopeHash,
|
|
95
|
+
consented: args.consented,
|
|
96
|
+
approver: args.approver,
|
|
97
|
+
})
|
|
98
|
+
.onConflictDoUpdate({
|
|
99
|
+
target: [aspectApprovals.moduleId, aspectApprovals.version],
|
|
100
|
+
set: {
|
|
101
|
+
scopeHash: args.scopeHash,
|
|
102
|
+
consented: args.consented,
|
|
103
|
+
approver: args.approver,
|
|
104
|
+
approvedAt: sql`(unixepoch())`,
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
.run();
|
|
108
|
+
return findAspectApproval(
|
|
109
|
+
args.moduleId,
|
|
110
|
+
args.version,
|
|
111
|
+
args.db,
|
|
112
|
+
) as typeof aspectApprovals.$inferSelect;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Record operator APPROVAL (consent = true). Thin wrapper over
|
|
117
|
+
* `recordAspectConsent` for the import path (`--accept-aspects`).
|
|
78
118
|
*/
|
|
79
119
|
export function recordAspectApproval(args: {
|
|
80
120
|
moduleId: string;
|
|
@@ -83,38 +123,28 @@ export function recordAspectApproval(args: {
|
|
|
83
123
|
approver: string | null;
|
|
84
124
|
db: DbClient;
|
|
85
125
|
}): typeof aspectApprovals.$inferSelect {
|
|
86
|
-
|
|
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;
|
|
126
|
+
return recordAspectConsent({ ...args, consented: true });
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
/**
|
|
102
|
-
* Check
|
|
103
|
-
*
|
|
104
|
-
* - 'approved':
|
|
105
|
-
* - '
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
130
|
+
* Check the operator's consent state for the current (moduleId,
|
|
131
|
+
* version) against the manifest's declared aspect scope. Returns:
|
|
132
|
+
* - 'approved': a matching-scope row with `consented: true` — run.
|
|
133
|
+
* - 'denied': a matching-scope row with `consented: false` — the
|
|
134
|
+
* operator explicitly refused (ISS-0027). Skip; do NOT re-prompt.
|
|
135
|
+
* - 'scope_changed': a row exists but the manifest's scope differs
|
|
136
|
+
* (D7 — the prior decision was about a different scope; re-prompt).
|
|
137
|
+
* - 'no_approval': no row for this version — undecided; prompt.
|
|
109
138
|
*/
|
|
110
139
|
export function checkAspectApproval(
|
|
111
140
|
moduleId: string,
|
|
112
141
|
version: string,
|
|
113
142
|
aspect: BaseModuleAspect,
|
|
114
143
|
db: DbClient,
|
|
115
|
-
): 'approved' | 'scope_changed' | 'no_approval' {
|
|
144
|
+
): 'approved' | 'denied' | 'scope_changed' | 'no_approval' {
|
|
116
145
|
const existing = findAspectApproval(moduleId, version, db);
|
|
117
146
|
if (!existing) return 'no_approval';
|
|
118
147
|
const currentHash = computeAspectScopeHash(aspect);
|
|
119
|
-
|
|
148
|
+
if (existing.scopeHash !== currentHash) return 'scope_changed';
|
|
149
|
+
return existing.consented ? 'approved' : 'denied';
|
|
120
150
|
}
|