sigrank 0.17.2 → 0.17.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/README.md CHANGED
@@ -147,7 +147,7 @@ smithery tool call sigrank rank_paste '{"text": "1000000 500000 50000 800000"}'
147
147
  ## Commands
148
148
 
149
149
  ```
150
- ⊙ SigRank CLI v0.17.0
150
+ ⊙ SigRank CLI v0.17.3
151
151
 
152
152
  Default (no args)
153
153
  sigrank unified dashboard: cascade + token pillars + board
package/keystore.mjs CHANGED
@@ -19,11 +19,15 @@ import {
19
19
  existsSync,
20
20
  chmodSync,
21
21
  unlinkSync,
22
+ readdirSync,
23
+ copyFileSync,
22
24
  } from "node:fs";
23
25
  import { generateKeyPairSync, randomUUID } from "node:crypto";
24
26
 
25
27
  const DIR = join(homedir(), ".sigrank-mcp");
26
28
  const PATH = join(DIR, "identity.json");
29
+ const BACKUP_PREFIX = "identity.json.bak-";
30
+ const MAX_BACKUPS = 5;
27
31
 
28
32
  /** SPKI DER prefix length for an ed25519 public key (12 bytes before the raw 32). */
29
33
  const SPKI_PREFIX_LEN = 12;
@@ -54,7 +58,8 @@ export function loadIdentity() {
54
58
  }
55
59
  }
56
60
 
57
- /** Write the identity with strict perms (dir 0700, file 0600). */
61
+ /** Write the identity with strict perms (dir 0700, file 0600). Backs up the existing
62
+ * file before overwriting so a corrupt write or accidental reset can be recovered. */
58
63
  export function persistIdentity(identity) {
59
64
  mkdirSync(DIR, { recursive: true });
60
65
  try {
@@ -62,6 +67,17 @@ export function persistIdentity(identity) {
62
67
  } catch {
63
68
  /* best-effort on platforms without chmod */
64
69
  }
70
+ // Back up the existing identity before overwriting (resilience: if the write
71
+ // fails or the file is later lost, the backup carries the binding + keys).
72
+ if (existsSync(PATH)) {
73
+ try {
74
+ const stamp = Date.now();
75
+ copyFileSync(PATH, join(DIR, `${BACKUP_PREFIX}${stamp}`));
76
+ pruneBackups();
77
+ } catch {
78
+ /* best-effort — don't block the write */
79
+ }
80
+ }
65
81
  writeFileSync(PATH, `${JSON.stringify(identity, null, 2)}\n`, {
66
82
  mode: 0o600,
67
83
  });
@@ -118,6 +134,9 @@ export function bindingForFreshIdentity(existing, fresh) {
118
134
  /**
119
135
  * Load the existing identity, or generate + persist a fresh one. Idempotent: a
120
136
  * complete existing identity is returned untouched (never rotates a live key).
137
+ * Self-healing: if the identity has keys + device_id but no codename (binding
138
+ * lost via partial write / version transition), scans backups for a matching
139
+ * device_id and restores the binding before returning.
121
140
  */
122
141
  export function ensureIdentity() {
123
142
  const existing = loadIdentity();
@@ -126,6 +145,16 @@ export function ensureIdentity() {
126
145
  existing?.public_key &&
127
146
  existing?.device_id
128
147
  ) {
148
+ // Self-healing: keys present but binding lost → try to restore from backups.
149
+ if (!existing.codename || !existing.operator_id) {
150
+ const restored = restoreBindingFromBackups(existing.device_id);
151
+ if (restored) {
152
+ existing.codename = restored.codename;
153
+ existing.operator_id = restored.operator_id;
154
+ existing.enrolled_at = restored.enrolled_at;
155
+ return persistIdentity(existing);
156
+ }
157
+ }
129
158
  return existing;
130
159
  }
131
160
  // Regeneration path: a partial/corrupt record. The binding is tied to device_id —
@@ -160,3 +189,54 @@ export function clearIdentity() {
160
189
  /* best-effort */
161
190
  }
162
191
  }
192
+
193
+ /** Keep at most MAX_BACKUPS backup files (oldest deleted first). */
194
+ function pruneBackups() {
195
+ try {
196
+ const files = readdirSync(DIR)
197
+ .filter((f) => f.startsWith(BACKUP_PREFIX))
198
+ .sort(); // timestamp-suffixed → lexicographic = chronological
199
+ while (files.length > MAX_BACKUPS) {
200
+ unlinkSync(join(DIR, files.shift()));
201
+ }
202
+ } catch {
203
+ /* best-effort */
204
+ }
205
+ }
206
+
207
+ /** Scan backup files for one with the same device_id that carries a codename +
208
+ * operator_id binding. Returns { codename, operator_id, enrolled_at } or null.
209
+ * Used by ensureIdentity() to self-heal when the live file lost its binding
210
+ * (e.g. a partial write or version transition that preserved keys but dropped
211
+ * the binding metadata). Only restores from a backup with the SAME device_id —
212
+ * never crosses device boundaries (Frankenstein-identity guard). */
213
+ export function restoreBindingFromBackups(device_id) {
214
+ if (!device_id) return null;
215
+ try {
216
+ const files = readdirSync(DIR)
217
+ .filter((f) => f.startsWith(BACKUP_PREFIX))
218
+ .sort()
219
+ .reverse(); // newest first — prefer the most recent binding
220
+ for (const f of files) {
221
+ try {
222
+ const bak = JSON.parse(readFileSync(join(DIR, f), "utf-8"));
223
+ if (
224
+ bak.device_id === device_id &&
225
+ bak.codename &&
226
+ bak.operator_id
227
+ ) {
228
+ return {
229
+ codename: bak.codename,
230
+ operator_id: bak.operator_id,
231
+ enrolled_at: bak.enrolled_at ?? null,
232
+ };
233
+ }
234
+ } catch {
235
+ /* skip corrupt backups */
236
+ }
237
+ }
238
+ } catch {
239
+ /* best-effort */
240
+ }
241
+ return null;
242
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
4
4
  "author": "SunrisesIllNeverSee (https://github.com/SunrisesIllNeverSee)",
5
5
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
6
6
  "mcpName": "io.github.SunrisesIllNeverSee/sigrank-mcp",
package/tools.mjs CHANGED
@@ -1461,6 +1461,30 @@ export async function callTool(name, args, opts = {}) {
1461
1461
  trust_status: ack.trust_status ?? "trusted",
1462
1462
  };
1463
1463
  }
1464
+ // Recovery: if the server says device_already_enrolled but includes the
1465
+ // codename/operator_id, the device IS bound server-side — record it locally
1466
+ // and return enrolled instead of erroring. This handles the case where the
1467
+ // local binding was lost (partial write, version transition) but the device
1468
+ // is still enrolled server-side.
1469
+ if (
1470
+ ack.reason === "device_already_enrolled" &&
1471
+ ack.codename &&
1472
+ ack.operator_id
1473
+ ) {
1474
+ if (!opts.identity)
1475
+ recordEnrollment({
1476
+ codename: ack.codename,
1477
+ operator_id: ack.operator_id,
1478
+ });
1479
+ return {
1480
+ status: "enrolled",
1481
+ codename: ack.codename,
1482
+ operator_id: ack.operator_id,
1483
+ device_id: id.device_id,
1484
+ trust_status: ack.trust_status ?? "trusted",
1485
+ recovered: true,
1486
+ };
1487
+ }
1464
1488
  return {
1465
1489
  status: "error",
1466
1490
  httpStatus: res.status,
package/tui.mjs CHANGED
@@ -2049,7 +2049,7 @@ export async function runTui({
2049
2049
  renderConnect(connectId, codeBuf, connectMsg);
2050
2050
  const signOutHint = isSignedIn(connectId)
2051
2051
  ? ` ${dim("[X]")} sign out`
2052
- : "";
2052
+ : " " + dim("[X]") + " reset device";
2053
2053
  setFooter([
2054
2054
  ` ${hr()}`,
2055
2055
  ` ${dim("[Enter]")} sign in ${dim("[Esc]")} clear/back${signOutHint} ${dim("← →")} tabs ${dim("[Q]")} quit`,
@@ -2166,14 +2166,16 @@ export async function runTui({
2166
2166
  try {
2167
2167
  const out = await callTool("enroll", { code });
2168
2168
  if (out.status === "enrolled") {
2169
- connectMsg = `${green("✓")} signed in as ${cyan(out.codename || "(operator)")}`;
2169
+ connectMsg = out.recovered
2170
+ ? `${green("✓")} recovered account: ${cyan(out.codename || "(operator)")} ${dim("(device was already bound)")}`
2171
+ : `${green("✓")} signed in as ${cyan(out.codename || "(operator)")}`;
2170
2172
  codeBuf = "";
2171
2173
  } else {
2172
2174
  const reasons = {
2173
2175
  code_invalid:
2174
2176
  "that code is invalid, expired, or already used — generate a fresh one.",
2175
2177
  device_already_enrolled:
2176
- 'this device is already signed in. Need a new key? Click "New key" at signalaf.com → Settings, then paste it here.',
2178
+ 'this device is already bound. Press [X] to reset the device, then paste a fresh code from signalaf.com → Settings → "New key".',
2177
2179
  bad_request: "the code or device key was malformed.",
2178
2180
  rate_limited:
2179
2181
  "too many attempts — wait a few minutes and retry.",
@@ -2214,14 +2216,20 @@ export async function runTui({
2214
2216
  await redraw();
2215
2217
  return;
2216
2218
  } // backspace
2217
- // [X] sign out / reset — only when signed in AND the code field is empty (so an
2218
- // 'x' typed mid-code still feeds the buffer). Drops the stale local credential so
2219
- // a server-revoked device stops reading as "signed in" + the next enroll provisions
2220
- // a fresh device_id (escapes the Frankenstein-identity / 409-re-enroll deadlock).
2221
- if (k === "x" && !codeBuf && isSignedIn(loadIdentity())) {
2219
+ // [X] sign out / reset — when the code field is empty (so an 'x' typed mid-code
2220
+ // still feeds the buffer). Drops the local credential so a server-revoked OR
2221
+ // server-enrolled-but-locally-unbound device stops holding a stale device_id,
2222
+ // and the next enroll provisions a fresh device_id (escapes the Frankenstein-
2223
+ // identity / 409-re-enroll deadlock). Works whether or not isSignedIn() —
2224
+ // otherwise a device enrolled server-side but unbound locally is un-escapable
2225
+ // (can't sign in: device_already_enrolled; can't sign out: X gated off).
2226
+ if (k === "x" && !codeBuf) {
2227
+ const wasSignedIn = isSignedIn(loadIdentity());
2222
2228
  clearIdentity();
2223
2229
  codeBuf = "";
2224
- connectMsg = `${green("✓")} signed out — paste a new code to sign in`;
2230
+ connectMsg = wasSignedIn
2231
+ ? `${green("✓")} signed out — paste a new code to sign in`
2232
+ : `${green("✓")} device reset — paste a new code to sign in`;
2225
2233
  await redraw();
2226
2234
  return;
2227
2235
  }