@celilo/cli 2.3.0 → 3.1.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/CELILO_CORE_MODULES.md +1 -1
- package/package.json +3 -3
- package/src/api/serve.ts +13 -1
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +11 -3
- package/src/cli/commands/module-publish.test.ts +22 -0
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.ts +69 -17
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.ts +43 -3
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +13 -1
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +16 -3
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +14 -5
- package/src/cli/commands/system-update.ts +11 -6
- package/src/cli/completion.ts +10 -1
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +12 -0
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/types.ts +9 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader.ts +49 -1
- package/src/hooks/executor.test.ts +5 -2
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +10 -2
- package/src/hooks/hook-trespass.test.ts +13 -4
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +19 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +69 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/policy/module-business-baseline.ts +17 -0
- package/src/registry/client.test.ts +65 -0
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +69 -3
- package/src/secrets/storage.ts +71 -1
- package/src/services/audit/health.test.ts +58 -0
- package/src/services/audit/health.ts +15 -3
- package/src/services/audit/index.test.ts +1 -1
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +106 -28
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +37 -1
- package/src/services/fleet-checks.ts +31 -1
- package/src/services/health-runner.ts +43 -5
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +72 -125
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/provider-converge.test.ts +209 -0
- package/src/services/provider-converge.ts +241 -0
- package/src/services/static-content-converge.test.ts +39 -0
- package/src/services/static-content-converge.ts +26 -5
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The breaking/additive classifier behind the request-contract gate
|
|
3
|
+
* (capability-shape-drift.test.ts Scan C and the regenerate script's guard).
|
|
4
|
+
*
|
|
5
|
+
* celilo#1361 shipped because a request type lost a required field and no
|
|
6
|
+
* version moved. These tests pin the classification that prevents the next
|
|
7
|
+
* one: member removal, optional → required, and type changes are BREAKING;
|
|
8
|
+
* an added optional member is additive.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { type RequestTypeShape, classifyRequestChange, requestTypes } from './capability-shape';
|
|
13
|
+
|
|
14
|
+
function shape(
|
|
15
|
+
name: string,
|
|
16
|
+
members: Array<[name: string, type: string, optional?: boolean]>,
|
|
17
|
+
): RequestTypeShape {
|
|
18
|
+
return {
|
|
19
|
+
name,
|
|
20
|
+
kind: 'interface',
|
|
21
|
+
members: members.map(([memberName, type, optional]) => ({
|
|
22
|
+
name: memberName,
|
|
23
|
+
type,
|
|
24
|
+
optional: optional ?? false,
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('classifyRequestChange', () => {
|
|
30
|
+
test('no members changed → unchanged', () => {
|
|
31
|
+
const before = [shape('R', [['path', 'string']])];
|
|
32
|
+
expect(classifyRequestChange(before, structuredClone(before))).toEqual({ kind: 'unchanged' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a removed member is breaking', () => {
|
|
36
|
+
const before = [
|
|
37
|
+
shape('R', [
|
|
38
|
+
['path', 'string'],
|
|
39
|
+
['sourceDir', 'string'],
|
|
40
|
+
]),
|
|
41
|
+
];
|
|
42
|
+
const after = [shape('R', [['path', 'string']])];
|
|
43
|
+
const result = classifyRequestChange(before, after);
|
|
44
|
+
expect(result).toEqual({ kind: 'breaking', reasons: ['R.sourceDir was removed'] });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('optional → required is breaking', () => {
|
|
48
|
+
const before = [shape('R', [['hostname', 'string', true]])];
|
|
49
|
+
const after = [shape('R', [['hostname', 'string']])];
|
|
50
|
+
expect(classifyRequestChange(before, after)).toEqual({
|
|
51
|
+
kind: 'breaking',
|
|
52
|
+
reasons: ['R.hostname went optional → required'],
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a type change is breaking (the diff cannot tell widened from narrowed)', () => {
|
|
57
|
+
const before = [shape('R', [['port', 'number']])];
|
|
58
|
+
const after = [shape('R', [['port', 'number | string']])];
|
|
59
|
+
expect(classifyRequestChange(before, after).kind).toBe('breaking');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a new REQUIRED member is breaking', () => {
|
|
63
|
+
const before = [shape('R', [['path', 'string']])];
|
|
64
|
+
const after = [
|
|
65
|
+
shape('R', [
|
|
66
|
+
['path', 'string'],
|
|
67
|
+
['digest', 'string'],
|
|
68
|
+
]),
|
|
69
|
+
];
|
|
70
|
+
expect(classifyRequestChange(before, after).kind).toBe('breaking');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a new optional member is additive', () => {
|
|
74
|
+
const before = [shape('R', [['path', 'string']])];
|
|
75
|
+
const after = [
|
|
76
|
+
shape('R', [
|
|
77
|
+
['path', 'string'],
|
|
78
|
+
['spaFallback', 'boolean', true],
|
|
79
|
+
]),
|
|
80
|
+
];
|
|
81
|
+
const result = classifyRequestChange(before, after);
|
|
82
|
+
expect(result.kind).toBe('additive');
|
|
83
|
+
if (result.kind === 'additive') expect(result.note).toContain('spaFallback');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('required → optional is additive', () => {
|
|
87
|
+
const before = [shape('R', [['hostname', 'string']])];
|
|
88
|
+
const after = [shape('R', [['hostname', 'string', true]])];
|
|
89
|
+
expect(classifyRequestChange(before, after).kind).toBe('additive');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a type leaving the reachable set is an interface change, not a request break', () => {
|
|
93
|
+
const before = [shape('Old', [['path', 'string']]), shape('New', [['path', 'string']])];
|
|
94
|
+
const after = [shape('New', [['path', 'string']])];
|
|
95
|
+
expect(classifyRequestChange(before, after)).toEqual({ kind: 'unchanged' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('public_web resolves its real request types, sourceDir no longer among them', () => {
|
|
99
|
+
const publish = requestTypes('public_web').find((t) => t.name === 'PublishStaticSiteRequest');
|
|
100
|
+
expect(publish).toBeDefined();
|
|
101
|
+
const names = publish?.members.map((m) => m.name) ?? [];
|
|
102
|
+
expect(names).toContain('path');
|
|
103
|
+
expect(names).not.toContain('sourceDir');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
15
15
|
import { join, resolve } from 'node:path';
|
|
16
16
|
import type { CapabilityTableDeclaration, CapabilityTables } from '@celilo/capabilities';
|
|
17
17
|
import ts from 'typescript';
|
|
@@ -95,6 +95,216 @@ function printedInterface(file: string, typeName: string): string {
|
|
|
95
95
|
throw new Error(`${typeName} not found in ${file}`);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// --- REQUEST / RESULT TYPES -------------------------------------------------
|
|
99
|
+
//
|
|
100
|
+
// The interface hash above references its request and result types by NAME
|
|
101
|
+
// only, so an edit to `PublishStaticSiteRequest` never moved a hash. That is
|
|
102
|
+
// the exact hole celilo#1361 fell through: `sourceDir` was removed from three
|
|
103
|
+
// request types, no contract version moved, and every new consumer build died
|
|
104
|
+
// against the deployed providers. Everything below resolves those types and
|
|
105
|
+
// hashes their members, so a request-contract change trips the gate like any
|
|
106
|
+
// interface change — and the recorded member text lets the regenerate script
|
|
107
|
+
// tell a breaking member diff (bump the MAJOR) from an additive one (minor).
|
|
108
|
+
|
|
109
|
+
interface DeclaredType {
|
|
110
|
+
file: string;
|
|
111
|
+
decl: ts.InterfaceDeclaration | ts.TypeAliasDeclaration;
|
|
112
|
+
source: ts.SourceFile;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** All interface and type-alias declarations in one capabilities src file. */
|
|
116
|
+
function declaredTypes(file: string): Map<string, DeclaredType> {
|
|
117
|
+
const out = new Map<string, DeclaredType>();
|
|
118
|
+
if (!existsSync(file)) return out;
|
|
119
|
+
const source = parse(file);
|
|
120
|
+
for (const statement of source.statements) {
|
|
121
|
+
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
|
|
122
|
+
out.set(statement.name.text, { file, decl: statement, source });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Named relative imports of one file: local name → the file it came from. */
|
|
129
|
+
function importsOf(file: string): Map<string, string> {
|
|
130
|
+
const out = new Map<string, string>();
|
|
131
|
+
if (!existsSync(file)) return out;
|
|
132
|
+
const source = parse(file);
|
|
133
|
+
for (const statement of source.statements) {
|
|
134
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
135
|
+
const specifier = (statement.moduleSpecifier as ts.StringLiteral).text;
|
|
136
|
+
if (!specifier.startsWith('./')) continue;
|
|
137
|
+
const target = join(CAPABILITIES_SRC, `${specifier.replace('./', '')}.ts`);
|
|
138
|
+
const bindings = statement.importClause?.namedBindings;
|
|
139
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
140
|
+
for (const element of bindings.elements) {
|
|
141
|
+
out.set(element.name.text, target);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Type-reference identifiers reachable from one declaration. */
|
|
148
|
+
function referencedTypeNames(decl: ts.Declaration): Set<string> {
|
|
149
|
+
const out = new Set<string>();
|
|
150
|
+
const walk = (node: ts.Node): void => {
|
|
151
|
+
if (ts.isTypeReferenceNode(node)) {
|
|
152
|
+
if (ts.isIdentifier(node.typeName)) out.add(node.typeName.text);
|
|
153
|
+
} else if (ts.isExpressionWithTypeArguments(node)) {
|
|
154
|
+
if (ts.isIdentifier(node.expression)) out.add(node.expression.text);
|
|
155
|
+
}
|
|
156
|
+
ts.forEachChild(node, (child) => walk(child));
|
|
157
|
+
};
|
|
158
|
+
walk(decl);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const shapePrinter = ts.createPrinter({ removeComments: true });
|
|
163
|
+
|
|
164
|
+
/** One member of a request type, in the form the baseline records and diffs. */
|
|
165
|
+
export interface RequestMemberShape {
|
|
166
|
+
name: string;
|
|
167
|
+
optional: boolean;
|
|
168
|
+
/** Printed member type, comments stripped. */
|
|
169
|
+
type: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One request or result type a capability's interface can reach. */
|
|
173
|
+
export interface RequestTypeShape {
|
|
174
|
+
name: string;
|
|
175
|
+
/** 'alias' types hash as one synthetic `_self` member. */
|
|
176
|
+
kind: 'interface' | 'alias';
|
|
177
|
+
members: RequestMemberShape[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function membersOfDecl(decl: DeclaredType): RequestMemberShape[] {
|
|
181
|
+
if (ts.isTypeAliasDeclaration(decl.decl)) {
|
|
182
|
+
return [
|
|
183
|
+
{
|
|
184
|
+
name: '_self',
|
|
185
|
+
optional: false,
|
|
186
|
+
type: shapePrinter.printNode(ts.EmitHint.Unspecified, decl.decl.type, decl.source),
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
const members: RequestMemberShape[] = [];
|
|
191
|
+
for (const member of decl.decl.members) {
|
|
192
|
+
if (!member.name) continue;
|
|
193
|
+
const typeNode =
|
|
194
|
+
ts.isPropertySignature(member) || ts.isMethodSignature(member) ? member.type : undefined;
|
|
195
|
+
members.push({
|
|
196
|
+
name: member.name.getText(decl.source),
|
|
197
|
+
optional: Boolean(member.questionToken),
|
|
198
|
+
type: typeNode
|
|
199
|
+
? shapePrinter.printNode(ts.EmitHint.Unspecified, typeNode, decl.source)
|
|
200
|
+
: shapePrinter.printNode(ts.EmitHint.Unspecified, member, decl.source),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return members.sort((a, b) => a.name.localeCompare(b.name));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const declsCache = new Map<string, Map<string, DeclaredType>>();
|
|
207
|
+
|
|
208
|
+
function declsOf(file: string): Map<string, DeclaredType> {
|
|
209
|
+
let cached = declsCache.get(file);
|
|
210
|
+
if (!cached) {
|
|
211
|
+
cached = declaredTypes(file);
|
|
212
|
+
declsCache.set(file, cached);
|
|
213
|
+
}
|
|
214
|
+
return cached;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function allSrcFiles(): string[] {
|
|
218
|
+
return readdirSync(CAPABILITIES_SRC)
|
|
219
|
+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
|
|
220
|
+
.map((name) => join(CAPABILITIES_SRC, name));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Resolve a referenced type name from the file that references it: the file's
|
|
225
|
+
* own declarations first, then its named imports, then a unique export
|
|
226
|
+
* anywhere in packages/capabilities/src. An unresolved name is not contract
|
|
227
|
+
* surface this repo owns (primitives, DOM types) and is ignored; an ambiguous
|
|
228
|
+
* one would hash the wrong declaration, so it refuses rather than guessing.
|
|
229
|
+
*/
|
|
230
|
+
function resolveName(name: string, fromFile: string): DeclaredType | undefined {
|
|
231
|
+
const local = declsOf(fromFile).get(name);
|
|
232
|
+
if (local) return local;
|
|
233
|
+
const imported = importsOf(fromFile).get(name);
|
|
234
|
+
if (imported) {
|
|
235
|
+
const found = declsOf(imported).get(name);
|
|
236
|
+
if (found) return found;
|
|
237
|
+
}
|
|
238
|
+
const hits: DeclaredType[] = [];
|
|
239
|
+
for (const file of allSrcFiles()) {
|
|
240
|
+
const found = declsOf(file).get(name);
|
|
241
|
+
const exported = found?.decl.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
242
|
+
if (found && exported) hits.push(found);
|
|
243
|
+
}
|
|
244
|
+
if (hits.length === 1) return hits[0];
|
|
245
|
+
if (hits.length > 1) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`capability-shape: type name '${name}' is declared in ${hits.length} files (${hits.map((h) => h.file).join(', ')}), so its request shape cannot be resolved unambiguously. Rename one of them.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Every request/result type reachable from a capability's interface. Names
|
|
255
|
+
* that resolve to nothing in packages/capabilities/src (primitives, DOM
|
|
256
|
+
* types) are ignored — they are not contract surface this repo owns.
|
|
257
|
+
*/
|
|
258
|
+
export function requestTypes(capability: string): RequestTypeShape[] {
|
|
259
|
+
const subject = capabilitySubjects().get(capability);
|
|
260
|
+
if (!subject) throw new Error(`${capability} is not in CapabilityRegistry`);
|
|
261
|
+
|
|
262
|
+
const start = parse(subject.file);
|
|
263
|
+
let iface: ts.InterfaceDeclaration | undefined;
|
|
264
|
+
for (const statement of start.statements) {
|
|
265
|
+
if (ts.isInterfaceDeclaration(statement) && statement.name.text === subject.typeName) {
|
|
266
|
+
iface = statement;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!iface) throw new Error(`${subject.typeName} not found in ${subject.file}`);
|
|
270
|
+
|
|
271
|
+
const visited = new Set<string>();
|
|
272
|
+
const shapes: RequestTypeShape[] = [];
|
|
273
|
+
const queue: Array<{ name: string; fromFile: string }> = [
|
|
274
|
+
{ name: subject.typeName, fromFile: subject.file },
|
|
275
|
+
];
|
|
276
|
+
while (queue.length > 0) {
|
|
277
|
+
const { name, fromFile } = queue.pop() as { name: string; fromFile: string };
|
|
278
|
+
if (visited.has(name)) continue;
|
|
279
|
+
visited.add(name);
|
|
280
|
+
const decl = resolveName(name, fromFile);
|
|
281
|
+
if (!decl) continue;
|
|
282
|
+
const members = membersOfDecl(decl);
|
|
283
|
+
shapes.push({
|
|
284
|
+
name,
|
|
285
|
+
kind: ts.isTypeAliasDeclaration(decl.decl) ? 'alias' : 'interface',
|
|
286
|
+
members,
|
|
287
|
+
});
|
|
288
|
+
const refs = referencedTypeNames(decl.decl);
|
|
289
|
+
for (const ref of refs) {
|
|
290
|
+
if (visited.has(ref)) continue;
|
|
291
|
+
const resolved = resolveName(ref, decl.file);
|
|
292
|
+
if (resolved) queue.push({ name: ref, fromFile: resolved.file });
|
|
293
|
+
else visited.add(ref);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return shapes.sort((a, b) => a.name.localeCompare(b.name));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The canonical request-shape text: the baseline's `requests` field, the diff
|
|
301
|
+
* input for the regenerate guard, and part of the shape hash. Sorted so member
|
|
302
|
+
* or type reordering is not a shape change.
|
|
303
|
+
*/
|
|
304
|
+
export function requestsKey(capability: string): string {
|
|
305
|
+
return canonical(requestTypes(capability));
|
|
306
|
+
}
|
|
307
|
+
|
|
98
308
|
/** Every `* satisfies CapabilityTables` const exported from a capability's module. */
|
|
99
309
|
export async function tablesOf(file: string): Promise<CapabilityTables> {
|
|
100
310
|
const loaded = (await import(file)) as Record<string, unknown>;
|
|
@@ -125,5 +335,76 @@ export async function shapeHash(capability: string): Promise<string> {
|
|
|
125
335
|
if (!subject) throw new Error(`${capability} is not in CapabilityRegistry`);
|
|
126
336
|
const iface = printedInterface(subject.file, subject.typeName);
|
|
127
337
|
const tables = canonical(await tablesOf(subject.file));
|
|
128
|
-
|
|
338
|
+
// Requests fold into the SAME hash as the interface: a request-contract
|
|
339
|
+
// change must move the version exactly like an interface change, and the
|
|
340
|
+
// regenerate script records `requests` beside the hash so it can tell the
|
|
341
|
+
// breaking member diff from an additive one (see classifyRequestChange).
|
|
342
|
+
const requests = requestsKey(capability);
|
|
343
|
+
return createHash('sha256')
|
|
344
|
+
.update(`${iface}\n--tables--\n${tables}\n--requests--\n${requests}`)
|
|
345
|
+
.digest('hex');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** How a capability's request-shape text changed from one recording to the next. */
|
|
349
|
+
export type RequestChangeClassification =
|
|
350
|
+
| { kind: 'unchanged' }
|
|
351
|
+
| { kind: 'additive'; note: string }
|
|
352
|
+
| { kind: 'breaking'; reasons: string[] };
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Classify a request-shape change between a baseline recording and the
|
|
356
|
+
* current code. ONLY these are breaking (the bump rule in
|
|
357
|
+
* capability-contract.ts):
|
|
358
|
+
* - a member was removed (old providers reject the request; old consumers
|
|
359
|
+
* stop compiling)
|
|
360
|
+
* - a member went optional → required (consumers that omitted it break)
|
|
361
|
+
* - a member's type changed (cannot tell widened from narrowed here, so
|
|
362
|
+
* treated as breaking; pass --non-breaking to the regenerate script
|
|
363
|
+
* when review agrees the widening is safe)
|
|
364
|
+
* Everything else — adding an optional member, relaxing required → optional —
|
|
365
|
+
* is additive and takes a minor per the bump rules.
|
|
366
|
+
*/
|
|
367
|
+
export function classifyRequestChange(
|
|
368
|
+
before: RequestTypeShape[],
|
|
369
|
+
after: RequestTypeShape[],
|
|
370
|
+
): RequestChangeClassification {
|
|
371
|
+
const beforeByName = new Map(before.map((t) => [t.name, t]));
|
|
372
|
+
const afterByName = new Map(after.map((t) => [t.name, t]));
|
|
373
|
+
const reasons: string[] = [];
|
|
374
|
+
const additions: string[] = [];
|
|
375
|
+
for (const [name, oldType] of beforeByName) {
|
|
376
|
+
const newType = afterByName.get(name);
|
|
377
|
+
if (!newType) {
|
|
378
|
+
// A type leaving the reachable set is an interface change, which the
|
|
379
|
+
// interface hash and its own bump cover. Not a request-level break.
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const oldMembers = new Map(oldType.members.map((m) => [m.name, m]));
|
|
383
|
+
const newMembers = new Map(newType.members.map((m) => [m.name, m]));
|
|
384
|
+
for (const [memberName, oldMember] of oldMembers) {
|
|
385
|
+
const newMember = newMembers.get(memberName);
|
|
386
|
+
if (!newMember) {
|
|
387
|
+
reasons.push(`${name}.${memberName} was removed`);
|
|
388
|
+
} else {
|
|
389
|
+
if (!oldMember.optional && newMember.optional) {
|
|
390
|
+
additions.push(`${name}.${memberName} became optional`);
|
|
391
|
+
} else if (oldMember.optional && !newMember.optional) {
|
|
392
|
+
reasons.push(`${name}.${memberName} went optional → required`);
|
|
393
|
+
} else if (oldMember.type !== newMember.type) {
|
|
394
|
+
reasons.push(
|
|
395
|
+
`${name}.${memberName} changed type (${oldMember.type} → ${newMember.type})`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
for (const newMember of newMembers.values()) {
|
|
401
|
+
if (!oldMembers.has(newMember.name)) {
|
|
402
|
+
if (newMember.optional) additions.push(`${name}.${newMember.name} added (optional)`);
|
|
403
|
+
else reasons.push(`${name}.${newMember.name} added as REQUIRED`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (reasons.length > 0) return { kind: 'breaking', reasons };
|
|
408
|
+
if (additions.length > 0) return { kind: 'additive', note: additions.join('; ') };
|
|
409
|
+
return { kind: 'unchanged' };
|
|
129
410
|
}
|
|
@@ -99,6 +99,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
99
99
|
count: 4,
|
|
100
100
|
why: 'PERMANENT — a provider VIEW, not a module-provided capability. The name was registered so it stops being invisible to audits (celilo#1007); these references predate that and are framework plumbing, not core branching on a provider',
|
|
101
101
|
},
|
|
102
|
+
{
|
|
103
|
+
file: 'apps/celilo/src/hooks/capability-loader.ts',
|
|
104
|
+
capability: 'provider_converge',
|
|
105
|
+
count: 2,
|
|
106
|
+
why: 'PERMANENT — a provider VIEW, not a module-provided capability. Core injects the generic converge every provider renders through; naming it is framework plumbing, not core branching on a provider (providers-converge-declared-state)',
|
|
107
|
+
},
|
|
102
108
|
{
|
|
103
109
|
file: 'apps/celilo/src/db/schema.ts',
|
|
104
110
|
capability: 'web_routes',
|
|
@@ -129,6 +135,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
129
135
|
count: 1,
|
|
130
136
|
why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
|
|
131
137
|
},
|
|
138
|
+
{
|
|
139
|
+
file: 'packages/capabilities/src/capability-contract.ts',
|
|
140
|
+
capability: 'provider_converge',
|
|
141
|
+
count: 1,
|
|
142
|
+
why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
|
|
143
|
+
},
|
|
132
144
|
{
|
|
133
145
|
file: 'apps/celilo/src/capabilities/well-known.ts',
|
|
134
146
|
capability: 'dhcp_server',
|
|
@@ -466,6 +478,11 @@ export const SERVICE_FILENAME_BASELINE: readonly ServiceFilenameRow[] = [
|
|
|
466
478
|
capability: 'cross_module_read',
|
|
467
479
|
why: 'PERMANENT — cross_module_read is a framework privilege, not a module capability',
|
|
468
480
|
},
|
|
481
|
+
{
|
|
482
|
+
file: 'apps/celilo/src/services/provider-converge.ts',
|
|
483
|
+
capability: 'provider_converge',
|
|
484
|
+
why: 'PERMANENT — provider_converge is a framework-granted view, not a module capability, and this service IS the generic mechanism it grants: it carries what a provider rendered and runs that provider own role. Naming it for one provider is what the scan exists to catch; naming it for the mechanism is the fix the scan asks for (providers-converge-declared-state)',
|
|
485
|
+
},
|
|
469
486
|
{
|
|
470
487
|
file: 'apps/celilo/src/services/dns-internal-records.ts',
|
|
471
488
|
capability: 'dns_internal',
|
|
@@ -228,6 +228,71 @@ describe('RegistryClient.publish — Cargo binary protocol', () => {
|
|
|
228
228
|
});
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
// ── RegistryClient.publish — failure bodies (ce-sxb3, celilo#1370) ───────────
|
|
232
|
+
|
|
233
|
+
describe('RegistryClient.publish — failure bodies (ce-sxb3)', () => {
|
|
234
|
+
let tmpFile: string;
|
|
235
|
+
let server: ReturnType<typeof Bun.serve> | undefined;
|
|
236
|
+
|
|
237
|
+
beforeEach(async () => {
|
|
238
|
+
tmpFile = `/tmp/test-publish-ce-sxb3-${Date.now()}.netapp`;
|
|
239
|
+
await Bun.write(tmpFile, 'fake netapp content');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
afterEach(async () => {
|
|
243
|
+
server?.stop(true);
|
|
244
|
+
if (await Bun.file(tmpFile).exists()) await Bun.write(tmpFile, '');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
function startStubServer(body: string | null, status: number, contentType?: string): string {
|
|
248
|
+
server = Bun.serve({
|
|
249
|
+
port: 0,
|
|
250
|
+
fetch: () =>
|
|
251
|
+
new Response(body, {
|
|
252
|
+
status,
|
|
253
|
+
...(contentType ? { headers: { 'Content-Type': contentType } } : {}),
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
return `http://localhost:${server.port}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
test('JSON error body reports the refusal detail and the HTTP status', async () => {
|
|
260
|
+
const client = new RegistryClient(
|
|
261
|
+
startStubServer(
|
|
262
|
+
JSON.stringify({ errors: [{ detail: 'version already exists' }] }),
|
|
263
|
+
409,
|
|
264
|
+
'application/json',
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
await expect(
|
|
268
|
+
client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }),
|
|
269
|
+
).rejects.toThrow('version already exists (HTTP 409)');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('JSON null body names the HTTP status instead of crashing on err.errors', async () => {
|
|
273
|
+
const client = new RegistryClient(startStubServer('null', 500, 'application/json'));
|
|
274
|
+
await expect(
|
|
275
|
+
client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }),
|
|
276
|
+
).rejects.toThrow('HTTP 500');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('non-JSON body names the HTTP status', async () => {
|
|
280
|
+
const client = new RegistryClient(
|
|
281
|
+
startStubServer('<html>bad gateway</html>', 502, 'text/html'),
|
|
282
|
+
);
|
|
283
|
+
await expect(
|
|
284
|
+
client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }),
|
|
285
|
+
).rejects.toThrow('HTTP 502');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('empty body names the HTTP status', async () => {
|
|
289
|
+
const client = new RegistryClient(startStubServer(null, 403));
|
|
290
|
+
await expect(
|
|
291
|
+
client.publish({ name: 'mod', version: '1.0.0+1', netappPath: tmpFile, token: 'tok' }),
|
|
292
|
+
).rejects.toThrow('HTTP 403');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
231
296
|
// ── module-owner admin endpoints (ce-1ch) ─────────────────────────────────────
|
|
232
297
|
|
|
233
298
|
describe('RegistryClient owner methods', () => {
|
package/src/registry/client.ts
CHANGED
|
@@ -305,10 +305,7 @@ export class RegistryClient {
|
|
|
305
305
|
});
|
|
306
306
|
|
|
307
307
|
if (!resp.ok) {
|
|
308
|
-
|
|
309
|
-
errors: Array<{ detail: string }>;
|
|
310
|
-
};
|
|
311
|
-
throw new Error(err.errors?.[0]?.detail ?? `HTTP ${resp.status}`);
|
|
308
|
+
throw new Error(await this.errorDetail(resp));
|
|
312
309
|
}
|
|
313
310
|
|
|
314
311
|
return resp.json() as Promise<{ ok: boolean; name: string; vers: string }>;
|
|
@@ -364,10 +361,13 @@ export class RegistryClient {
|
|
|
364
361
|
|
|
365
362
|
/** Extract `errors[0].detail` from a Cargo-protocol error body, or fall back to the status. */
|
|
366
363
|
private async errorDetail(resp: Response): Promise<string> {
|
|
367
|
-
|
|
364
|
+
// The body can be a JSON error object, JSON null, prose, or nothing. Only
|
|
365
|
+
// the first carries a detail; everything else falls back to the status.
|
|
366
|
+
const body = (await resp.json().catch(() => null)) as {
|
|
368
367
|
errors?: Array<{ detail: string }>;
|
|
369
|
-
};
|
|
370
|
-
|
|
368
|
+
} | null;
|
|
369
|
+
const detail = body?.errors?.[0]?.detail;
|
|
370
|
+
return detail ? `${detail} (HTTP ${resp.status})` : `HTTP ${resp.status}`;
|
|
371
371
|
}
|
|
372
372
|
|
|
373
373
|
private async get(url: string): Promise<Response> {
|
|
@@ -7,13 +7,20 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
7
7
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import { eq } from 'drizzle-orm';
|
|
10
|
+
import { and, eq } from 'drizzle-orm';
|
|
11
11
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
12
12
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
|
13
13
|
import type { DbClient } from '../db/client';
|
|
14
14
|
import * as schema from '../db/schema';
|
|
15
|
-
import { systemSecrets } from '../db/schema';
|
|
16
|
-
import {
|
|
15
|
+
import { secrets, systemSecrets } from '../db/schema';
|
|
16
|
+
import { decryptSecret } from './encryption';
|
|
17
|
+
import { getOrCreateMasterKey } from './master-key';
|
|
18
|
+
import {
|
|
19
|
+
getSystemSecret,
|
|
20
|
+
recordProviderSecret,
|
|
21
|
+
storeSystemSecret,
|
|
22
|
+
systemSecretExists,
|
|
23
|
+
} from './storage';
|
|
17
24
|
|
|
18
25
|
describe('secret storage', () => {
|
|
19
26
|
let testDir: string;
|
|
@@ -112,4 +119,63 @@ describe('secret storage', () => {
|
|
|
112
119
|
const decrypted = await getSystemSecret('test.long', testDb);
|
|
113
120
|
expect(decrypted).toBe(longSecret);
|
|
114
121
|
});
|
|
122
|
+
|
|
123
|
+
describe('recordProviderSecret (hook-owned-state D3)', () => {
|
|
124
|
+
const providerRow = (moduleId: string, name: string) =>
|
|
125
|
+
testDb
|
|
126
|
+
.select()
|
|
127
|
+
.from(secrets)
|
|
128
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
129
|
+
.get();
|
|
130
|
+
|
|
131
|
+
const decryptRow = async (moduleId: string, name: string) => {
|
|
132
|
+
const row = providerRow(moduleId, name);
|
|
133
|
+
expect(row).toBeDefined();
|
|
134
|
+
if (!row) throw new Error('provider secret row missing');
|
|
135
|
+
const mk = await getOrCreateMasterKey();
|
|
136
|
+
return decryptSecret(
|
|
137
|
+
{ encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag },
|
|
138
|
+
mk,
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
test('records an encrypted secret onto the provider module', async () => {
|
|
143
|
+
await recordProviderSecret(testDb, 'some-provider', 'api_key', 'minted-value');
|
|
144
|
+
|
|
145
|
+
expect(await decryptRow('some-provider', 'api_key')).toBe('minted-value');
|
|
146
|
+
// Encrypted on disk, never the plaintext.
|
|
147
|
+
const row = providerRow('some-provider', 'api_key');
|
|
148
|
+
expect(row && row.encryptedValue !== 'minted-value').toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('upserts on (moduleId, name) instead of duplicating', async () => {
|
|
152
|
+
await recordProviderSecret(testDb, 'some-provider', 'api_key', 'first');
|
|
153
|
+
await recordProviderSecret(testDb, 'some-provider', 'api_key', 'second');
|
|
154
|
+
|
|
155
|
+
const rows = testDb.select().from(secrets).all();
|
|
156
|
+
expect(rows).toHaveLength(1);
|
|
157
|
+
expect(await decryptRow('some-provider', 'api_key')).toBe('second');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('keeps two providers with the same secret name distinct', async () => {
|
|
161
|
+
await recordProviderSecret(testDb, 'provider-a', 'api_key', 'value-a');
|
|
162
|
+
await recordProviderSecret(testDb, 'provider-b', 'api_key', 'value-b');
|
|
163
|
+
|
|
164
|
+
const rows = testDb.select().from(secrets).all();
|
|
165
|
+
expect(rows).toHaveLength(2);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('refuses an empty module id, name, or value', async () => {
|
|
169
|
+
await expect(recordProviderSecret(testDb, '', 'api_key', 'v')).rejects.toThrow(
|
|
170
|
+
'providerModuleId is required',
|
|
171
|
+
);
|
|
172
|
+
await expect(recordProviderSecret(testDb, 'p', ' ', 'v')).rejects.toThrow(
|
|
173
|
+
'name is required',
|
|
174
|
+
);
|
|
175
|
+
await expect(recordProviderSecret(testDb, 'p', 'api_key', '')).rejects.toThrow(
|
|
176
|
+
'value is required',
|
|
177
|
+
);
|
|
178
|
+
expect(testDb.select().from(secrets).all()).toHaveLength(0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
115
181
|
});
|