@threadbase-sh/streamer 1.57.0 → 1.58.1

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/dist/index.cjs CHANGED
@@ -1305,11 +1305,19 @@ var CodexPtyRunner = class {
1305
1305
  onStatusChange;
1306
1306
  onPhaseChange;
1307
1307
  onReady;
1308
- // Broadcasts Codex's blocking startup gates (directory trust, hooks review)
1309
- // as question cards; null dismisses the card once the gate leaves the screen.
1308
+ // Every Codex prompt the client can answer — startup gates (directory trust,
1309
+ // hooks review), command approvals, and the rate-limit model picker is
1310
+ // broadcast through this one channel; null dismisses the card once the prompt
1311
+ // leaves the screen.
1312
+ //
1313
+ // Deliberately NOT onLiveQuestion/onLiveQuestionGone, which is Claude's
1314
+ // AskUserQuestion transport. Both channels land on the same mobile
1315
+ // QuestionCard, and the permission one is the correct fit for Codex: its menus
1316
+ // are answered by the option's real on-screen number (parseCodexNumberedOptions
1317
+ // emits `answerKeys: "2\r"`), which is exactly what `permissionIndices` carries
1318
+ // and what AskUserQuestion's down-arrow-count model cannot express. Wiring the
1319
+ // question channel as well would be a second path to the same card.
1310
1320
  onPermissionChange;
1311
- onLiveQuestion;
1312
- onLiveQuestionGone;
1313
1321
  onUserMessage;
1314
1322
  log;
1315
1323
  // Tracks sessions whose PTY has spawned but Codex hasn't yet reached its
@@ -1359,8 +1367,6 @@ var CodexPtyRunner = class {
1359
1367
  this.onPhaseChange = options.onPhaseChange;
1360
1368
  this.onReady = options.onReady;
1361
1369
  this.onPermissionChange = options.onPermissionChange;
1362
- this.onLiveQuestion = options.onLiveQuestion;
1363
- this.onLiveQuestionGone = options.onLiveQuestionGone;
1364
1370
  this.onUserMessage = options.onUserMessage;
1365
1371
  this.log = options.logger ?? getLogger("codex-pty");
1366
1372
  }
@@ -4188,7 +4194,12 @@ function toDeviceView(row) {
4188
4194
  capabilities: parseCapabilities(row.capabilities),
4189
4195
  createdAt: row.created_at,
4190
4196
  lastSeenAt: row.last_seen_at,
4191
- revokedAt: row.revoked_at
4197
+ revokedAt: row.revoked_at,
4198
+ // Reported from `e2ee_required` rather than from the key's presence. The
4199
+ // two agree today, but they answer different questions — the key is what a
4200
+ // handshake is checked against, `e2ee_required` is whether plaintext is
4201
+ // refused — and it is the second one a user is asking about.
4202
+ e2ee: row.e2ee_required === 1
4192
4203
  };
4193
4204
  }
