@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.
Files changed (135) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +44 -6
  3. package/dist/auth/trust-circle.js +115 -58
  4. package/dist/auth/trust-circle.js.map +1 -1
  5. package/dist/auth/types.d.ts +4 -1
  6. package/dist/auth/types.js.map +1 -1
  7. package/dist/authority/authority-node-client.d.ts +77 -0
  8. package/dist/authority/authority-node-client.js +143 -0
  9. package/dist/authority/authority-node-client.js.map +1 -0
  10. package/dist/authority/index.d.ts +11 -0
  11. package/dist/authority/index.js +11 -0
  12. package/dist/authority/index.js.map +1 -0
  13. package/dist/bin/host.js +210 -97
  14. package/dist/bin/host.js.map +1 -1
  15. package/dist/index.d.ts +10 -6
  16. package/dist/index.js +6 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/installer/browser.js +16 -17
  19. package/dist/installer/browser.js.map +1 -1
  20. package/dist/installer/config.d.ts +25 -0
  21. package/dist/installer/config.js +1 -1
  22. package/dist/installer/config.js.map +1 -1
  23. package/dist/installer/index.d.ts +5 -2
  24. package/dist/installer/index.js +14 -10
  25. package/dist/installer/index.js.map +1 -1
  26. package/dist/installer/service-host/launchd.js +1 -1
  27. package/dist/installer/service-host/launchd.js.map +1 -1
  28. package/dist/installer/service-host/systemd.js +1 -1
  29. package/dist/installer/service-host/systemd.js.map +1 -1
  30. package/dist/nat/index.d.ts +1 -1
  31. package/dist/nat/nat-service.d.ts +45 -3
  32. package/dist/nat/nat-service.js +116 -3
  33. package/dist/nat/nat-service.js.map +1 -1
  34. package/dist/nat/secrets/index.d.ts +7 -0
  35. package/dist/nat/secrets/index.js +28 -2
  36. package/dist/nat/secrets/index.js.map +1 -1
  37. package/dist/nat/types.d.ts +3 -1
  38. package/dist/nat/types.js.map +1 -1
  39. package/dist/orchestrator/host-process-orchestrator.d.ts +60 -1
  40. package/dist/orchestrator/host-process-orchestrator.js +246 -39
  41. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  42. package/dist/orchestrator/index.d.ts +3 -2
  43. package/dist/orchestrator/index.js +1 -1
  44. package/dist/orchestrator/index.js.map +1 -1
  45. package/dist/orchestrator/state-store.d.ts +6 -5
  46. package/dist/orchestrator/state-store.js +10 -2
  47. package/dist/orchestrator/state-store.js.map +1 -1
  48. package/dist/orchestrator/types.d.ts +45 -10
  49. package/dist/orchestrator/types.js.map +1 -1
  50. package/dist/push/index.d.ts +67 -0
  51. package/dist/push/index.js +101 -0
  52. package/dist/push/index.js.map +1 -0
  53. package/dist/server/error-handler.d.ts +2 -0
  54. package/dist/server/error-handler.js +4 -0
  55. package/dist/server/error-handler.js.map +1 -1
  56. package/dist/server/routes/nodes.d.ts +4 -3
  57. package/dist/server/routes/nodes.js +38 -24
  58. package/dist/server/routes/nodes.js.map +1 -1
  59. package/dist/server/static.d.ts +12 -0
  60. package/dist/server/static.js +24 -7
  61. package/dist/server/static.js.map +1 -1
  62. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  63. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  64. package/dist/ui/index.html +1 -1
  65. package/dist/update/apply.d.ts +23 -1
  66. package/dist/update/apply.js +46 -4
  67. package/dist/update/apply.js.map +1 -1
  68. package/dist/update/index.d.ts +14 -3
  69. package/dist/update/index.js +35 -2
  70. package/dist/update/index.js.map +1 -1
  71. package/dist/update/manifest.d.ts +28 -9
  72. package/dist/update/manifest.js +56 -29
  73. package/dist/update/manifest.js.map +1 -1
  74. package/dist/update/release-key.d.ts +18 -7
  75. package/dist/update/release-key.js +28 -15
  76. package/dist/update/release-key.js.map +1 -1
  77. package/dist/update/sign.d.ts +46 -0
  78. package/dist/update/sign.js +53 -0
  79. package/dist/update/sign.js.map +1 -0
  80. package/package.json +9 -4
  81. package/service/README.md +95 -86
  82. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  83. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  84. package/src/__tests__/cli.smoke.test.ts +6 -1
  85. package/src/__tests__/host-authority.smoke.test.ts +123 -0
  86. package/src/__tests__/orchestrator-authority.test.ts +198 -0
  87. package/src/__tests__/orchestrator-push.test.ts +145 -0
  88. package/src/auth/__tests__/trust-circle-integration.test.ts +40 -21
  89. package/src/auth/__tests__/trust-circle.test.ts +90 -23
  90. package/src/auth/trust-circle.ts +363 -304
  91. package/src/auth/types.ts +4 -1
  92. package/src/authority/__tests__/authority-node-client.test.ts +166 -0
  93. package/src/authority/authority-node-client.ts +199 -0
  94. package/src/authority/index.ts +12 -0
  95. package/src/bin/host.ts +1038 -905
  96. package/src/index.ts +34 -3
  97. package/src/installer/__tests__/installer.smoke.test.ts +64 -0
  98. package/src/installer/browser.ts +40 -42
  99. package/src/installer/config.ts +27 -1
  100. package/src/installer/index.ts +18 -12
  101. package/src/installer/service-host/launchd.ts +1 -1
  102. package/src/installer/service-host/systemd.ts +1 -1
  103. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  104. package/src/nat/__tests__/nat-service.test.ts +192 -0
  105. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  106. package/src/nat/__tests__/secrets.test.ts +23 -1
  107. package/src/nat/index.ts +1 -1
  108. package/src/nat/nat-service.ts +131 -5
  109. package/src/nat/secrets/index.ts +32 -2
  110. package/src/nat/types.ts +3 -1
  111. package/src/orchestrator/host-process-orchestrator.ts +294 -54
  112. package/src/orchestrator/index.ts +9 -2
  113. package/src/orchestrator/state-store.ts +21 -3
  114. package/src/orchestrator/types.ts +48 -2
  115. package/src/push/__tests__/push.test.ts +90 -0
  116. package/src/push/index.ts +146 -0
  117. package/src/server/__tests__/error-handler.test.ts +4 -4
  118. package/src/server/__tests__/nodes-route.test.ts +90 -10
  119. package/src/server/__tests__/publishers.test.ts +1 -1
  120. package/src/server/__tests__/static.test.ts +145 -0
  121. package/src/server/__tests__/status-route.test.ts +2 -2
  122. package/src/server/error-handler.ts +4 -0
  123. package/src/server/routes/nodes.ts +48 -26
  124. package/src/server/static.ts +24 -8
  125. package/src/update/__tests__/apply.test.ts +58 -1
  126. package/src/update/__tests__/manifest.test.ts +41 -6
  127. package/src/update/__tests__/release-key.test.ts +29 -0
  128. package/src/update/__tests__/sign.test.ts +78 -0
  129. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  130. package/src/update/apply.ts +46 -4
  131. package/src/update/index.ts +41 -3
  132. package/src/update/manifest.ts +214 -172
  133. package/src/update/release-key.ts +30 -16
  134. package/src/update/sign.ts +74 -0
  135. 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
