@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.d.cts CHANGED
@@ -1204,6 +1204,11 @@ interface DeviceRow {
1204
1204
  created_at: number;
1205
1205
  last_seen_at: number | null;
1206
1206
  revoked_at: number | null;
1207
+ /** Noise static public key, base64. Null on every row that predates E2EE. */
1208
+ e2ee_static_pub: string | null;
1209
+ /** The downgrade lock. 1 once this device has completed a handshake. */
1210
+ e2ee_required: number;
1211
+ e2ee_version: number | null;
1207
1212
  }
1208
1213
  /** A device as reported over the API. Deliberately carries no credential. */
1209
1214
  interface DeviceView {
@@ -1213,6 +1218,16 @@ interface DeviceView {
1213
1218
  createdAt: number;
1214
1219
  lastSeenAt: number | null;
1215
1220
  revokedAt: number | null;
1221
+ /**
1222
+ * Whether this device is pinned to encryption.
1223
+ *
1224
+ * A boolean, never the key and never a hash of it: `GET /api/devices` exists
1225
+ * so a user can spot a device they did not pair, and answering "is this one
1226
+ * encrypted" needs no key material to do it. Publishing the static key would
1227
+ * hand an attacker who reached this endpoint the value that identifies a
1228
+ * device.
1229
+ */
1230
+ e2ee: boolean;
1216
1231
  }
1217
1232
  interface RegisteredDevice {
1218
1233
  deviceId: string;
@@ -1229,6 +1244,8 @@ declare class DevicesRepository {
1229
1244
  private touchStmt;
1230
1245
  private deleteStmt;
1231
1246
  private deleteRevokedStmt;
1247
+ private byE2eeStaticPubStmt;
1248
+ private repairStmt;
1232
1249
  constructor(db: Database.Database);
1233
1250
  /**
1234
1251
  * Record a newly paired device and mint its token.
@@ -1241,7 +1258,16 @@ declare class DevicesRepository {
1241
1258
  name?: string | null;
1242
1259
  preset?: CapabilityPreset;
1243
1260
  now?: number;
1261
+ /**
1262
+ * The device's Noise static public key, base64, when pairing completed a
1263
+ * handshake. Absent on a plaintext pairing, which stays exactly as it is
1264
+ * today: no key, no pin, no behaviour change.
1265
+ */
1266
+ e2eeStaticPub?: string;
1267
+ e2eeVersion?: number;
1244
1268
  }): RegisteredDevice;
1269
+ /** The device that owns a Noise static key, or null. */
1270
+ getByE2eeStaticPub(staticPub: string): DeviceRow | null;
1245
1271
  /**
1246
1272
  * Resolve a presented token to a device, or null.
1247
1273
  *
package/dist/index.d.ts CHANGED
@@ -1204,6 +1204,11 @@ interface DeviceRow {
1204
1204
  created_at: number;
1205
1205
  last_seen_at: number | null;
1206
1206
  revoked_at: number | null;
1207
+ /** Noise static public key, base64. Null on every row that predates E2EE. */
1208
+ e2ee_static_pub: string | null;
1209
+ /** The downgrade lock. 1 once this device has completed a handshake. */
1210
+ e2ee_required: number;
1211
+ e2ee_version: number | null;
1207
1212
  }
1208
1213
  /** A device as reported over the API. Deliberately carries no credential. */