4194
4205
  var DevicesRepository = class {
@@ -4200,14 +4211,30 @@ var DevicesRepository = class {
4200
4211
  touchStmt;
4201
4212
  deleteStmt;
4202
4213
  deleteRevokedStmt;
4214
+ byE2eeStaticPubStmt;
4215
+ repairStmt;
4203
4216
  constructor(db) {
4204
4217
  this.insertStmt = db.prepare(`
4205
4218
  INSERT INTO devices (
4206
- device_id, public_key, token_hash, name, capabilities, created_at
4219
+ device_id, public_key, token_hash, name, capabilities, created_at,
4220
+ e2ee_static_pub, e2ee_required, e2ee_version
4207
4221
  ) VALUES (
4208
- @device_id, @public_key, @token_hash, @name, @capabilities, @created_at
4222
+ @device_id, @public_key, @token_hash, @name, @capabilities, @created_at,
4223
+ @e2ee_static_pub, @e2ee_required, @e2ee_version
4209
4224
  )
4210
4225
  `);
4226
+ this.byE2eeStaticPubStmt = db.prepare("SELECT * FROM devices WHERE e2ee_static_pub = ?");
4227
+ this.repairStmt = db.prepare(`
4228
+ UPDATE devices SET
4229
+ public_key = @public_key,
4230
+ token_hash = @token_hash,
4231
+ name = @name,
4232
+ capabilities = @capabilities,
4233
+ e2ee_required = 1,
4234
+ e2ee_version = @e2ee_version,
4235
+ revoked_at = NULL
4236
+ WHERE device_id = @device_id
4237
+ `);
4211
4238
  this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
4212
4239
  this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
4213
4240
  this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
@@ -4223,19 +4250,42 @@ var DevicesRepository = class {
4223
4250
  * moment it exists outside the client.
4224
4251
  */
4225
4252
  register(args) {
4226
- const deviceId = (0, import_crypto4.randomUUID)();
4227
4253
  const deviceToken = generateDeviceToken();
4228
4254
  const capabilities = capabilitiesForPreset(args.preset ?? "full");
4255
+ const now = args.now ?? Date.now();
4256
+ const existing = args.e2eeStaticPub ? this.byE2eeStaticPubStmt.get(args.e2eeStaticPub) : void 0;
4257
+ if (existing) {
4258
+ this.repairStmt.run({
4259
+ device_id: existing.device_id,
4260
+ public_key: args.publicKey,
4261
+ token_hash: hashDeviceToken(deviceToken),
4262
+ name: args.name ?? null,
4263
+ capabilities: JSON.stringify(capabilities),
4264
+ e2ee_version: args.e2eeVersion ?? null
4265
+ });
4266
+ return { deviceId: existing.device_id, deviceToken, capabilities };
4267
+ }
4268
+ const deviceId = (0, import_crypto4.randomUUID)();
4229
4269
  this.insertStmt.run({
4230
4270
  device_id: deviceId,
4231
4271
  public_key: args.publicKey,
4232
4272
  token_hash: hashDeviceToken(deviceToken),
4233
4273
  name: args.name ?? null,
4234
4274
  capabilities: JSON.stringify(capabilities),
4235
- created_at: args.now ?? Date.now()
4275
+ created_at: now,
4276
+ e2ee_static_pub: args.e2eeStaticPub ?? null,
4277
+ // The downgrade lock, set in the same write that records the key. Once
4278
+ // set, nothing a client can send clears it (design.md §6.3) — which is
4279
+ // what makes it a lock rather than a preference.
4280
+ e2ee_required: args.e2eeStaticPub ? 1 : 0,
4281
+ e2ee_version: args.e2eeVersion ?? null
4236
4282
  });
4237
4283
  return { deviceId, deviceToken, capabilities };
4238
4284
  }
4285
+ /** The device that owns a Noise static key, or null. */
4286
+ getByE2eeStaticPub(staticPub) {
4287
+ return this.byE2eeStaticPubStmt.get(staticPub) ?? null;
4288
+ }
4239
4289
  /**
4240
4290
  * Resolve a presented token to a device, or null.
4241
4291
  *
@@ -11347,7 +11397,37 @@ var PairTokenStore = class {
11347
11397
  expiresInSeconds: Math.floor(this.ttlMs / 1e3)
11348
11398
  };
11349
11399
  }
11400
+ /**
11401
+ * Whether `consume` would succeed right now, WITHOUT spending the token.
11402
+ *
11403
+ * Exists so a caller can reject a bad token before doing any work, and still
11404
+ * spend the token only once the work has succeeded. A pair token is
11405
+ * single-use and lives 180 seconds, so spending it on a request that then
11406
+ * fails costs the user a whole new QR — and, worse, makes their retry
11407
+ * indistinguishable from an attacker replaying a photographed code, which is
11408
+ * the one signal `design.md` §2.6 designates as replay detection.
11409
+ *
11410
+ * Advisory, not a reservation: it takes no lock and holds nothing. The
11411
+ * authoritative answer is still `consume`'s.
11412
+ */
11413
+ verify(token) {
11414
+ const result = this.check(token);
11415
+ return result.ok ? { ok: true } : result;
11416
+ }
11350
11417
  consume(token) {
11418
+ const result = this.check(token);
11419
+ if (!result.ok) return result;
11420
+ result.record.used = true;
11421
+ return { ok: true };
11422
+ }
11423
+ /**
11424
+ * The shared predicate behind `verify` and `consume`.
11425
+ *
11426
+ * One implementation on purpose: two copies of "is this token usable" is two
11427
+ * places for the expiry or single-use rule to drift, and a drift in this
11428
+ * direction fails open.
11429
+ */
11430
+ check(token) {
11351
11431
  const record2 = this.current;
11352
11432
  if (!record2 || record2.token !== token) return { ok: false, reason: "unknown" };
11353
11433
  if (Date.now() > record2.expiresAt) {
@@ -11355,8 +11435,7 @@ var PairTokenStore = class {
11355
11435
  return { ok: false, reason: "expired" };
11356
11436
  }
11357
11437
  if (record2.used) return { ok: false, reason: "used" };
11358
- record2.used = true;
11359
- return { ok: true };
11438
+ return { ok: true, record: record2 };
11360
11439
  }
11361
11440
  peek() {
11362
11441
  return this.current;
@@ -16146,9 +16225,9 @@ var StreamerServer = class {
16146
16225
  json(res, 400, { error: "Missing token or clientPublicKey" });
16147
16226
  return;
16148
16227
  }
16149
- const result = this.pairTokens.consume(token);
16150
- if (!result.ok) {
16151
- json(res, 401, { error: `Pair token ${result.reason}` });
16228
+ const precheck = this.pairTokens.verify(token);
16229
+ if (!precheck.ok) {
16230
+ json(res, 401, { error: `Pair token ${precheck.reason}` });
16152
16231
  return;
16153
16232
  }
16154
16233
  let sealed;
@@ -16159,6 +16238,11 @@ var StreamerServer = class {
16159
16238
  json(res, 400, { error: message });
16160
16239
  return;
16161
16240
  }
16241
+ const result = this.pairTokens.consume(token);
16242
+ if (!result.ok) {
16243
+ json(res, 401, { error: `Pair token ${result.reason}` });
16244
+ return;
16245
+ }
16162
16246
  const ts = (/* @__PURE__ */ new Date()).toISOString();
16163
16247
  this.log.info(`[pair] token exchanged from ${ip} at ${ts}`, {
16164
16248
  event: "pair.token_exchanged",