- * v1 limits: cadre-host doesn't yet auto-spawn nodes (the trust-circle →
10
- * node mapping ticket is separate). Until it lands, `listNodes()` is
11
- * usually empty and start/restart are stubs. Stop on a running node works.
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 orchestrator's createContainer requires a full
86
- // OrchestratorCreateRequest (partyId, bootstrapNodes, profile, ...).
87
- // cadre-host doesn't yet own a "spawn this node from saved config" path
88
- // — that lives in the trust-circle-driven node spawner planned for a
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
- return reply.code(501).send({
93
- ok: false,
94
- error: {
95
- code: 'not_implemented',
96
- message:
97
- `start ${request.params.id}: cadre-host v1 has no auto-spawn path. ` +
98
- `Restart the cadre-host service to bring up persisted nodes.`,
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
- return reply.code(501).send({
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
  /**
@@ -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
- function resolveSafe(rootDir: string, urlPath: string): string | null {
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 target = normalize(join(rootDir, cleaned === '/' ? '' : cleaned));
125
- // Disallow path traversal above rootDir.
126
- if (!target.startsWith(normalize(rootDir))) return null;
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&amp')).toBe('"with&amp"');
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, signManifestForTesting, verifyManifest } from '../manifest.js';
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 = signManifestForTesting(sampleManifest, kp.privateKey);
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 = signManifestForTesting(sampleManifest, kp.privateKey);
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 = signManifestForTesting(sampleManifest, kp.privateKey);
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 = signManifestForTesting(sampleManifest, kp.privateKey);
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 = signManifestForTesting(sampleManifest, kp.privateKey);
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 { signManifestForTesting } from '../manifest.js';
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 = signManifestForTesting(manifest('0.7.0', { releaseNotesUrl: 'https://x' }), kp.privateKey);
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 = signManifestForTesting(manifest('0.6.0'), kp.privateKey);
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 = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
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 = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
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 = signManifestForTesting(manifest('0.8.0', { minPreviousVersion: '0.7.0' }), kp.privateKey);
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 = signManifestForTesting(manifest('0.6.0'), kp.privateKey);
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 = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
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 = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
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,
@@ -116,12 +116,25 @@ async function tryRollback(
116
116
  }
117
117
  }
118
118
 
119
- /** Default executor: spawn `npm` with the given args. Adds a hard timeout. */
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
- // Windows resolves `npm` via `npm.cmd`; using shell:true is the
123
- // simplest cross-platform answer that still works with PATH lookup.
124
- const child = spawn('npm', args, { shell: process.platform === 'win32', windowsHide: true });
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
  }