1209
1214
  interface DeviceView {
@@ -1213,6 +1218,16 @@ interface DeviceView {
1213
1218
  createdAt: number;
1214
1219
  lastSeenAt: number | null;
1215
1220
  revokedAt: number | null;
1221
+ /**
1222
+ * Whether this device is pinned to encryption.
1223
+ *
1224
+ * A boolean, never the key and never a hash of it: `GET /api/devices` exists
1225
+ * so a user can spot a device they did not pair, and answering "is this one
1226
+ * encrypted" needs no key material to do it. Publishing the static key would
1227
+ * hand an attacker who reached this endpoint the value that identifies a
1228
+ * device.
1229
+ */
1230
+ e2ee: boolean;
1216
1231
  }
1217
1232
  interface RegisteredDevice {
1218
1233
  deviceId: string;
@@ -1229,6 +1244,8 @@ declare class DevicesRepository {
1229
1244
  private touchStmt;
1230
1245
  private deleteStmt;
1231
1246
  private deleteRevokedStmt;
1247
+ private byE2eeStaticPubStmt;
1248
+ private repairStmt;
1232
1249
  constructor(db: Database.Database);
1233
1250
  /**
1234
1251
  * Record a newly paired device and mint its token.
@@ -1241,7 +1258,16 @@ declare class DevicesRepository {
1241
1258
  name?: string | null;
1242
1259
  preset?: CapabilityPreset;
1243
1260
  now?: number;
1261
+ /**
1262
+ * The device's Noise static public key, base64, when pairing completed a
1263
+ * handshake. Absent on a plaintext pairing, which stays exactly as it is
1264
+ * today: no key, no pin, no behaviour change.
1265
+ */
1266
+ e2eeStaticPub?: string;
1267
+ e2eeVersion?: number;
1244
1268
  }): RegisteredDevice;
1269
+ /** The device that owns a Noise static key, or null. */
1270
+ getByE2eeStaticPub(staticPub: string): DeviceRow | null;
1245
1271
  /**
1246
1272
  * Resolve a presented token to a device, or null.
1247
1273
  *
package/dist/index.js CHANGED
@@ -1252,11 +1252,19 @@ var CodexPtyRunner = class {
1252
1252
  onStatusChange;
1253
1253
  onPhaseChange;
1254
1254
  onReady;
1255
- // Broadcasts Codex's blocking startup gates (directory trust, hooks review)
1256
- // as question cards; null dismisses the card once the gate leaves the screen.
1255
+ // Every Codex prompt the client can answer — startup gates (directory trust,
1256
+ // hooks review), command approvals, and the rate-limit model picker is
1257
+ // broadcast through this one channel; null dismisses the card once the prompt
1258
+ // leaves the screen.
1259
+ //
1260
+ // Deliberately NOT onLiveQuestion/onLiveQuestionGone, which is Claude's
1261
+ // AskUserQuestion transport. Both channels land on the same mobile
1262
+ // QuestionCard, and the permission one is the correct fit for Codex: its menus
1263
+ // are answered by the option's real on-screen number (parseCodexNumberedOptions
1264
+ // emits `answerKeys: "2\r"`), which is exactly what `permissionIndices` carries
1265
+ // and what AskUserQuestion's down-arrow-count model cannot express. Wiring the
1266
+ // question channel as well would be a second path to the same card.
1257
1267
  onPermissionChange;
1258
- onLiveQuestion;
1259
- onLiveQuestionGone;
1260
1268
  onUserMessage;
1261
1269
  log;
1262
1270
  // Tracks sessions whose PTY has spawned but Codex hasn't yet reached its
@@ -1306,8 +1314,6 @@ var CodexPtyRunner = class {
1306
1314
  this.onPhaseChange = options.onPhaseChange;
1307
1315
  this.onReady = options.onReady;
1308
1316
  this.onPermissionChange = options.onPermissionChange;
1309
- this.onLiveQuestion = options.onLiveQuestion;
1310
- this.onLiveQuestionGone = options.onLiveQuestionGone;
1311
1317
  this.onUserMessage = options.onUserMessage;
1312
1318
  this.log = options.logger ?? getLogger("codex-pty");
1313
1319
  }
@@ -4135,7 +4141,12 @@ function toDeviceView(row) {
4135
4141
  capabilities: parseCapabilities(row.capabilities),
4136
4142
  createdAt: row.created_at,
4137
4143
  lastSeenAt: row.last_seen_at,
4138
- revokedAt: row.revoked_at
4144
+ revokedAt: row.revoked_at,
4145
+ // Reported from `e2ee_required` rather than from the key's presence. The
4146
+ // two agree today, but they answer different questions — the key is what a
4147
+ // handshake is checked against, `e2ee_required` is whether plaintext is
4148
+ // refused — and it is the second one a user is asking about.
4149
+ e2ee: row.e2ee_required === 1
4139
4150
  };
4140
4151
  }
4141
4152
  var DevicesRepository = class {
@@ -4147,14 +4158,30 @@ var DevicesRepository = class {
4147
4158
  touchStmt;
4148
4159
  deleteStmt;
4149
4160
  deleteRevokedStmt;
4161
+ byE2eeStaticPubStmt;
4162
+ repairStmt;
4150
4163
  constructor(db) {
4151
4164
  this.insertStmt = db.prepare(`
4152
4165
  INSERT INTO devices (
4153
- device_id, public_key, token_hash, name, capabilities, created_at
4166
+ device_id, public_key, token_hash, name, capabilities, created_at,
4167
+ e2ee_static_pub, e2ee_required, e2ee_version
4154
4168
  ) VALUES (
4155
- @device_id, @public_key, @token_hash, @name, @capabilities, @created_at
4169
+ @device_id, @public_key, @token_hash, @name, @capabilities, @created_at,
4170
+ @e2ee_static_pub, @e2ee_required, @e2ee_version
4156
4171
  )
4157
4172
  `);
4173
+ this.byE2eeStaticPubStmt = db.prepare("SELECT * FROM devices WHERE e2ee_static_pub = ?");
4174
+ this.repairStmt = db.prepare(`
4175
+ UPDATE devices SET
4176
+ public_key = @public_key,
4177
+ token_hash = @token_hash,
4178
+ name = @name,
4179
+ capabilities = @capabilities,
4180
+ e2ee_required = 1,
4181
+ e2ee_version = @e2ee_version,
4182
+ revoked_at = NULL
4183
+ WHERE device_id = @device_id
4184
+ `);
4158
4185
  this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
4159
4186
  this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
4160
4187
  this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
@@ -4170,19 +4197,42 @@ var DevicesRepository = class {
4170
4197
  * moment it exists outside the client.
4171
4198
  */
4172
4199
  register(args) {
4173
- const deviceId = randomUUID3();
4174
4200
  const deviceToken = generateDeviceToken();
4175
4201
  const capabilities = capabilitiesForPreset(args.preset ?? "full");
4202
+ const now = args.now ?? Date.now();
4203
+ const existing = args.e2eeStaticPub ? this.byE2eeStaticPubStmt.get(args.e2eeStaticPub) : void 0;
4204
+ if (existing) {
4205
+ this.repairStmt.run({
4206
+ device_id: existing.device_id,
4207
+ public_key: args.publicKey,
4208
+ token_hash: hashDeviceToken(deviceToken),
4209
+ name: args.name ?? null,
4210
+ capabilities: JSON.stringify(capabilities),
4211
+ e2ee_version: args.e2eeVersion ?? null
4212
+ });
4213
+ return { deviceId: existing.device_id, deviceToken, capabilities };
4214
+ }
4215
+ const deviceId = randomUUID3();
4176
4216
  this.insertStmt.run({
4177
4217
  device_id: deviceId,
4178
4218
  public_key: args.publicKey,
4179
4219
  token_hash: hashDeviceToken(deviceToken),
4180
4220
  name: args.name ?? null,
4181
4221
  capabilities: JSON.stringify(capabilities),
4182
- created_at: args.now ?? Date.now()
4222
+ created_at: now,
4223
+ e2ee_static_pub: args.e2eeStaticPub ?? null,
4224
+ // The downgrade lock, set in the same write that records the key. Once
4225
+ // set, nothing a client can send clears it (design.md §6.3) — which is
4226
+ // what makes it a lock rather than a preference.
4227
+ e2ee_required: args.e2eeStaticPub ? 1 : 0,
4228
+ e2ee_version: args.e2eeVersion ?? null
4183
4229
  });
4184
4230
  return { deviceId, deviceToken, capabilities };
4185
4231
  }
4232
+ /** The device that owns a Noise static key, or null. */
4233
+ getByE2eeStaticPub(staticPub) {
4234
+ return this.byE2eeStaticPubStmt.get(staticPub) ?? null;
4235
+ }
4186
4236
  /**
4187
4237
  * Resolve a presented token to a device, or null.
4188
4238
  *
@@ -11302,7 +11352,37 @@ var PairTokenStore = class {
11302
11352
  expiresInSeconds: Math.floor(this.ttlMs / 1e3)
11303
11353
  };
11304
11354
  }
11355
+ /**
11356
+ * Whether `consume` would succeed right now, WITHOUT spending the token.
11357
+ *
11358
+ * Exists so a caller can reject a bad token before doing any work, and still
11359
+ * spend the token only once the work has succeeded. A pair token is
11360
+ * single-use and lives 180 seconds, so spending it on a request that then
11361
+ * fails costs the user a whole new QR — and, worse, makes their retry
11362
+ * indistinguishable from an attacker replaying a photographed code, which is
11363
+ * the one signal `design.md` §2.6 designates as replay detection.
11364
+ *
11365
+ * Advisory, not a reservation: it takes no lock and holds nothing. The
11366
+ * authoritative answer is still `consume`'s.
11367
+ */
11368
+ verify(token) {
11369
+ const result = this.check(token);
11370
+ return result.ok ? { ok: true } : result;
11371
+ }
11305
11372
  consume(token) {
11373
+ const result = this.check(token);
11374
+ if (!result.ok) return result;
11375
+ result.record.used = true;
11376
+ return { ok: true };
11377
+ }
11378
+ /**
11379
+ * The shared predicate behind `verify` and `consume`.
11380
+ *
11381
+ * One implementation on purpose: two copies of "is this token usable" is two
11382
+ * places for the expiry or single-use rule to drift, and a drift in this
11383
+ * direction fails open.
11384
+ */
11385
+ check(token) {
11306
11386
  const record2 = this.current;
11307
11387
  if (!record2 || record2.token !== token) return { ok: false, reason: "unknown" };
11308
11388
  if (Date.now() > record2.expiresAt) {
@@ -11310,8 +11390,7 @@ var PairTokenStore = class {
11310
11390
  return { ok: false, reason: "expired" };
11311
11391
  }
11312
11392
  if (record2.used) return { ok: false, reason: "used" };
11313
- record2.used = true;
11314
- return { ok: true };
11393
+ return { ok: true, record: record2 };
11315
11394
  }
11316
11395
  peek() {
11317
11396
  return this.current;
@@ -16103,9 +16182,9 @@ var StreamerServer = class {
16103
16182
  json(res, 400, { error: "Missing token or clientPublicKey" });
16104
16183
  return;
16105
16184
  }
16106
- const result = this.pairTokens.consume(token);
16107
- if (!result.ok) {
16108
- json(res, 401, { error: `Pair token ${result.reason}` });
16185
+ const precheck = this.pairTokens.verify(token);
16186
+ if (!precheck.ok) {
16187
+ json(res, 401, { error: `Pair token ${precheck.reason}` });
16109
16188
  return;
16110
16189
  }
16111
16190
  let sealed;
@@ -16116,6 +16195,11 @@ var StreamerServer = class {
16116
16195
  json(res, 400, { error: message });
16117
16196
  return;
16118
16197
  }
16198
+ const result = this.pairTokens.consume(token);
16199
+ if (!result.ok) {
16200
+ json(res, 401, { error: `Pair token ${result.reason}` });
16201
+ return;
16202
+ }
16119
16203
  const ts = (/* @__PURE__ */ new Date()).toISOString();
16120
16204
  this.log.info(`[pair] token exchanged from ${ip} at ${ts}`, {
16121
16205
  event: "pair.token_exchanged",