rollbridge 0.1.36 → 0.1.38

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.
@@ -77,6 +77,8 @@ let ownerMutationId
77
77
  let retiringClient
78
78
  /** @type {string | undefined} */
79
79
  let retiringReplacementId
80
+ /** @type {Promise<Error | undefined> | undefined} */
81
+ let legacyOwnerClaim
80
82
  let shuttingDown = false
81
83
  /** @type {net.Socket | undefined} */
82
84
  let shutdownClient
@@ -117,11 +119,7 @@ const server = net.createServer((socket) => {
117
119
  }
118
120
  if (retiringClient === socket) finalizeReplacementRetirement()
119
121
  if (replacementClient === socket) {
120
- replacementClient = undefined
121
- replacementId = undefined
122
- replacementAuthority = undefined
123
- replacementOwnerState = undefined
124
- if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: "replacement-aborted"})}\n`)
122
+ abortReplacement("Replacement candidate disconnected before commit")
125
123
  }
126
124
  if (shutdownClient === socket) void finishShutdown()
127
125
  })
@@ -180,6 +178,10 @@ async function handleLine(socket, line) {
180
178
  async function execute(request, socket) {
181
179
  if (shuttingDown) throw new Error("Process guardian is shutting down")
182
180
 
181
+ if (request.command === "owner-replacement-capabilities") {
182
+ return {commands: ["commit-retired-owner-replacement"], protocol: "owner-replacement", version: 1}
183
+ }
184
+
183
185
  if (request.command === "claim-owner") {
184
186
  if (!ownerClient) {
185
187
  if (ownerState !== undefined && !isDeepStrictEqual(request.authority, ownerAuthority(ownerState))) {
@@ -225,6 +227,26 @@ async function execute(request, socket) {
225
227
  return {abandoned: true}
226
228
  }
227
229
 
230
+ if (request.command === "begin-legacy-owner-claim") {
231
+ if (!legacyGuardian) throw new Error("Guardian is not a legacy upgrade coordinator")
232
+ requireReplacement(socket, request)
233
+ if (legacyOwnerClaim) throw new Error("Legacy guardian owner claim is already pending")
234
+ legacyOwnerClaim = legacyGuardian.claimOwner(request.graceMs ?? 30000, ownerAuthority(ownerState)).then(
235
+ () => undefined,
236
+ (error) => error instanceof Error ? error : new Error(String(error))
237
+ )
238
+ return {prepared: true}
239
+ }
240
+
241
+ if (request.command === "complete-legacy-owner-claim") {
242
+ if (!legacyGuardian || !legacyOwnerClaim) throw new Error("Legacy guardian owner claim was not prepared")
243
+ requireReplacement(socket, request)
244
+ const claimError = await legacyOwnerClaim
245
+
246
+ if (claimError) throw claimError
247
+ return {claimed: true}
248
+ }
249
+
228
250
  if (request.command === "publish-owner-state") {
229
251
  requireOwner(socket, request.command)
230
252
  if (request.ownerState === undefined) throw new Error("Guardian owner publication requires ownerState")
@@ -470,7 +492,10 @@ function requireOwner(socket, command) {
470
492
 
471
493
  /** @param {string} reason - Abort diagnostic. */
472
494
  function abortReplacement(reason) {
473
- if (replacementClient && !replacementClient.destroyed) replacementClient.write(`${JSON.stringify({event: "replacement-aborted", reason})}\n`)
495
+ const event = `${JSON.stringify({event: "replacement-aborted", reason})}\n`
496
+
497
+ if (replacementClient && !replacementClient.destroyed) replacementClient.write(event)
498
+ if (ownerClient && !ownerClient.destroyed) ownerClient.write(event)
474
499
  replacementClient = undefined
475
500
  replacementId = undefined
476
501
  replacementAuthority = undefined
@@ -0,0 +1,106 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs/promises"
4
+ import net from "node:net"
5
+
6
+ const [socketPath, backendPath, mode = "partial"] = process.argv.slice(2)
7
+
8
+ if (!socketPath || !backendPath || !process.send) throw new Error("partial process-guardian requires frontend and backend sockets plus a private bootstrap channel")
9
+
10
+ const token = await new Promise((resolve, reject) => {
11
+ process.once("disconnect", () => reject(new Error("Partial guardian bootstrap channel closed before authentication capability arrived")))
12
+ process.once("message", (message) => {
13
+ if (!message || typeof message !== "object" || !("token" in message) || typeof message.token !== "string" || !message.token) {
14
+ reject(new Error("Partial guardian bootstrap authentication capability is invalid"))
15
+ return
16
+ }
17
+ resolve(message.token)
18
+ })
19
+ })
20
+
21
+ const clients = new Set()
22
+ const server = net.createServer((frontend) => {
23
+ const backend = net.createConnection(backendPath)
24
+ let frontendBuffer = ""
25
+ let backendBuffer = ""
26
+
27
+ clients.add(frontend)
28
+ frontend.setEncoding("utf8")
29
+ backend.setEncoding("utf8")
30
+ frontend.on("error", () => frontend.destroy())
31
+ backend.on("error", () => backend.destroy())
32
+ frontend.once("close", () => {
33
+ clients.delete(frontend)
34
+ backend.destroy()
35
+ })
36
+ backend.once("close", () => frontend.destroy())
37
+ frontend.on("data", (chunk) => {
38
+ frontendBuffer += chunk
39
+ let newline = frontendBuffer.indexOf("\n")
40
+
41
+ while (newline >= 0) {
42
+ const line = frontendBuffer.slice(0, newline)
43
+ const request = JSON.parse(line)
44
+
45
+ frontendBuffer = frontendBuffer.slice(newline + 1)
46
+ if (request.token !== token) {
47
+ frontend.write(`${JSON.stringify({error: "Guardian authentication failed", id: request.id})}\n`)
48
+ } else if (request.command === "owner-replacement-capabilities") {
49
+ const result = mode === "malformed-capability"
50
+ ? {protocol: "owner-replacement", version: 1}
51
+ : undefined
52
+
53
+ frontend.write(`${JSON.stringify(result
54
+ ? {id: request.id, result}
55
+ : {error: "Guardian owner-replacement-capabilities requires a process key", id: request.id})}\n`)
56
+ } else if (request.command === "commit-retired-owner-replacement") {
57
+ frontend.write(`${JSON.stringify({
58
+ error: request.key
59
+ ? "Guardian commit-retired-owner-replacement requires the committed owner"
60
+ : "Guardian commit-retired-owner-replacement requires a process key",
61
+ id: request.id
62
+ })}\n`)
63
+ } else {
64
+ if (mode === "wrong-provenance" && request.command === "register") {
65
+ request.provenance = `${request.provenance}-tampered`
66
+ backend.write(`${JSON.stringify(request)}\n`)
67
+ } else {
68
+ backend.write(`${line}\n`)
69
+ }
70
+ }
71
+ newline = frontendBuffer.indexOf("\n")
72
+ }
73
+ })
74
+ backend.on("data", (chunk) => {
75
+ backendBuffer += chunk
76
+ let newline = backendBuffer.indexOf("\n")
77
+
78
+ while (newline >= 0) {
79
+ const line = backendBuffer.slice(0, newline)
80
+
81
+ backendBuffer = backendBuffer.slice(newline + 1)
82
+ frontend.write(`${line}\n`)
83
+ newline = backendBuffer.indexOf("\n")
84
+ }
85
+ })
86
+ })
87
+
88
+ server.on("error", (error) => {
89
+ if (process.send) process.send({error: error.message})
90
+ else throw error
91
+ })
92
+ server.listen(socketPath, async () => {
93
+ await fs.chmod(socketPath, 0o600)
94
+ process.send?.({ready: true})
95
+ process.disconnect?.()
96
+ })
97
+
98
+ const shutdown = () => {
99
+ for (const client of clients) client.destroy()
100
+ server.close(() => {
101
+ void fs.rm(socketPath, {force: true}).finally(() => process.exit(0))
102
+ })
103
+ }
104
+
105
+ process.once("SIGINT", shutdown)
106
+ process.once("SIGTERM", shutdown)
@@ -210,6 +210,32 @@ test("replacement staging rejects owner state published after prepare", async ()
210
210
  }
211
211
  })
212
212
 
213
+ test("replacement abort notifies both the candidate and committed owner", async () => {
214
+ const fixture = await createGuardian()
215
+ const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
216
+ const authority = {configDigest: "incumbent", runtime: null}
217
+ const nextAuthority = {configDigest: "candidate", runtime: null}
218
+
219
+ try {
220
+ await fixture.client.publishOwnerState({authority, snapshot: {activeReleaseId: "v1"}})
221
+ await candidate.connect()
222
+ const prepared = await candidate.prepareOwnerReplacement(authority, nextAuthority)
223
+ const incumbentAborted = fixture.client.waitForEvent("replacement-aborted")
224
+ const candidateAborted = candidate.waitForEvent("replacement-aborted")
225
+
226
+ await candidate.abortOwnerReplacement(prepared.replacementId)
227
+ assert.deepEqual(await Promise.all([incumbentAborted, candidateAborted]), [
228
+ {event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"},
229
+ {event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"}
230
+ ])
231
+ await fixture.client.shutdown()
232
+ await fixture.client.guardianExit()
233
+ } finally {
234
+ candidate.disconnect()
235
+ await cleanupGuardian(fixture)
236
+ }
237
+ })
238
+
213
239
  test("retired owner replacement commit carries its exact recovered process key", async () => {
214
240
  const client = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
215
241
  const replacementId = "prepared-replacement"
@@ -224,6 +250,54 @@ test("retired owner replacement commit carries its exact recovered process key",
224
250
  await client.commitRetiredOwnerReplacement(replacementId, processKey)
225
251
  })
226
252
 
253
+ test("guardian owner-replacement capability classification is explicit and fail closed", async () => {
254
+ const current = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
255
+
256
+ current.request = async (request) => {
257
+ assert.deepEqual(request, {command: "owner-replacement-capabilities"})
258
+ return {
259
+ commands: ["commit-retired-owner-replacement", "future-command"],
260
+ futureField: {supported: true},
261
+ protocol: "owner-replacement",
262
+ version: 2
263
+ }
264
+ }
265
+ assert.equal(await current.ownerReplacementProtocol(), "atomic")
266
+
267
+ for (const [commitDiagnostic, expected] of [
268
+ ["Owner replacement transaction is not the prepared candidate", "atomic"],
269
+ ["Guardian commit-retired-owner-replacement requires a process key", "legacy"],
270
+ ["Unknown guardian command: commit-retired-owner-replacement", "legacy"]
271
+ ]) {
272
+ const older = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
273
+ const requests = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ ([])
274
+
275
+ older.request = async (request) => {
276
+ requests.push(request)
277
+ if (request.command === "owner-replacement-capabilities") throw new Error("Guardian owner-replacement-capabilities requires a process key")
278
+ throw new Error(commitDiagnostic)
279
+ }
280
+ assert.equal(await older.ownerReplacementProtocol(), expected)
281
+ assert.deepEqual(requests, [
282
+ {command: "owner-replacement-capabilities"},
283
+ {command: "commit-retired-owner-replacement", replacementId: "owner-replacement-capability-probe"}
284
+ ])
285
+ }
286
+
287
+ for (const fixture of [
288
+ async () => ({commands: [], protocol: "owner-replacement", version: 1}),
289
+ async (/** @type {Record<string, import("../src/json.js").JsonValue>} */ request) => {
290
+ if (request.command === "owner-replacement-capabilities") throw new Error("Guardian owner-replacement-capabilities requires a process key")
291
+ throw new Error("Guardian commit-retired-owner-replacement requires the committed owner")
292
+ }
293
+ ]) {
294
+ const ambiguous = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
295
+
296
+ ambiguous.request = fixture
297
+ await assert.rejects(() => ambiguous.ownerReplacementProtocol(), /invalid owner-replacement capability response|ambiguous retired-owner capability response/)
298
+ }
299
+ })
300
+
227
301
  test("reserved process recovery rejects a reconstructed definition with different provenance", async () => {
228
302
  const fixture = await createGuardian()
229
303
  const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})