@serfab/cadre-host 0.7.1 → 0.8.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/README.md +296 -101
- package/dist/auth/trust-circle.d.ts +44 -6
- package/dist/auth/trust-circle.js +115 -58
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +4 -1
- package/dist/auth/types.js.map +1 -1
- package/dist/authority/authority-node-client.d.ts +77 -0
- package/dist/authority/authority-node-client.js +143 -0
- package/dist/authority/authority-node-client.js.map +1 -0
- package/dist/authority/index.d.ts +11 -0
- package/dist/authority/index.js +11 -0
- package/dist/authority/index.js.map +1 -0
- package/dist/bin/host.js +210 -97
- package/dist/bin/host.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/installer/browser.js +16 -17
- package/dist/installer/browser.js.map +1 -1
- package/dist/installer/config.d.ts +25 -0
- package/dist/installer/config.js +1 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +5 -2
- package/dist/installer/index.js +14 -10
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/service-host/launchd.js +1 -1
- package/dist/installer/service-host/launchd.js.map +1 -1
- package/dist/installer/service-host/systemd.js +1 -1
- package/dist/installer/service-host/systemd.js.map +1 -1
- package/dist/nat/index.d.ts +1 -1
- package/dist/nat/nat-service.d.ts +45 -3
- package/dist/nat/nat-service.js +116 -3
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/secrets/index.d.ts +7 -0
- package/dist/nat/secrets/index.js +28 -2
- package/dist/nat/secrets/index.js.map +1 -1
- package/dist/nat/types.d.ts +3 -1
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +60 -1
- package/dist/orchestrator/host-process-orchestrator.js +246 -39
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -2
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +6 -5
- package/dist/orchestrator/state-store.js +10 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +45 -10
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/push/index.d.ts +67 -0
- package/dist/push/index.js +101 -0
- package/dist/push/index.js.map +1 -0
- package/dist/server/error-handler.d.ts +2 -0
- package/dist/server/error-handler.js +4 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +4 -3
- package/dist/server/routes/nodes.js +38 -24
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/static.d.ts +12 -0
- package/dist/server/static.js +24 -7
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BlMXQPSw.js +14 -0
- package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/update/apply.d.ts +23 -1
- package/dist/update/apply.js +46 -4
- package/dist/update/apply.js.map +1 -1
- package/dist/update/index.d.ts +14 -3
- package/dist/update/index.js +35 -2
- package/dist/update/index.js.map +1 -1
- package/dist/update/manifest.d.ts +28 -9
- package/dist/update/manifest.js +56 -29
- package/dist/update/manifest.js.map +1 -1
- package/dist/update/release-key.d.ts +18 -7
- package/dist/update/release-key.js +28 -15
- package/dist/update/release-key.js.map +1 -1
- package/dist/update/sign.d.ts +46 -0
- package/dist/update/sign.js +53 -0
- package/dist/update/sign.js.map +1 -0
- package/package.json +9 -4
- package/service/README.md +95 -86
- package/src/__tests__/cli-invite.smoke.test.ts +6 -1
- package/src/__tests__/cli-nat.smoke.test.ts +6 -1
- package/src/__tests__/cli.smoke.test.ts +6 -1
- package/src/__tests__/host-authority.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-authority.test.ts +198 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +40 -21
- package/src/auth/__tests__/trust-circle.test.ts +90 -23
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/authority/__tests__/authority-node-client.test.ts +166 -0
- package/src/authority/authority-node-client.ts +199 -0
- package/src/authority/index.ts +12 -0
- package/src/bin/host.ts +1038 -905
- package/src/index.ts +34 -3
- package/src/installer/__tests__/installer.smoke.test.ts +64 -0
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +27 -1
- package/src/installer/index.ts +18 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/nat/__tests__/ddns-updater.test.ts +1 -1
- package/src/nat/__tests__/nat-service.test.ts +192 -0
- package/src/nat/__tests__/port-mapper.test.ts +1 -1
- package/src/nat/__tests__/secrets.test.ts +23 -1
- package/src/nat/index.ts +1 -1
- package/src/nat/nat-service.ts +131 -5
- package/src/nat/secrets/index.ts +32 -2
- package/src/nat/types.ts +3 -1
- package/src/orchestrator/host-process-orchestrator.ts +294 -54
- package/src/orchestrator/index.ts +9 -2
- package/src/orchestrator/state-store.ts +21 -3
- package/src/orchestrator/types.ts +48 -2
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +4 -4
- package/src/server/__tests__/nodes-route.test.ts +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +2 -2
- package/src/server/error-handler.ts +4 -0
- package/src/server/routes/nodes.ts +48 -26
- package/src/server/static.ts +24 -8
- package/src/update/__tests__/apply.test.ts +58 -1
- package/src/update/__tests__/manifest.test.ts +41 -6
- package/src/update/__tests__/release-key.test.ts +29 -0
- package/src/update/__tests__/sign.test.ts +78 -0
- package/src/update/__tests__/update-service.integration.test.ts +44 -9
- package/src/update/apply.ts +46 -4
- package/src/update/index.ts +41 -3
- package/src/update/manifest.ts +214 -172
- package/src/update/release-key.ts +30 -16
- package/src/update/sign.ts +74 -0
- package/dist/ui/assets/index-C9uChk2P.js +0 -14
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* `onStateChange` (forwarded by `createLocalUiServer.start`) — the route
|
|
7
7
|
* handlers don't re-publish.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* start/restart are real for the admin's authority node (re-spawned from the
|
|
10
|
+
* persisted `AuthoritySpawnConfig`). Generic per-member node spawn from saved
|
|
11
|
+
* config is out of scope and returns `not_implemented`. Stop works for any
|
|
12
|
+
* running node.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
@@ -82,35 +83,56 @@ export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptio
|
|
|
82
83
|
return { ok: true };
|
|
83
84
|
});
|
|
84
85
|
|
|
85
|
-
// start / restart: the
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// follow-up ticket. Until then these endpoints stub out cleanly so the
|
|
90
|
-
// SPA gets a predictable error rather than a route-not-found 404.
|
|
86
|
+
// start / restart: real for the admin's authority node (spawned from the
|
|
87
|
+
// persisted AuthoritySpawnConfig). Generic per-member node spawn from saved
|
|
88
|
+
// config is out of scope — those ids return a clear not_implemented; unknown
|
|
89
|
+
// ids 404.
|
|
91
90
|
app.post<{ Params: { id: string } }>('/api/nodes/:id/start', async (request, reply) => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
const { id } = request.params;
|
|
92
|
+
if (orchestrator.isAuthorityNode(id)) {
|
|
93
|
+
if (!orchestrator.hasAuthorityConfig()) {
|
|
94
|
+
return notImplemented(reply, `start ${id}: authority node has no saved spawn config.`);
|
|
95
|
+
}
|
|
96
|
+
const node = await orchestrator.ensureAuthorityNode();
|
|
97
|
+
return { ok: true, data: { node } };
|
|
98
|
+
}
|
|
99
|
+
return startRestartFallback(reply, orchestrator, id, 'start');
|
|
101
100
|
});
|
|
102
101
|
|
|
103
102
|
app.post<{ Params: { id: string } }>('/api/nodes/:id/restart', async (request, reply) => {
|
|
104
|
-
|
|
103
|
+
const { id } = request.params;
|
|
104
|
+
if (orchestrator.isAuthorityNode(id)) {
|
|
105
|
+
if (!orchestrator.hasAuthorityConfig()) {
|
|
106
|
+
return notImplemented(reply, `restart ${id}: authority node has no saved spawn config.`);
|
|
107
|
+
}
|
|
108
|
+
const node = await orchestrator.restartAuthorityNode();
|
|
109
|
+
return { ok: true, data: { node } };
|
|
110
|
+
}
|
|
111
|
+
return startRestartFallback(reply, orchestrator, id, 'restart');
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 404 for unknown ids; 501 not_implemented for known non-authority nodes. */
|
|
116
|
+
function startRestartFallback(
|
|
117
|
+
reply: import('fastify').FastifyReply,
|
|
118
|
+
orchestrator: HostProcessOrchestrator,
|
|
119
|
+
id: string,
|
|
120
|
+
verb: string,
|
|
121
|
+
) {
|
|
122
|
+
if (!orchestrator.getNode(id)) {
|
|
123
|
+
return reply.code(404).send({
|
|
105
124
|
ok: false,
|
|
106
|
-
error: {
|
|
107
|
-
code: 'not_implemented',
|
|
108
|
-
message:
|
|
109
|
-
`restart ${request.params.id}: cadre-host v1 has no auto-spawn path. ` +
|
|
110
|
-
`Restart the cadre-host service to bring up persisted nodes.`,
|
|
111
|
-
},
|
|
125
|
+
error: { code: 'not_found', message: `Unknown node: ${id}` },
|
|
112
126
|
});
|
|
113
|
-
}
|
|
127
|
+
}
|
|
128
|
+
return notImplemented(
|
|
129
|
+
reply,
|
|
130
|
+
`${verb} ${id}: only the authority node is start/restart-able; generic node spawn is out of scope.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function notImplemented(reply: import('fastify').FastifyReply, message: string) {
|
|
135
|
+
return reply.code(501).send({ ok: false, error: { code: 'not_implemented', message } });
|
|
114
136
|
}
|
|
115
137
|
|
|
116
138
|
/**
|
package/src/server/static.ts
CHANGED
|
@@ -13,13 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
16
|
-
import { dirname, extname, join, normalize, resolve } from 'node:path';
|
|
16
|
+
import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
|
|
19
19
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
20
|
-
import debug from 'debug';
|
|
21
|
-
|
|
22
|
-
const log = debug('cadre:host:static');
|
|
23
20
|
|
|
24
21
|
const MIME_TYPES: Record<string, string> = {
|
|
25
22
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -118,12 +115,31 @@ function serveStatic(rootDir: string, request: FastifyRequest, reply: FastifyRep
|
|
|
118
115
|
});
|
|
119
116
|
}
|
|
120
117
|
|
|
121
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a request path to an absolute file path inside `rootDir`, or `null`
|
|
120
|
+
* if it would escape the root.
|
|
121
|
+
*
|
|
122
|
+
* Exported for direct unit testing. The containment check uses `path.relative`
|
|
123
|
+
* rather than a bare `startsWith` prefix test: the latter is the classic
|
|
124
|
+
* prefix-bypass anti-pattern, accepting sibling directories whose name shares
|
|
125
|
+
* `rootDir`'s basename as a prefix (e.g. `dist/ui-private` vs `dist/ui`).
|
|
126
|
+
* `relative` respects the path-separator boundary on both POSIX and Windows,
|
|
127
|
+
* and surfaces cross-drive escapes on Windows as an absolute result.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveSafe(rootDir: string, urlPath: string): string | null {
|
|
122
130
|
const cleaned = decodeSafe(urlPath);
|
|
123
131
|
if (cleaned === null) return null;
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
132
|
+
const root = normalize(rootDir);
|
|
133
|
+
const target = normalize(join(root, cleaned === '/' ? '' : cleaned));
|
|
134
|
+
// Containment: target must be rootDir itself or strictly beneath it.
|
|
135
|
+
// `relative(root, target)` is '' for root itself (accepted) and a descendant
|
|
136
|
+
// path with no leading '..' for in-root assets. Reject when it climbs out
|
|
137
|
+
// (exactly '..', or '..' + separator) or — cross-drive on Windows — is
|
|
138
|
+
// absolute. Guarding on '..' + separator rather than a bare '..' prefix avoids
|
|
139
|
+
// falsely rejecting legitimate in-root names that merely *begin* with '..'
|
|
140
|
+
// (e.g. a '..foo' asset), which would re-introduce a looser prefix check.
|
|
141
|
+
const rel = relative(root, target);
|
|
142
|
+
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return null;
|
|
127
143
|
return target;
|
|
128
144
|
}
|
|
129
145
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import { applyUpdate, type NpmExecutor, type NpmResult } from '../apply.js';
|
|
3
|
+
import { applyUpdate, buildNpmSpawnSpec, quoteWindowsArg, type NpmExecutor, type NpmResult } from '../apply.js';
|
|
4
4
|
import { UpdateErrorException } from '../types.js';
|
|
5
5
|
|
|
6
6
|
function recordingExecutor(responses: NpmResult[]): { exec: NpmExecutor; calls: string[][] } {
|
|
@@ -99,3 +99,60 @@ describe('applyUpdate', () => {
|
|
|
99
99
|
]);
|
|
100
100
|
});
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
describe('quoteWindowsArg', () => {
|
|
104
|
+
it('leaves safe args untouched', () => {
|
|
105
|
+
expect(quoteWindowsArg('install')).toBe('install');
|
|
106
|
+
expect(quoteWindowsArg('-g')).toBe('-g');
|
|
107
|
+
expect(quoteWindowsArg('@serfab/cadre-host@0.7.0')).toBe('@serfab/cadre-host@0.7.0');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('quotes args containing whitespace or shell metachars', () => {
|
|
111
|
+
expect(quoteWindowsArg('has space')).toBe('"has space"');
|
|
112
|
+
expect(quoteWindowsArg('with&')).toBe('"with&"');
|
|
113
|
+
expect(quoteWindowsArg('pipe|me')).toBe('"pipe|me"');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('escapes embedded quotes and trailing backslashes', () => {
|
|
117
|
+
expect(quoteWindowsArg('with"quote')).toBe('"with\\"quote"');
|
|
118
|
+
expect(quoteWindowsArg('trailing\\')).toBe('trailing\\');
|
|
119
|
+
expect(quoteWindowsArg('trail ing\\')).toBe('"trail ing\\\\"');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects control characters', () => {
|
|
123
|
+
expect(() => quoteWindowsArg('bad\nnewline')).toThrow(/control character/);
|
|
124
|
+
expect(() => quoteWindowsArg('bad\x00null')).toThrow(/control character/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('buildNpmSpawnSpec', () => {
|
|
129
|
+
it('builds a single-string command with empty args on Windows', () => {
|
|
130
|
+
const original = process.platform;
|
|
131
|
+
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
132
|
+
try {
|
|
133
|
+
const spec = buildNpmSpawnSpec(['install', '-g', '@serfab/cadre-host@0.7.0']);
|
|
134
|
+
expect(spec).toEqual({
|
|
135
|
+
command: 'npm install -g @serfab/cadre-host@0.7.0',
|
|
136
|
+
args: [],
|
|
137
|
+
shell: true,
|
|
138
|
+
});
|
|
139
|
+
} finally {
|
|
140
|
+
Object.defineProperty(process, 'platform', { value: original, configurable: true });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('passes args through verbatim on posix (no shell)', () => {
|
|
145
|
+
const original = process.platform;
|
|
146
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
147
|
+
try {
|
|
148
|
+
const spec = buildNpmSpawnSpec(['install', '-g', '@serfab/cadre-host@0.7.0']);
|
|
149
|
+
expect(spec).toEqual({
|
|
150
|
+
command: 'npm',
|
|
151
|
+
args: ['install', '-g', '@serfab/cadre-host@0.7.0'],
|
|
152
|
+
shell: false,
|
|
153
|
+
});
|
|
154
|
+
} finally {
|
|
155
|
+
Object.defineProperty(process, 'platform', { value: original, configurable: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
2
|
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
|
3
3
|
|
|
4
|
-
import { canonicalJson,
|
|
4
|
+
import { canonicalJson, signManifest, verifyManifest } from '../manifest.js';
|
|
5
5
|
import type { UpdateManifest } from '../types.js';
|
|
6
6
|
|
|
7
7
|
/** Generate a fresh Ed25519 keypair and surface the raw 32-byte public key. */
|
|
@@ -47,19 +47,19 @@ describe('verifyManifest', () => {
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
it('round-trips: sign + verify', () => {
|
|
50
|
-
const envelope =
|
|
50
|
+
const envelope = signManifest(sampleManifest, kp.privateKey);
|
|
51
51
|
const out = verifyManifest(envelope);
|
|
52
52
|
expect(out).toEqual(sampleManifest);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
it('rejects a mutated payload (signature_invalid)', () => {
|
|
56
|
-
const envelope =
|
|
56
|
+
const envelope = signManifest(sampleManifest, kp.privateKey);
|
|
57
57
|
const tampered = { ...envelope, manifest: { ...envelope.manifest, version: '99.0.0' } };
|
|
58
58
|
expect(() => verifyManifest(tampered)).toThrow(/signature/i);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it('rejects a mutated signature (signature_invalid)', () => {
|
|
62
|
-
const envelope =
|
|
62
|
+
const envelope = signManifest(sampleManifest, kp.privateKey);
|
|
63
63
|
const sigBytes = Buffer.from(envelope.sig.slice('ed25519:'.length), 'base64');
|
|
64
64
|
sigBytes[0] ^= 0xff;
|
|
65
65
|
const tampered = { ...envelope, sig: `ed25519:${sigBytes.toString('base64')}` };
|
|
@@ -67,7 +67,7 @@ describe('verifyManifest', () => {
|
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
it('rejects an envelope signed by a different key (signature_invalid)', () => {
|
|
70
|
-
const envelope =
|
|
70
|
+
const envelope = signManifest(sampleManifest, kp.privateKey);
|
|
71
71
|
// Swap the env-configured public key to a different fresh one.
|
|
72
72
|
const other = freshKeypair();
|
|
73
73
|
process.env.CADRE_HOST_UPDATE_DEV_KEY = other.publicRawB64;
|
|
@@ -86,8 +86,43 @@ describe('verifyManifest', () => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
it('rejects unsupported signature schemes', () => {
|
|
89
|
-
const envelope =
|
|
89
|
+
const envelope = signManifest(sampleManifest, kp.privateKey);
|
|
90
90
|
const tampered = { ...envelope, sig: envelope.sig.replace('ed25519:', 'rsa:') };
|
|
91
91
|
expect(() => verifyManifest(tampered)).toThrow(/unsupported signature scheme/);
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
it('rejects non-semver manifest.version (manifest_invalid)', () => {
|
|
95
|
+
const envelope = signManifest(
|
|
96
|
+
{ ...sampleManifest, version: 'not-a-semver' },
|
|
97
|
+
kp.privateKey,
|
|
98
|
+
);
|
|
99
|
+
expect(() => verifyManifest(envelope)).toThrow(/not a valid semver/i);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects non-semver minPreviousVersion (manifest_invalid)', () => {
|
|
103
|
+
const envelope = signManifest(
|
|
104
|
+
{ ...sampleManifest, minPreviousVersion: '0.6' },
|
|
105
|
+
kp.privateKey,
|
|
106
|
+
);
|
|
107
|
+
expect(() => verifyManifest(envelope)).toThrow(/minPreviousVersion.*not a valid semver/i);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects publishedAt that is not a round-trippable ISO-8601 string', () => {
|
|
111
|
+
const envelope = signManifest(
|
|
112
|
+
{ ...sampleManifest, publishedAt: 'sometime last week' },
|
|
113
|
+
kp.privateKey,
|
|
114
|
+
);
|
|
115
|
+
expect(() => verifyManifest(envelope)).toThrow(/publishedAt.*ISO-8601/i);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('rejects an npm package name that fails the naming regex', () => {
|
|
119
|
+
const envelope = signManifest(
|
|
120
|
+
{
|
|
121
|
+
...sampleManifest,
|
|
122
|
+
channels: { npm: { package: 'BAD CHARS!', tag: 'latest' } },
|
|
123
|
+
},
|
|
124
|
+
kp.privateKey,
|
|
125
|
+
);
|
|
126
|
+
expect(() => verifyManifest(envelope)).toThrow(/not a valid npm package name/);
|
|
127
|
+
});
|
|
93
128
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { isPlaceholderReleaseKey } from '../release-key.js';
|
|
5
|
+
|
|
6
|
+
function freshPublicRawB64(): string {
|
|
7
|
+
const { publicKey } = generateKeyPairSync('ed25519');
|
|
8
|
+
const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12);
|
|
9
|
+
return Buffer.from(raw).toString('base64');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('isPlaceholderReleaseKey', () => {
|
|
13
|
+
afterEach(() => { delete process.env.CADRE_HOST_UPDATE_DEV_KEY; });
|
|
14
|
+
|
|
15
|
+
it('is true for the default all-zeros placeholder (no override)', () => {
|
|
16
|
+
delete process.env.CADRE_HOST_UPDATE_DEV_KEY;
|
|
17
|
+
expect(isPlaceholderReleaseKey()).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('is false when a real key overrides via CADRE_HOST_UPDATE_DEV_KEY', () => {
|
|
21
|
+
process.env.CADRE_HOST_UPDATE_DEV_KEY = freshPublicRawB64();
|
|
22
|
+
expect(isPlaceholderReleaseKey()).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('honors an explicit env argument over process.env', () => {
|
|
26
|
+
expect(isPlaceholderReleaseKey({ CADRE_HOST_UPDATE_DEV_KEY: freshPublicRawB64() })).toBe(false);
|
|
27
|
+
expect(isPlaceholderReleaseKey({})).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { buildManifest, derivePublicKeyBase64, signAndSelfVerify, type ManifestFields } from '../sign.js';
|
|
5
|
+
import { verifyManifest } from '../manifest.js';
|
|
6
|
+
|
|
7
|
+
function freshKeypair(): { privateKey: KeyObject; publicRawB64: string } {
|
|
8
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
9
|
+
const spki = publicKey.export({ format: 'der', type: 'spki' });
|
|
10
|
+
return { privateKey, publicRawB64: Buffer.from(spki.subarray(12)).toString('base64') };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const validFields: ManifestFields = {
|
|
14
|
+
version: '0.7.0',
|
|
15
|
+
package: '@serfab/cadre-host',
|
|
16
|
+
tag: 'latest',
|
|
17
|
+
publishedAt: '2026-05-15T18:00:00.000Z',
|
|
18
|
+
releaseNotesUrl: 'https://example.com/notes',
|
|
19
|
+
minPreviousVersion: '0.6.0',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('buildManifest', () => {
|
|
23
|
+
it('constructs a well-formed manifest from flat fields', () => {
|
|
24
|
+
expect(buildManifest(validFields)).toEqual({
|
|
25
|
+
v: 1,
|
|
26
|
+
version: '0.7.0',
|
|
27
|
+
publishedAt: '2026-05-15T18:00:00.000Z',
|
|
28
|
+
channels: { npm: { package: '@serfab/cadre-host', tag: 'latest' } },
|
|
29
|
+
releaseNotesUrl: 'https://example.com/notes',
|
|
30
|
+
minPreviousVersion: '0.6.0',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('omits optional fields when not provided', () => {
|
|
35
|
+
const m = buildManifest({ version: '1.0.0', package: 'pkg', tag: 'latest', publishedAt: '2026-01-01T00:00:00.000Z' });
|
|
36
|
+
expect(m.releaseNotesUrl).toBeUndefined();
|
|
37
|
+
expect(m.minPreviousVersion).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects a bad semver version', () => {
|
|
41
|
+
expect(() => buildManifest({ ...validFields, version: 'not-semver' })).toThrow(/not a valid semver/i);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects a bad npm package name', () => {
|
|
45
|
+
expect(() => buildManifest({ ...validFields, package: 'BAD CHARS!' })).toThrow(/not a valid npm package name/i);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects a non-ISO publishedAt', () => {
|
|
49
|
+
expect(() => buildManifest({ ...validFields, publishedAt: 'last tuesday' })).toThrow(/ISO-8601/i);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects a bad minPreviousVersion', () => {
|
|
53
|
+
expect(() => buildManifest({ ...validFields, minPreviousVersion: '0.6' })).toThrow(/minPreviousVersion.*not a valid semver/i);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('signAndSelfVerify', () => {
|
|
58
|
+
afterEach(() => { delete process.env.CADRE_HOST_UPDATE_DEV_KEY; });
|
|
59
|
+
|
|
60
|
+
it('produces an envelope that verifies against the derived public key', () => {
|
|
61
|
+
const kp = freshKeypair();
|
|
62
|
+
const manifest = buildManifest(validFields);
|
|
63
|
+
const envelope = signAndSelfVerify(manifest, kp.privateKey);
|
|
64
|
+
// derivePublicKeyBase64 must agree with the independently-extracted raw key.
|
|
65
|
+
expect(derivePublicKeyBase64(kp.privateKey)).toBe(kp.publicRawB64);
|
|
66
|
+
// Independent verification against the derived key round-trips the manifest.
|
|
67
|
+
process.env.CADRE_HOST_UPDATE_DEV_KEY = kp.publicRawB64;
|
|
68
|
+
expect(verifyManifest(envelope)).toEqual(manifest);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('the self-verify rejects verification against a different key', () => {
|
|
72
|
+
const kp = freshKeypair();
|
|
73
|
+
const other = freshKeypair();
|
|
74
|
+
const envelope = signAndSelfVerify(buildManifest(validFields), kp.privateKey);
|
|
75
|
+
process.env.CADRE_HOST_UPDATE_DEV_KEY = other.publicRawB64;
|
|
76
|
+
expect(() => verifyManifest(envelope)).toThrow(/signature/i);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -4,8 +4,9 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { signManifest } from '../manifest.js';
|
|
8
8
|
import { UpdateService } from '../index.js';
|
|
9
|
+
import { UpdateStateStore } from '../store.js';
|
|
9
10
|
import type { UpdateManifest } from '../types.js';
|
|
10
11
|
|
|
11
12
|
function freshKeypair(): { privateKey: KeyObject; publicRawB64: string } {
|
|
@@ -50,7 +51,7 @@ describe('UpdateService', () => {
|
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
it('check(): records an available update when manifest > current', async () => {
|
|
53
|
-
const envelope =
|
|
54
|
+
const envelope = signManifest(manifest('0.7.0', { releaseNotesUrl: 'https://x' }), kp.privateKey);
|
|
54
55
|
const svc = new UpdateService({
|
|
55
56
|
dataDir: tmp,
|
|
56
57
|
currentVersion: '0.6.0',
|
|
@@ -69,7 +70,7 @@ describe('UpdateService', () => {
|
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
it('check(): clears stale available when up-to-date', async () => {
|
|
72
|
-
const envelope =
|
|
73
|
+
const envelope = signManifest(manifest('0.6.0'), kp.privateKey);
|
|
73
74
|
const svc = new UpdateService({
|
|
74
75
|
dataDir: tmp,
|
|
75
76
|
currentVersion: '0.7.0',
|
|
@@ -96,7 +97,7 @@ describe('UpdateService', () => {
|
|
|
96
97
|
});
|
|
97
98
|
|
|
98
99
|
it('check(): records signature_invalid as lastError', async () => {
|
|
99
|
-
const envelope =
|
|
100
|
+
const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
|
|
100
101
|
// Swap the trusted key after signing.
|
|
101
102
|
const other = freshKeypair();
|
|
102
103
|
process.env.CADRE_HOST_UPDATE_DEV_KEY = other.publicRawB64;
|
|
@@ -113,7 +114,7 @@ describe('UpdateService', () => {
|
|
|
113
114
|
});
|
|
114
115
|
|
|
115
116
|
it('apply(): records applyInProgress, calls npm + restart, clears on success', async () => {
|
|
116
|
-
const envelope =
|
|
117
|
+
const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
|
|
117
118
|
const calls: string[][] = [];
|
|
118
119
|
const svc = new UpdateService({
|
|
119
120
|
dataDir: tmp,
|
|
@@ -132,7 +133,7 @@ describe('UpdateService', () => {
|
|
|
132
133
|
});
|
|
133
134
|
|
|
134
135
|
it('apply(): rejects when current is below minPreviousVersion', async () => {
|
|
135
|
-
const envelope =
|
|
136
|
+
const envelope = signManifest(manifest('0.8.0', { minPreviousVersion: '0.7.0' }), kp.privateKey);
|
|
136
137
|
const svc = new UpdateService({
|
|
137
138
|
dataDir: tmp,
|
|
138
139
|
currentVersion: '0.6.0',
|
|
@@ -143,7 +144,7 @@ describe('UpdateService', () => {
|
|
|
143
144
|
});
|
|
144
145
|
|
|
145
146
|
it('apply(): rejects when current >= latest', async () => {
|
|
146
|
-
const envelope =
|
|
147
|
+
const envelope = signManifest(manifest('0.6.0'), kp.privateKey);
|
|
147
148
|
const svc = new UpdateService({
|
|
148
149
|
dataDir: tmp,
|
|
149
150
|
currentVersion: '0.7.0',
|
|
@@ -154,7 +155,7 @@ describe('UpdateService', () => {
|
|
|
154
155
|
});
|
|
155
156
|
|
|
156
157
|
it('apply(): records lastError + clears applyInProgress when npm install fails', async () => {
|
|
157
|
-
const envelope =
|
|
158
|
+
const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
|
|
158
159
|
let installCount = 0;
|
|
159
160
|
const svc = new UpdateService({
|
|
160
161
|
dataDir: tmp,
|
|
@@ -188,7 +189,7 @@ describe('UpdateService', () => {
|
|
|
188
189
|
});
|
|
189
190
|
|
|
190
191
|
it('autoApply: check() triggers apply when settings.autoApply is true', async () => {
|
|
191
|
-
const envelope =
|
|
192
|
+
const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
|
|
192
193
|
const calls: string[][] = [];
|
|
193
194
|
const svc = new UpdateService({
|
|
194
195
|
dataDir: tmp,
|
|
@@ -203,6 +204,40 @@ describe('UpdateService', () => {
|
|
|
203
204
|
expect(calls.some((c) => c[2] === '@serfab/cadre-host@0.7.0')).toBe(true);
|
|
204
205
|
});
|
|
205
206
|
|
|
207
|
+
it('recovers from a stale applyInProgress at construction time', async () => {
|
|
208
|
+
// Simulate a crash: write a state file that records an in-flight apply.
|
|
209
|
+
const seed = new UpdateStateStore(tmp);
|
|
210
|
+
seed.update({
|
|
211
|
+
applyInProgress: {
|
|
212
|
+
fromVersion: '0.6.0',
|
|
213
|
+
toVersion: '0.7.0',
|
|
214
|
+
startedAt: '2026-05-15T18:00:00.000Z',
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const svc = new UpdateService({
|
|
219
|
+
dataDir: tmp,
|
|
220
|
+
currentVersion: '0.6.0',
|
|
221
|
+
fetcher: fakeFetch({}),
|
|
222
|
+
manifestUrl: 'https://example.com/m',
|
|
223
|
+
});
|
|
224
|
+
const state = await svc.getState();
|
|
225
|
+
expect(state.applyInProgress).toBeUndefined();
|
|
226
|
+
expect(state.lastError?.code).toBe('apply_failed');
|
|
227
|
+
expect(state.lastError?.message).toMatch(/interrupted/i);
|
|
228
|
+
|
|
229
|
+
// Now apply() must not short-circuit on apply_in_progress; with a stub
|
|
230
|
+
// manifest mismatch we expect the usual no_update_available path instead.
|
|
231
|
+
const envelope = signManifest(manifest('0.6.0'), kp.privateKey);
|
|
232
|
+
const svc2 = new UpdateService({
|
|
233
|
+
dataDir: tmp,
|
|
234
|
+
currentVersion: '0.7.0',
|
|
235
|
+
fetcher: fakeFetch(envelope),
|
|
236
|
+
manifestUrl: 'https://example.com/m',
|
|
237
|
+
});
|
|
238
|
+
await expect(svc2.apply()).rejects.toMatchObject({ code: 'no_update_available' });
|
|
239
|
+
});
|
|
240
|
+
|
|
206
241
|
it('start()/stop(): timer can be armed and cleared', async () => {
|
|
207
242
|
const svc = new UpdateService({
|
|
208
243
|
dataDir: tmp,
|
package/src/update/apply.ts
CHANGED
|
@@ -116,12 +116,25 @@ async function tryRollback(
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Default executor: spawn `npm` with the given args. Adds a hard timeout.
|
|
121
|
+
*
|
|
122
|
+
* On Windows, npm ships as `npm.cmd` (a batch file). CreateProcess can't run
|
|
123
|
+
* batch files directly, so a shell is required — but Node's DEP0190 warns
|
|
124
|
+
* against `spawn(cmd, args, { shell: true })` because args are concatenated
|
|
125
|
+
* (not escaped) into the resulting command line. To dodge the deprecation we
|
|
126
|
+
* pre-quote each arg ourselves and pass the result as a single command string
|
|
127
|
+
* with an empty args array. Inputs come from a signed manifest after
|
|
128
|
+
* regex-validated package + semver checks, so injection surface is minimal;
|
|
129
|
+
* quoting is defense in depth.
|
|
130
|
+
*/
|
|
120
131
|
export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
|
|
121
132
|
return (args: string[]) => new Promise<NpmResult>((resolveExec) => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
133
|
+
const spawnSpec = buildNpmSpawnSpec(args);
|
|
134
|
+
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
135
|
+
shell: spawnSpec.shell,
|
|
136
|
+
windowsHide: true,
|
|
137
|
+
});
|
|
125
138
|
let stdout = '';
|
|
126
139
|
let stderr = '';
|
|
127
140
|
child.stdout?.on('data', (chunk: Buffer | string) => { stdout += String(chunk); });
|
|
@@ -143,6 +156,35 @@ export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
|
|
|
143
156
|
});
|
|
144
157
|
}
|
|
145
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Build the `(command, args, shell)` triple passed to `spawn`. Exported for
|
|
161
|
+
* tests — the spawn shape differs per platform and the quoting matters.
|
|
162
|
+
*/
|
|
163
|
+
export function buildNpmSpawnSpec(args: string[]): { command: string; args: string[]; shell: boolean } {
|
|
164
|
+
if (process.platform === 'win32') {
|
|
165
|
+
const commandLine = ['npm', ...args.map(quoteWindowsArg)].join(' ');
|
|
166
|
+
return { command: commandLine, args: [], shell: true };
|
|
167
|
+
}
|
|
168
|
+
return { command: 'npm', args, shell: false };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** cmd.exe quoting — wrap args containing whitespace or shell metachars. */
|
|
172
|
+
export function quoteWindowsArg(arg: string): string {
|
|
173
|
+
// \x00 is an intentional security guard: reject NUL/CR/LF in args before they
|
|
174
|
+
// reach the cmd.exe command line. The control char is the point of the check.
|
|
175
|
+
// eslint-disable-next-line no-control-regex
|
|
176
|
+
if (/[\x00\r\n]/.test(arg)) {
|
|
177
|
+
throw new Error('refusing to spawn npm with control character in argument');
|
|
178
|
+
}
|
|
179
|
+
if (arg.length === 0) return '""';
|
|
180
|
+
if (!/[\s"&|<>^()%!]/.test(arg)) return arg;
|
|
181
|
+
// Escape backslashes preceding a quote per MSVCRT/cmd rules, then double the
|
|
182
|
+
// quote itself (the cmd-friendly form rather than backslash-quote).
|
|
183
|
+
const escaped = arg.replace(/(\\*)"/g, (_m, slashes: string) => slashes + slashes + '\\"');
|
|
184
|
+
const trailing = escaped.replace(/(\\*)$/, (_m, slashes: string) => slashes + slashes);
|
|
185
|
+
return `"${trailing}"`;
|
|
186
|
+
}
|
|
187
|
+
|
|
146
188
|
function tail(s: string, n: number): string {
|
|
147
189
|
return s.length <= n ? s : '…' + s.slice(-n);
|
|
148
190
|
}
|