@breeztech/breez-sdk-spark 0.13.10-dev → 0.13.11-dev1

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.
Files changed (41) hide show
  1. package/breez-sdk-spark.tgz +0 -0
  2. package/bundler/breez_sdk_spark_wasm.d.ts +33 -0
  3. package/bundler/breez_sdk_spark_wasm.js +1 -1
  4. package/bundler/breez_sdk_spark_wasm_bg.js +66 -24
  5. package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
  6. package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
  7. package/deno/breez_sdk_spark_wasm.d.ts +33 -0
  8. package/deno/breez_sdk_spark_wasm.js +66 -24
  9. package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
  10. package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
  11. package/nodejs/breez_sdk_spark_wasm.d.ts +33 -0
  12. package/nodejs/breez_sdk_spark_wasm.js +67 -24
  13. package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
  14. package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
  15. package/nodejs/index.js +34 -0
  16. package/nodejs/index.mjs +1 -0
  17. package/nodejs/mysql-storage/errors.cjs +19 -0
  18. package/nodejs/mysql-storage/index.cjs +1366 -0
  19. package/nodejs/mysql-storage/migrations.cjs +387 -0
  20. package/nodejs/mysql-storage/package.json +9 -0
  21. package/nodejs/mysql-token-store/errors.cjs +9 -0
  22. package/nodejs/mysql-token-store/index.cjs +988 -0
  23. package/nodejs/mysql-token-store/migrations.cjs +255 -0
  24. package/nodejs/mysql-token-store/package.json +9 -0
  25. package/nodejs/mysql-tree-store/errors.cjs +9 -0
  26. package/nodejs/mysql-tree-store/index.cjs +939 -0
  27. package/nodejs/mysql-tree-store/migrations.cjs +221 -0
  28. package/nodejs/mysql-tree-store/package.json +9 -0
  29. package/nodejs/package.json +3 -0
  30. package/nodejs/postgres-storage/index.cjs +147 -92
  31. package/nodejs/postgres-storage/migrations.cjs +85 -4
  32. package/nodejs/postgres-token-store/index.cjs +176 -89
  33. package/nodejs/postgres-token-store/migrations.cjs +92 -3
  34. package/nodejs/postgres-tree-store/index.cjs +168 -83
  35. package/nodejs/postgres-tree-store/migrations.cjs +80 -3
  36. package/package.json +1 -1
  37. package/ssr/index.js +5 -0
  38. package/web/breez_sdk_spark_wasm.d.ts +40 -5
  39. package/web/breez_sdk_spark_wasm.js +66 -24
  40. package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
  41. package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
