@serfab/cadre-host 0.7.1 → 0.9.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/README.md +296 -101
- package/dist/auth/trust-circle.d.ts +45 -7
- package/dist/auth/trust-circle.js +116 -59
- 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/bin/host.js +426 -112
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +136 -0
- package/dist/donation/donation-service.js +306 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +42 -0
- package/dist/donation/donation-store.js +141 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/grant-service.d.ts +71 -0
- package/dist/donation/grant-service.js +163 -0
- package/dist/donation/grant-service.js.map +1 -0
- package/dist/donation/grant-store.d.ts +38 -0
- package/dist/donation/grant-store.js +135 -0
- package/dist/donation/grant-store.js.map +1 -0
- package/dist/donation/index.d.ts +18 -0
- package/dist/donation/index.js +16 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +175 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +8 -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 +51 -0
- package/dist/installer/config.js +14 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +7 -2
- package/dist/installer/index.js +17 -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/installer/wizard.d.ts +4 -0
- package/dist/installer/wizard.js +5 -1
- package/dist/installer/wizard.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 +68 -1
- package/dist/orchestrator/host-process-orchestrator.js +272 -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/owner/index.d.ts +11 -0
- package/dist/owner/index.js +11 -0
- package/dist/owner/index.js.map +1 -0
- package/dist/owner/owner-node-client.d.ts +95 -0
- package/dist/owner/owner-node-client.js +167 -0
- package/dist/owner/owner-node-client.js.map +1 -0
- 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 +17 -0
- package/dist/server/error-handler.js +45 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.js +37 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +17 -0
- package/dist/server/routes/grants-admin.js +32 -0
- package/dist/server/routes/grants-admin.js.map +1 -0
- package/dist/server/routes/grants.d.ts +28 -0
- package/dist/server/routes/grants.js +163 -0
- package/dist/server/routes/grants.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +5 -3
- package/dist/server/routes/nodes.js +39 -24
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +6 -1
- package/dist/server/routes/settings.js +5 -2
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +8 -4
- package/dist/server/routes/status.js +9 -4
- package/dist/server/routes/status.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 +29 -14
- 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-owner.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-owner.test.ts +198 -0
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
- package/src/auth/__tests__/trust-circle.test.ts +91 -24
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/bin/host.ts +1258 -905
- package/src/donation/__tests__/donation-service.test.ts +290 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -0
- package/src/donation/__tests__/grant-store.test.ts +113 -0
- package/src/donation/donation-service.ts +392 -0
- package/src/donation/donation-store.ts +164 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +49 -0
- package/src/donation/types.ts +220 -0
- package/src/index.ts +66 -3
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +94 -0
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +61 -1
- package/src/installer/index.ts +23 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/installer/wizard.ts +12 -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 +322 -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/owner/__tests__/owner-node-client.test.ts +194 -0
- package/src/owner/index.ts +12 -0
- package/src/owner/owner-node-client.ts +233 -0
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +26 -4
- package/src/server/__tests__/grants-admin-route.test.ts +73 -0
- package/src/server/__tests__/grants-route.test.ts +189 -0
- package/src/server/__tests__/nodes-route.test.ts +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +23 -2
- package/src/server/error-handler.ts +47 -0
- package/src/server/index.ts +62 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +186 -0
- package/src/server/routes/nodes.ts +49 -26
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- 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 +42 -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 +31 -15
- package/src/update/sign.ts +74 -0
- package/dist/ui/assets/index-C9uChk2P.js +0 -14
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed signing core for the release-manifest pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The offline `.mjs` CLI wrappers (`scripts/sign-manifest.mjs`) stay thin by
|
|
5
|
+
* delegating manifest construction, field validation, and the sign + self-verify
|
|
6
|
+
* round-trip to this module — keeping the logic typed and unit-testable instead
|
|
7
|
+
* of hand-rolled in a script. None of this ships in the running binary's hot
|
|
8
|
+
* path: it is only reached by the operator's offline signing tool (and tests).
|
|
9
|
+
*/
|
|
10
|
+
import { type KeyObject } from 'node:crypto';
|
|
11
|
+
import type { SignedManifest, UpdateManifest } from './types.js';
|
|
12
|
+
/** Flat input shape the signing CLI collects from flags or a JSON file. */
|
|
13
|
+
export interface ManifestFields {
|
|
14
|
+
/** Released version (semver). */
|
|
15
|
+
version: string;
|
|
16
|
+
/** npm package the release publishes under. */
|
|
17
|
+
package: string;
|
|
18
|
+
/** npm dist-tag (e.g. `latest`). */
|
|
19
|
+
tag: string;
|
|
20
|
+
/** ISO-8601 publish timestamp (must round-trip exactly). */
|
|
21
|
+
publishedAt: string;
|
|
22
|
+
/** Optional human-facing release-notes URL. */
|
|
23
|
+
releaseNotesUrl?: string;
|
|
24
|
+
/** Optional minimum previous version that can step directly to this release. */
|
|
25
|
+
minPreviousVersion?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Construct a well-formed `UpdateManifest` from flat fields and run the exact
|
|
29
|
+
* `validateManifestFields` rules `verifyManifest` will later apply — so the
|
|
30
|
+
* signing tool fails loudly on a bad semver / npm name / non-ISO timestamp
|
|
31
|
+
* rather than emitting a manifest the verifier would reject as `manifest_invalid`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildManifest(fields: ManifestFields): UpdateManifest;
|
|
34
|
+
/**
|
|
35
|
+
* Derive the raw 32-byte Ed25519 public key (base64) from a private key by
|
|
36
|
+
* stripping the 12-byte SPKI DER header — the same form `release-key.ts`
|
|
37
|
+
* embeds and `ed25519FromRaw` consumes.
|
|
38
|
+
*/
|
|
39
|
+
export declare function derivePublicKeyBase64(privateKey: KeyObject): string;
|
|
40
|
+
/**
|
|
41
|
+
* Sign a manifest and immediately verify the result against the public key
|
|
42
|
+
* *derived from the same private key*, independent of whatever key the running
|
|
43
|
+
* environment trusts. Throws if the self-check fails, so the signing tool never
|
|
44
|
+
* emits an envelope it could not itself verify.
|
|
45
|
+
*/
|
|
46
|
+
export declare function signAndSelfVerify(manifest: UpdateManifest, privateKey: KeyObject): SignedManifest;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed signing core for the release-manifest pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The offline `.mjs` CLI wrappers (`scripts/sign-manifest.mjs`) stay thin by
|
|
5
|
+
* delegating manifest construction, field validation, and the sign + self-verify
|
|
6
|
+
* round-trip to this module — keeping the logic typed and unit-testable instead
|
|
7
|
+
* of hand-rolled in a script. None of this ships in the running binary's hot
|
|
8
|
+
* path: it is only reached by the operator's offline signing tool (and tests).
|
|
9
|
+
*/
|
|
10
|
+
import { createPublicKey } from 'node:crypto';
|
|
11
|
+
import { signManifest, validateManifestFields, verifyManifest } from './manifest.js';
|
|
12
|
+
/**
|
|
13
|
+
* Construct a well-formed `UpdateManifest` from flat fields and run the exact
|
|
14
|
+
* `validateManifestFields` rules `verifyManifest` will later apply — so the
|
|
15
|
+
* signing tool fails loudly on a bad semver / npm name / non-ISO timestamp
|
|
16
|
+
* rather than emitting a manifest the verifier would reject as `manifest_invalid`.
|
|
17
|
+
*/
|
|
18
|
+
export function buildManifest(fields) {
|
|
19
|
+
const manifest = {
|
|
20
|
+
v: 1,
|
|
21
|
+
version: fields.version,
|
|
22
|
+
publishedAt: fields.publishedAt,
|
|
23
|
+
channels: { npm: { package: fields.package, tag: fields.tag } },
|
|
24
|
+
...(fields.releaseNotesUrl !== undefined ? { releaseNotesUrl: fields.releaseNotesUrl } : {}),
|
|
25
|
+
...(fields.minPreviousVersion !== undefined ? { minPreviousVersion: fields.minPreviousVersion } : {}),
|
|
26
|
+
};
|
|
27
|
+
validateManifestFields(manifest);
|
|
28
|
+
return manifest;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Derive the raw 32-byte Ed25519 public key (base64) from a private key by
|
|
32
|
+
* stripping the 12-byte SPKI DER header — the same form `release-key.ts`
|
|
33
|
+
* embeds and `ed25519FromRaw` consumes.
|
|
34
|
+
*/
|
|
35
|
+
export function derivePublicKeyBase64(privateKey) {
|
|
36
|
+
const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' });
|
|
37
|
+
return Buffer.from(spki.subarray(12)).toString('base64');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Sign a manifest and immediately verify the result against the public key
|
|
41
|
+
* *derived from the same private key*, independent of whatever key the running
|
|
42
|
+
* environment trusts. Throws if the self-check fails, so the signing tool never
|
|
43
|
+
* emits an envelope it could not itself verify.
|
|
44
|
+
*/
|
|
45
|
+
export function signAndSelfVerify(manifest, privateKey) {
|
|
46
|
+
const signed = signManifest(manifest, privateKey);
|
|
47
|
+
const publicRawB64 = derivePublicKeyBase64(privateKey);
|
|
48
|
+
// verifyManifest honors the env override; point it at the derived key only so
|
|
49
|
+
// the self-check is against this key pair, not the embedded/dev key.
|
|
50
|
+
verifyManifest(signed, { CADRE_HOST_UPDATE_DEV_KEY: publicRawB64 });
|
|
51
|
+
return signed;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=sign.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sign.js","sourceRoot":"","sources":["../../src/update/sign.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,eAAe,EAAkB,MAAM,aAAa,CAAC;AAE9D,OAAO,EAAE,YAAY,EAAE,sBAAsB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAmBrF;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,MAAsB;IAClD,MAAM,QAAQ,GAAmB;QAC/B,CAAC,EAAE,CAAC;QACJ,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,QAAQ,EAAE,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,EAAE;QAC/D,GAAG,CAAC,MAAM,CAAC,eAAe,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,MAAM,CAAC,eAAe,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5F,GAAG,CAAC,MAAM,CAAC,kBAAkB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,kBAAkB,EAAE,MAAM,CAAC,kBAAkB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACtG,CAAC;IACF,sBAAsB,CAAC,QAAQ,CAAC,CAAC;IACjC,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,UAAqB;IACzD,MAAM,IAAI,GAAG,eAAe,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;IACjF,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AAC3D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,QAAwB,EAAE,UAAqB;IAC/E,MAAM,MAAM,GAAG,YAAY,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;IAClD,MAAM,YAAY,GAAG,qBAAqB,CAAC,UAAU,CAAC,CAAC;IACvD,8EAA8E;IAC9E,qEAAqE;IACrE,cAAc,CAAC,MAAM,EAAE,EAAE,yBAAyB,EAAE,YAAY,EAAuB,CAAC,CAAC;IACzF,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serfab/cadre-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Self-hosted cadre node manager for basement-PC deployments",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"build:server": "tsc -p tsconfig.build.json",
|
|
39
39
|
"build:ui": "vite build --config ui/vite.config.ts",
|
|
40
40
|
"dev:ui": "vite --config ui/vite.config.ts",
|
|
41
|
+
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
|
42
|
+
"check:svelte": "svelte-check --tsconfig tsconfig.json --workspace ui",
|
|
41
43
|
"test": "vitest run --passWithNoTests",
|
|
42
44
|
"dev:test": "vitest",
|
|
43
45
|
"start": "node dist/bin/host.js"
|
|
@@ -59,9 +61,12 @@
|
|
|
59
61
|
},
|
|
60
62
|
"dependencies": {
|
|
61
63
|
"@achingbrain/nat-port-mapper": "^4.0.0",
|
|
62
|
-
"@
|
|
63
|
-
"@
|
|
64
|
-
"@
|
|
64
|
+
"@libp2p/crypto": "^5.1.13",
|
|
65
|
+
"@libp2p/interface": "^3.1.0",
|
|
66
|
+
"@libp2p/peer-id": "^6.0.4",
|
|
67
|
+
"@serfab/cadre-cli": "^0.9.0",
|
|
68
|
+
"@serfab/cadre-core": "^0.9.0",
|
|
69
|
+
"@serfab/cadre-provider": "^0.9.0",
|
|
65
70
|
"commander": "^14.0.2",
|
|
66
71
|
"debug": "^4.4.3",
|
|
67
72
|
"fastify": "^5.7.1",
|
package/service/README.md
CHANGED
|
@@ -1,86 +1,95 @@
|
|
|
1
|
-
# Cadre Host Service Units
|
|
2
|
-
|
|
3
|
-
Host cadre-host as a per-user system service. All three units are configured
|
|
4
|
-
so that **a clean exit (code 0) is final** — `cadre-host uninstall` (or any
|
|
5
|
-
explicit `systemctl stop` / `nssm stop`) leaves the service stopped. Crashes
|
|
6
|
-
still trigger a restart.
|
|
7
|
-
|
|
8
|
-
| Platform | File | Service host |
|
|
9
|
-
| -------- | ---- | ------------ |
|
|
10
|
-
| Linux | [`cadre-host.service.tmpl`](./cadre-host.service.tmpl) | systemd (user) |
|
|
11
|
-
| macOS | [`com.serfab.cadre-host.plist.tmpl`](./com.serfab.cadre-host.plist.tmpl) | launchd (LaunchAgent) |
|
|
12
|
-
| Windows | [`install-service.ps1`](./install-service.ps1) / [`uninstall-service.ps1`](./uninstall-service.ps1) | NSSM |
|
|
13
|
-
|
|
14
|
-
The `.tmpl` files contain `@TOKEN@` placeholders that the installer
|
|
15
|
-
substitutes at install time (`@NODE_PATH@`, `@HOST_JS@`, `@DATA_DIR@`).
|
|
16
|
-
Inspect the rendered files under `~/.config/systemd/user/cadre-host.service`
|
|
17
|
-
or `~/Library/LaunchAgents/com.serfab.cadre-host.plist` after running
|
|
18
|
-
`cadre-host install`.
|
|
19
|
-
|
|
20
|
-
## Scope (v1)
|
|
21
|
-
|
|
22
|
-
**Per-user install only.** The systemd unit is installed under the user's
|
|
23
|
-
control (`systemctl --user`), the launchd unit is a `LaunchAgent` (not a
|
|
24
|
-
`LaunchDaemon`), and NSSM runs as the current user.
|
|
25
|
-
|
|
26
|
-
A `--system` flag is accepted by `cadre-host install` but currently errors
|
|
27
|
-
out — running cadre-host as a dedicated system user requires more work
|
|
28
|
-
around data-dir ownership and capability dropping. Tracked separately.
|
|
29
|
-
|
|
30
|
-
## Linux (systemd) — manual smoke
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
# Render + install + enable + start in one go:
|
|
34
|
-
cadre-host install --non-interactive --data-dir ~/.local/share/cadre-host
|
|
35
|
-
|
|
36
|
-
# After install, the unit lives at:
|
|
37
|
-
# ~/.config/systemd/user/cadre-host.service
|
|
38
|
-
# Inspect status:
|
|
39
|
-
systemctl --user status cadre-host
|
|
40
|
-
journalctl --user -u cadre-host -f
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
`Restart=on-failure` + `RestartPreventExitStatus=0` make exit 0 final.
|
|
44
|
-
|
|
45
|
-
The installer
|
|
46
|
-
logout / reboot.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
cadre-host install
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`
|
|
85
|
-
|
|
86
|
-
|
|
1
|
+
# Cadre Host Service Units
|
|
2
|
+
|
|
3
|
+
Host cadre-host as a per-user system service. All three units are configured
|
|
4
|
+
so that **a clean exit (code 0) is final** — `cadre-host uninstall` (or any
|
|
5
|
+
explicit `systemctl stop` / `nssm stop`) leaves the service stopped. Crashes
|
|
6
|
+
still trigger a restart.
|
|
7
|
+
|
|
8
|
+
| Platform | File | Service host |
|
|
9
|
+
| -------- | ---- | ------------ |
|
|
10
|
+
| Linux | [`cadre-host.service.tmpl`](./cadre-host.service.tmpl) | systemd (user) |
|
|
11
|
+
| macOS | [`com.serfab.cadre-host.plist.tmpl`](./com.serfab.cadre-host.plist.tmpl) | launchd (LaunchAgent) |
|
|
12
|
+
| Windows | [`install-service.ps1`](./install-service.ps1) / [`uninstall-service.ps1`](./uninstall-service.ps1) | NSSM |
|
|
13
|
+
|
|
14
|
+
The `.tmpl` files contain `@TOKEN@` placeholders that the installer
|
|
15
|
+
substitutes at install time (`@NODE_PATH@`, `@HOST_JS@`, `@DATA_DIR@`).
|
|
16
|
+
Inspect the rendered files under `~/.config/systemd/user/cadre-host.service`
|
|
17
|
+
or `~/Library/LaunchAgents/com.serfab.cadre-host.plist` after running
|
|
18
|
+
`cadre-host install`.
|
|
19
|
+
|
|
20
|
+
## Scope (v1)
|
|
21
|
+
|
|
22
|
+
**Per-user install only.** The systemd unit is installed under the user's
|
|
23
|
+
control (`systemctl --user`), the launchd unit is a `LaunchAgent` (not a
|
|
24
|
+
`LaunchDaemon`), and NSSM runs as the current user.
|
|
25
|
+
|
|
26
|
+
A `--system` flag is accepted by `cadre-host install` but currently errors
|
|
27
|
+
out — running cadre-host as a dedicated system user requires more work
|
|
28
|
+
around data-dir ownership and capability dropping. Tracked separately.
|
|
29
|
+
|
|
30
|
+
## Linux (systemd) — manual smoke
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Render + install + enable + start in one go:
|
|
34
|
+
cadre-host install --non-interactive --data-dir ~/.local/share/cadre-host
|
|
35
|
+
|
|
36
|
+
# After install, the unit lives at:
|
|
37
|
+
# ~/.config/systemd/user/cadre-host.service
|
|
38
|
+
# Inspect status:
|
|
39
|
+
systemctl --user status cadre-host
|
|
40
|
+
journalctl --user -u cadre-host -f
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`Restart=on-failure` + `RestartPreventExitStatus=0` make exit 0 final.
|
|
44
|
+
|
|
45
|
+
The installer attempts `loginctl enable-linger <user>` so the unit survives
|
|
46
|
+
logout / reboot. This is the **one** step in the install that requires root,
|
|
47
|
+
and it is opportunistic: if `loginctl` is missing or you aren't root, the
|
|
48
|
+
installer logs a warning and continues. Without linger the user-systemd
|
|
49
|
+
service runs only while you have an active session (desktop or SSH) and
|
|
50
|
+
stops when you log out. You can enable linger separately, before or after
|
|
51
|
+
`cadre-host install`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
sudo loginctl enable-linger "$USER"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## macOS (launchd) — manual smoke
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
cadre-host install --non-interactive --data-dir "$HOME/Library/Application Support/CadreHost"
|
|
61
|
+
|
|
62
|
+
# After install, the plist lives at:
|
|
63
|
+
# ~/Library/LaunchAgents/com.serfab.cadre-host.plist
|
|
64
|
+
launchctl print gui/$(id -u)/com.serfab.cadre-host
|
|
65
|
+
tail -f "$HOME/Library/Application Support/CadreHost/logs/out.log"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`KeepAlive.SuccessfulExit = false` plus `KeepAlive.Crashed = true` mean a
|
|
69
|
+
clean exit stays down while a crash is relaunched.
|
|
70
|
+
|
|
71
|
+
## Windows (NSSM) — manual smoke
|
|
72
|
+
|
|
73
|
+
Install [NSSM](https://nssm.cc/download) and put `nssm.exe` on PATH first.
|
|
74
|
+
|
|
75
|
+
```powershell
|
|
76
|
+
cadre-host install --non-interactive --data-dir $env:LocalAppData\CadreHost
|
|
77
|
+
|
|
78
|
+
# After install, inspect the service:
|
|
79
|
+
nssm.exe status CadreHost
|
|
80
|
+
Get-Service CadreHost
|
|
81
|
+
Get-Content $env:LocalAppData\CadreHost\logs\out.log -Tail 30 -Wait
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`AppExit 0 Exit` tells NSSM not to restart on clean exit; other exit codes
|
|
85
|
+
are handled by `AppExit Default Restart` with a 5-second delay and a
|
|
86
|
+
10-second throttle. NSSM is **not** bundled with the npm package; the
|
|
87
|
+
standalone-binary distribution (see `tickets/backlog/cadre-host-standalone-binary.md`)
|
|
88
|
+
will ship it.
|
|
89
|
+
|
|
90
|
+
## CI gap
|
|
91
|
+
|
|
92
|
+
Cross-platform CI does **not** invoke `systemctl --user enable`,
|
|
93
|
+
`launchctl bootstrap`, or `nssm install`. The installer's unit-rendering
|
|
94
|
+
logic is covered by unit tests; the actual service-host registration must
|
|
95
|
+
be smoke-tested manually on each platform before tagging a release.
|
|
@@ -13,11 +13,16 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { dirname, resolve } from 'node:path';
|
|
16
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
17
|
|
|
18
18
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
const binPath = resolve(here, '../../dist/bin/host.js');
|
|
20
20
|
|
|
21
|
+
// These smoke tests cold-start the built CLI as a child process (Node boot +,
|
|
22
|
+
// for some, an HTTP server). That comfortably exceeds vitest's 5s default under
|
|
23
|
+
// heavy parallel load, so give the subprocess generous headroom.
|
|
24
|
+
vi.setConfig({ testTimeout: 30000 });
|
|
25
|
+
|
|
21
26
|
interface StubServer {
|
|
22
27
|
server: Server;
|
|
23
28
|
port: number;
|
|
@@ -15,11 +15,16 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
|
|
15
15
|
import { existsSync } from 'node:fs';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { dirname, resolve } from 'node:path';
|
|
18
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
19
19
|
|
|
20
20
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
21
|
const binPath = resolve(here, '../../dist/bin/host.js');
|
|
22
22
|
|
|
23
|
+
// These smoke tests cold-start the built CLI as a child process (Node boot +,
|
|
24
|
+
// for some, an HTTP server). That comfortably exceeds vitest's 5s default under
|
|
25
|
+
// heavy parallel load, so give the subprocess generous headroom.
|
|
26
|
+
vi.setConfig({ testTimeout: 30000 });
|
|
27
|
+
|
|
23
28
|
interface StubServer {
|
|
24
29
|
server: Server;
|
|
25
30
|
port: number;
|
|
@@ -7,11 +7,16 @@ import { execFileSync } from 'node:child_process';
|
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { dirname, resolve } from 'node:path';
|
|
10
|
-
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
11
11
|
|
|
12
12
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
const binPath = resolve(here, '../../dist/bin/host.js');
|
|
14
14
|
|
|
15
|
+
// These smoke tests cold-start the built CLI as a child process (Node boot +,
|
|
16
|
+
// for some, an HTTP server). That comfortably exceeds vitest's 5s default under
|
|
17
|
+
// heavy parallel load, so give the subprocess generous headroom.
|
|
18
|
+
vi.setConfig({ testTimeout: 30000 });
|
|
19
|
+
|
|
15
20
|
describe('cadre-host CLI smoke test', () => {
|
|
16
21
|
it('prints help with all subcommands', () => {
|
|
17
22
|
if (!existsSync(binPath)) {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-level smoke: the /auth/* HTTP contract over the owner-node
|
|
3
|
+
* channel adapter. Validates the two behaviours the realignment must
|
|
4
|
+
* preserve:
|
|
5
|
+
* 1. With the node up, POST /auth/invites returns an encodedInvite (no 500).
|
|
6
|
+
* 2. With the node refusing connections, GET /auth/trust-circle still lists
|
|
7
|
+
* local labels, and POST /auth/invites returns 503 (not a raw 500).
|
|
8
|
+
*
|
|
9
|
+
* The owner node is represented by a stub HTTP server matching the 6.6
|
|
10
|
+
* admin contract (the spawn path itself is covered by
|
|
11
|
+
* orchestrator-owner.test.ts).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
15
|
+
import Fastify from 'fastify';
|
|
16
|
+
import http from 'node:http';
|
|
17
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { TrustCircleService, TrustCircleStore, createTrustCircleHandlers } from '../auth/index.js';
|
|
22
|
+
import { OwnerNodeClient } from '../owner/index.js';
|
|
23
|
+
import { registerErrorHandler } from '../server/error-handler.js';
|
|
24
|
+
import { registerTrustCircleRoutes } from '../server/routes/trust-circle.js';
|
|
25
|
+
import { EventBus } from '../server/events/bus.js';
|
|
26
|
+
|
|
27
|
+
const TOKEN = 'tok';
|
|
28
|
+
|
|
29
|
+
let tmpRoot: string;
|
|
30
|
+
beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-smoke-')); });
|
|
31
|
+
afterEach(() => { try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ } });
|
|
32
|
+
|
|
33
|
+
function buildApp(service: TrustCircleService) {
|
|
34
|
+
const app = Fastify();
|
|
35
|
+
registerErrorHandler(app);
|
|
36
|
+
registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(service), events: new EventBus() });
|
|
37
|
+
return app;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('host /auth delegation — node up', () => {
|
|
41
|
+
let server: http.Server;
|
|
42
|
+
let baseUrl: string;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
server = http.createServer((req, res) => {
|
|
46
|
+
const chunks: Buffer[] = [];
|
|
47
|
+
req.on('data', (c) => chunks.push(c as Buffer));
|
|
48
|
+
req.on('end', () => {
|
|
49
|
+
if (req.headers['authorization'] !== `Bearer ${TOKEN}`) {
|
|
50
|
+
res.writeHead(401); res.end(JSON.stringify({ ok: false, error: { code: 'not_authorized', message: 'x' } })); return;
|
|
51
|
+
}
|
|
52
|
+
if (req.url === '/admin/invites' && req.method === 'POST') {
|
|
53
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
54
|
+
res.end(JSON.stringify({ ok: true, data: { invite: { partyId: 'p', ownerAddrs: [], token: 't', createdAt: 1 }, encodedInvite: 'ENC' } }));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (req.url === '/admin/members' && req.method === 'GET') {
|
|
58
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
59
|
+
res.end(JSON.stringify({ ok: true, data: { members: [] } }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
res.writeHead(400); res.end(JSON.stringify({ ok: false, error: { code: 'bad_request', message: req.url } }));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
|
|
66
|
+
const a = server.address();
|
|
67
|
+
baseUrl = `http://127.0.0.1:${typeof a === 'object' && a ? a.port : 0}`;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(async () => { await new Promise<void>((resolve) => server.close(() => resolve())); });
|
|
71
|
+
|
|
72
|
+
it('POST /auth/invites returns an encodedInvite (no 500)', async () => {
|
|
73
|
+
const client = new OwnerNodeClient({ baseUrl, token: TOKEN });
|
|
74
|
+
const service = new TrustCircleService({ cadreNode: client, store: new TrustCircleStore(tmpRoot) });
|
|
75
|
+
const app = buildApp(service);
|
|
76
|
+
try {
|
|
77
|
+
const res = await app.inject({ method: 'POST', url: '/auth/invites', payload: { label: 'Mom' } });
|
|
78
|
+
expect(res.statusCode).toBe(200);
|
|
79
|
+
const body = res.json() as { encodedInvite: string };
|
|
80
|
+
expect(body.encodedInvite).toBe('ENC');
|
|
81
|
+
} finally {
|
|
82
|
+
await app.close();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('host /auth delegation — node refusing connections', () => {
|
|
88
|
+
let closedBaseUrl: string;
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
const tmp = http.createServer();
|
|
92
|
+
const port = await new Promise<number>((resolve) => {
|
|
93
|
+
tmp.listen(0, '127.0.0.1', () => {
|
|
94
|
+
const a = tmp.address();
|
|
95
|
+
resolve(typeof a === 'object' && a ? a.port : 0);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
await new Promise<void>((resolve) => tmp.close(() => resolve()));
|
|
99
|
+
closedBaseUrl = `http://127.0.0.1:${port}`;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('GET /auth/trust-circle lists local labels and POST /auth/invites returns 503', async () => {
|
|
103
|
+
const store = new TrustCircleStore(tmpRoot);
|
|
104
|
+
store.addMember({ peerId: '12D3KooWLocal', label: 'My laptop', addedAt: '2025-01-01T00:00:00Z' });
|
|
105
|
+
|
|
106
|
+
const client = new OwnerNodeClient({ baseUrl: closedBaseUrl, token: TOKEN });
|
|
107
|
+
const service = new TrustCircleService({ cadreNode: client, store });
|
|
108
|
+
const app = buildApp(service);
|
|
109
|
+
try {
|
|
110
|
+
const list = await app.inject({ method: 'GET', url: '/auth/trust-circle' });
|
|
111
|
+
expect(list.statusCode).toBe(200);
|
|
112
|
+
const snap = list.json() as { members: Array<{ peerId: string; label: string }> };
|
|
113
|
+
expect(snap.members.some((m) => m.peerId === '12D3KooWLocal' && m.label === 'My laptop')).toBe(true);
|
|
114
|
+
|
|
115
|
+
const invite = await app.inject({ method: 'POST', url: '/auth/invites', payload: { label: 'Dad' } });
|
|
116
|
+
expect(invite.statusCode).toBe(503);
|
|
117
|
+
const body = invite.json() as { error: { code: string } };
|
|
118
|
+
expect(body.error.code).toBe('node_unavailable');
|
|
119
|
+
} finally {
|
|
120
|
+
await app.close();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|