@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.
Files changed (55) hide show
  1. package/drizzle/0008_aspect_consent.sql +1 -0
  2. package/drizzle/meta/_journal.json +7 -0
  3. package/package.json +5 -6
  4. package/src/cli/command-registry.ts +38 -0
  5. package/src/cli/commands/backup-pull.test.ts +48 -0
  6. package/src/cli/commands/backup-pull.ts +116 -0
  7. package/src/cli/commands/events.test.ts +108 -0
  8. package/src/cli/commands/events.ts +243 -0
  9. package/src/cli/commands/module-generate.ts +5 -4
  10. package/src/cli/commands/module-import-aspect.test.ts +116 -0
  11. package/src/cli/commands/module-import.ts +12 -1
  12. package/src/cli/commands/storage-add-s3.ts +91 -46
  13. package/src/cli/completion.ts +2 -1
  14. package/src/cli/index.ts +11 -0
  15. package/src/db/client.ts +4 -0
  16. package/src/db/schema.ts +9 -1
  17. package/src/hooks/capability-loader.test.ts +31 -1
  18. package/src/hooks/capability-loader.ts +65 -16
  19. package/src/manifest/contracts/v1.ts +12 -0
  20. package/src/manifest/schema.ts +13 -1
  21. package/src/manifest/template-validator.ts +1 -0
  22. package/src/module/packaging/build.test.ts +75 -0
  23. package/src/module/packaging/build.ts +9 -20
  24. package/src/module/packaging/package-rules.ts +44 -0
  25. package/src/secrets/generators.test.ts +14 -1
  26. package/src/secrets/generators.ts +63 -1
  27. package/src/services/aspect-approvals.test.ts +30 -10
  28. package/src/services/aspect-approvals.ts +61 -31
  29. package/src/services/aspect-runner.test.ts +161 -8
  30. package/src/services/aspect-runner.ts +156 -34
  31. package/src/services/backup-create.ts +11 -2
  32. package/src/services/bus-ensure-flow.test.ts +19 -1
  33. package/src/services/bus-interview.ts +56 -0
  34. package/src/services/bus-secret-flow.test.ts +19 -1
  35. package/src/services/celilo-events.test.ts +122 -0
  36. package/src/services/celilo-events.ts +144 -0
  37. package/src/services/celilo-mgmt-hooks.test.ts +9 -1
  38. package/src/services/config-interview.ts +38 -19
  39. package/src/services/deploy-planner.test.ts +66 -0
  40. package/src/services/deploy-planner.ts +16 -2
  41. package/src/services/deploy-preflight.ts +18 -1
  42. package/src/services/deployed-systems.ts +30 -1
  43. package/src/services/dns-provider-backfill.test.ts +150 -0
  44. package/src/services/dns-provider-backfill.ts +72 -2
  45. package/src/services/e2e-guard.test.ts +38 -0
  46. package/src/services/e2e-guard.ts +43 -0
  47. package/src/services/module-deploy.ts +12 -26
  48. package/src/services/responder-probe.test.ts +87 -0
  49. package/src/services/responder-probe.ts +29 -0
  50. package/src/services/restore-from-file.ts +16 -6
  51. package/src/services/storage-providers/s3.test.ts +101 -0
  52. package/src/templates/generator.test.ts +77 -0
  53. package/src/templates/generator.ts +69 -2
  54. package/src/variables/context.ts +34 -0
  55. 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 handled by `shouldExclude` directly (path-aware) so
52
- * the framework's own `@celilo/*` packages can still be bundled — see
53
- * the comment on shouldExclude for why.
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` exclusion is path-aware: regular npm dependencies are
79
- * skipped (size + bun re-installs them at module-import time), but the
80
- * framework's own `@celilo/capabilities` package stays in. That
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
- const nmIdx = segments.indexOf('node_modules');
95
- if (nmIdx >= 0) {
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('rejects duplicate (moduleId, version) via unique constraint', () => {
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
- expect(() =>
134
- recordAspectApproval({
135
- moduleId: 'knot-unbound-internal',
136
- version: '1.0.0',
137
- scopeHash: 'def',
138
- approver: null,
139
- db,
140
- }),
141
- ).toThrow();
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 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).
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
- * --accept-aspects is used in a context with no USER, or when
77
- * approval is automated.
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
- 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;
126
+ return recordAspectConsent({ ...args, consented: true });
99
127
  }
100
128
 
101
129
  /**
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
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
- return existing.scopeHash === currentHash ? 'approved' : 'scope_changed';
148
+ if (existing.scopeHash !== currentHash) return 'scope_changed';
149
+ return existing.consented ? 'approved' : 'denied';
120
150
  }