replica-bridge 0.3.20 → 0.3.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,8 +22,11 @@ const CHANGES_PAGE = 100;
22
22
  const CATCH_UP_PAGES = 1000;
23
23
  /** More changed tasks than this in one event, and the phone reads the whole board instead. */
24
24
  export const CHANGED_ITEMS_MAX = 200;
25
- /** What this bridge can do beyond reading, for board/state (PROTOCOL.md "看板"). */
26
- export const BOARD_CAPS = ["post", "delta", "items", "tasks"] ;
25
+ /**
26
+ * What this bridge can do beyond reading, for board/state (PROTOCOL.md "看板"). `ring`: this connection takes the
27
+ * ring/* frames (PROTOCOL.md "设备环:电脑一侧"), since a board connection has no resume to say it in.
28
+ */
29
+ export const BOARD_CAPS = ["post", "delta", "items", "tasks", "ring"] ;
27
30
  /** Up to this many changed tasks in one event come with their contents (`task`); past it the phone reads them itself. */
28
31
  export const CARRIED_TASKS_MAX = 50;
29
32
  /** A task bigger than this is left for the phone to read. */
@@ -11,6 +11,9 @@
11
11
  // config [--public-url <url>]
12
12
  // devices
13
13
  // revoke <deviceId>
14
+ // ring [status] the device ring this computer is anchored to, and anchors waiting
15
+ // ring accept|reject [<n|fingerprint>] confirm or refuse a waiting anchor (lists them without an argument)
16
+ // ring reset [--yes] leave the ring, so a different one can be anchored
14
17
  import fs from "node:fs";
15
18
  import os from "node:os";
16
19
  import readline from "node:readline/promises";
@@ -20,8 +23,10 @@ import { spawnCommandSync } from "./command.js";
20
23
  import { defaultRelayURL, enroll, ensureEnrolled, ensureRelay } from "./enroll.js";
21
24
  import { PACKAGE_VERSION, clearPid, installService, logPath, pauseService, readServiceInfo, runningPid, servicePaused, servicePlan, uninstallService, writePid } from "./install.js";
22
25
  import { pair, publicRelayURL } from "./pair.js";
26
+ import { acceptAnchor, anchorPrompt, describeAnchor, findPending, rejectAnchor, removeDevice, resetRing, ringSummary } from "./ring.js";
27
+
23
28
  import { BridgeService } from "./service.js";
24
- import { loadState, saveState } from "./state.js";
29
+ import { hostKeyPair, loadState, saveState, updateState } from "./state.js";
25
30
 
26
31
  // A leading option means no command: `replica-bridge --pair` is the default command with --pair.
27
32
  const argv = process.argv.slice(2);
@@ -53,7 +58,8 @@ const usage = `replica-bridge ${PACKAGE_VERSION}: lets the ChatReplica app use C
53
58
  in the background; run again for the status
54
59
  npx replica-bridge pair pair another phone
55
60
  npx replica-bridge uninstall stop and remove the background service
56
- Others: status, install, start (in this terminal), init, config, devices, revoke <id>.
61
+ npx replica-bridge ring accept confirm a device ring asking to include this computer (ring reject, ring reset)
62
+ Others: status, install, start (in this terminal), init, config, devices, revoke <id>, ring.
57
63
  The relay is ${defaultRelayURL()} unless --relay or REPLICA_RELAY says otherwise.`;
58
64
 
59
65
  // The Windows service has no terminal to write to: its output goes to the log file it names.
@@ -78,11 +84,115 @@ function startBridge() {
78
84
  if (process.env.REPLICA_BRIDGE_SERVICE === "1" && process.platform === "win32") pauseService();
79
85
  process.exit(0);
80
86
  };
81
- const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]), onReplaced });
87
+ const service = new BridgeService({
88
+ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]), onReplaced,
89
+ onAnchorRequest: (prompt) => askAnchor(service, prompt),
90
+ });
82
91
  service.start();
83
92
  return service;
84
93
  }
85
94
 
