@huynle/hub 0.7.1 → 0.7.3
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/CHANGELOG.md +87 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- 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
|
+
}
|