OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar commits / 24bba05

0.2.55: boot harness for the name RPCs, and fix a pre-existing red test
The sidecar handlers had no boot harness — the only sidecar test was constructor-only — so #142 and #143 were both unproven against a running service. test/name-update-rpc.test.js boots a fully scratch Sidecar (own root, stores, socket; no swarm, no seeding, no subscriptions) and drives it over UDS the way the CLI does. The identity is a SYNTHETIC LINKED DEVICE: a generated keet root that has attested a generated device, written out as a linked-device.json. That is the shape #142 was about, and it gives update-name a real chain to sign and verify rather than a hand-made identity object. Verified the harness actually catches the bug: with identityRootHex reverted to its pre-#142 form, 3 of the 4 tests fail (claim anchors to the device key, identity-info reports the device, and the update's anchor assertion). All 4 pass restored. keet-identity-key is declared as a devDependency and symlinked into node_modules, matching how the other siblings here are wired — this package's deps are hand-linked, not npm-installed. Also fixes test/sidecar.test.js, which asserted a raw identity.json exists whenever source !== 'hyperident'. source === 'linked' writes no local keypair, so it failed on any machine that has run `pgh device join`. Pre-existing: it fails identically at 0.2.52, before any of this work. Now gated on raw/legacy, which are the only paths that write that file. 26/26, 85/85 across the sidecar suite. Forge org #142, #143. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24bba05d6fbeb4fbf8b48f34656a3a109eb05016
by plankatron <fleeky@gmail.com> · 2026-08-25T14:46:44.000Z

Changed files (3) +174 -7

