/**
* claim-name / update-name over the real UDS surface (forge org #142 + #143).
*
* The sidecar handlers had no boot harness — the other sidecar test is
* constructor-only — so both fixes were unproven against an actual running
* service. This boots a fully scratch Sidecar (own root, own stores, own
* socket, no swarm, no seeding, no subscriptions) and drives it as 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 exact shape #142 was about — `tryLoadLinkedDevice` populates
* rootPubkeyHex and never masterPublicKey, so a handler reaching for
* masterPublicKey alone anchored the claim to the DEVICE key. Using a real
* chain (rather than a hand-made identity object) also gives update-name
* something it can actually sign and verify.
*/
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import process from '#node:process'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { Client } from 'hyper-svc'
import { Sidecar } from '../lib/index.js'
async function bootScratchSidecar (t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-sidecar-rpc-'))
// A real keet identity: root attests device. Written as the payload
// `pgh device join` leaves behind.
const mnemonic = IdentityKey.generateMnemonic()
const identityKey = await IdentityKey.from({ mnemonic })
const device = crypto.keyPair()
const proof = await identityKey.bootstrap(device.publicKey)
const rootHex = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex')
const deviceHex = b4a.toString(device.publicKey, 'hex')
const linkedFile = path.join(dir, 'linked-device.json')
fs.writeFileSync(linkedFile, JSON.stringify({
version: 1,
label: 'scratch',
rootPubkeyHex: rootHex,
devicePublicKeyHex: deviceHex,
deviceSecretKeyHex: b4a.toString(device.secretKey, 'hex'),
chainB64: b4a.toString(proof, 'base64'),
createdAt: 1787600000000
}))
// Bare's process.env is a Proxy whose deleteProperty returns false, so
// `delete` throws — clear by assignment. Readers treat '' as unset.
const prev = {
linked: process.env.PGH_LINKED_DEVICE_FILE || '',
nameRegs: process.env.PGH_NAME_REGISTRIES || '',
relRegs: process.env.PGH_RELEASE_REGISTRIES || '',
subs: process.env.PGH_SUBSCRIPTIONS_PATH || ''
}
process.env.PGH_LINKED_DEVICE_FILE = linkedFile
process.env.PGH_NAME_REGISTRIES = ''
process.env.PGH_RELEASE_REGISTRIES = ''
process.env.PGH_SUBSCRIPTIONS_PATH = path.join(dir, 'subscriptions.json')
const sidecar = new Sidecar({
root: dir,
socketPath: path.join(dir, 'svc.sock'),
identityPath: path.join(dir, 'identity'),
storeRoot: path.join(dir, 'store'),
registryStore: path.join(dir, 'release-registry'),
nameRegistryStore: path.join(dir, 'name-registry'),
releaseRegistryReplicasDir: path.join(dir, 'release-registries'),
overlayStoreRoot: path.join(dir, 'overlay-store'),
// No swarm, no DeployServer, and no replicas to warm.
enableSeed: false,
nameRegistrySubscriptions: [],
releaseRegistrySubscriptions: []
})
await sidecar.start()
const client = await Client.connectUds(path.join(dir, 'svc.sock'))
t.teardown(async () => {
try { await client.close() } catch {}
// stop(), not close() — stop() is what clears the daily compact + gc
// timers. Calling a method that does not exist inside a silent catch
// left the sidecar fully live while the tmpdir was removed underneath
// it, and the surviving compaction then threw ENOENT from a timer.
// Left unguarded on purpose: a teardown that fails should be loud.
await sidecar.stop()
process.env.PGH_LINKED_DEVICE_FILE = prev.linked
process.env.PGH_NAME_REGISTRIES = prev.nameRegs
process.env.PGH_RELEASE_REGISTRIES = prev.relRegs
process.env.PGH_SUBSCRIPTIONS_PATH = prev.subs
fs.rmSync(dir, { recursive: true, force: true })
})
return { client, rootHex, deviceHex }
}
test('a claim from a linked device anchors to the ROOT (org #142)', async (t) => {
const { client, rootHex, deviceHex } = await bootScratchSidecar(t)
const claim = await client.call('claim-name', {
name: 'scratch-app',
description: 'boot harness'
})
t.is(claim.publisher.identityRoot, rootHex, 'anchored to the root')
t.not(claim.publisher.identityRoot, deviceHex, 'NOT the device key')
t.is(claim.publisher.devicePubkey, deviceHex, 'device recorded separately')
})
test('identity-info reports the root, not the device (org #142)', async (t) => {
const { client, rootHex, deviceHex } = await bootScratchSidecar(t)
const info = await client.call('identity-info', {})
t.is(info.rootPubkey, rootHex, 'rootPubkey is the root')
t.is(info.devicePubkey, deviceHex, 'devicePubkey is the device')
t.not(info.rootPubkey, info.devicePubkey, 'the two are distinguished')
})
test('update-name re-points a name this identity owns (org #143)', async (t) => {
const { client, rootHex } = await bootScratchSidecar(t)
await client.call('claim-name', { name: 'scratch-app', description: 'before' })
const rotated = 'e'.repeat(64)
const updated = await client.call('update-name', {
name: 'scratch-app',
registryKey: rotated,
description: 'after'
})
t.is(updated.publisher.registryKey, rotated, 'registryKey rotated')
t.is(updated.description, 'after', 'description updated')
t.is(updated.publisher.identityRoot, rootHex, 'anchor unchanged by the update')
// And it survives a re-read rather than being a return-value artefact.
const reread = await client.call('resolve-name', { name: 'scratch-app' })
t.is(reread.publisher.registryKey, rotated, 'persisted to the view')
})
test('update-name refuses an unclaimed name and an empty update', async (t) => {
const { client } = await bootScratchSidecar(t)
await t.exception(
() => client.call('update-name', { name: 'never-claimed', description: 'x' }),
/not claimed/,
'unclaimed name is refused by name'
)
await client.call('claim-name', { name: 'scratch-app' })
await t.exception(
() => client.call('update-name', { name: 'scratch-app' }),
/at least one of/,
'an update with no fields is refused'
)
})