95
+ /** True only for y or yes: anchoring is the one question here whose default is no. */
96
+ async function confirm(question ) {
97
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
98
+ try { return /^y(es)?$/i.test((await prompt.question(question)).trim()); } finally { prompt.close(); }
99
+ }
100
+
101
+ /** Anchor questions in this terminal, one at a time, once nothing else here is asking (askAnchorsHere). */
102
+ let anchorQuestions = Promise.resolve();
103
+ let askAnchorsHere = false;
104
+
105
+ function askAnchor(bridge , prompt ) {
106
+ if (!askAnchorsHere) {
107
+ console.log(`A device asks to anchor this computer to its device ring (fingerprint ${prompt.fingerprint}): run "npx replica-bridge ring accept" to confirm.`);
108
+ return;
109
+ }
110
+ anchorQuestions = anchorQuestions.then(async () => {
111
+ // Answered meanwhile with `ring accept` or `ring reject` in another terminal.
112
+ if (!bridge.pendingAnchors().some((p) => p.ring === prompt.ring)) return;
113
+ console.log(`\n${describeAnchor(prompt)}`);
114
+ const yes = await confirm("Anchor this computer to this ring? [y/N] ");
115
+ try {
116
+ const { removed } = bridge.answerAnchor(prompt.ring, yes);
117
+ console.log(yes ? `Anchored. The ring's signers can use this computer now.${removed.length ? ` Removed ${removed.join(", ")}, revoked in the ring.` : ""}` : "Rejected. The device can ask again.");
118
+ } catch (error) {
119
+ console.error(`replica-bridge: ${(error ).message}`);
120
+ }
121
+ });
122
+ }
123
+
124
+ /** From here on this terminal asks about anchor requests, starting with any already waiting. */
125
+ function askAnchorsInTerminal(bridge ) {
126
+ if (!process.stdin.isTTY || process.env.REPLICA_BRIDGE_SERVICE === "1") return;
127
+ askAnchorsHere = true;
128
+ for (const prompt of bridge.pendingAnchors()) askAnchor(bridge, prompt);
129
+ }
130
+
131
+ /** With the background service serving the computer: ask here about the anchors it recorded (it notices the answer). */
132
+ async function answerPendingHere() {
133
+ if (!process.stdin.isTTY) return;
134
+ for (const pending of loadState().ringPending ?? []) {
135
+ console.log(`\n${describeAnchor(anchorPrompt(pending))}`);
136
+ const yes = await confirm("Anchor this computer to this ring? [y/N] ");
137
+ if (yes) acceptAnchor(pending.ring, hostKeyPair(loadState()).publicKey);
138
+ else rejectAnchor(pending.ring);
139
+ console.log(yes ? "Anchored." : "Rejected.");
140
+ if (yes) break;
141
+ }
142
+ }
143
+
144
+ async function ringCommand() {
145
+ const sub = positionals[0] ?? "status";
146
+ const state = loadState();
147
+ if (sub === "status") {
148
+ for (const line of ringSummary(state)) console.log(line);
149
+ if (state.ringPending?.length) console.log('Confirm with "npx replica-bridge ring accept", refuse with "npx replica-bridge ring reject".');
150
+ return;
151
+ }
152
+ if (sub === "accept" || sub === "reject") {
153
+ const pending = state.ringPending ?? [];
154
+ if (!pending.length) { console.log("No device ring is waiting for confirmation."); return; }
155
+ let which = positionals[1];
156
+ if (!which) {
157
+ pending.forEach((p, i) => console.log(`\n${i + 1}. ${describeAnchor(anchorPrompt(p))}`));
158
+ if (!process.stdin.isTTY) throw new Error(`say which: replica-bridge ring ${sub} <number or fingerprint>`);
159
+ if (pending.length === 1) {
160
+ if (!(await confirm(`\n${sub === "accept" ? "Anchor this computer to this ring" : "Reject this request"}? [y/N] `))) return;
161
+ which = "1";
162
+ } else {
163
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
164
+ try { which = (await prompt.question(`\nWhich one (1-${pending.length}, empty to stop)? `)).trim(); } finally { prompt.close(); }
165
+ if (!which) return;
166
+ }
167
+ } else {
168
+ console.log(describeAnchor(anchorPrompt(findPending(state, which))));
169
+ }
170
+ // The ring id of what was shown, so a list that changed meanwhile cannot put another ring under the same number.
171
+ const shown = findPending(state, which).ring;
172
+ if (sub === "reject") {
173
+ rejectAnchor(shown);
174
+ console.log("Rejected. Nothing changed; the device can ask again.");
175
+ return;
176
+ }
177
+ const { ring, removed } = acceptAnchor(shown, hostKeyPair(state).publicKey);
178
+ console.log(`Anchored to ring ${ring.id}. Its signers can use this computer now; "npx replica-bridge ring reset" leaves it.`);
179
+ if (removed.length) console.log(`Removed ${removed.join(", ")} from the paired devices: the ring revoked them.`);
180
+ return;
181
+ }
182
+ if (sub === "reset") {
183
+ if (!state.ring && !state.ringPending?.length) { console.log("Not anchored to a device ring."); return; }
184
+ for (const line of ringSummary(state)) console.log(line);
185
+ if (!values.yes) {
186
+ if (!process.stdin.isTTY) throw new Error("add --yes to leave the ring without a terminal to ask in");
187
+ if (!(await confirm("Leave this ring? Its signers can no longer use this computer unless paired here. [y/N] "))) return;
188
+ }
189
+ resetRing();
190
+ console.log("Left the device ring. Phones paired here by QR code still connect; a device can ask to anchor again.");
191
+ return;
192
+ }
193
+ throw new Error("usage: replica-bridge ring [status | accept [<n>] | reject [<n>] | reset [--yes]]");
194
+ }
195
+
86
196
  /**
87
197
  * Warns, without stopping, when Codex does not answer the way the bridge will run it (through the login shell).
88
198
  * The shell is interactive (-i) so it reads the rc file where people put Codex on PATH, and an interactive shell that
@@ -197,6 +307,7 @@ function status() {
197
307
  console.log(`Owner: ${state.owner ? `${state.owner.login} (${state.owner.provider})` : "none yet"}; paired phones: ${state.devices.map((d) => d.name).join(", ") || "none"}`);
198
308
  if (info) console.log(`Background service ${info.version}: ${pid ? `running (pid ${pid})` : "installed, not running right now"}. Log: ${logPath()}`);
199
309
  else console.log(pid ? `Running (pid ${pid}).` : `Not running: "npx replica-bridge" sets up the background service.`);
310
+ if (state.ring || state.ringPending?.length) for (const line of ringSummary(state)) console.log(line);
200
311
  }
201
312
 
202
313
  async function main() {
@@ -215,6 +326,7 @@ async function main() {
215
326
  if (moved || values.install || installed.version !== PACKAGE_VERSION) await install();
216
327
  if (moved || values.pair || !state.devices.length) await pairOrExplain();
217
328
  else status();
329
+ await answerPendingHere();
218
330
  break;
219
331
  }
220
332
  if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()}); stop it first`);
@@ -234,6 +346,7 @@ async function main() {
234
346
  }
235
347
  for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
236
348
  console.log(`Running in this terminal: paired phones can use Codex on ${state.hostName} until it closes (Ctrl-C stops it).`);
349
+ askAnchorsInTerminal(bridge);
237
350
  if (!(await bridge.waitForRelay(RELAY_WAIT_MS))) console.warn(`Still not connected to the relay${bridge.relayProblem ? ` (${bridge.relayProblem})` : ""}: the phone shows ${state.hostName} offline until it is.`);
238
351
  break;
239
352
  }
@@ -282,6 +395,7 @@ async function main() {
282
395
  if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()})${asService ? "" : '; "npx replica-bridge uninstall" stops the background one'}`);
283
396
  const bridge = startBridge();
284
397
  if (asService) { writePid(); process.on("exit", clearPid); }
398
+ askAnchorsInTerminal(bridge);
285
399
  for (const signal of ["SIGINT", "SIGTERM"] ) process.on(signal, () => { bridge.stop(); process.exit(0); });
286
400
  break;
287
401
  }
@@ -298,11 +412,18 @@ async function main() {
298
412
  if (!deviceId) throw new Error("usage: replica-bridge revoke <deviceId>");
299
413
  const state = loadState();
300
414
  await relayRequest(state.relay, "DELETE", `/v1/host/devices/${encodeURIComponent(deviceId)}`, { token: state.hostToken });
301
- state.devices = state.devices.filter((d) => d.deviceId !== deviceId);
302
- saveState(state);
415
+ // Read again after the request: the running bridge may have stored ring entries meanwhile.
416
+ let name = null;
417
+ const after = updateState((latest) => { name = removeDevice(latest, deviceId); });
303
418
  console.log(`Removed ${deviceId} from this computer.`);
419
+ if (name && after.ring) {
420
+ console.log(`It stays refused here even while this computer's device ring lists it as a signer. To remove "${name}" from your other computers too, remove it from the ring in the app.`);
421
+ }
304
422
  break;
305
423
  }
424
+ case "ring":
425
+ await ringCommand();
426
+ break;
306
427
  default:
307
428
  console.log(usage);
308
429
  process.exitCode = 1;
@@ -136,6 +136,8 @@ export async function pair(options )
136
136
  ...s,
137
137
  owner: s.owner ?? { login: info.login, provider: info.provider },
138
138
  devices: [...s.devices.filter((d) => d.deviceId !== claim .deviceId), { deviceId: claim .deviceId , publicKey: b64url(devicePublicKey), name: info.deviceName, login: info.login, pairedAt: new Date().toISOString() }],
139
+ // Scanning it again here is a person saying yes to this key: it is no longer refused (ring.ts removeDevice).
140
+ ...(s.denied ? { denied: s.denied.filter((d) => d.key !== b64url(devicePublicKey)) } : {}),
139
141
  });
140
142
  saveState(withDevice(loadState()));
141
143
  try {
@@ -0,0 +1,427 @@
1
+ // The device ring on the computer's side (PROTOCOL.md "设备环:电脑一侧"). A bridge is a member, never a signer: it
2
+ // only keeps the log, checks every entry with RingState, and lets the ring's active signers connect once a person on
3
+ // this computer has confirmed which ring it belongs to (anchoring). Everything is kept in state.json.
4
+ import { RelayHTTPError } from "../http.js";
5
+ import { RING_ENTRIES_PER_FRAME, RingError, RingState, b64url, fromB64url, ringB64, ringFingerprint } from "../protocol.js";
6
+
7
+ import { loadState, updateState } from "./state.js";
8
+
9
+
10
+ /** Longest log the bridge keeps; far more than a person's devices ever write. */
11
+ export const RING_LOG_MAX_ENTRIES = 10_000;
12
+ /** Most bytes (base64url of entries and signatures) the anchored log may take in state.json: some 6,000 typical entries. */
13
+ export const RING_LOG_MAX_BYTES = 2 * 1024 * 1024;
14
+ /** Most bytes a pending anchor's log may take; it lands in state.json before anyone has confirmed anything. */
15
+ export const PENDING_LOG_MAX_BYTES = 1024 * 1024;
16
+ /** Different rings waiting for confirmation at once; a newer request for one of them replaces its log. */
17
+ export const PENDING_ANCHORS_MAX = 4;
18
+ /** Largest relay answer the bridge reads for one page of the log. */
19
+ export const RELAY_PAGE_MAX_BYTES = 8 * 1024 * 1024;
20
+
21
+ /** base64url lengths: the longest an entry can be (RING_ENTRY_MAX_BYTES), and a 64-byte signature. */
22
+ const ENTRY_B64_MAX = Math.ceil((16 * 1024 * 4) / 3);
23
+ const SIGNATURE_B64 = 86;
24
+
25
+ export const logBytes = (entries ) => entries.reduce((sum, entry) => sum + entry.e.length + entry.s.length, 0);
26
+
27
+ /**
28
+ * Entries being added to a log, checked one at a time as they arrive: the first that does not apply ends it, and so
29
+ * does reaching `until` or the size limits. Nothing is kept unverified, whatever a peer or the relay sends.
30
+ */
31
+ export class LogGrowth {
32
+ added = [];
33
+ /** Why it stopped short of `until`: an entry that did not apply, or the size limit (`full`). */
34
+ stopped = null;
35
+ full = false;
36
+ bytes ;
37
+ count ;
38
+ state ;
39
+ until ;
40
+ maxBytes ;
41
+
42
+ constructor(state , base , until , maxBytes = RING_LOG_MAX_BYTES) {
43
+ this.state = state; this.until = until; this.maxBytes = maxBytes;
44
+ this.bytes = logBytes(base); this.count = base.length;
45
+ }
46
+
47
+ get done() { return this.state.seq >= this.until || this.stopped !== null; }
48
+
49
+ /**
50
+ * Applies a page; false once nothing more should be asked for. Only an entry that verifies can make the log full:
51
+ * an oversized one that does not would otherwise let anyone stop the ring with one bogus entry.
52
+ */
53
+ take(page , from ) {
54
+ for (const entry of page) {
55
+ if (this.done) return false;
56
+ const size = entry.e.length + entry.s.length;
57
+ const bad = (why ) => { this.stopped = `${from} sent an entry for seq ${this.state.seq + 1} that does not apply (${why})`; return false; };
58
+ // Longer than any entry can be: refused before any work (RingState would say too_large or bad_encoding).
59
+ if (entry.e.length > ENTRY_B64_MAX || entry.s.length !== SIGNATURE_B64) return bad("too_large: longer than any entry");
60
+ const seq = this.state.seq;
61
+ try { this.state.apply(entry); } catch (error) { return bad((error ).message); }
62
+ if (this.count + 1 > RING_LOG_MAX_ENTRIES || this.bytes + size > this.maxBytes) {
63
+ // Verified but past the limit: not kept (storeLog replays only `added`), and the log counts as full.
64
+ this.full = true;
65
+ this.stopped = `the log reached this bridge's limit at seq ${seq}`;
66
+ return false;
67
+ }
68
+ this.added.push(entry);
69
+ this.bytes += size;
70
+ this.count++;
71
+ }
72
+ return !this.done;
73
+ }
74
+ }
75
+
76
+ /** Replays a log as far as it is valid: the state of that prefix, and why it stopped (null when it did not). */
77
+ export function replayPrefix(entries , from ) {
78
+ const state = from?.state ?? new RingState();
79
+ let applied = 0;
80
+ for (const entry of entries) {
81
+ try { state.apply(entry); } catch (error) { return { state, applied, error: error }; }
82
+ applied++;
83
+ }
84
+ return { state, applied, error: null };
85
+ }
86
+
87
+ /** A copy of a state by replaying its log again; RingState has no clone and the logs are small. */
88
+ export function replayAll(entries ) {
89
+ const { state, error } = replayPrefix(entries);
90
+ if (error) throw error;
91
+ return state;
92
+ }
93
+
94
+ /**
95
+ * The anchored ring's state, replayed from state.json whenever the stored log differs from what was last replayed.
96
+ * A stored log that stops replaying (edited by hand) counts up to its last valid entry; one whose genesis is not the
97
+ * anchored ring counts as no ring at all, so it grants nothing.
98
+ */
99
+ export class RingCache {
100
+ key = "";
101
+ state = null;
102
+
103
+ get(ring , log = () => {}) {
104
+ if (!ring) { this.key = ""; this.state = null; return null; }
105
+ const key = `${ring.id}|${ring.entries.length}|${ring.entries.at(-1)?.e ?? ""}`;
106
+ if (key === this.key) return this.state;
107
+ const { state, error } = replayPrefix(ring.entries);
108
+ if (error) log(`device ring: the stored log stops at seq ${state.seq}: ${error.message}`);
109
+ this.key = key;
110
+ this.state = state.ringId && b64url(state.ringId) === ring.id ? state : null;
111
+ if (!this.state) log("device ring: the stored log does not start with the anchored ring's genesis; the ring grants nothing");
112
+ return this.state;
113
+ }
114
+ }
115
+
116
+
117
+
118
+
119
+
120
+
121
+
122
+
123
+ /**
124
+ * Who may connect: the devices paired here by QR code, minus any key revoked in the anchored ring, plus the anchored
125
+ * ring's active signers. Members never connect to other members. A computer revoked from its own ring takes nobody in
126
+ * through the ring, though its paired devices still connect.
127
+ */
128
+ export function admit(state , ring , hostKey , deviceId , offeredKey ) {
129
+ const paired = state.devices.find((d) => d.deviceId === deviceId);
130
+ // Removed here: a local no that the ring cannot overrule.
131
+ const denied = (key ) => state.denied?.some((d) => d.key === b64url(key));
132
+ if (offeredKey && !paired && denied(offeredKey)) return { refused: "removed from this computer" };
133
+ if (paired) {
134
+ const key = fromB64url(paired.publicKey);
135
+ if (denied(key)) return { refused: "removed from this computer" };
136
+ if (ring?.isRevokedKey("x25519", key)) return { refused: "removed from the device ring" };
137
+ return { via: "qr", key, name: paired.name };
138
+ }
139
+ if (!ring || !offeredKey) return { refused: "not paired with this computer" };
140
+ if (!ring.activeMemberWithKey("x25519", hostKey)) return { refused: "this computer is no longer in its device ring" };
141
+ // A log that stopped growing at the size limit may be missing revocations: the ring lets nobody in until a reset.
142
+ if (state.ring?.stalled) return { refused: "this computer's copy of its device ring is full" };
143
+ const signer = ring.signers.find((m) => m.key.equals(offeredKey));
144
+ if (signer) return { via: "ring", key: offeredKey, name: signer.name };
145
+ if (ring.activeMemberWithKey("x25519", offeredKey)) return { refused: "only the ring's signers connect to its members" };
146
+ return { refused: "not paired with this computer and not a signer of its device ring" };
147
+ }
148
+
149
+ /**
150
+ * The three conditions for anchoring (docs/design/DEVICE-RING.md): the requester is paired here by QR code, it is an
151
+ * active signer of the ring with the transport key of this very connection and the signing key it named, and this
152
+ * computer's own key is an active member. The log must replay from genesis to the head the requester claimed.
153
+ */
154
+ export function checkAnchor(state , hostKey , request ) {
155
+ const paired = state.devices.find((d) => d.deviceId === request.deviceId);
156
+ if (!paired || !fromB64url(paired.publicKey).equals(request.key)) return { refused: "only a device paired here by QR code can ask" };
157
+ if (request.entries.length > RING_LOG_MAX_ENTRIES || logBytes(request.entries) > PENDING_LOG_MAX_BYTES) return { refused: "the log is too long" };
158
+ const { state: ring, error } = replayPrefix(request.entries);
159
+ if (error) return { refused: `the log does not verify: ${error.message}` };
160
+ if (!ring.ringId || b64url(ring.ringId) !== request.ring) return { refused: "the log is not this ring's" };
161
+ const signer = ring.signers.find((m) => m.key.equals(request.key) && m.signingKey .equals(request.signingKey));
162
+ if (!signer) return { refused: "the device is not an active signer of the ring with this key" };
163
+ if (ring.isRevokedKey("x25519", hostKey) || !ring.activeMemberWithKey("x25519", hostKey)) return { refused: "this computer is not an active member of the ring" };
164
+ return { ring, signer };
165
+ }
166
+
167
+ /** What a person on this computer is asked to confirm. */
168
+
169
+
170
+
171
+
172
+
173
+
174
+
175
+
176
+
177
+
178
+ export function anchorPrompt(pending ) {
179
+ const ring = replayAll(pending.entries);
180
+ const key = fromB64url(pending.requester.key);
181
+ const inRing = ring.signers.find((m) => m.key.equals(key))?.name ?? pending.requester.name;
182
+ return {
183
+ ring: pending.ring, fingerprint: ringFingerprint(fromB64url(pending.ring)),
184
+ // Always both: the name it was paired under here is the one a stranger's ring cannot choose; the six digits
185
+ // alone can be matched by trying about a million genesis nonces.
186
+ requester: `${inRing} (paired here as "${pending.requester.name}")`,
187
+ signers: ring.signers.map((m) => m.name), members: ring.active.length, requestedAt: pending.requestedAt,
188
+ };
189
+ }
190
+
191
+ /** The question, as the terminal and `ring accept` print it. */
192
+ export function describeAnchor(prompt ) {
193
+ return [
194
+ "A device asks to anchor this computer to its device ring.",
195
+ ` Asked by: ${prompt.requester}`,
196
+ ` Signers: ${prompt.signers.join(", ")} (${prompt.members} devices in the ring)`,
197
+ ` Fingerprint: ${prompt.fingerprint} (the app shows the same six digits)`,
198
+ "Once confirmed, every signer of this ring can use this computer, and the ring can only change with",
199
+ '"npx replica-bridge ring reset". Confirm only if this is your ring and the digits match.',
200
+ ].join("\n");
201
+ }
202
+
203
+ /**
204
+ * Records (or refreshes) a pending anchor once checkAnchor passed. Nothing else changes until a person confirms.
205
+ * Returns false when this computer is already anchored or too many other rings are waiting.
206
+ */
207
+ export function recordPending(request ) {
208
+ let recorded = false;
209
+ updateState((state) => {
210
+ if (state.ring) return;
211
+ const pending = state.ringPending ?? [];
212
+ const index = pending.findIndex((p) => p.ring === request.ring);
213
+ if (index >= 0) {
214
+ pending[index] = { ...request, requestedAt: pending[index].requestedAt };
215
+ } else {
216
+ if (pending.length >= PENDING_ANCHORS_MAX) return;
217
+ pending.push(request);
218
+ }
219
+ state.ringPending = pending;
220
+ recorded = true;
221
+ });
222
+ return recorded;
223
+ }
224
+
225
+ /** A pending anchor by its number in the list (1-based), its fingerprint, or the start of its ring id. */
226
+ export function findPending(state , which ) {
227
+ const pending = state.ringPending ?? [];
228
+ if (!pending.length) throw new Error("no device ring is waiting for confirmation");
229
+ const byNumber = /^\d{1,2}$/.test(which) ? pending[Number(which) - 1] : undefined;
230
+ const found = byNumber ?? pending.filter((p) => ringFingerprint(fromB64url(p.ring)) === which || (which.length >= 6 && p.ring.startsWith(which)));
231
+ if (!Array.isArray(found)) return found;
232
+ if (found.length !== 1) throw new Error(found.length ? `"${which}" matches more than one pending ring` : `no pending ring matches "${which}"`);
233
+ return found[0];
234
+ }
235
+
236
+ /**
237
+ * Removes a paired device on this computer and remembers its key as refused, so a ring that still lists it as a
238
+ * signer cannot bring it back; pairing it here by QR code again lifts that. Returns its name, or null when unknown.
239
+ */
240
+ export function removeDevice(state , deviceId ) {
241
+ // Paired here by QR code, or only ever let in through the ring: either way its key is on file.
242
+ const device = state.devices.find((d) => d.deviceId === deviceId) ?? state.ringDevices?.find((d) => d.deviceId === deviceId);
243
+ state.devices = state.devices.filter((d) => d.deviceId !== deviceId);
244
+ if (state.ringDevices) state.ringDevices = state.ringDevices.filter((d) => d.deviceId !== deviceId);
245
+ if (!device) return null;
246
+ const publicKey = "publicKey" in device ? device.publicKey : device.key;
247
+ state.denied = [...(state.denied ?? []).filter((d) => d.key !== publicKey), { key: publicKey, name: device.name, at: new Date().toISOString() }];
248
+ return device.name;
249
+ }
250
+
251
+ /** Most ring-only devices remembered for `revoke`; a person has far fewer. */
252
+ export const RING_DEVICES_MAX = 64;
253
+
254
+ /** Remembers a device let in through the ring under its relay device id (for `revoke`); false when nothing changed. */
255
+ export function noteRingDevice(state , deviceId , key , name ) {
256
+ const known = state.ringDevices?.find((d) => d.deviceId === deviceId);
257
+ if (known && known.key === b64url(key) && known.name === name) return false;
258
+ const others = (state.ringDevices ?? []).filter((d) => d.deviceId !== deviceId);
259
+ state.ringDevices = [...others, { deviceId, key: b64url(key), name, seenAt: new Date().toISOString() }].slice(-RING_DEVICES_MAX);
260
+ return true;
261
+ }
262
+
263
+
264
+ /** Drops paired devices whose key was revoked in the ring, for good: a reset does not bring them back. */
265
+ export function pruneRevoked(state , ring ) {
266
+ const removed = state.devices.filter((d) => ring.isRevokedKey("x25519", fromB64url(d.publicKey)));
267
+ state.devices = state.devices.filter((d) => !removed.includes(d));
268
+ return removed.map((d) => d.name);
269
+ }
270
+
271
+ /**
272
+ * Confirms a pending anchor: checks the three conditions again against the paired devices as they are now, then
273
+ * anchors for good and forgets every other pending ring.
274
+ */
275
+ export function acceptAnchor(which , hostKey ) {
276
+ let result = null;
277
+ updateState((state) => {
278
+ if (state.ring) throw new Error(`this computer is already anchored to ring ${state.ring.id}; "replica-bridge ring reset" first`);
279
+ const pending = findPending(state, which);
280
+ const checked = checkAnchor(state, hostKey, {
281
+ deviceId: pending.requester.deviceId, key: fromB64url(pending.requester.key), signingKey: fromB64url(pending.requester.signingKey), ring: pending.ring, entries: pending.entries,
282
+ });
283
+ if ("refused" in checked) throw new Error(`cannot anchor to this ring any more: ${checked.refused}`);
284
+ const ring = { id: pending.ring, entries: pending.entries, anchoredAt: new Date().toISOString(), anchoredBy: { deviceId: pending.requester.deviceId, name: pending.requester.name } };
285
+ state.ring = ring;
286
+ delete state.ringPending;
287
+ result = { ring, removed: pruneRevoked(state, checked.ring) };
288
+ });
289
+ return result ;
290
+ }
291
+
292
+ export function rejectAnchor(which ) {
293
+ let rejected = null;
294
+ updateState((state) => {
295
+ rejected = findPending(state, which);
296
+ state.ringPending = state.ringPending .filter((p) => p !== rejected);
297
+ if (!state.ringPending.length) delete state.ringPending;
298
+ });
299
+ return rejected ;
300
+ }
301
+
302
+ /** Leaves the ring: its signers can no longer connect unless paired here. Devices it revoked stay removed. */
303
+ export function resetRing() {
304
+ let was ;
305
+ updateState((state) => {
306
+ was = state.ring;
307
+ delete state.ring;
308
+ delete state.ringPending;
309
+ });
310
+ return was;
311
+ }
312
+
313
+ /**
314
+ * Adds verified entries to the anchored log. `added` extends `base` (the log as it was when the caller started
315
+ * fetching); when the stored log grew meanwhile, only the entries past it are applied again on top. All in one
316
+ * synchronous step, so this process never interleaves two writers and the stored log only ever grows.
317
+ */
318
+ export function storeLog(ringId , base , added , stalled ) {
319
+ let result = null;
320
+ updateState((state) => {
321
+ if (state.ring?.id !== ringId) return;
322
+ const stored = state.ring.entries;
323
+ if (stored.length < base.length || stored[base.length - 1]?.e !== base.at(-1)?.e) return;
324
+ const growth = new LogGrowth(replayAll(stored), stored, Number.MAX_SAFE_INTEGER);
325
+ growth.take(added.slice(stored.length - base.length), "a peer");
326
+ if (stalled && !state.ring.stalled) state.ring.stalled = stalled;
327
+ if (!growth.added.length && !stalled) return;
328
+ state.ring.entries = [...stored, ...growth.added];
329
+ result = { seq: growth.state.seq, removed: pruneRevoked(state, growth.state) };
330
+ });
331
+ return result;
332
+ }
333
+
334
+ /** The anchored ring's summary, for `replica-bridge ring` and status. */
335
+ export function ringSummary(state = loadState()) {
336
+ const lines = [];
337
+ if (state.ring) {
338
+ const { state: ring } = replayPrefix(state.ring.entries);
339
+ lines.push(`Device ring ${state.ring.id} (fingerprint ${ringFingerprint(fromB64url(state.ring.id))}), anchored ${state.ring.anchoredAt} at the request of ${state.ring.anchoredBy.name}`);
340
+ if (state.ring.stalled) lines.push(` Stopped growing (${state.ring.stalled}): the ring lets nobody in until "replica-bridge ring reset".`);
341
+ lines.push(` ${ring.seq + 1} entries; signers: ${ring.signers.map((m) => m.name).join(", ") || "none"}; members: ${ring.active.filter((m) => m.role === "member").map((m) => m.name).join(", ") || "none"}`);
342
+ } else lines.push("Not anchored to a device ring.");
343
+ for (const d of state.ringDevices ?? []) lines.push(` through the ring: ${d.name} (${d.deviceId}), last ${d.seenAt}; "replica-bridge revoke ${d.deviceId}" refuses it here`);
344
+ (state.ringPending ?? []).forEach((pending, i) => {
345
+ const prompt = anchorPrompt(pending);
346
+ lines.push(` waiting ${i + 1}: ring ${prompt.ring.slice(0, 12)}…, fingerprint ${prompt.fingerprint}, asked by ${prompt.requester} at ${prompt.requestedAt}`);
347
+ });
348
+ return lines;
349
+ }
350
+
351
+ /** Checks the shape of a ring frame's entries, so nothing below sees anything but {e, s} strings. */
352
+ export function wireEntries(value ) {
353
+ if (!Array.isArray(value) || value.length > RING_ENTRIES_PER_FRAME) return null;
354
+ for (const item of value) if (typeof item?.e !== "string" || typeof item?.s !== "string") return null;
355
+ return value.map((item) => ({ e: item.e, s: item.s }));
356
+ }
357
+
358
+ const withTimeout = (promise , ms ) => Promise.race([
359
+ promise, new Promise ((_, reject) => setTimeout(() => reject(new Error(`no answer in ${ms / 1000} s`)), ms).unref()),
360
+ ]);
361
+
362
+ /**
363
+ * The relay's copy of the log (docs/design/DEVICE-RING.md "中转服务器:存储和推送"), used only after it says
364
+ * `ring-v1` at /v1/ring/capabilities; an older relay answers 404 and the bridge stays with what the peers gave it.
365
+ * The relay cannot forge entries, so whatever it returns is checked like a peer's.
366
+ */
367
+ export class RelayRingStore {
368
+ known = null;
369
+ relay ;
370
+ constructor(relay ) { this.relay = relay; }
371
+
372
+ /** Asks again next time: the relay socket reopened, perhaps onto a newer (or older) relay. */
373
+ forget() { this.known = null; }
374
+
375
+ async supported() {
376
+ if (this.known && Date.now() - this.known.at < 5 * 60_000) return this.known.supported;
377
+ const { relay } = this.relay();
378
+ let supported = false;
379
+ try {
380
+ const answer = await withTimeout(readJSON(new URL("v1/ring/capabilities", relay.endsWith("/") ? relay : `${relay}/`)), 10_000) ;
381
+ supported = answer.ring === "ring-v1";
382
+ } catch (error) {
383
+ // A relay that is down says nothing about what it supports; ask again next time.
384
+ if (!(error instanceof RelayHTTPError)) return false;
385
+ }
386
+ this.known = { supported, at: Date.now() };
387
+ return supported;
388
+ }
389
+
390
+ /** Pages of the log after the growth's head into `growth`, each checked as it arrives, until it is done. */
391
+ async fetchInto(ringId , growth ) {
392
+ if (!(await this.supported())) throw new Error("the relay has no ring endpoints");
393
+ const { relay, hostToken } = this.relay();
394
+ while (!growth.done) {
395
+ const after = growth.state.seq;
396
+ const url = new URL(`v1/ring/${encodeURIComponent(ringId)}${after >= 0 ? `?after=${after}` : ""}`, relay.endsWith("/") ? relay : `${relay}/`);
397
+ const page = await withTimeout(readJSON(url, hostToken), 15_000) ;
398
+ if (page.ring !== undefined && page.ring !== ringId) throw new Error("the relay answered for another ring");
399
+ const entries = Array.isArray(page.entries) ? page.entries.filter((e) => typeof e?.e === "string" && typeof e?.s === "string").map((e) => ({ e: e.e , s: e.s })) : [];
400
+ if (!growth.take(entries, "the relay") || !page.more || !entries.length) return;
401
+ }
402
+ }
403
+ }
404
+
405
+ /** A GET (with the computer's token when given) whose answer may be at most RELAY_PAGE_MAX_BYTES: a relay cannot run the bridge out of memory. */
406
+ async function readJSON(url , token ) {
407
+ const response = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} });
408
+ const reader = response.body?.getReader();
409
+ const chunks = [];
410
+ let size = 0;
411
+ for (;;) {
412
+ const { done, value } = reader ? await reader.read() : { done: true, value: undefined };
413
+ if (done) break;
414
+ size += value.length;
415
+ if (size > RELAY_PAGE_MAX_BYTES) { await reader .cancel(); throw new Error("the relay's answer is too large"); }
416
+ chunks.push(value);
417
+ }
418
+ const text = Buffer.concat(chunks).toString("utf8");
419
+ if (!response.ok) throw new RelayHTTPError(response.status, String(response.status), text.slice(0, 200));
420
+ return JSON.parse(text);
421
+ }
422
+
423
+ /** True when a string is canonical base64url of 32 bytes (a ring id, an entry id, a key). */
424
+ export function isKey32(text ) {
425
+ if (typeof text !== "string") return false;
426
+ try { return ringB64(text).length === 32; } catch (error) { if (error instanceof RingError) return false; throw error; }
427
+ }