@@ -22,8 +22,16 @@ class TokenStoreMigrationManager {
22
22
  /**
23
23
  * Run all pending migrations inside a single transaction with an advisory lock.
24
24
  * @param {import('pg').Pool} pool
25
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
26
+ * identifying the tenant. Used to backfill `user_id` columns in the
27
+ * multi-tenant scoping migration. Required.
25
28
  */
26
- async migrate(pool) {
29
+ async migrate(pool, identity) {
30
+ if (!identity || identity.length !== 33) {
31
+ throw new TokenStoreError(
32
+ "tenant identity (33-byte secp256k1 pubkey) is required"
33
+ );
34
+ }
27
35
  const client = await pool.connect();
28
36
  try {
29
37
  await client.query("BEGIN");
@@ -45,7 +53,7 @@ class TokenStoreMigrationManager {
45
53
  );
46
54
  const currentVersion = versionResult.rows[0].version;
47
55
 
48
- const migrations = this._getMigrations();
56
+ const migrations = this._getMigrations(identity);
49
57
 
50
58
  if (currentVersion >= migrations.length) {
51
59
  this._log("info", `Token store database is up to date (version ${currentVersion})`);
@@ -96,8 +104,16 @@ class TokenStoreMigrationManager {
96
104
 
97
105
  /**
98
106
  * Migrations matching the Rust PostgresTokenStore schema exactly.
107
+ *
108
+ * @param {Buffer|Uint8Array} identity - tenant identity inlined as a hex
109
+ * BYTEA literal in the multi-tenant scoping migration. Safe because the
110
+ * bytes come from a typed secp256k1 pubkey (`[0-9a-f]{66}` after hex
111
+ * encoding) — not user-controlled input.
99
112
  */
100
- _getMigrations() {
113
+ _getMigrations(identity) {
114
+ const idHex = Buffer.from(identity).toString("hex");
115
+ const idLit = `'\\x${idHex}'::bytea`;
116
+
101
117
  return [
102
118
  {
103
119
  name: "Create token store tables with race condition protection",
@@ -156,6 +172,79 @@ class TokenStoreMigrationManager {
156
172
  `INSERT INTO token_swap_status (id) VALUES (1) ON CONFLICT DO NOTHING`,
157
173
  ],
158
174
  },
175
+ {
176
+ // Mirrors Rust migration 2 in spark-postgres/src/token_store.rs.
177
+ // Adds user_id to every token-store table (including token_metadata —
178
+ // per-tenant to avoid 0-balance leakage for tokens a tenant never
179
+ // owned), backfills with the connecting tenant, and rewrites primary
180
+ // keys / FKs / indexes to lead with user_id. Composite FKs use NO
181
+ // ACTION because column-list SET NULL is PG15+ and a whole-row SET
182
+ // NULL would null user_id (NOT NULL).
183
+ name: "Multi-tenant scoping: add user_id and rewrite primary keys",
184
+ sql: [
185
+ // Drop dependent FKs FIRST so we can rebuild parent PKs they
186
+ // reference. Inline `REFERENCES` clauses get auto-named
187
+ // `<table>_<column>_fkey`.
188
+ `ALTER TABLE token_outputs
189
+ DROP CONSTRAINT IF EXISTS token_outputs_reservation_id_fkey`,
190
+ `ALTER TABLE token_outputs
191
+ DROP CONSTRAINT IF EXISTS token_outputs_token_identifier_fkey`,
192
+
193
+ // token_metadata: per-tenant scoping (privacy — see header).
194
+ `ALTER TABLE token_metadata ADD COLUMN user_id BYTEA`,
195
+ `UPDATE token_metadata SET user_id = ${idLit}`,
196
+ `ALTER TABLE token_metadata
197
+ ALTER COLUMN user_id SET NOT NULL,
198
+ DROP CONSTRAINT IF EXISTS token_metadata_pkey,
199
+ ADD PRIMARY KEY (user_id, identifier)`,
200
+ `DROP INDEX IF EXISTS idx_token_metadata_issuer_pk`,
201
+ `CREATE INDEX idx_token_metadata_user_issuer_pk
202
+ ON token_metadata (user_id, issuer_public_key)`,
203
+
204
+ // token_reservations: scope by user_id.
205
+ `ALTER TABLE token_reservations ADD COLUMN user_id BYTEA`,
206
+ `UPDATE token_reservations SET user_id = ${idLit}`,
207
+ `ALTER TABLE token_reservations
208
+ ALTER COLUMN user_id SET NOT NULL,
209
+ DROP CONSTRAINT IF EXISTS token_reservations_pkey,
210
+ ADD PRIMARY KEY (user_id, id)`,
211
+
212
+ // token_outputs: scope by user_id, rekey, re-add composite FKs.
213
+ `ALTER TABLE token_outputs ADD COLUMN user_id BYTEA`,
214
+ `UPDATE token_outputs SET user_id = ${idLit}`,
215
+ `ALTER TABLE token_outputs
216
+ ALTER COLUMN user_id SET NOT NULL,
217
+ DROP CONSTRAINT IF EXISTS token_outputs_pkey,
218
+ ADD PRIMARY KEY (user_id, id),
219
+ ADD FOREIGN KEY (user_id, token_identifier)
220
+ REFERENCES token_metadata(user_id, identifier),
221
+ ADD FOREIGN KEY (user_id, reservation_id)
222
+ REFERENCES token_reservations(user_id, id)`,
223
+ `DROP INDEX IF EXISTS idx_token_outputs_identifier`,
224
+ `DROP INDEX IF EXISTS idx_token_outputs_reservation`,
225
+ `CREATE INDEX idx_token_outputs_user_identifier
226
+ ON token_outputs (user_id, token_identifier)`,
227
+ `CREATE INDEX idx_token_outputs_user_reservation
228
+ ON token_outputs (user_id, reservation_id)
229
+ WHERE reservation_id IS NOT NULL`,
230
+
231
+ // token_spent_outputs: scope by user_id.
232
+ `ALTER TABLE token_spent_outputs ADD COLUMN user_id BYTEA`,
233
+ `UPDATE token_spent_outputs SET user_id = ${idLit}`,
234
+ `ALTER TABLE token_spent_outputs
235
+ ALTER COLUMN user_id SET NOT NULL,
236
+ DROP CONSTRAINT IF EXISTS token_spent_outputs_pkey,
237
+ ADD PRIMARY KEY (user_id, output_id)`,
238
+
239
+ // token_swap_status: drop the singleton id, rekey by user_id.
240
+ `ALTER TABLE token_swap_status DROP COLUMN id CASCADE`,
241
+ `ALTER TABLE token_swap_status ADD COLUMN user_id BYTEA`,
242
+ `UPDATE token_swap_status SET user_id = ${idLit}`,
243
+ `ALTER TABLE token_swap_status
244
+ ALTER COLUMN user_id SET NOT NULL,
245
+ ADD PRIMARY KEY (user_id)`,
246
+ ],
247
+ },
159
248
  ];
160
249
  }
161
250
  }
@@ -25,10 +25,10 @@ const { TreeStoreError } = require("./errors.cjs");
25
25
  const { TreeStoreMigrationManager } = require("./migrations.cjs");
26
26
 
27
27
  /**
28
- * Advisory lock key for serializing tree store write operations.
29
- * Matches the Rust constant TREE_STORE_WRITE_LOCK_KEY = 0x7472_6565_5354_4f52
28
+ * Domain prefix mixed into the per-tenant advisory-lock key. Distinct prefixes
29
+ * guarantee that locks from different stores (tree, token, …) never collide.
30
30
  */
31
- const TREE_STORE_WRITE_LOCK_KEY = "8390880541608791890"; // 0x7472656553544f52 as decimal string
31
+ const TREE_STORE_LOCK_PREFIX = "breez-spark-sdk:tree:";
32
32
 
33
33
  /**
34
34
  * Timeout for reservations in seconds. Reservations older than this are stale.
@@ -40,9 +40,37 @@ const RESERVATION_TIMEOUT_SECS = 300; // 5 minutes
40
40
  */
41
41
  const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
42
42
 
43
+ /**
44
+ * Derive a stable per-tenant 64-bit advisory-lock key by hashing a domain
45
+ * prefix together with the identity pubkey and folding the first 8 bytes of
46
+ * the SHA-256 digest into a signed big-endian i64 — the type expected by
47
+ * `pg_advisory_xact_lock(bigint)`. The 64-bit space keeps cross-tenant
48
+ * collisions negligible (~1.2e-10 at 65k tenants).
49
+ */
50
+ function _identityLockKey(prefix, identity) {
51
+ const crypto = require("crypto");
52
+ const hash = crypto.createHash("sha256");
53
+ hash.update(prefix);
54
+ hash.update(Buffer.from(identity));
55
+ return hash.digest().readBigInt64BE(0);
56
+ }
57
+
43
58
  class PostgresTreeStore {
44
- constructor(pool, logger = null) {
59
+ /**
60
+ * @param {import('pg').Pool} pool
61
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
62
+ * identifying the tenant. All reads and writes are scoped by this.
63
+ * @param {object} [logger]
64
+ */
65
+ constructor(pool, identity, logger = null) {
66
+ if (!identity || identity.length !== 33) {
67
+ throw new TreeStoreError(
68
+ "tenant identity (33-byte secp256k1 pubkey) is required"
69
+ );
70
+ }
45
71
  this.pool = pool;
72
+ this.identity = Buffer.from(identity);
73
+ this.lockKey = _identityLockKey(TREE_STORE_LOCK_PREFIX, identity);
46
74
  this.logger = logger;
47
75
  }
48
76
 
@@ -52,7 +80,7 @@ class PostgresTreeStore {
52
80
  async initialize() {
53
81
  try {
54
82
  const migrationManager = new TreeStoreMigrationManager(this.logger);
55
- await migrationManager.migrate(this.pool);
83
+ await migrationManager.migrate(this.pool, this.identity);
56
84
  return this;
57
85
  } catch (error) {
58
86
  throw new TreeStoreError(
@@ -84,7 +112,10 @@ class PostgresTreeStore {
84
112
  const client = await this.pool.connect();
85
113
  try {
86
114
  await client.query("BEGIN");
87
- await client.query(`SELECT pg_advisory_xact_lock(${TREE_STORE_WRITE_LOCK_KEY})`);
115
+ // Per-tenant advisory lock: 64-bit key derived from a tree-store domain
116
+ // prefix and the tenant identity, so different tenants don't serialize
117
+ // on each other and tree/token locks never collide.
118
+ await client.query("SELECT pg_advisory_xact_lock($1)", [this.lockKey]);
88
119
  const result = await fn(client);
89
120
  await client.query("COMMIT");
90
121
  return result;
@@ -160,14 +191,20 @@ class PostgresTreeStore {
160
191
  */
161
192
  async getAvailableBalance() {
162
193
  try {
163
- const result = await this.pool.query(`
194
+ const result = await this.pool.query(
195
+ `
164
196
  SELECT COALESCE(SUM((l.data->>'value')::bigint), 0)::bigint AS balance
165
197
  FROM tree_leaves l
166
- LEFT JOIN tree_reservations r ON l.reservation_id = r.id
167
- WHERE
168
- (l.reservation_id IS NULL AND l.status = 'Available')
169
- OR r.purpose = 'Swap'
170
- `);
198
+ LEFT JOIN tree_reservations r
199
+ ON l.reservation_id = r.id AND l.user_id = r.user_id
200
+ WHERE l.user_id = $1
201
+ AND (
202
+ (l.reservation_id IS NULL AND l.status = 'Available')
203
+ OR r.purpose = 'Swap'
204
+ )
205
+ `,
206
+ [this.identity]
207
+ );
171
208
  return BigInt(result.rows[0].balance);
172
209
  } catch (error) {
173
210
  throw new TreeStoreError(
@@ -179,12 +216,17 @@ class PostgresTreeStore {
179
216
 
180
217
  async getLeaves() {
181
218
  try {
182
- const result = await this.pool.query(`
219
+ const result = await this.pool.query(
220
+ `
183
221
  SELECT l.id, l.status, l.is_missing_from_operators, l.data,
184
222
  l.reservation_id, r.purpose
185
223
  FROM tree_leaves l
186
- LEFT JOIN tree_reservations r ON l.reservation_id = r.id
187
- `);
224
+ LEFT JOIN tree_reservations r
225
+ ON l.reservation_id = r.id AND l.user_id = r.user_id
226
+ WHERE l.user_id = $1
227
+ `,
228
+ [this.identity]
229
+ );
188
230
 
189
231
  const available = [];
190
232
  const notAvailable = [];
@@ -246,11 +288,21 @@ class PostgresTreeStore {
246
288
  await this._cleanupStaleReservations(client);
247
289
 
248
290
  // Check for active swap or swap completed during refresh
249
- const swapCheckResult = await client.query(`
291
+ const swapCheckResult = await client.query(
292
+ `
250
293
  SELECT
251
- EXISTS(SELECT 1 FROM tree_reservations WHERE purpose = 'Swap') AS has_active_swap,
252
- COALESCE((SELECT last_completed_at >= $1 FROM tree_swap_status WHERE id = 1), FALSE) AS swap_completed_during_refresh
253
- `, [refreshTimestamp]);
294
+ EXISTS(
295
+ SELECT 1 FROM tree_reservations
296
+ WHERE user_id = $1 AND purpose = 'Swap'
297
+ ) AS has_active_swap,
298
+ COALESCE(
299
+ (SELECT last_completed_at >= $2
300
+ FROM tree_swap_status WHERE user_id = $1),
301
+ FALSE
302
+ ) AS swap_completed_during_refresh
303
+ `,
304
+ [this.identity, refreshTimestamp]
305
+ );
254
306
 
255
307
  const { has_active_swap, swap_completed_during_refresh } = swapCheckResult.rows[0];
256
308
 
@@ -262,18 +314,18 @@ class PostgresTreeStore {
262
314
  await this._cleanupSpentMarkers(client, refreshTimestamp);
263
315
 
264
316
  const spentResult = await client.query(
265
- "SELECT leaf_id FROM tree_spent_leaves WHERE spent_at >= $1",
266
- [refreshTimestamp]
317
+ "SELECT leaf_id FROM tree_spent_leaves WHERE user_id = $1 AND spent_at >= $2",
318
+ [this.identity, refreshTimestamp]
267
319
  );
268
320
  const spentIds = new Set(spentResult.rows.map((r) => r.leaf_id));
269
321
 
270
322
  // Delete non-reserved leaves added before refresh started.
271
- // Includes leaves released earlier in this transaction by _cleanupStaleReservations
272
- // (FK ON DELETE SET NULL) — those rows kept their old added_at, so they are
273
- // dropped here and re-fetched from the operator response in the upsert below.
323
+ // Includes leaves released earlier in this transaction by
324
+ // _cleanupStaleReservations (which now NULLs reservation_id explicitly,
325
+ // since the composite FK uses NO ACTION).
274
326
  await client.query(
275
- "DELETE FROM tree_leaves WHERE reservation_id IS NULL AND added_at < $1",
276
- [refreshTimestamp]
327
+ "DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id IS NULL AND added_at < $2",
328
+ [this.identity, refreshTimestamp]
277
329
  );
278
330
 
279
331
  // Upsert all leaves (filtering spent)
@@ -306,8 +358,8 @@ class PostgresTreeStore {
306
358
  try {
307
359
  await this._withTransaction(async (client) => {
308
360
  const res = await client.query(
309
- "SELECT id FROM tree_reservations WHERE id = $1",
310
- [id]
361
+ "SELECT id FROM tree_reservations WHERE user_id = $1 AND id = $2",
362
+ [this.identity, id]
311
363
  );
312
364
 
313
365
  if (res.rows.length === 0) {
@@ -315,13 +367,13 @@ class PostgresTreeStore {
315
367
  }
316
368
 
317
369
  await client.query(
318
- "DELETE FROM tree_leaves WHERE reservation_id = $1",
319
- [id]
370
+ "DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
371
+ [this.identity, id]
320
372
  );
321
373
 
322
374
  await client.query(
323
- "DELETE FROM tree_reservations WHERE id = $1",
324
- [id]
375
+ "DELETE FROM tree_reservations WHERE user_id = $1 AND id = $2",
376
+ [this.identity, id]
325
377
  );
326
378
 
327
379
  if (leavesToKeep && leavesToKeep.length > 0) {
@@ -351,8 +403,8 @@ class PostgresTreeStore {
351
403
  await this._withWriteTransaction(async (client) => {
352
404
  // Check if reservation exists and get purpose
353
405
  const res = await client.query(
354
- "SELECT id, purpose FROM tree_reservations WHERE id = $1",
355
- [id]
406
+ "SELECT id, purpose FROM tree_reservations WHERE user_id = $1 AND id = $2",
407
+ [this.identity, id]
356
408
  );
357
409
 
358
410
  let isSwap = false;
@@ -360,18 +412,18 @@ class PostgresTreeStore {
360
412
  if (res.rows.length > 0) {
361
413
  isSwap = res.rows[0].purpose === "Swap";
362
414
  const leafResult = await client.query(
363
- "SELECT id FROM tree_leaves WHERE reservation_id = $1",
364
- [id]
415
+ "SELECT id FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
416
+ [this.identity, id]
365
417
  );
366
418
  reservedLeafIds = leafResult.rows.map((r) => r.id);
367
419
  await this._batchInsertSpentLeaves(client, reservedLeafIds);
368
420
  await client.query(
369
- "DELETE FROM tree_leaves WHERE reservation_id = $1",
370
- [id]
421
+ "DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
422
+ [this.identity, id]
371
423
  );
372
424
  await client.query(
373
- "DELETE FROM tree_reservations WHERE id = $1",
374
- [id]
425
+ "DELETE FROM tree_reservations WHERE user_id = $1 AND id = $2",
426
+ [this.identity, id]
375
427
  );
376
428
  }
377
429
 
@@ -380,10 +432,15 @@ class PostgresTreeStore {
380
432
  await this._batchUpsertLeaves(client, newLeaves, false, null);
381
433
  }
382
434
 
383
- // If swap with new leaves, update last_completed_at
435
+ // If swap with new leaves, update last_completed_at. UPSERT so a tenant
436
+ // that joined after migration 3 (and thus has no row) gets one created.
384
437
  if (isSwap && newLeaves && newLeaves.length > 0) {
385
438
  await client.query(
386
- "UPDATE tree_swap_status SET last_completed_at = NOW() WHERE id = 1"
439
+ `INSERT INTO tree_swap_status (user_id, last_completed_at)
440
+ VALUES ($1, NOW())
441
+ ON CONFLICT (user_id) DO UPDATE
442
+ SET last_completed_at = EXCLUDED.last_completed_at`,
443
+ [this.identity]
387
444
  );
388
445
  }
389
446
  });
@@ -412,13 +469,17 @@ class PostgresTreeStore {
412
469
  // True total available, computed server-side over ALL eligible leaves.
413
470
  // Required for the WaitForPending decision below — must NOT be derived
414
471
  // from the prefiltered set since the prefilter may exclude big leaves.
415
- const totalResult = await client.query(`
472
+ const totalResult = await client.query(
473
+ `
416
474
  SELECT COALESCE(SUM((data->>'value')::bigint), 0)::bigint AS total
417
475
  FROM tree_leaves
418
- WHERE status = 'Available'
476
+ WHERE user_id = $1
477
+ AND status = 'Available'
419
478
  AND is_missing_from_operators = FALSE
420
479
  AND reservation_id IS NULL
421
- `);
480
+ `,
481
+ [this.identity]
482
+ );
422
483
  const available = Number(totalResult.rows[0].total);
423
484
 
424
485
  // Slim projection: only (id, value) for leaves the selection might use.
@@ -426,25 +487,30 @@ class PostgresTreeStore {
426
487
  // small-leaf accumulators for the minimum-amount path) plus the single
427
488
  // smallest leaf with value > maxTarget (covers the minimum-amount
428
489
  // fallback case where one larger leaf is sufficient).
429
- const slimResult = await client.query(`
490
+ const slimResult = await client.query(
491
+ `
430
492
  SELECT id, (data->>'value')::bigint AS value
431
493
  FROM tree_leaves
432
- WHERE status = 'Available'
494
+ WHERE user_id = $1
495
+ AND status = 'Available'
433
496
  AND is_missing_from_operators = FALSE
434
497
  AND reservation_id IS NULL
435
498
  AND (
436
- (data->>'value')::bigint <= $1
499
+ (data->>'value')::bigint <= $2
437
500
  OR id = (
438
501
  SELECT id FROM tree_leaves
439
- WHERE status = 'Available'
502
+ WHERE user_id = $1
503
+ AND status = 'Available'
440
504
  AND is_missing_from_operators = FALSE
441
505
  AND reservation_id IS NULL
442
- AND (data->>'value')::bigint > $1
506
+ AND (data->>'value')::bigint > $2
443
507
  ORDER BY (data->>'value')::bigint
444
508
  LIMIT 1
445
509
  )
446
510
  )
447
- `, [maxTarget]);
511
+ `,
512
+ [this.identity, maxTarget]
513
+ );
448
514
 
449
515
  const slimLeaves = slimResult.rows.map((r) => ({
450
516
  id: r.id,
@@ -543,8 +609,8 @@ class PostgresTreeStore {
543
609
  async _fetchFullLeavesByIds(client, ids) {
544
610
  if (!ids || ids.length === 0) return [];
545
611
  const result = await client.query(
546
- "SELECT data FROM tree_leaves WHERE id = ANY($1)",
547
- [ids]
612
+ "SELECT data FROM tree_leaves WHERE user_id = $2 AND id = ANY($1)",
613
+ [ids, this.identity]
548
614
  );
549
615
  return result.rows.map((r) => r.data);
550
616
  }
@@ -577,8 +643,8 @@ class PostgresTreeStore {
577
643
  return await this._withTransaction(async (client) => {
578
644
  // Check if reservation exists
579
645
  const res = await client.query(
580
- "SELECT id FROM tree_reservations WHERE id = $1",
581
- [reservationId]
646
+ "SELECT id FROM tree_reservations WHERE user_id = $1 AND id = $2",
647
+ [this.identity, reservationId]
582
648
  );
583
649
 
584
650
  if (res.rows.length === 0) {
@@ -587,15 +653,15 @@ class PostgresTreeStore {
587
653
 
588
654
  // Get old reserved leaf IDs and mark as spent
589
655
  const oldLeavesResult = await client.query(
590
- "SELECT id FROM tree_leaves WHERE reservation_id = $1",
591
- [reservationId]
656
+ "SELECT id FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
657
+ [this.identity, reservationId]
592
658
  );
593
659
  const oldLeafIds = oldLeavesResult.rows.map((r) => r.id);
594
660
 
595
661
  await this._batchInsertSpentLeaves(client, oldLeafIds);
596
662
  await client.query(
597
- "DELETE FROM tree_leaves WHERE reservation_id = $1",
598
- [reservationId]
663
+ "DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
664
+ [this.identity, reservationId]
599
665
  );
600
666
 
601
667
  // Upsert change leaves to available pool
@@ -610,8 +676,8 @@ class PostgresTreeStore {
610
676
 
611
677
  // Clear pending change amount
612
678
  await client.query(
613
- "UPDATE tree_reservations SET pending_change_amount = 0 WHERE id = $1",
614
- [reservationId]
679
+ "UPDATE tree_reservations SET pending_change_amount = 0 WHERE user_id = $1 AND id = $2",
680
+ [this.identity, reservationId]
615
681
  );
616
682
 
617
683
  return {
@@ -793,7 +859,8 @@ class PostgresTreeStore {
793
859
  */
794
860
  async _calculatePendingBalance(client) {
795
861
  const result = await client.query(
796
- "SELECT COALESCE(SUM(pending_change_amount), 0)::BIGINT AS pending FROM tree_reservations"
862
+ "SELECT COALESCE(SUM(pending_change_amount), 0)::BIGINT AS pending FROM tree_reservations WHERE user_id = $1",
863
+ [this.identity]
797
864
  );
798
865
  return Number(result.rows[0].pending);
799
866
  }
@@ -803,8 +870,8 @@ class PostgresTreeStore {
803
870
  */
804
871
  async _createReservation(client, reservationId, leaves, purpose, pendingChange) {
805
872
  await client.query(
806
- "INSERT INTO tree_reservations (id, purpose, pending_change_amount) VALUES ($1, $2, $3)",
807
- [reservationId, purpose, pendingChange]
873
+ "INSERT INTO tree_reservations (user_id, id, purpose, pending_change_amount) VALUES ($1, $2, $3, $4)",
874
+ [this.identity, reservationId, purpose, pendingChange]
808
875
  );
809
876
 
810
877
  const leafIds = leaves.map((l) => l.id);
@@ -829,16 +896,16 @@ class PostgresTreeStore {
829
896
  const dataValues = filtered.map((l) => JSON.stringify(l));
830
897
 
831
898
  await client.query(
832
- `INSERT INTO tree_leaves (id, status, is_missing_from_operators, data, added_at)
833
- SELECT id, status, missing, data::jsonb, NOW()
899
+ `INSERT INTO tree_leaves (user_id, id, status, is_missing_from_operators, data, added_at)
900
+ SELECT $5, id, status, missing, data::jsonb, NOW()
834
901
  FROM UNNEST($1::text[], $2::text[], $3::bool[], $4::text[])
835
902
  AS t(id, status, missing, data)
836
- ON CONFLICT (id) DO UPDATE SET
903
+ ON CONFLICT (user_id, id) DO UPDATE SET
837
904
  status = EXCLUDED.status,
838
905
  is_missing_from_operators = EXCLUDED.is_missing_from_operators,
839
906
  data = EXCLUDED.data,
840
907
  added_at = NOW()`,
841
- [ids, statuses, missingFlags, dataValues]
908
+ [ids, statuses, missingFlags, dataValues, this.identity]
842
909
  );
843
910
  }
844
911
 
@@ -849,8 +916,8 @@ class PostgresTreeStore {
849
916
  if (leafIds.length === 0) return;
850
917
 
851
918
  await client.query(
852
- "UPDATE tree_leaves SET reservation_id = $1 WHERE id = ANY($2)",
853
- [reservationId, leafIds]
919
+ "UPDATE tree_leaves SET reservation_id = $1 WHERE user_id = $3 AND id = ANY($2)",
920
+ [reservationId, leafIds, this.identity]
854
921
  );
855
922
  }
856
923
 
@@ -861,8 +928,10 @@ class PostgresTreeStore {
861
928
  if (leafIds.length === 0) return;
862
929
 
863
930
  await client.query(
864
- "INSERT INTO tree_spent_leaves (leaf_id) SELECT * FROM UNNEST($1::text[]) ON CONFLICT DO NOTHING",
865
- [leafIds]
931
+ `INSERT INTO tree_spent_leaves (user_id, leaf_id)
932
+ SELECT $2, leaf_id FROM UNNEST($1::text[]) AS t(leaf_id)
933
+ ON CONFLICT DO NOTHING`,
934
+ [leafIds, this.identity]
866
935
  );
867
936
  }
868
937
 
@@ -873,19 +942,33 @@ class PostgresTreeStore {
873
942
  if (leafIds.length === 0) return;
874
943
 
875
944
  await client.query(
876
- "DELETE FROM tree_spent_leaves WHERE leaf_id = ANY($1)",
877
- [leafIds]
945
+ "DELETE FROM tree_spent_leaves WHERE user_id = $2 AND leaf_id = ANY($1)",
946
+ [leafIds, this.identity]
878
947
  );
879
948
  }
880
949
 
881
950
  /**
882
- * Clean up stale reservations.
951
+ * Clean up stale reservations. Releases the leaves by clearing their
952
+ * reservation_id first, then deletes the parent reservations — the composite
953
+ * FK uses NO ACTION (the default) since column-list SET NULL is PG15+ and a
954
+ * whole-row SET NULL would null user_id (NOT NULL).
883
955
  */
884
956
  async _cleanupStaleReservations(client) {
957
+ await client.query(
958
+ `UPDATE tree_leaves SET reservation_id = NULL
959
+ WHERE user_id = $2
960
+ AND reservation_id IN (
961
+ SELECT id FROM tree_reservations
962
+ WHERE user_id = $2
963
+ AND created_at < NOW() - make_interval(secs => $1)
964
+ )`,
965
+ [RESERVATION_TIMEOUT_SECS, this.identity]
966
+ );
885
967
  await client.query(
886
968
  `DELETE FROM tree_reservations
887
- WHERE created_at < NOW() - make_interval(secs => $1)`,
888
- [RESERVATION_TIMEOUT_SECS]
969
+ WHERE user_id = $2
970
+ AND created_at < NOW() - make_interval(secs => $1)`,
971
+ [RESERVATION_TIMEOUT_SECS, this.identity]
889
972
  );
890
973
  }
891
974
 
@@ -897,8 +980,8 @@ class PostgresTreeStore {
897
980
  const cleanupCutoff = new Date(refreshTimestamp.getTime() - thresholdMs);
898
981
 
899
982
  await client.query(
900
- "DELETE FROM tree_spent_leaves WHERE spent_at < $1",
901
- [cleanupCutoff]
983
+ "DELETE FROM tree_spent_leaves WHERE user_id = $2 AND spent_at < $1",
984
+ [cleanupCutoff, this.identity]
902
985
  );
903
986
  }
904
987
  }
@@ -911,28 +994,30 @@ class PostgresTreeStore {
911
994
  * @param {number} config.maxPoolSize - Maximum number of connections in the pool
912
995
  * @param {number} config.createTimeoutSecs - Timeout in seconds for establishing a new connection
913
996
  * @param {number} config.recycleTimeoutSecs - Timeout in seconds before recycling an idle connection
997
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
914
998
  * @param {object} [logger] - Optional logger
915
999
  * @returns {Promise<PostgresTreeStore>}
916
1000
  */
917
- async function createPostgresTreeStore(config, logger = null) {
1001
+ async function createPostgresTreeStore(config, identity, logger = null) {
918
1002
  const pool = new pg.Pool({
919
1003
  connectionString: config.connectionString,
920
1004
  max: config.maxPoolSize,
921
1005
  connectionTimeoutMillis: config.createTimeoutSecs * 1000,
922
1006
  idleTimeoutMillis: config.recycleTimeoutSecs * 1000,
923
1007
  });
924
- return createPostgresTreeStoreWithPool(pool, logger);
1008
+ return createPostgresTreeStoreWithPool(pool, identity, logger);
925
1009
  }
926
1010
 
927
1011
  /**
928
1012
  * Create a PostgresTreeStore instance from an existing pg.Pool.
929
1013
  *
930
1014
  * @param {pg.Pool} pool - An existing connection pool
1015
+ * @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
931
1016
  * @param {object} [logger] - Optional logger
932
1017
  * @returns {Promise<PostgresTreeStore>}
933
1018
  */
934
- async function createPostgresTreeStoreWithPool(pool, logger = null) {
935
- const store = new PostgresTreeStore(pool, logger);
1019
+ async function createPostgresTreeStoreWithPool(pool, identity, logger = null) {
1020
+ const store = new PostgresTreeStore(pool, identity, logger);
936
1021
  await store.initialize();
937
1022
  return store;
938
1023
  }