@huynle/hub 0.7.1 → 0.7.2

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 (185) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,323 @@
1
+ // ArtifactManifest and TrustRoot: how a hubctl download is authenticated before it is installed.
2
+ // - A release publishes one manifest listing, per supported target, the file, its size and its sha256.
3
+ // - The manifest is signed (ed25519, detached, over its canonical JSON without `signatures`) by release keys
4
+ // listed in a trust root that ships inside hubctl. `threshold` distinct active keys must sign.
5
+ // - The trust root rotates only forward: a new root (higher version) must be signed by the current root's keys.
6
+ // - The installer picks the host's exact target (never a "close enough" one), checks size and digest of the
7
+ // bytes, and refuses downgrades unless explicitly allowed; nothing goes below the security floor.
8
+ import { createPublicKey, verify } from "node:crypto"
9
+ import { canonicalJSON } from "./canonical.ts"
10
+ import type { InstallHarness } from "./actions.ts"
11
+ import { HARNESS_EVIDENCE, HARNESS_ORDER, type EvidenceKind, type HarnessID } from "./harness.ts"
12
+ import { checkTarget } from "./platform.ts"
13
+ import { Checker, digest, fail, FILE_NAME, header, httpsURL, instant, int, isRecord, list, ok, oneOf, pointer, semver, SEMVER, shape, subset, text, type Result } from "./validate.ts"
14
+
15
+ export const MANIFEST_SCHEMA = "hub.artifact-manifest"
16
+ export const MANIFEST_VERSION = 1
17
+ export const TRUST_ROOT_SCHEMA = "hub.trust-root"
18
+
19
+ export interface ArtifactEntry {
20
+ readonly target: string
21
+ /** A plain file name in the release (no directories). */
22
+ readonly file: string
23
+ readonly size: number
24
+ readonly sha256: string
25
+ }
26
+
27
+ /**
28
+ * A harness artifact pinned by hub's signed manifest: hub vouches for exactly this upstream file (transitive
29
+ * trust), so even an upstream that publishes no signature (OpenCode) is installed only if its bytes match.
30
+ */
31
+ export interface HarnessPin {
32
+ readonly harness: HarnessID
33
+ /** Exact version; never a range or channel. */
34
+ readonly version: string
35
+ readonly target: string
36
+ /** The upstream download (https). */
37
+ readonly url: string
38
+ readonly file: string
39
+ readonly size: number
40
+ readonly sha256: string
41
+ /** Upstream evidence available for this file (attempted at install, recorded, never required). */
42
+ readonly evidence: readonly EvidenceKind[]
43
+ /** This harness's security floor: no install below it, even with --allow-downgrade. */
44
+ readonly minimumVersion?: string
45
+ }
46
+
47
+ export interface Signature {
48
+ readonly keyID: string
49
+ readonly algorithm: "ed25519"
50
+ /** base64 of the raw signature. */
51
+ readonly value: string
52
+ }
53
+
54
+ export interface ArtifactManifest {
55
+ readonly schema: typeof MANIFEST_SCHEMA
56
+ readonly version: typeof MANIFEST_VERSION
57
+ readonly product: "hubctl"
58
+ readonly release: string
59
+ readonly channel: "stable" | "next"
60
+ /** The security floor: no install of an older release than this is ever accepted once seen. */
61
+ readonly minimumVersion: string
62
+ readonly issuedAt: number
63
+ readonly artifacts: readonly ArtifactEntry[]
64
+ /** Harness artifacts this release pins, per harness and target (covered by the signatures). */
65
+ readonly harnesses: readonly HarnessPin[]
66
+ readonly signatures: readonly Signature[]
67
+ }
68
+
69
+ declare const VERIFIED: unique symbol
70
+ /** A manifest whose signatures verifyManifest checked; only it can produce one. */
71
+ export type VerifiedManifest = ArtifactManifest & { readonly [VERIFIED]: true }
72
+
73
+ export interface TrustKey {
74
+ readonly keyID: string
75
+ readonly algorithm: "ed25519"
76
+ /** base64 of the DER (SPKI) public key. */
77
+ readonly publicKey: string
78
+ /** active: counts; retired: no longer signs new releases or roots; revoked: never counts. */
79
+ readonly status: "active" | "retired" | "revoked"
80
+ }
81
+
82
+ export interface TrustRoot {
83
+ readonly schema: typeof TRUST_ROOT_SCHEMA
84
+ readonly version: number
85
+ readonly threshold: number
86
+ readonly keys: readonly TrustKey[]
87
+ }
88
+
89
+ const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/
90
+ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/
91
+
92
+ function checkSignatures(c: Checker, path: string, v: unknown) {
93
+ list(c, path, v, 16, (p, s) => {
94
+ const r = shape(c, p, s, ["keyID", "algorithm", "value"])
95
+ if (!r) return
96
+ text(c, pointer(p, "keyID"), r.keyID, 64, KEY_ID)
97
+ oneOf(c, pointer(p, "algorithm"), r.algorithm, ["ed25519"] as const)
98
+ text(c, pointer(p, "value"), r.value, 1024, BASE64)
99
+ })
100
+ }
101
+
102
+ export function validateArtifactManifest(value: unknown): Result<ArtifactManifest> {
103
+ const c = new Checker()
104
+ const r = shape(c, "", value, ["schema", "version", "product", "release", "channel", "minimumVersion", "issuedAt", "artifacts", "harnesses", "signatures"])
105
+ if (!r) return c.result(value)
106
+ header(c, "", r, MANIFEST_SCHEMA, MANIFEST_VERSION)
107
+ if (r.product !== "hubctl") c.add("/product", "invalid_value", "expected product hubctl")
108
+ const releaseOK = semver(c, "/release", r.release)
109
+ const floorOK = semver(c, "/minimumVersion", r.minimumVersion)
110
+ if (releaseOK && floorOK && compareVersions(r.minimumVersion as string, r.release as string) > 0) c.add("/minimumVersion", "inconsistent", "the security floor cannot be above the release")
111
+ oneOf(c, "/channel", r.channel, ["stable", "next"] as const)
112
+ instant(c, "/issuedAt", r.issuedAt)
113
+ if (Array.isArray(r.artifacts) && r.artifacts.length === 0) c.add("/artifacts", "invalid_value", "a manifest lists at least one artifact")
114
+ list(c, "/artifacts", r.artifacts, 16, (p, a) => {
115
+ const e = shape(c, p, a, ["target", "file", "size", "sha256"])
116
+ if (!e) return
117
+ if (typeof e.target !== "string" || !checkTarget(e.target).ok) c.add(pointer(p, "target"), "unsupported_platform", `${String(e.target)} is not a supported target`)
118
+ if (typeof e.file !== "string" || !FILE_NAME.test(e.file)) c.add(pointer(p, "file"), "invalid_path", "expected a plain file name")
119
+ int(c, pointer(p, "size"), e.size, 1, 2 ** 40)
120
+ digest(c, pointer(p, "sha256"), e.sha256)
121
+ }, (a) => (isRecord(a) && typeof a.target === "string" ? a.target : undefined))
122
+ list(c, "/harnesses", r.harnesses, 64, (p, h) => checkHarnessPinShape(c, p, h), (h) => (isRecord(h) ? `${String(h.harness)}@${String(h.target)}` : undefined))
123
+ checkSignatures(c, "/signatures", r.signatures)
124
+ return c.result(value)
125
+ }
126
+
127
+ function checkHarnessPinShape(c: Checker, p: string, v: unknown) {
128
+ const h = shape(c, p, v, ["harness", "version", "target", "url", "file", "size", "sha256", "evidence"], ["minimumVersion"])
129
+ if (!h) return
130
+ const harnessOK = oneOf(c, pointer(p, "harness"), h.harness, HARNESS_ORDER)
131
+ const versionOK = semver(c, pointer(p, "version"), h.version)
132
+ if (typeof h.target !== "string" || !checkTarget(h.target).ok) c.add(pointer(p, "target"), "unsupported_platform", `${String(h.target)} is not a supported target`)
133
+ httpsURL(c, pointer(p, "url"), h.url)
134
+ if (typeof h.file !== "string" || !FILE_NAME.test(h.file)) c.add(pointer(p, "file"), "invalid_path", "expected a plain file name")
135
+ int(c, pointer(p, "size"), h.size, 1, 2 ** 40)
136
+ digest(c, pointer(p, "sha256"), h.sha256)
137
+ if (harnessOK) subset(c, pointer(p, "evidence"), h.evidence, HARNESS_EVIDENCE[h.harness as HarnessID])
138
+ if (h.minimumVersion !== undefined && semver(c, pointer(p, "minimumVersion"), h.minimumVersion) && versionOK && compareVersions(h.minimumVersion as string, h.version as string) > 0)
139
+ c.add(pointer(p, "minimumVersion"), "inconsistent", "the harness floor cannot be above the pinned version")
140
+ }
141
+
142
+ export function validateTrustRoot(value: unknown): Result<TrustRoot> {
143
+ const c = new Checker()
144
+ const r = shape(c, "", value, ["schema", "version", "threshold", "keys"])
145
+ if (!r) return c.result(value)
146
+ if (r.schema !== TRUST_ROOT_SCHEMA) c.add("/schema", "invalid_value", `expected schema ${TRUST_ROOT_SCHEMA}`)
147
+ int(c, "/version", r.version, 1, 2 ** 31)
148
+ list(c, "/keys", r.keys, 32, (p, k) => {
149
+ const e = shape(c, p, k, ["keyID", "algorithm", "publicKey", "status"])
150
+ if (!e) return
151
+ text(c, pointer(p, "keyID"), e.keyID, 64, KEY_ID)
152
+ oneOf(c, pointer(p, "algorithm"), e.algorithm, ["ed25519"] as const)
153
+ text(c, pointer(p, "publicKey"), e.publicKey, 1024, BASE64)
154
+ oneOf(c, pointer(p, "status"), e.status, ["active", "retired", "revoked"] as const)
155
+ }, (k) => (isRecord(k) && typeof k.keyID === "string" ? k.keyID : undefined))
156
+ const active = Array.isArray(r.keys) ? r.keys.filter((k) => isRecord(k) && k.status === "active").length : 0
157
+ if (int(c, "/threshold", r.threshold, 1, 32) && (r.threshold as number) > active) c.add("/threshold", "inconsistent", "the threshold exceeds the active keys")
158
+ return c.result(value)
159
+ }
160
+
161
+ export type SignatureVerifier = (payload: string, signature: string, publicKey: string) => boolean
162
+
163
+ /** ed25519 over the payload's UTF-8 bytes; false on any malformed input. */
164
+ export const ed25519Verify: SignatureVerifier = (payload, signature, publicKey) => {
165
+ try {
166
+ const key = createPublicKey({ key: Buffer.from(publicKey, "base64"), format: "der", type: "spki" })
167
+ return key.asymmetricKeyType === "ed25519" && verify(null, Buffer.from(payload, "utf8"), key, Buffer.from(signature, "base64"))
168
+ } catch {
169
+ return false
170
+ }
171
+ }
172
+
173
+ /** The bytes a manifest's signatures cover: its canonical JSON without `signatures`. */
174
+ export function manifestPayload(manifest: Omit<ArtifactManifest, "signatures"> & { readonly signatures?: unknown }): string {
175
+ const { signatures: _signatures, ...body } = manifest
176
+ return canonicalJSON(body)
177
+ }
178
+
179
+ /** How many distinct active keys of `root` validly signed `payload`. */
180
+ function countSigners(payload: string, signatures: readonly Signature[], root: TrustRoot, verifier: SignatureVerifier): number {
181
+ const signed = new Set<string>()
182
+ for (const s of signatures) {
183
+ const key = root.keys.find((k) => k.keyID === s.keyID && k.status === "active" && k.algorithm === s.algorithm)
184
+ if (key && !signed.has(key.keyID) && verifier(payload, s.value, key.publicKey)) signed.add(key.keyID)
185
+ }
186
+ return signed.size
187
+ }
188
+
189
+ export function verifyManifest(manifest: unknown, root: TrustRoot, verifier: SignatureVerifier = ed25519Verify): Result<VerifiedManifest> {
190
+ const m = validateArtifactManifest(manifest)
191
+ if (!m.ok) return m as Result<VerifiedManifest>
192
+ const r = validateTrustRoot(root)
193
+ if (!r.ok) return fail("/signatures", "signature_invalid", `no usable trust root: ${r.issues.map((i) => i.message).join("; ")}`)
194
+ const signers = countSigners(manifestPayload(m.value), m.value.signatures, root, verifier)
195
+ if (signers < root.threshold) return fail("/signatures", "signature_invalid", `the manifest needs ${root.threshold} valid signature(s) from trusted keys, found ${signers}`)
196
+ return ok(m.value as VerifiedManifest)
197
+ }
198
+
199
+ /** Accepts `next` only if it is newer and signed (over its canonical JSON) by `threshold` active keys of `current`. */
200
+ export function rotateTrustRoot(current: TrustRoot, next: unknown, signatures: readonly Signature[], verifier: SignatureVerifier = ed25519Verify): Result<TrustRoot> {
201
+ const n = validateTrustRoot(next)
202
+ if (!n.ok) return n
203
+ if (n.value.version <= current.version) return fail("/version", "trust_root_rollback", `trust root version ${n.value.version} is not newer than ${current.version}`)
204
+ const signers = countSigners(canonicalJSON(n.value), signatures, current, verifier)
205
+ if (signers < current.threshold) return fail("/signatures", "signature_invalid", "the new trust root is not signed by the current root's keys")
206
+ return n
207
+ }
208
+
209
+ /** The manifest's artifact for exactly this target. */
210
+ export function selectArtifact(manifest: ArtifactManifest, target: string): Result<ArtifactEntry> {
211
+ if (!checkTarget(target).ok) return fail("", "wrong_platform", `${target} is not a supported target`)
212
+ const entry = manifest.artifacts.find((a) => a.target === target)
213
+ return entry ? ok(entry) : fail("", "wrong_platform", `release ${manifest.release} has no hubctl for ${target}`)
214
+ }
215
+
216
+ /** The downloaded bytes against the manifest entry. */
217
+ export function checkArtifactBytes(entry: ArtifactEntry, bytes: { readonly sha256: string; readonly size: number }): Result<ArtifactEntry> {
218
+ if (bytes.size !== entry.size) return fail("/size", "integrity_mismatch", `expected ${entry.size} bytes, got ${bytes.size}`)
219
+ if (bytes.sha256 !== entry.sha256) return fail("/sha256", "integrity_mismatch", "the download does not match the manifest digest")
220
+ return ok(entry)
221
+ }
222
+
223
+ type Parsed = { core: [number, number, number]; pre: string[] }
224
+
225
+ function parse(version: string): Parsed {
226
+ const m = SEMVER.exec(version)
227
+ if (!m) throw new Error(`not a semantic version: ${version}`)
228
+ return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split(".") : [] }
229
+ }
230
+
231
+ /** Semantic-version order (build metadata ignored; a pre-release sorts before its release). */
232
+ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
233
+ const x = parse(a)
234
+ const y = parse(b)
235
+ for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i]! < y.core[i]! ? -1 : 1
236
+ if (x.pre.length === 0 || y.pre.length === 0) return x.pre.length === y.pre.length ? 0 : x.pre.length === 0 ? 1 : -1
237
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
238
+ const p = x.pre[i]
239
+ const q = y.pre[i]
240
+ if (p === undefined) return -1
241
+ if (q === undefined) return 1
242
+ if (p === q) continue
243
+ const pn = /^\d+$/.test(p)
244
+ const qn = /^\d+$/.test(q)
245
+ if (pn && qn) return Number(p) < Number(q) ? -1 : 1
246
+ if (pn !== qn) return pn ? -1 : 1
247
+ return p < q ? -1 : 1
248
+ }
249
+ return 0
250
+ }
251
+
252
+ export interface InstallDecisionInput {
253
+ readonly candidate: { readonly release: string; readonly minimumVersion: string; readonly sha256: string }
254
+ readonly installed?: { readonly version: string; readonly sha256: string }
255
+ /** The highest minimumVersion this machine has seen in a verified manifest. */
256
+ readonly floor?: string
257
+ /** Explicit user consent to install an older release (still never below the floor). */
258
+ readonly allowDowngrade?: boolean
259
+ }
260
+
261
+ export type InstallKind = "install" | "upgrade" | "reinstall" | "downgrade"
262
+
263
+ export function decideInstall(input: InstallDecisionInput): Result<InstallKind> {
264
+ const { candidate, installed } = input
265
+ const floors = [candidate.minimumVersion, ...(input.floor ? [input.floor] : [])]
266
+ const floor = floors.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b))
267
+ if (compareVersions(candidate.release, floor) < 0) return fail("/release", "below_security_floor", `${candidate.release} is below the security floor ${floor}`)
268
+ if (!installed) return ok("install")
269
+ const order = compareVersions(candidate.release, installed.version)
270
+ if (order > 0) return ok("upgrade")
271
+ if (order === 0) return installed.sha256 === candidate.sha256 ? ok("reinstall") : fail("/sha256", "integrity_mismatch", `${candidate.release} is already installed with different bytes`)
272
+ return input.allowDowngrade ? ok("downgrade") : fail("/release", "downgrade_rejected", `${candidate.release} is older than the installed ${installed.version}; pass --allow-downgrade to install it`)
273
+ }
274
+
275
+ // ---------- pinned harness installs ----------
276
+
277
+ const sameSet = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((x) => b.includes(x))
278
+
279
+ /**
280
+ * An install-harness action against the verified manifest it names: the pin for exactly this harness and target
281
+ * must exist at exactly this version (else unpinned_artifact), with the same source, size and digest (else
282
+ * integrity_mismatch), and the action must attempt exactly the upstream evidence the pin lists (inconsistent).
283
+ */
284
+ export function checkHarnessPin(manifest: VerifiedManifest, action: InstallHarness): Result<HarnessPin> {
285
+ if (action.source.manifest !== manifest.release) return fail("/source/manifest", "unpinned_artifact", `the install names hub ${action.source.manifest}, but the verified manifest is ${manifest.release}`)
286
+ const pin = manifest.harnesses.find((h) => h.harness === action.harness && h.target === action.target)
287
+ if (!pin) return fail("", "unpinned_artifact", `hub ${manifest.release} pins no ${action.harness} for ${action.target}`)
288
+ if (pin.version !== action.version) return fail("/version", "unpinned_artifact", `hub ${manifest.release} pins ${action.harness} ${pin.version}, not ${action.version}`)
289
+ if (pin.url !== action.source.url || pin.file !== action.source.file) return fail("/source", "integrity_mismatch", "the download source differs from the pinned one")
290
+ if (pin.sha256 !== action.sha256 || pin.size !== action.size) return fail("/sha256", "integrity_mismatch", "the digest or size differs from the pinned one")
291
+ if (!sameSet(pin.evidence, action.evidence)) return fail("/evidence", "inconsistent", `the install attempts exactly the available evidence (${pin.evidence.join(", ") || "none"})`)
292
+ return ok(pin)
293
+ }
294
+
295
+ /** The result of one evidence check on a downloaded harness artifact. */
296
+ export interface EvidenceResult {
297
+ readonly kind: EvidenceKind | "sha256-pin"
298
+ readonly status: "verified" | "unavailable" | "failed"
299
+ /** One line ("no gpg on PATH", "Good signature"); never a credential. */
300
+ readonly detail?: string
301
+ }
302
+
303
+ /**
304
+ * Checks the downloaded bytes against the pinned digest first (mandatory: there is always at least this
305
+ * evidence), then folds in the upstream checks: any failure rejects the artifact (evidence_failed), a planned check
306
+ * that did not run is "unavailable", and a check nobody planned is refused. Returns the report to record.
307
+ */
308
+ export function checkHarnessEvidence(action: InstallHarness, bytes: { readonly sha256: string; readonly size: number }, results: readonly EvidenceResult[]): Result<EvidenceResult[]> {
309
+ if (bytes.sha256 !== action.sha256 || bytes.size !== action.size) return fail("/sha256", "integrity_mismatch", "the download does not match the pinned digest")
310
+ for (const result of results) {
311
+ if (result.kind === "sha256-pin" || !action.evidence.includes(result.kind)) return fail("/evidence", "inconsistent", `${result.kind} was not planned for this install`)
312
+ if (result.status === "failed") return fail("/evidence", "evidence_failed", `${result.kind} verification failed${result.detail ? `: ${result.detail}` : ""}`)
313
+ }
314
+ return ok([{ kind: "sha256-pin", status: "verified" }, ...action.evidence.map((kind): EvidenceResult => results.find((r) => r.kind === kind) ?? { kind, status: "unavailable" })])
315
+ }
316
+
317
+ /** The downgrade policy for a pinned harness (decideInstall with the pin's floor). */
318
+ export function decideHarnessInstall(
319
+ pin: HarnessPin,
320
+ options: { readonly installed?: { readonly version: string; readonly sha256: string }; readonly floor?: string; readonly allowDowngrade?: boolean },
321
+ ): Result<InstallKind> {
322
+ return decideInstall({ candidate: { release: pin.version, minimumVersion: pin.minimumVersion ?? "0.0.0", sha256: pin.sha256 }, ...options })
323
+ }
@@ -0,0 +1,54 @@
1
+ // Canonical JSON and digests. A plan digest, a preview token and a manifest signature all hash the same bytes on
2
+ // every machine and every run: object keys sorted at every depth, no whitespace, `undefined` fields dropped, and
3
+ // anything JSON cannot represent exactly (NaN, Infinity, dates, functions, class instances) refused outright.
4
+ import { createHash } from "node:crypto"
5
+
6
+ export function canonicalJSON(value: unknown): string {
7
+ return encode(value, "")
8
+ }
9
+
10
+ function encode(value: unknown, path: string): string {
11
+ if (value === null) return "null"
12
+ switch (typeof value) {
13
+ case "boolean":
14
+ return value ? "true" : "false"
15
+ case "string":
16
+ return JSON.stringify(value)
17
+ case "number":
18
+ if (!Number.isFinite(value)) throw new TypeError(`canonicalJSON: ${path || "value"} is not a finite number`)
19
+ return JSON.stringify(value)
20
+ case "object": {
21
+ if (Array.isArray(value)) return `[${value.map((v, i) => encode(v === undefined ? null : v, `${path}/${i}`)).join(",")}]`
22
+ const proto = Object.getPrototypeOf(value)
23
+ if (proto !== Object.prototype && proto !== null) throw new TypeError(`canonicalJSON: ${path || "value"} is not a plain object`)
24
+ const entries = Object.entries(value as Record<string, unknown>)
25
+ .filter(([, v]) => v !== undefined)
26
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
27
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${encode(v, `${path}/${k}`)}`).join(",")}}`
28
+ }
29
+ default:
30
+ throw new TypeError(`canonicalJSON: ${path || "value"} (${typeof value}) has no JSON form`)
31
+ }
32
+ }
33
+
34
+ export const sha256Hex = (data: string | Uint8Array): string => createHash("sha256").update(data).digest("hex")
35
+
36
+ /** "sha256:<hex>" of the canonical JSON of `value`. */
37
+ export const digestOf = (value: unknown): string => `sha256:${sha256Hex(canonicalJSON(value))}`
38
+
39
+ /** What identifies the host a plan was made for. The raw machine ID never leaves this function. */
40
+ export interface HostIdentity {
41
+ /** /etc/machine-id (Linux) or IOPlatformUUID (macOS). */
42
+ readonly machineID: string
43
+ readonly platform: string
44
+ readonly arch: string
45
+ readonly uid: number
46
+ readonly home: string
47
+ }
48
+
49
+ /**
50
+ * The host fingerprint previews and journals are bound to: a digest over the machine ID, platform, architecture,
51
+ * user and home, so a preview made for another machine or another account never applies here.
52
+ */
53
+ export const hostFingerprint = (host: HostIdentity): string =>
54
+ digestOf({ kind: "hub.host-fingerprint", machineID: sha256Hex(host.machineID), platform: host.platform, arch: host.arch, uid: host.uid, home: host.home })
@@ -0,0 +1,108 @@
1
+ // Machine enrollment and the credential store. One machine identity (machineID + machine token, from the existing
2
+ // device-code flow in packages/spoke-sdk/src/enroll.ts against POST /spoke/enroll and /spoke/enroll/poll) is shared
3
+ // by every spoke and `hub node` on the machine. The token lives only in the CredentialStore; everything else
4
+ // (status output, plans, journals, logs, generated Ansible) sees an EnrollmentStatus, which has no field for it.
5
+ import type { EnrollPoll, EnrollStart } from "../../../../protocol/src/index.ts"
6
+ import { findSecrets } from "./secrets.ts"
7
+ import { Checker, header, hubURL, instant, isInsecureHubURL, oneOf, pointer, shape, text, type Result } from "./validate.ts"
8
+
9
+ /**
10
+ * unconfigured: no machine credential. configured: a credential is stored but not checked. verified: the hub
11
+ * accepted it just now. revoked: the hub rejected it (re-enroll). offline: the hub could not be reached to check.
12
+ * insecure (v1.1): a credential is stored for a plain-http hub that is not on loopback. It is reported (with a
13
+ * warning) and never sent there to be checked; a new enrollment over plain http to such a hub stays refused.
14
+ */
15
+ export const ENROLLMENT_STATES = ["unconfigured", "configured", "verified", "revoked", "offline", "insecure"] as const
16
+ export type EnrollmentState = (typeof ENROLLMENT_STATES)[number]
17
+
18
+ export const ENROLLMENT_SCHEMA = "hub.enrollment-status"
19
+
20
+ /** Secret-free: what `hubctl machine status` prints. */
21
+ export interface EnrollmentStatus {
22
+ readonly schema: typeof ENROLLMENT_SCHEMA
23
+ readonly version: 1
24
+ readonly state: EnrollmentState
25
+ readonly server?: string
26
+ readonly machineID?: string
27
+ readonly machineName?: string
28
+ readonly checkedAt?: number
29
+ /** One line for people; never a credential. */
30
+ readonly detail?: string
31
+ }
32
+
33
+ /** The stored identity. Only the CredentialStore reads or writes it; never serialize it anywhere else. */
34
+ export interface MachineCredential {
35
+ readonly server: string
36
+ readonly machineID: string
37
+ readonly machineName?: string
38
+ readonly token: string
39
+ }
40
+
41
+ /**
42
+ * Where the machine credential lives (today: the 0600 spoke config.json, see src/config.ts). Implementations must
43
+ * write atomically with owner-only permissions and merge losslessly: keys they do not know are preserved.
44
+ */
45
+ export interface CredentialStore {
46
+ /** The file (or keychain item) it uses, for status output. */
47
+ readonly location: string
48
+ read(): Promise<MachineCredential | undefined>
49
+ write(credential: MachineCredential): Promise<void>
50
+ /** Removes the credential only, keeping the rest of the config. */
51
+ clear(): Promise<void>
52
+ }
53
+
54
+ /** The device-code flow (implemented by spoke-sdk startEnrollment / pollEnrollment). */
55
+ export interface MachineEnrollment {
56
+ start(server: string, machineName: string, signal?: AbortSignal): Promise<EnrollStart>
57
+ poll(server: string, start: EnrollStart, signal?: AbortSignal): Promise<Exclude<EnrollPoll, { status: "pending" }> | { status: "cancelled" }>
58
+ }
59
+
60
+ /** The result of asking the hub whether the stored credential is still good. */
61
+ export interface CredentialProbe {
62
+ readonly reachable: boolean
63
+ /** Set when reachable: did the hub accept the credential? */
64
+ readonly accepted?: boolean
65
+ }
66
+
67
+ const MACHINE_ID = /^[A-Za-z0-9_.:-]{1,128}$/
68
+ const MACHINE_NAME = /^[A-Za-z0-9._ -]{1,64}$/
69
+
70
+ export function enrollmentStatusFrom(credential: MachineCredential | undefined, probe: CredentialProbe | undefined, now: number): EnrollmentStatus {
71
+ const base = { schema: ENROLLMENT_SCHEMA, version: 1 } as const
72
+ if (!credential) return { ...base, state: "unconfigured" }
73
+ // v1.1: a plain-http, non-loopback hub is never probed, so no probe result can make it verified.
74
+ const insecure = isInsecureHubURL(credential.server)
75
+ if (insecure) probe = undefined
76
+ const state: EnrollmentState = insecure ? "insecure" : !probe ? "configured" : !probe.reachable ? "offline" : probe.accepted ? "verified" : "revoked"
77
+ return {
78
+ ...base,
79
+ state,
80
+ server: credential.server,
81
+ machineID: credential.machineID,
82
+ ...(credential.machineName ? { machineName: credential.machineName } : {}),
83
+ ...(probe ? { checkedAt: now } : {}),
84
+ }
85
+ }
86
+
87
+ export function validateEnrollmentStatus(value: unknown): Result<EnrollmentStatus> {
88
+ const c = new Checker()
89
+ const r = shape(c, "", value, ["schema", "version", "state"], ["server", "machineID", "machineName", "checkedAt", "detail"])
90
+ if (!r) return c.result(value)
91
+ header(c, "", r, ENROLLMENT_SCHEMA, 1)
92
+ oneOf(c, "/state", r.state, ENROLLMENT_STATES)
93
+ if (r.server !== undefined) {
94
+ if (r.state === "insecure") {
95
+ if (!isInsecureHubURL(r.server)) c.add("/server", "inconsistent", "insecure names a plain-http hub that is not on loopback")
96
+ } else hubURL(c, "/server", r.server)
97
+ }
98
+ if (r.state === "insecure" && r.checkedAt !== undefined) c.add(pointer("", "checkedAt"), "inconsistent", "an insecure hub is never checked")
99
+ if (r.machineID !== undefined) text(c, "/machineID", r.machineID, 128, MACHINE_ID)
100
+ if (r.machineName !== undefined) text(c, "/machineName", r.machineName, 64, MACHINE_NAME)
101
+ if (r.checkedAt !== undefined) instant(c, "/checkedAt", r.checkedAt)
102
+ if (r.detail !== undefined) text(c, "/detail", r.detail)
103
+ if (r.state === "unconfigured" && (r.server !== undefined || r.machineID !== undefined)) c.add("", "inconsistent", "an unconfigured machine has no server or machine ID")
104
+ if (r.state !== "unconfigured" && typeof r.state === "string" && (r.server === undefined || r.machineID === undefined)) c.add("", "inconsistent", `${r.state} needs the server and machine ID`)
105
+ if ((r.state === "verified" || r.state === "revoked") && r.checkedAt === undefined) c.add("/checkedAt", "inconsistent", `${r.state} records when it was checked`)
106
+ for (const p of findSecrets(value)) c.add(p, "secret", "status never carries credentials")
107
+ return c.result(value)
108
+ }