@celilo/cli 2.1.0 → 2.2.1
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/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/schemas/system_config.json +7 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/backup-rename.test.ts +2 -1
- package/src/cli/cli.test.ts +2 -1
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/firewall-interface-list.test.ts +158 -8
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +22 -2
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +2 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +5 -12
- package/src/cli/commands/module-update.test.ts +87 -4
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/module-upgrade.test.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +54 -2
- package/src/cli/commands/module-verify.test.ts +2 -3
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/registry-owner.test.ts +2 -1
- package/src/cli/commands/registry-token.test.ts +2 -1
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor.test.ts +71 -0
- package/src/cli/commands/system-doctor.ts +110 -24
- package/src/cli/commands/system-init-deprecation.test.ts +6 -3
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +6 -4
- package/src/cli/restore-command.test.ts +2 -1
- package/src/cli/restore-migration-failure.test.ts +160 -0
- package/src/config/paths.test.ts +3 -3
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +62 -135
- package/src/db/migrate.ts +17 -12
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +52 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +106 -19
- package/src/hooks/executor.ts +120 -13
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +168 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +39 -5
- package/src/hooks/jail.test.ts +62 -3
- package/src/hooks/jail.ts +67 -8
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +130 -2
- package/src/module/packaging/extract.ts +1 -5
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +235 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +264 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/module-integrity.test.ts +0 -21
- package/src/services/audit/module-integrity.ts +0 -14
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +47 -3
- package/src/services/backup-in-flight-refusal.test.ts +2 -1
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-ensure-flow.test.ts +2 -1
- package/src/services/bus-interview-park.test.ts +2 -1
- package/src/services/bus-interview.ts +37 -14
- package/src/services/bus-secret-flow.test.ts +2 -1
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/config-provenance.ts +4 -0
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +2 -1
- package/src/services/control-plane-bootstrap.test.ts +123 -2
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/cross-module-read.test.ts +2 -1
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +25 -2
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +3 -2
- package/src/services/fleet-key.test.ts +68 -3
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/infrastructure-selector.test.ts +2 -1
- package/src/services/infrastructure-variable-resolver.test.ts +2 -1
- package/src/services/machine-pool.test.ts +2 -1
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +188 -0
- package/src/services/module-deploy.ts +199 -21
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +2 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/restore-from-file.test.ts +23 -2
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/restore-preflight.test.ts +2 -1
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +2 -1
- package/src/services/static-content-converge.test.ts +144 -5
- package/src/services/static-content-converge.ts +87 -17
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/system-state-stage.test.ts +2 -1
- package/src/services/trusted-sources.test.ts +33 -2
- package/src/services/trusted-sources.ts +47 -10
- package/src/services/zone-detector.test.ts +2 -1
- package/src/templates/generator.ts +9 -2
- package/src/test-utils/bus-responder.ts +5 -3
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/variables/context.ts +16 -5
- package/src/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the child-side hook store view (hook-store-proxy).
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing behavior is the transaction buffer: the hook's callback
|
|
5
|
+
* runs against a buffering view, and NOTHING crosses the transport until the
|
|
6
|
+
* callback resolves. A hook that writes then throws must send no frame at all
|
|
7
|
+
* — that is what makes D4's discard real rather than a promise the broker
|
|
8
|
+
* keeps. These tests watch the transport, which stands in for the socket.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'bun:test';
|
|
12
|
+
import { type StoreTransport, buildStoreView } from './hook-store-proxy';
|
|
13
|
+
|
|
14
|
+
/** Record every transport call; answer `get` from a scripted map. */
|
|
15
|
+
function fakeTransport(scripted: Record<string, string> = {}) {
|
|
16
|
+
const calls: Array<{ store: string; method: string; args: unknown[] }> = [];
|
|
17
|
+
const transport: StoreTransport = (store, method, args) => {
|
|
18
|
+
calls.push({ store, method, args });
|
|
19
|
+
if (method === 'get') {
|
|
20
|
+
return Promise.resolve(scripted[args[0] as string]);
|
|
21
|
+
}
|
|
22
|
+
return Promise.resolve(null);
|
|
23
|
+
};
|
|
24
|
+
return { calls, transport };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('hook store view (child side)', () => {
|
|
28
|
+
it('keeps the map surface readable alongside the accessor methods', () => {
|
|
29
|
+
const { transport } = fakeTransport({ bot_token: 'v' });
|
|
30
|
+
const view = buildStoreView('secrets', { bot_token: 'v' }, transport);
|
|
31
|
+
|
|
32
|
+
expect(view.bot_token).toBe('v');
|
|
33
|
+
expect(view.get('bot_token')).resolves.toBe('v');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('forwards set/get/delete to the transport with the store name', async () => {
|
|
37
|
+
const { calls, transport } = fakeTransport();
|
|
38
|
+
const view = buildStoreView('config', {}, transport);
|
|
39
|
+
|
|
40
|
+
await view.set('public_ip', '203.0.113.7');
|
|
41
|
+
await view.delete('public_ip');
|
|
42
|
+
|
|
43
|
+
expect(calls).toEqual([
|
|
44
|
+
{ store: 'config', method: 'set', args: ['public_ip', '203.0.113.7'] },
|
|
45
|
+
{ store: 'config', method: 'delete', args: ['public_ip'] },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('transaction sends one frame with the buffered ops after fn resolves', async () => {
|
|
50
|
+
const { calls, transport } = fakeTransport();
|
|
51
|
+
const view = buildStoreView('secrets', {}, transport);
|
|
52
|
+
|
|
53
|
+
await view.transaction((s) => {
|
|
54
|
+
s.set('bot_token', 'a');
|
|
55
|
+
s.set('api_key', 'b');
|
|
56
|
+
s.delete('old_key');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(calls).toEqual([
|
|
60
|
+
{
|
|
61
|
+
store: 'secrets',
|
|
62
|
+
method: 'transaction',
|
|
63
|
+
args: [
|
|
64
|
+
[
|
|
65
|
+
{ op: 'set', name: 'bot_token', value: 'a' },
|
|
66
|
+
{ op: 'set', name: 'api_key', value: 'b' },
|
|
67
|
+
{ op: 'delete', name: 'old_key' },
|
|
68
|
+
],
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('a throwing fn sends NOTHING — the discard is on the wire', async () => {
|
|
75
|
+
const { calls, transport } = fakeTransport();
|
|
76
|
+
const view = buildStoreView('secrets', {}, transport);
|
|
77
|
+
|
|
78
|
+
await expect(
|
|
79
|
+
view.transaction(async (s) => {
|
|
80
|
+
s.set('bot_token', 'doomed');
|
|
81
|
+
throw new Error('hook failed midway');
|
|
82
|
+
}),
|
|
83
|
+
).rejects.toThrow('hook failed midway');
|
|
84
|
+
|
|
85
|
+
expect(calls).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('get inside a transaction reads committed state, not the buffer', async () => {
|
|
89
|
+
const { transport } = fakeTransport({ bot_token: 'committed' });
|
|
90
|
+
const view = buildStoreView('secrets', {}, transport);
|
|
91
|
+
|
|
92
|
+
let seenInside: string | undefined;
|
|
93
|
+
await view.transaction(async (s) => {
|
|
94
|
+
s.set('bot_token', 'buffered');
|
|
95
|
+
seenInside = await s.get('bot_token');
|
|
96
|
+
});
|
|
97
|
+
expect(seenInside).toBe('committed');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('a nested transaction rejects instead of reordering writes', async () => {
|
|
101
|
+
const { calls, transport } = fakeTransport();
|
|
102
|
+
const view = buildStoreView('secrets', {}, transport);
|
|
103
|
+
|
|
104
|
+
await expect(view.transaction((s) => s.transaction(() => Promise.resolve()))).rejects.toThrow(
|
|
105
|
+
/nested|transaction/i,
|
|
106
|
+
);
|
|
107
|
+
expect(calls).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The asking half of the hook-owned-state accessor.
|
|
3
|
+
*
|
|
4
|
+
* Lives in the hook's own process (the runner shim builds the context from
|
|
5
|
+
* this), where every operation must cross the socket as a `store` frame. The
|
|
6
|
+
* answering half is `hook-store.ts` broker-side.
|
|
7
|
+
*
|
|
8
|
+
* The one subtlety is `transaction` (hook-owned-state design D4): the hook's
|
|
9
|
+
* callback runs entirely child-side against a BUFFERING view, and nothing is
|
|
10
|
+
* sent until the callback resolves. A hook that writes three names and throws
|
|
11
|
+
* therefore sends no frame at all — the discard is a property of the wire, not
|
|
12
|
+
* a promise the broker has to keep. This is the inverse of plain `set`, which
|
|
13
|
+
* lands when called; the design makes that trade deliberately and puts the
|
|
14
|
+
* choice at the call site.
|
|
15
|
+
*
|
|
16
|
+
* Pure construction (Rule 10.4) — the transport is injected, so the buffering
|
|
17
|
+
* rules are testable with no socket.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { HookStore } from '@celilo/capabilities';
|
|
21
|
+
import type { BufferedStoreOp } from './hook-protocol';
|
|
22
|
+
|
|
23
|
+
/** One operation on a store, transported. The injectable boundary. */
|
|
24
|
+
export type StoreTransport = (
|
|
25
|
+
store: 'secrets' | 'config',
|
|
26
|
+
method: 'get' | 'set' | 'delete' | 'transaction',
|
|
27
|
+
args: unknown[],
|
|
28
|
+
) => Promise<unknown>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The names a transaction's callback must not call through its buffering view.
|
|
32
|
+
* A nested transaction has no honest meaning — there is nothing to commit the
|
|
33
|
+
* outer buffer against yet — and silently ignoring it would reorder writes.
|
|
34
|
+
*/
|
|
35
|
+
class NestedTransactionError extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super('transaction() cannot be called inside a transaction; apply the writes directly.');
|
|
38
|
+
this.name = 'NestedTransactionError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the object the hook sees as `context.secrets` / `context.config`:
|
|
44
|
+
* the plain record of current values (the map surface older hooks still read),
|
|
45
|
+
* plus the four accessor methods.
|
|
46
|
+
*
|
|
47
|
+
* Methods sit ON the record deliberately during the migration window — a
|
|
48
|
+
* module declaring a secret or config key named `get`, `set`, `delete` or
|
|
49
|
+
* `transaction` would shadow one. That collision is rejected at the manifest
|
|
50
|
+
* layer only after task 3.6 removes the map surface; until then the risk is
|
|
51
|
+
* carried, not hidden.
|
|
52
|
+
*/
|
|
53
|
+
export function buildStoreView<T extends Record<string, unknown>>(
|
|
54
|
+
store: 'secrets' | 'config',
|
|
55
|
+
currentValues: T,
|
|
56
|
+
transport: StoreTransport,
|
|
57
|
+
): T & HookStore {
|
|
58
|
+
const call = (method: 'get' | 'set' | 'delete', args: unknown[]) =>
|
|
59
|
+
transport(store, method, args) as Promise<unknown>;
|
|
60
|
+
|
|
61
|
+
const view = {
|
|
62
|
+
get: (name: string) => call('get', [name]) as Promise<string | undefined>,
|
|
63
|
+
set: (name: string, value: string) => call('set', [name, value]) as Promise<void>,
|
|
64
|
+
delete: (name: string) => call('delete', [name]) as Promise<void>,
|
|
65
|
+
transaction: async (fn: (buffered: HookStore) => void | Promise<void>) => {
|
|
66
|
+
const ops: BufferedStoreOp[] = [];
|
|
67
|
+
const buffered: HookStore = {
|
|
68
|
+
get: view.get,
|
|
69
|
+
set: (name, value) => {
|
|
70
|
+
ops.push({ op: 'set', name, value });
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
},
|
|
73
|
+
delete: (name) => {
|
|
74
|
+
ops.push({ op: 'delete', name });
|
|
75
|
+
return Promise.resolve();
|
|
76
|
+
},
|
|
77
|
+
transaction: () => Promise.reject(new NestedTransactionError()),
|
|
78
|
+
};
|
|
79
|
+
await fn(buffered);
|
|
80
|
+
await transport(store, 'transaction', [ops]);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return { ...currentValues, ...view } as T & HookStore;
|
|
85
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the broker-side hook state stores (hook-owned-state tasks 3.2-3.4).
|
|
3
|
+
*
|
|
4
|
+
* The stores are what a jailed hook's `context.secrets` / `context.config`
|
|
5
|
+
* accessors answer against. Everything here runs against a real temp database
|
|
6
|
+
* with real encryption, because the interesting failures are storage failures:
|
|
7
|
+
* a value that will not decrypt back is worse than one that was never written.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { type DbClient, closeDb, createDbClient } from '../db/client';
|
|
15
|
+
import { runMigrations } from '../db/migrate';
|
|
16
|
+
import { moduleConfigs, modules, secrets } from '../db/schema';
|
|
17
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
18
|
+
import { createHookStores } from './hook-store';
|
|
19
|
+
|
|
20
|
+
describe('hook stores', () => {
|
|
21
|
+
let testDbPath: string;
|
|
22
|
+
let testDir: string;
|
|
23
|
+
let db: DbClient;
|
|
24
|
+
|
|
25
|
+
const MANIFEST = {
|
|
26
|
+
secrets: { declares: [{ name: 'bot_token' }, { name: 'api_key' }] },
|
|
27
|
+
variables: {
|
|
28
|
+
owns: [
|
|
29
|
+
{ name: 'public_ip', type: 'string', source: 'hook' },
|
|
30
|
+
{ name: 'registered_peers', type: 'string-map', source: 'hook' },
|
|
31
|
+
{ name: 'acme_email', type: 'string', source: 'user' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
testDir = mkdtempSync(join(tmpdir(), 'celilo-hook-store-'));
|
|
38
|
+
testDbPath = join(testDir, 'test.db');
|
|
39
|
+
process.env.CELILO_DB_PATH = testDbPath;
|
|
40
|
+
// Redirect the master key so the test never touches the operator's real one.
|
|
41
|
+
process.env.CELILO_MASTER_KEY_PATH = join(testDir, 'master.key');
|
|
42
|
+
await runMigrations(testDbPath);
|
|
43
|
+
|
|
44
|
+
db = createDbClient({ path: testDbPath });
|
|
45
|
+
db.insert(modules)
|
|
46
|
+
.values({
|
|
47
|
+
id: 'test-module',
|
|
48
|
+
name: 'Test Module',
|
|
49
|
+
version: '1.0.0',
|
|
50
|
+
manifestData: MANIFEST,
|
|
51
|
+
sourcePath: '/test/path',
|
|
52
|
+
})
|
|
53
|
+
.run();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
closeDb();
|
|
58
|
+
// The variable pointed at this suite's (now removed) temp database;
|
|
59
|
+
// leaving it set sends the next var-less reader at a dangling path.
|
|
60
|
+
// Reset to the scratch path so no later reader reaches the operator's
|
|
61
|
+
// real celilo.db (celilo#1315).
|
|
62
|
+
resetTestDbPath();
|
|
63
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
64
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('round-trips a secret through encrypt and decrypt', async () => {
|
|
68
|
+
const { secrets: store } = await createHookStores(db, 'test-module');
|
|
69
|
+
|
|
70
|
+
await store.set('bot_token', 's3cret-value');
|
|
71
|
+
expect(await store.get('bot_token')).toBe('s3cret-value');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('round-trips a hook-owned config value as plaintext', async () => {
|
|
75
|
+
const { config } = await createHookStores(db, 'test-module');
|
|
76
|
+
|
|
77
|
+
await config.set('public_ip', '203.0.113.7');
|
|
78
|
+
expect(await config.get('public_ip')).toBe('203.0.113.7');
|
|
79
|
+
|
|
80
|
+
// Plaintext: the row lands in module_configs, never in the secrets table.
|
|
81
|
+
const rows = db.select().from(moduleConfigs).all();
|
|
82
|
+
expect(rows.some((r) => r.key === 'public_ip' && r.valueJson === '"203.0.113.7"')).toBe(true);
|
|
83
|
+
expect(rows.some((r) => r.key === 'public_ip' && r.source === 'hook')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('set on an undeclared secret throws naming the module and the declared set', async () => {
|
|
87
|
+
const { secrets: store } = await createHookStores(db, 'test-module');
|
|
88
|
+
|
|
89
|
+
expect(store.set('not_declared', 'x')).rejects.toThrow(
|
|
90
|
+
/test-module[\s\S]*not_declared[\s\S]*bot_token[\s\S]*api_key/,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('set on a name not owned as source: hook config throws naming the declared set', async () => {
|
|
95
|
+
const { config } = await createHookStores(db, 'test-module');
|
|
96
|
+
|
|
97
|
+
// acme_email is declared, but as source: user — the operator owns it.
|
|
98
|
+
await expect(config.set('acme_email', 'x')).rejects.toThrow(/source: hook/);
|
|
99
|
+
await expect(config.set('not_declared', 'x')).rejects.toThrow(/public_ip/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('delete of an absent name is a no-op', async () => {
|
|
103
|
+
const { secrets: store, config } = await createHookStores(db, 'test-module');
|
|
104
|
+
|
|
105
|
+
await expect(store.delete('bot_token')).resolves.toBeUndefined();
|
|
106
|
+
await expect(config.delete('public_ip')).resolves.toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('delete removes an existing row', async () => {
|
|
110
|
+
const { config } = await createHookStores(db, 'test-module');
|
|
111
|
+
|
|
112
|
+
await config.set('public_ip', '203.0.113.7');
|
|
113
|
+
await config.delete('public_ip');
|
|
114
|
+
expect(await config.get('public_ip')).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('applyTransaction commits every buffered op on success', async () => {
|
|
118
|
+
const { secrets: store } = await createHookStores(db, 'test-module');
|
|
119
|
+
|
|
120
|
+
await store.applyTransaction([
|
|
121
|
+
{ op: 'set', name: 'bot_token', value: 'committed' },
|
|
122
|
+
{ op: 'set', name: 'api_key', value: 'committed-too' },
|
|
123
|
+
]);
|
|
124
|
+
expect(await store.get('bot_token')).toBe('committed');
|
|
125
|
+
expect(await store.get('api_key')).toBe('committed-too');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('an undeclared name inside a transaction discards the whole batch', async () => {
|
|
129
|
+
const { secrets: store } = await createHookStores(db, 'test-module');
|
|
130
|
+
|
|
131
|
+
await expect(
|
|
132
|
+
store.applyTransaction([
|
|
133
|
+
{ op: 'set', name: 'api_key', value: 'good' },
|
|
134
|
+
{ op: 'set', name: 'not_declared', value: 'bad' },
|
|
135
|
+
]),
|
|
136
|
+
).rejects.toThrow(/not_declared/);
|
|
137
|
+
|
|
138
|
+
expect(await store.get('api_key')).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('a name owned as config is not writable through the secrets store and vice versa', async () => {
|
|
142
|
+
const { secrets: store, config } = await createHookStores(db, 'test-module');
|
|
143
|
+
|
|
144
|
+
await expect(store.set('public_ip', 'x')).rejects.toThrow(/bot_token|api_key/);
|
|
145
|
+
await expect(config.set('bot_token', 'x')).rejects.toThrow(/source: hook|public_ip/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('reads secrets the ordinary interview path wrote, and writes stay visible to it', async () => {
|
|
149
|
+
// The interview encrypts directly into the secrets table; the store must
|
|
150
|
+
// read those rows, not only its own writes.
|
|
151
|
+
const { encryptSecret } = await import('../secrets/encryption');
|
|
152
|
+
const { getOrCreateMasterKey } = await import('../secrets/master-key');
|
|
153
|
+
const masterKey = await getOrCreateMasterKey();
|
|
154
|
+
const encrypted = encryptSecret('interview-value', masterKey);
|
|
155
|
+
db.insert(secrets)
|
|
156
|
+
.values({ moduleId: 'test-module', name: 'bot_token', ...encrypted })
|
|
157
|
+
.run();
|
|
158
|
+
|
|
159
|
+
const { secrets: store } = await createHookStores(db, 'test-module');
|
|
160
|
+
expect(await store.get('bot_token')).toBe('interview-value');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('reports the declared sets in the store metadata', async () => {
|
|
164
|
+
const stores = await createHookStores(db, 'test-module');
|
|
165
|
+
expect(stores.declaredSecretNames.sort()).toEqual(['api_key', 'bot_token']);
|
|
166
|
+
expect(stores.declaredConfigNames.sort()).toEqual(['public_ip', 'registered_peers']);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The broker side of the hook-owned-state accessor (hook-owned-state tasks
|
|
3
|
+
* 3.1-3.4, design option C).
|
|
4
|
+
*
|
|
5
|
+
* A hook runs out of process and reaches its own persistent state only through
|
|
6
|
+
* the wire (see `hook-protocol.ts` `StoreCallFrame`). This module is the thing
|
|
7
|
+
* those frames land on: two stores over `(db, moduleId, manifest, masterKey)`
|
|
8
|
+
* — `secrets`, validated against `manifest.secrets.declares` and encrypted,
|
|
9
|
+
* and `config`, validated against `variables.owns` where `source: hook` and
|
|
10
|
+
* stored plaintext in `module_configs`.
|
|
11
|
+
*
|
|
12
|
+
* Validation is against the MANIFEST, not the database. The whole point
|
|
13
|
+
* (design D2) is that a typo in a hook is a loud error naming the module and
|
|
14
|
+
* the declared set, never a phantom row some later reader mistakes for state.
|
|
15
|
+
*
|
|
16
|
+
* The transaction is applied inside one `db.transaction` (same mechanism as
|
|
17
|
+
* IPAM allocation in `variables/context.ts`). The buffering half lives
|
|
18
|
+
* child-side in the runner proxy, so a throwing `fn` sends no frame at all;
|
|
19
|
+
* what arrives here is a finished operation list that either applies whole or
|
|
20
|
+
* throws whole.
|
|
21
|
+
*
|
|
22
|
+
* Execution functions (Rule 10.1) — each method performs its own storage
|
|
23
|
+
* effects; construction is pure planning.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { and, eq } from 'drizzle-orm';
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
import type { DbClient } from '../db/client';
|
|
29
|
+
import { moduleConfigs, modules, secrets } from '../db/schema';
|
|
30
|
+
import { type EncryptedSecret, decryptSecret, encryptSecret } from '../secrets/encryption';
|
|
31
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
32
|
+
import { deleteModuleConfig, upsertModuleConfig } from '../services/module-config';
|
|
33
|
+
import type { BufferedStoreOp } from './hook-protocol';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The manifest surface the stores validate against, read out of
|
|
37
|
+
* `modules.manifestData` and narrowed. Everything else in the manifest is
|
|
38
|
+
* irrelevant here — this is deliberately a projection, not a parse of the
|
|
39
|
+
* whole schema, so a manifest feature the stores never read cannot break a
|
|
40
|
+
* hook run.
|
|
41
|
+
*/
|
|
42
|
+
const StoreManifestSchema = z.object({
|
|
43
|
+
secrets: z
|
|
44
|
+
.object({ declares: z.array(z.object({ name: z.string() }).passthrough()).optional() })
|
|
45
|
+
.optional(),
|
|
46
|
+
variables: z
|
|
47
|
+
.object({
|
|
48
|
+
owns: z
|
|
49
|
+
.array(z.object({ name: z.string(), source: z.string().optional() }).passthrough())
|
|
50
|
+
.optional(),
|
|
51
|
+
})
|
|
52
|
+
.optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** The two stores a hook may touch, keyed as the wire names them. */
|
|
56
|
+
export interface HookStores {
|
|
57
|
+
readonly secrets: HookStoreBackend;
|
|
58
|
+
readonly config: HookStoreBackend;
|
|
59
|
+
/** Diagnostic mirror of what each store validates against. */
|
|
60
|
+
readonly declaredSecretNames: string[];
|
|
61
|
+
readonly declaredConfigNames: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One store's answering half. Shaped like `HookStore` from
|
|
66
|
+
* `@celilo/capabilities` except that `transaction` takes the operation list
|
|
67
|
+
* the child buffered — the buffering happens there, the atomicity here.
|
|
68
|
+
*/
|
|
69
|
+
export interface HookStoreBackend {
|
|
70
|
+
get(name: string): Promise<string | undefined>;
|
|
71
|
+
set(name: string, value: string): Promise<void>;
|
|
72
|
+
delete(name: string): Promise<void>;
|
|
73
|
+
applyTransaction(ops: readonly BufferedStoreOp[]): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Throw for an undeclared name, naming the module and the declared set
|
|
78
|
+
* (task 3.4 — ERROR, not warning, design D2).
|
|
79
|
+
*/
|
|
80
|
+
function undeclared(
|
|
81
|
+
moduleId: string,
|
|
82
|
+
kind: 'secret' | 'hook-owned config',
|
|
83
|
+
name: string,
|
|
84
|
+
declared: string[],
|
|
85
|
+
): Error {
|
|
86
|
+
const declaredList = declared.length > 0 ? declared.join(', ') : '(none)';
|
|
87
|
+
const where =
|
|
88
|
+
kind === 'secret'
|
|
89
|
+
? `Add '${name}' to secrets.declares in the module's manifest.yml.`
|
|
90
|
+
: `Add '${name}' to variables.owns with source: hook in the module's manifest.yml.`;
|
|
91
|
+
return new Error(
|
|
92
|
+
`Module '${moduleId}' has no declared ${kind} '${name}'. Declared ${kind} names: ${declaredList}. ${where}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Secrets store: encrypted rows in the `secrets` table, keyed `(moduleId, name)`. */
|
|
97
|
+
function createSecretsStore(db: DbClient, moduleId: string, declared: string[]): HookStoreBackend {
|
|
98
|
+
const assertDeclared = (name: string) => {
|
|
99
|
+
if (!declared.includes(name)) throw undeclared(moduleId, 'secret', name, declared);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const upsert = (name: string, encrypted: EncryptedSecret) => {
|
|
103
|
+
const existing = db
|
|
104
|
+
.select()
|
|
105
|
+
.from(secrets)
|
|
106
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
107
|
+
.get();
|
|
108
|
+
if (existing) {
|
|
109
|
+
db.update(secrets)
|
|
110
|
+
.set({
|
|
111
|
+
encryptedValue: encrypted.encryptedValue,
|
|
112
|
+
iv: encrypted.iv,
|
|
113
|
+
authTag: encrypted.authTag,
|
|
114
|
+
updatedAt: new Date(),
|
|
115
|
+
})
|
|
116
|
+
.where(eq(secrets.id, existing.id))
|
|
117
|
+
.run();
|
|
118
|
+
} else {
|
|
119
|
+
db.insert(secrets)
|
|
120
|
+
.values({ moduleId, name, ...encrypted })
|
|
121
|
+
.run();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
async get(name) {
|
|
127
|
+
assertDeclared(name);
|
|
128
|
+
const row = db
|
|
129
|
+
.select()
|
|
130
|
+
.from(secrets)
|
|
131
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
132
|
+
.get();
|
|
133
|
+
if (!row) return undefined;
|
|
134
|
+
const masterKey = await getOrCreateMasterKey();
|
|
135
|
+
return decryptSecret(
|
|
136
|
+
{ encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag },
|
|
137
|
+
masterKey,
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async set(name, value) {
|
|
142
|
+
assertDeclared(name);
|
|
143
|
+
const masterKey = await getOrCreateMasterKey();
|
|
144
|
+
upsert(name, encryptSecret(value, masterKey));
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async delete(name) {
|
|
148
|
+
assertDeclared(name);
|
|
149
|
+
db.delete(secrets)
|
|
150
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
151
|
+
.run();
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async applyTransaction(ops) {
|
|
155
|
+
// Validate EVERY name before writing ANY row: a batch with one undeclared
|
|
156
|
+
// name must discard whole, not half-apply then throw (task 3.7).
|
|
157
|
+
for (const entry of ops) assertDeclared(entry.name);
|
|
158
|
+
const masterKey = await getOrCreateMasterKey();
|
|
159
|
+
db.transaction((tx) => {
|
|
160
|
+
for (const entry of ops) {
|
|
161
|
+
if (entry.op === 'set') {
|
|
162
|
+
upsertTx(
|
|
163
|
+
tx as unknown as DbClient,
|
|
164
|
+
entry.name,
|
|
165
|
+
encryptSecret(entry.value ?? '', masterKey),
|
|
166
|
+
);
|
|
167
|
+
} else {
|
|
168
|
+
tx.delete(secrets)
|
|
169
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, entry.name)))
|
|
170
|
+
.run();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
function upsertTx(tx: DbClient, name: string, encrypted: EncryptedSecret): void {
|
|
178
|
+
const existing = tx
|
|
179
|
+
.select()
|
|
180
|
+
.from(secrets)
|
|
181
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
182
|
+
.get();
|
|
183
|
+
if (existing) {
|
|
184
|
+
tx.update(secrets)
|
|
185
|
+
.set({
|
|
186
|
+
encryptedValue: encrypted.encryptedValue,
|
|
187
|
+
iv: encrypted.iv,
|
|
188
|
+
authTag: encrypted.authTag,
|
|
189
|
+
updatedAt: new Date(),
|
|
190
|
+
})
|
|
191
|
+
.where(eq(secrets.id, existing.id))
|
|
192
|
+
.run();
|
|
193
|
+
} else {
|
|
194
|
+
tx.insert(secrets)
|
|
195
|
+
.values({ moduleId, name, ...encrypted })
|
|
196
|
+
.run();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Config store: plaintext rows in `module_configs`, `source: hook`. */
|
|
202
|
+
function createConfigStore(db: DbClient, moduleId: string, declared: string[]): HookStoreBackend {
|
|
203
|
+
const assertDeclared = (name: string) => {
|
|
204
|
+
if (!declared.includes(name)) {
|
|
205
|
+
throw undeclared(moduleId, 'hook-owned config', name, declared);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
async get(name) {
|
|
211
|
+
assertDeclared(name);
|
|
212
|
+
const row = db
|
|
213
|
+
.select()
|
|
214
|
+
.from(moduleConfigs)
|
|
215
|
+
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, name)))
|
|
216
|
+
.get();
|
|
217
|
+
// Hook-owned rows are always written through `set`, so `valueJson` holds
|
|
218
|
+
// a JSON string literal of exactly what the hook sent. Absent means never
|
|
219
|
+
// written; null valueJson is a legacy row shape no hook-owned key can
|
|
220
|
+
// have (the source column did not exist when those were written).
|
|
221
|
+
if (!row?.valueJson) return undefined;
|
|
222
|
+
const parsed: unknown = JSON.parse(row.valueJson);
|
|
223
|
+
return typeof parsed === 'string' ? parsed : JSON.stringify(parsed);
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async set(name, value) {
|
|
227
|
+
assertDeclared(name);
|
|
228
|
+
upsertModuleConfig(db, moduleId, name, value, 'hook');
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
async delete(name) {
|
|
232
|
+
assertDeclared(name);
|
|
233
|
+
deleteModuleConfig(db, moduleId, name);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async applyTransaction(ops) {
|
|
237
|
+
for (const entry of ops) assertDeclared(entry.name);
|
|
238
|
+
db.transaction((tx) => {
|
|
239
|
+
// Same narrowing the secrets store applies below: drizzle hands the
|
|
240
|
+
// callback a transaction handle, and module-config's writers accept
|
|
241
|
+
// the full client it derives from.
|
|
242
|
+
const txDb = tx as unknown as DbClient;
|
|
243
|
+
for (const entry of ops) {
|
|
244
|
+
if (entry.op === 'set') {
|
|
245
|
+
upsertModuleConfig(txDb, moduleId, entry.name, entry.value ?? '', 'hook');
|
|
246
|
+
} else {
|
|
247
|
+
deleteModuleConfig(txDb, moduleId, entry.name);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Build both stores for a module from its stored manifest.
|
|
257
|
+
*
|
|
258
|
+
* The master key is NOT touched here — it loads lazily inside the operations
|
|
259
|
+
* that need it, so building stores for a hook that never writes a secret has
|
|
260
|
+
* no key-file side effect.
|
|
261
|
+
*
|
|
262
|
+
* Execution function — reads the module row. Throws when the module row or its
|
|
263
|
+
* manifest is missing, because a hook for a module celilo cannot describe is a
|
|
264
|
+
* run that cannot be attributed.
|
|
265
|
+
*/
|
|
266
|
+
export async function createHookStores(db: DbClient, moduleId: string): Promise<HookStores> {
|
|
267
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
268
|
+
if (!module) {
|
|
269
|
+
throw new Error(`Cannot build hook stores: module '${moduleId}' not found.`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const parsed = StoreManifestSchema.safeParse(module.manifestData);
|
|
273
|
+
if (!parsed.success) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`Cannot build hook stores for '${moduleId}': stored manifest does not match the expected shape (${parsed.error.issues[0]?.message ?? 'invalid'}).`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const declaredSecretNames = (parsed.data.secrets?.declares ?? []).map((s) => s.name);
|
|
280
|
+
const declaredConfigNames = (parsed.data.variables?.owns ?? [])
|
|
281
|
+
.filter((v) => v.source === 'hook')
|
|
282
|
+
.map((v) => v.name);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
secrets: createSecretsStore(db, moduleId, declaredSecretNames),
|
|
286
|
+
config: createConfigStore(db, moduleId, declaredConfigNames),
|
|
287
|
+
declaredSecretNames,
|
|
288
|
+
declaredConfigNames,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -23,6 +23,7 @@ import { tmpdir } from 'node:os';
|
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { executeHookScript } from './executor';
|
|
25
25
|
import { createCapturingLogger } from './logger';
|
|
26
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
26
27
|
import type { HookContext } from './types';
|
|
27
28
|
|
|
28
29
|
const FIXTURES = join(__dirname, 'test-fixtures');
|
|
@@ -40,8 +41,8 @@ function scratch(): string {
|
|
|
40
41
|
|
|
41
42
|
function contextFor(config: Record<string, unknown>): HookContext {
|
|
42
43
|
return {
|
|
43
|
-
config,
|
|
44
|
-
secrets:
|
|
44
|
+
config: configStore(config),
|
|
45
|
+
secrets: secretStore(),
|
|
45
46
|
systems: [],
|
|
46
47
|
logger: createCapturingLogger().logger,
|
|
47
48
|
debug: false,
|