package.json +3 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh-sidecar",
3 "version": "0.2.54",
3 "version": "0.2.55",
44 "description": "Small optional user-level daemon for pgh \u2014 shared identity vault, OTA polling, device linking, bootstrap seed, NameRegistry host",
55 "type": "module",
66 "main": "lib/index.js",
……@@ -55,7 +55,8 @@
5555 },
5656 "devDependencies": {
5757 "@hyperswarm/testnet": "^3.0.0",
58 "brittle": "^3.0.0"
58 "brittle": "^3.0.0",
59 "keet-identity-key": "^3.1.0"
5960 },
6061 "engines": {
6162 "node": ">=18",
test/name-update-rpc.test.js +162 -0
……@@ -0,0 +1,162 @@
1/**
2 * claim-name / update-name over the real UDS surface (forge org #142 + #143).
3 *
4 * The sidecar handlers had no boot harness — the other sidecar test is
5 * constructor-only — so both fixes were unproven against an actual running
6 * service. This boots a fully scratch Sidecar (own root, own stores, own
7 * socket, no swarm, no seeding, no subscriptions) and drives it as the CLI
8 * does.
9 *
10 * The identity is a SYNTHETIC LINKED DEVICE: a generated keet root that has
11 * attested a generated device, written out as a linked-device.json. That is
12 * the exact shape #142 was about — `tryLoadLinkedDevice` populates
13 * rootPubkeyHex and never masterPublicKey, so a handler reaching for
14 * masterPublicKey alone anchored the claim to the DEVICE key. Using a real
15 * chain (rather than a hand-made identity object) also gives update-name
16 * something it can actually sign and verify.
17 */
18
19import test from 'brittle'
20import fs from '#node:fs'
21import path from '#node:path'
22import os from '#node:os'
23import process from '#node:process'
24import IdentityKey from 'keet-identity-key'
25import crypto from 'hypercore-crypto'
26import b4a from 'b4a'
27import { Client } from 'hyper-svc'
28
29import { Sidecar } from '../lib/index.js'
30
31async function bootScratchSidecar (t) {
32 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-sidecar-rpc-'))
33
34 // A real keet identity: root attests device. Written as the payload
35 // `pgh device join` leaves behind.
36 const mnemonic = IdentityKey.generateMnemonic()
37 const identityKey = await IdentityKey.from({ mnemonic })
38 const device = crypto.keyPair()
39 const proof = await identityKey.bootstrap(device.publicKey)
40
41 const rootHex = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex')
42 const deviceHex = b4a.toString(device.publicKey, 'hex')
43
44 const linkedFile = path.join(dir, 'linked-device.json')
45 fs.writeFileSync(linkedFile, JSON.stringify({
46 version: 1,
47 label: 'scratch',
48 rootPubkeyHex: rootHex,
49 devicePublicKeyHex: deviceHex,
50 deviceSecretKeyHex: b4a.toString(device.secretKey, 'hex'),
51 chainB64: b4a.toString(proof, 'base64'),
52 createdAt: 1787600000000
53 }))
54
55 // Bare's process.env is a Proxy whose deleteProperty returns false, so
56 // `delete` throws — clear by assignment. Readers treat '' as unset.
57 const prev = {
58 linked: process.env.PGH_LINKED_DEVICE_FILE || '',
59 nameRegs: process.env.PGH_NAME_REGISTRIES || '',
60 relRegs: process.env.PGH_RELEASE_REGISTRIES || '',
61 subs: process.env.PGH_SUBSCRIPTIONS_PATH || ''
62 }
63 process.env.PGH_LINKED_DEVICE_FILE = linkedFile
64 process.env.PGH_NAME_REGISTRIES = ''
65 process.env.PGH_RELEASE_REGISTRIES = ''
66 process.env.PGH_SUBSCRIPTIONS_PATH = path.join(dir, 'subscriptions.json')
67
68 const sidecar = new Sidecar({
69 root: dir,
70 socketPath: path.join(dir, 'svc.sock'),
71 identityPath: path.join(dir, 'identity'),
72 storeRoot: path.join(dir, 'store'),
73 registryStore: path.join(dir, 'release-registry'),
74 nameRegistryStore: path.join(dir, 'name-registry'),
75 releaseRegistryReplicasDir: path.join(dir, 'release-registries'),
76 overlayStoreRoot: path.join(dir, 'overlay-store'),
77 // No swarm, no DeployServer, and no replicas to warm.
78 enableSeed: false,
79 nameRegistrySubscriptions: [],
80 releaseRegistrySubscriptions: []
81 })
82
83 await sidecar.start()
84 const client = await Client.connectUds(path.join(dir, 'svc.sock'))
85
86 t.teardown(async () => {
87 try { await client.close() } catch {}
88 // stop(), not close() — stop() is what clears the daily compact + gc
89 // timers. Calling a method that does not exist inside a silent catch
90 // left the sidecar fully live while the tmpdir was removed underneath
91 // it, and the surviving compaction then threw ENOENT from a timer.
92 // Left unguarded on purpose: a teardown that fails should be loud.
93 await sidecar.stop()
94 process.env.PGH_LINKED_DEVICE_FILE = prev.linked
95 process.env.PGH_NAME_REGISTRIES = prev.nameRegs
96 process.env.PGH_RELEASE_REGISTRIES = prev.relRegs
97 process.env.PGH_SUBSCRIPTIONS_PATH = prev.subs
98 fs.rmSync(dir, { recursive: true, force: true })
99 })
100
101 return { client, rootHex, deviceHex }
102}
103
104test('a claim from a linked device anchors to the ROOT (org #142)', async (t) => {
105 const { client, rootHex, deviceHex } = await bootScratchSidecar(t)
106
107 const claim = await client.call('claim-name', {
108 name: 'scratch-app',
109 description: 'boot harness'
110 })
111
112 t.is(claim.publisher.identityRoot, rootHex, 'anchored to the root')
113 t.not(claim.publisher.identityRoot, deviceHex, 'NOT the device key')
114 t.is(claim.publisher.devicePubkey, deviceHex, 'device recorded separately')
115})
116
117test('identity-info reports the root, not the device (org #142)', async (t) => {
118 const { client, rootHex, deviceHex } = await bootScratchSidecar(t)
119
120 const info = await client.call('identity-info', {})
121 t.is(info.rootPubkey, rootHex, 'rootPubkey is the root')
122 t.is(info.devicePubkey, deviceHex, 'devicePubkey is the device')
123 t.not(info.rootPubkey, info.devicePubkey, 'the two are distinguished')
124})
125
126test('update-name re-points a name this identity owns (org #143)', async (t) => {
127 const { client, rootHex } = await bootScratchSidecar(t)
128
129 await client.call('claim-name', { name: 'scratch-app', description: 'before' })
130
131 const rotated = 'e'.repeat(64)
132 const updated = await client.call('update-name', {
133 name: 'scratch-app',
134 registryKey: rotated,
135 description: 'after'
136 })
137
138 t.is(updated.publisher.registryKey, rotated, 'registryKey rotated')
139 t.is(updated.description, 'after', 'description updated')
140 t.is(updated.publisher.identityRoot, rootHex, 'anchor unchanged by the update')
141
142 // And it survives a re-read rather than being a return-value artefact.
143 const reread = await client.call('resolve-name', { name: 'scratch-app' })
144 t.is(reread.publisher.registryKey, rotated, 'persisted to the view')
145})
146
147test('update-name refuses an unclaimed name and an empty update', async (t) => {
148 const { client } = await bootScratchSidecar(t)
149
150 await t.exception(
151 () => client.call('update-name', { name: 'never-claimed', description: 'x' }),
152 /not claimed/,
153 'unclaimed name is refused by name'
154 )
155
156 await client.call('claim-name', { name: 'scratch-app' })
157 await t.exception(
158 () => client.call('update-name', { name: 'scratch-app' }),
159 /at least one of/,
160 'an update with no fields is refused'
161 )
162})
test/sidecar.test.js +9 -5
……@@ -56,11 +56,15 @@ test('start creates identity + store dirs and opens UDS', async (t) => {
5656 t.ok(sidecar.identity && sidecar.identity.publicKeyHex, 'identity loaded')
5757 t.is(sidecar.identity.publicKey.length, 32, 'pubkey 32 bytes')
5858 t.is(sidecar.identity.secretKey.length, 64, 'secret 64 bytes')
59 // On machines with a hyperident vault, source === 'hyperident' and
60 // no local identity.json is written (the vault lives at ~/.hyperident/).
61 // On machines without, a raw keypair is written to
62 // <root>/identity/identity.json with mode 0o600.
63 if (sidecar.identity.source !== 'hyperident') {
59 // Only the raw/legacy fallback writes <root>/identity/identity.json (mode
60 // 0o600). A hyperident vault lives at ~/.hyperident/, and a LINKED device
61 // reads ~/.pgh/linked-device.json — neither writes a local keypair.
62 //
63 // This was `!== 'hyperident'`, which made the assertion fire for
64 // source === 'linked' too and failed on any machine that has ever run
65 // `pgh device join` (i.e. every mirror, and tabertron). Pre-existing and
66 // environment-dependent — it fails identically at 0.2.52.
67 if (sidecar.identity.source === 'raw' || sidecar.identity.source === 'legacy') {
6468 t.ok(fs.existsSync(path.join(root, 'identity', 'identity.json')),
6569 'raw keypair on disk')
6670 }