@celilo/cli 2.1.0 → 2.2.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/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- 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 +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- 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.ts +15 -1
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +82 -11
- 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 +162 -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 +29 -5
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +14 -7
- 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/build.ts +70 -2
- 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 +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -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/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +121 -1
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- 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/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +163 -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/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- 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/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
|
@@ -37,6 +37,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
37
37
|
import { executeHookScript } from './executor';
|
|
38
38
|
import { detectJailBackend } from './jail';
|
|
39
39
|
import { createCapturingLogger } from './logger';
|
|
40
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
40
41
|
import type { HookContext } from './types';
|
|
41
42
|
|
|
42
43
|
const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-toolchain-hook.ts');
|
|
@@ -115,8 +116,8 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
|
|
|
115
116
|
|
|
116
117
|
const { logger } = createCapturingLogger();
|
|
117
118
|
const context: HookContext = {
|
|
118
|
-
config: PROBE_BROWSER ? { browser_executable: PROBE_BROWSER } :
|
|
119
|
-
secrets:
|
|
119
|
+
config: PROBE_BROWSER ? configStore({ browser_executable: PROBE_BROWSER }) : configStore(),
|
|
120
|
+
secrets: secretStore(),
|
|
120
121
|
systems: [],
|
|
121
122
|
logger,
|
|
122
123
|
debug: false,
|
|
@@ -35,6 +35,7 @@ const PLANTED_KEY_NAME = 'id_celilo_jail_probe';
|
|
|
35
35
|
import { executeHookScript } from './executor';
|
|
36
36
|
import { detectJailBackend, jailPolicy } from './jail';
|
|
37
37
|
import { createCapturingLogger } from './logger';
|
|
38
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
38
39
|
import type { HookContext } from './types';
|
|
39
40
|
|
|
40
41
|
const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-probe-hook.ts');
|
|
@@ -110,12 +111,12 @@ async function runProbe(): Promise<Rig> {
|
|
|
110
111
|
|
|
111
112
|
const { logger } = createCapturingLogger();
|
|
112
113
|
const context: HookContext = {
|
|
113
|
-
config: {
|
|
114
|
+
config: configStore({
|
|
114
115
|
planted_secret: plantedSecret,
|
|
115
116
|
sibling_file: siblingFile,
|
|
116
117
|
staged_input: stagedInput,
|
|
117
|
-
},
|
|
118
|
-
secrets:
|
|
118
|
+
}),
|
|
119
|
+
secrets: secretStore(),
|
|
119
120
|
systems: [],
|
|
120
121
|
logger,
|
|
121
122
|
debug: false,
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { z } from 'zod';
|
|
27
27
|
|
|
28
|
-
export const HOOK_PROTOCOL_VERSION =
|
|
28
|
+
export const HOOK_PROTOCOL_VERSION = 2;
|
|
29
29
|
|
|
30
30
|
/** Environment variable carrying the broker's socket path to the child. */
|
|
31
31
|
export const HOOK_SOCKET_ENV = 'CELILO_HOOK_SOCKET';
|
|
@@ -91,6 +91,39 @@ export const CallFrameSchema = z.object({
|
|
|
91
91
|
args: z.array(z.unknown()),
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* One hook-owned-state store operation, correlated like a `call` (and answered
|
|
96
|
+
* by the same `return`/`throw` frames — the runner's pending map keys on `id`
|
|
97
|
+
* alone).
|
|
98
|
+
*
|
|
99
|
+
* `store` names which of the module's two stores the call targets; `method` is
|
|
100
|
+
* one of the four `HookStore` operations. A `transaction` call carries the
|
|
101
|
+
* buffered operation list the CHILD accumulated — the hook's `fn` ran entirely
|
|
102
|
+
* child-side, so a throwing `fn` never sends anything, which is what makes the
|
|
103
|
+
* discard real rather than a promise the parent has to honor.
|
|
104
|
+
*
|
|
105
|
+
* Hook-owned-state design: the accessor is implemented once broker-side and
|
|
106
|
+
* reaches the hook as its own RPC family over this envelope, NOT as a fake
|
|
107
|
+
* capability — a fake one would surface in the `capabilities` shape frame and
|
|
108
|
+
* appear to the hook as a provider module.
|
|
109
|
+
*/
|
|
110
|
+
export const StoreCallFrameSchema = z.object({
|
|
111
|
+
type: z.literal('store'),
|
|
112
|
+
id: z.string(),
|
|
113
|
+
store: z.enum(['secrets', 'config']),
|
|
114
|
+
method: z.enum(['get', 'set', 'delete', 'transaction']),
|
|
115
|
+
args: z.array(z.unknown()),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** One buffered operation inside a `transaction` store call. */
|
|
119
|
+
export const BufferedStoreOpSchema = z.object({
|
|
120
|
+
op: z.enum(['set', 'delete']),
|
|
121
|
+
name: z.string(),
|
|
122
|
+
value: z.string().optional(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export type BufferedStoreOp = z.infer<typeof BufferedStoreOpSchema>;
|
|
126
|
+
|
|
94
127
|
/** One `ctx.logger` call. Fire and forget — the hook does not wait on it. */
|
|
95
128
|
export const LogFrameSchema = z.object({
|
|
96
129
|
type: z.literal('log'),
|
|
@@ -113,6 +146,7 @@ export const HookThrewFrameSchema = z.object({
|
|
|
113
146
|
export const ChildFrameSchema = z.discriminatedUnion('type', [
|
|
114
147
|
ReadyFrameSchema,
|
|
115
148
|
CallFrameSchema,
|
|
149
|
+
StoreCallFrameSchema,
|
|
116
150
|
LogFrameSchema,
|
|
117
151
|
ResultFrameSchema,
|
|
118
152
|
HookThrewFrameSchema,
|
|
@@ -145,6 +179,11 @@ export const MountEntrySchema = z.object({
|
|
|
145
179
|
path: z.string(),
|
|
146
180
|
mode: z.enum(['ro', 'rw', 'tmpfs']),
|
|
147
181
|
reason: z.string(),
|
|
182
|
+
// Kept on the wire so this stays the structural twin its docblock below
|
|
183
|
+
// claims. The child ignores it — absence is decided when the set is derived,
|
|
184
|
+
// not by the hook — but a twin that has quietly stopped being one is how the
|
|
185
|
+
// round-trip test starts asserting about a shape nothing sends.
|
|
186
|
+
absence: z.enum(['required', 'declared-only', 'runtime', 'conditional']),
|
|
148
187
|
});
|
|
149
188
|
|
|
150
189
|
/** The derived mount set, on the wire. Structural twin of `MountSet`. */
|
|
@@ -272,6 +311,12 @@ export function createLineReader(onLine: (line: string) => void): (chunk: string
|
|
|
272
311
|
/**
|
|
273
312
|
* The handshake check, in one place so both ends produce the same sentence.
|
|
274
313
|
*
|
|
314
|
+
* Version 2 added the `store` frame family (hook-owned-state task 3.5). Both
|
|
315
|
+
* ends of this protocol ship in the same celilo binary, so the check exists
|
|
316
|
+
* for one situation: a runner answering a broker built from different source
|
|
317
|
+
* than itself. It aborts the hook loudly instead of letting a store write
|
|
318
|
+
* disappear into a frame the other side cannot parse.
|
|
319
|
+
*
|
|
275
320
|
* Names both numbers: a mismatch is an install skew (a `.deb` upgraded while a
|
|
276
321
|
* module's bundled copy was not), and the operator needs to know which side is
|
|
277
322
|
* which to fix it.
|
package/src/hooks/hook-runner.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
serializeError,
|
|
27
27
|
versionMismatch,
|
|
28
28
|
} from './hook-protocol';
|
|
29
|
+
import { buildStoreView } from './hook-store-proxy';
|
|
29
30
|
import type { HookContext, HookLogger } from './types';
|
|
30
31
|
import { forwardLintWarnings } from './unjailed-lint';
|
|
31
32
|
|
|
@@ -110,6 +111,22 @@ function callBroker(capability: string, method: string, args: unknown[]): Promis
|
|
|
110
111
|
});
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Store calls share the capability call's correlation machinery: the broker
|
|
116
|
+
* answers both families with the same `return`/`throw` frames, keyed by `id`.
|
|
117
|
+
*/
|
|
118
|
+
function callStore(
|
|
119
|
+
store: 'secrets' | 'config',
|
|
120
|
+
method: 'get' | 'set' | 'delete' | 'transaction',
|
|
121
|
+
args: unknown[],
|
|
122
|
+
): Promise<unknown> {
|
|
123
|
+
const id = `c${nextCallId++}`;
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
pending.set(id, { resolve, reject });
|
|
126
|
+
send({ type: 'store', id, store, method, args });
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
113
130
|
/**
|
|
114
131
|
* The `defineHook` brand check, moved here from the executor unchanged
|
|
115
132
|
* (HOOK_API_V2 Phase 8 / D8). The brand is a `Symbol.for` key, so it survives
|
|
@@ -133,8 +150,27 @@ async function runHook(): Promise<void> {
|
|
|
133
150
|
// hook's logger from here on (anything earlier was buffered).
|
|
134
151
|
forwardLintWarnings(logger.warn);
|
|
135
152
|
|
|
153
|
+
// Attach the hook-owned-state accessor onto both maps. The maps crossed
|
|
154
|
+
// the context frame as plain data; the accessor methods exist only on
|
|
155
|
+
// this side, where the socket is. Old map reads keep working; new code
|
|
156
|
+
// calls `secrets.set(...)` / `config.get(...)` (hook-owned-state task 3.5).
|
|
157
|
+
// The casts say something true: celilo built both fields and controls
|
|
158
|
+
// their shape, the same rationale as the field check above.
|
|
159
|
+
const stores = buildStoreView(
|
|
160
|
+
'secrets',
|
|
161
|
+
(contextData.secrets ?? {}) as Record<string, string>,
|
|
162
|
+
callStore,
|
|
163
|
+
);
|
|
164
|
+
const configStores = buildStoreView(
|
|
165
|
+
'config',
|
|
166
|
+
(contextData.config ?? {}) as Record<string, unknown>,
|
|
167
|
+
callStore,
|
|
168
|
+
);
|
|
169
|
+
|
|
136
170
|
const context = {
|
|
137
171
|
...contextData,
|
|
172
|
+
config: configStores,
|
|
173
|
+
secrets: stores,
|
|
138
174
|
logger,
|
|
139
175
|
capabilities: buildCapabilities(shape),
|
|
140
176
|
} as unknown as HookContext;
|
|
@@ -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,162 @@
|
|
|
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 { createHookStores } from './hook-store';
|
|
18
|
+
|
|
19
|
+
describe('hook stores', () => {
|
|
20
|
+
let testDbPath: string;
|
|
21
|
+
let testDir: string;
|
|
22
|
+
let db: DbClient;
|
|
23
|
+
|
|
24
|
+
const MANIFEST = {
|
|
25
|
+
secrets: { declares: [{ name: 'bot_token' }, { name: 'api_key' }] },
|
|
26
|
+
variables: {
|
|
27
|
+
owns: [
|
|
28
|
+
{ name: 'public_ip', type: 'string', source: 'hook' },
|
|
29
|
+
{ name: 'registered_peers', type: 'string-map', source: 'hook' },
|
|
30
|
+
{ name: 'acme_email', type: 'string', source: 'user' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
testDir = mkdtempSync(join(tmpdir(), 'celilo-hook-store-'));
|
|
37
|
+
testDbPath = join(testDir, 'test.db');
|
|
38
|
+
process.env.CELILO_DB_PATH = testDbPath;
|
|
39
|
+
// Redirect the master key so the test never touches the operator's real one.
|
|
40
|
+
process.env.CELILO_MASTER_KEY_PATH = join(testDir, 'master.key');
|
|
41
|
+
await runMigrations(testDbPath);
|
|
42
|
+
|
|
43
|
+
db = createDbClient({ path: testDbPath });
|
|
44
|
+
db.insert(modules)
|
|
45
|
+
.values({
|
|
46
|
+
id: 'test-module',
|
|
47
|
+
name: 'Test Module',
|
|
48
|
+
version: '1.0.0',
|
|
49
|
+
manifestData: MANIFEST,
|
|
50
|
+
sourcePath: '/test/path',
|
|
51
|
+
})
|
|
52
|
+
.run();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
closeDb();
|
|
57
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
58
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('round-trips a secret through encrypt and decrypt', async () => {
|
|
62
|
+
const { secrets: store } = await createHookStores(db, 'test-module');
|
|
63
|
+
|
|
64
|
+
await store.set('bot_token', 's3cret-value');
|
|
65
|
+
expect(await store.get('bot_token')).toBe('s3cret-value');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('round-trips a hook-owned config value as plaintext', async () => {
|
|
69
|
+
const { config } = await createHookStores(db, 'test-module');
|
|
70
|
+
|
|
71
|
+
await config.set('public_ip', '203.0.113.7');
|
|
72
|
+
expect(await config.get('public_ip')).toBe('203.0.113.7');
|
|
73
|
+
|
|
74
|
+
// Plaintext: the row lands in module_configs, never in the secrets table.
|
|
75
|
+
const rows = db.select().from(moduleConfigs).all();
|
|
76
|
+
expect(rows.some((r) => r.key === 'public_ip' && r.valueJson === '"203.0.113.7"')).toBe(true);
|
|
77
|
+
expect(rows.some((r) => r.key === 'public_ip' && r.source === 'hook')).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('set on an undeclared secret throws naming the module and the declared set', async () => {
|
|
81
|
+
const { secrets: store } = await createHookStores(db, 'test-module');
|
|
82
|
+
|
|
83
|
+
expect(store.set('not_declared', 'x')).rejects.toThrow(
|
|
84
|
+
/test-module[\s\S]*not_declared[\s\S]*bot_token[\s\S]*api_key/,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('set on a name not owned as source: hook config throws naming the declared set', async () => {
|
|
89
|
+
const { config } = await createHookStores(db, 'test-module');
|
|
90
|
+
|
|
91
|
+
// acme_email is declared, but as source: user — the operator owns it.
|
|
92
|
+
await expect(config.set('acme_email', 'x')).rejects.toThrow(/source: hook/);
|
|
93
|
+
await expect(config.set('not_declared', 'x')).rejects.toThrow(/public_ip/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('delete of an absent name is a no-op', async () => {
|
|
97
|
+
const { secrets: store, config } = await createHookStores(db, 'test-module');
|
|
98
|
+
|
|
99
|
+
await expect(store.delete('bot_token')).resolves.toBeUndefined();
|
|
100
|
+
await expect(config.delete('public_ip')).resolves.toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('delete removes an existing row', async () => {
|
|
104
|
+
const { config } = await createHookStores(db, 'test-module');
|
|
105
|
+
|
|
106
|
+
await config.set('public_ip', '203.0.113.7');
|
|
107
|
+
await config.delete('public_ip');
|
|
108
|
+
expect(await config.get('public_ip')).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('applyTransaction commits every buffered op on success', async () => {
|
|
112
|
+
const { secrets: store } = await createHookStores(db, 'test-module');
|
|
113
|
+
|
|
114
|
+
await store.applyTransaction([
|
|
115
|
+
{ op: 'set', name: 'bot_token', value: 'committed' },
|
|
116
|
+
{ op: 'set', name: 'api_key', value: 'committed-too' },
|
|
117
|
+
]);
|
|
118
|
+
expect(await store.get('bot_token')).toBe('committed');
|
|
119
|
+
expect(await store.get('api_key')).toBe('committed-too');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('an undeclared name inside a transaction discards the whole batch', async () => {
|
|
123
|
+
const { secrets: store } = await createHookStores(db, 'test-module');
|
|
124
|
+
|
|
125
|
+
await expect(
|
|
126
|
+
store.applyTransaction([
|
|
127
|
+
{ op: 'set', name: 'api_key', value: 'good' },
|
|
128
|
+
{ op: 'set', name: 'not_declared', value: 'bad' },
|
|
129
|
+
]),
|
|
130
|
+
).rejects.toThrow(/not_declared/);
|
|
131
|
+
|
|
132
|
+
expect(await store.get('api_key')).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('a name owned as config is not writable through the secrets store and vice versa', async () => {
|
|
136
|
+
const { secrets: store, config } = await createHookStores(db, 'test-module');
|
|
137
|
+
|
|
138
|
+
await expect(store.set('public_ip', 'x')).rejects.toThrow(/bot_token|api_key/);
|
|
139
|
+
await expect(config.set('bot_token', 'x')).rejects.toThrow(/source: hook|public_ip/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('reads secrets the ordinary interview path wrote, and writes stay visible to it', async () => {
|
|
143
|
+
// The interview encrypts directly into the secrets table; the store must
|
|
144
|
+
// read those rows, not only its own writes.
|
|
145
|
+
const { encryptSecret } = await import('../secrets/encryption');
|
|
146
|
+
const { getOrCreateMasterKey } = await import('../secrets/master-key');
|
|
147
|
+
const masterKey = await getOrCreateMasterKey();
|
|
148
|
+
const encrypted = encryptSecret('interview-value', masterKey);
|
|
149
|
+
db.insert(secrets)
|
|
150
|
+
.values({ moduleId: 'test-module', name: 'bot_token', ...encrypted })
|
|
151
|
+
.run();
|
|
152
|
+
|
|
153
|
+
const { secrets: store } = await createHookStores(db, 'test-module');
|
|
154
|
+
expect(await store.get('bot_token')).toBe('interview-value');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('reports the declared sets in the store metadata', async () => {
|
|
158
|
+
const stores = await createHookStores(db, 'test-module');
|
|
159
|
+
expect(stores.declaredSecretNames.sort()).toEqual(['api_key', 'bot_token']);
|
|
160
|
+
expect(stores.declaredConfigNames.sort()).toEqual(['public_ip', 'registered_peers']);
|
|
161
|
+
});
|
|
162
|
+
});
|