@stelis/say-ur-intent 0.0.3 → 0.0.4

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.
@@ -1,4 +1,4 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { chmodSync, mkdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import Database from "better-sqlite3";
@@ -7,6 +7,8 @@ import { parseLifecycleValidatedReviewState } from "../action/reviewStateValidat
7
7
  import { actionPlanSchema, executionResultSchema } from "../action/schemas.js";
8
8
  import { parseSuiAddress } from "../suiAddress.js";
9
9
  import { SqlitePreferencesRepository } from "../preferences/sqlitePreferencesRepository.js";
10
+ import { SqliteTransactionMaterialStore } from "../session/sqliteTransactionMaterialStore.js";
11
+ import { SqliteSessionRecordStore, SqlitePrivateReviewArtifactStore, createSqliteWalletIdentityRecordStore, createSqliteSettingsRecordStore } from "../session/sqliteSessionStore.js";
10
12
  import { SqliteLocalDataService } from "./localDataService.js";
11
13
  import { ActivityStoreReadError, REVIEW_ACTIVITY_DETAIL_MAX_ITEMS, REVIEW_ACTIVITY_LOW_SAMPLE_THRESHOLD } from "./activityStore.js";
12
14
  import { configureDatabase, initializeDatabase } from "./sqliteActivityStoreSchema.js";
@@ -16,13 +18,24 @@ export { ActivityStoreError };
16
18
  const ACTIVE_ACCOUNT_SINGLETON_ID = 1;
17
19
  export const DATA_DIR_ENV = "SAY_UR_INTENT_DATA_DIR";
18
20
  export const ACTIVITY_DATABASE_FILENAME = "say-ur-intent.sqlite";
21
+ // Best-effort permission hardening. The owner-only data directory is the primary
22
+ // protection; failures (e.g. on Windows, which ignores POSIX modes) are non-fatal.
23
+ function restrictPathPermissions(path, mode) {
24
+ try {
25
+ chmodSync(path, mode);
26
+ }
27
+ catch {
28
+ // Non-fatal: the 0700 directory remains the primary protection.
29
+ }
30
+ }
19
31
  export class SqliteActivityStore {
20
32
  db;
21
33
  validateAdapterLifecycle;
22
34
  constructor(options) {
23
35
  this.validateAdapterLifecycle = options.validateAdapterLifecycle;
36
+ const dataDirectory = dirname(options.databasePath);
24
37
  try {
25
- mkdirSync(dirname(options.databasePath), { recursive: true });
38
+ mkdirSync(dataDirectory, { recursive: true, mode: 0o700 });
26
39
  }
27
40
  catch {
28
41
  throw new ActivityStoreError(`Could not create the local activity data directory. Check directory permissions or set ${DATA_DIR_ENV}.`);
@@ -36,6 +49,12 @@ export class SqliteActivityStore {
36
49
  this.db.close();
37
50
  throw error;
38
51
  }
52
+ // This database persists unsigned transaction material (Option B), so restrict it to
53
+ // the owner. The 0700 directory is the primary protection (it also covers the WAL/SHM
54
+ // sidecars and blocks other OS users); the 0600 file is belt-and-suspenders. Best-effort
55
+ // because some platforms (e.g. Windows) ignore POSIX modes.
56
+ restrictPathPermissions(dataDirectory, 0o700);
57
+ restrictPathPermissions(options.databasePath, 0o600);
39
58
  }
40
59
  async upsertAccount(address, source, now = new Date()) {
41
60
  return this.upsertAccountSync(address, source, now.toISOString());
@@ -613,6 +632,21 @@ export class SqliteActivityStore {
613
632
  createCoinMetadataCache() {
614
633
  return new SqliteCoinMetadataCache(this.db);
615
634
  }
635
+ createTransactionMaterialStore() {
636
+ return new SqliteTransactionMaterialStore(this.db);
637
+ }
638
+ createSessionRecordStore() {
639
+ return new SqliteSessionRecordStore(this.db);
640
+ }
641
+ createPrivateReviewArtifactStore() {
642
+ return new SqlitePrivateReviewArtifactStore(this.db);
643
+ }
644
+ createWalletIdentityRecordStore() {
645
+ return createSqliteWalletIdentityRecordStore(this.db);
646
+ }
647
+ createSettingsRecordStore() {
648
+ return createSqliteSettingsRecordStore(this.db);
649
+ }
616
650
  upsertAccountSync(address, source, timestamp) {
617
651
  const normalized = parseSuiAddress(address);
618
652
  if (!normalized) {
@@ -7,6 +7,9 @@ export function configureDatabase(db) {
7
7
  db.exec("PRAGMA journal_mode=WAL");
8
8
  db.exec("PRAGMA synchronous=NORMAL");
9
9
  db.exec("PRAGMA foreign_keys=ON");
10
+ // Multiple client processes share this database; wait instead of failing with
11
+ // SQLITE_BUSY when another process holds the write lock.
12
+ db.exec("PRAGMA busy_timeout=5000");
10
13
  }
11
14
  export function initializeDatabase(db) {
12
15
  const currentUserVersion = db.pragma("user_version", { simple: true });
@@ -159,6 +162,57 @@ export function initializeDatabase(db) {
159
162
  PRIMARY KEY (coin_type, chain_identifier)
160
163
  );
161
164
 
165
+ `);
166
+ // Live multi-client state (Option B): runtime session state lives in the shared
167
+ // database so any review-server process can serve any session. Added WITHOUT a
168
+ // DB_USER_VERSION bump — older runtimes ignore unknown tables, so a newer client can
169
+ // introduce them without breaking a concurrently-running older client.
170
+ db.exec(`
171
+ CREATE TABLE IF NOT EXISTS live_transaction_materials (
172
+ material_id TEXT PRIMARY KEY,
173
+ review_session_id TEXT NOT NULL,
174
+ plan_id TEXT NOT NULL,
175
+ account TEXT NOT NULL,
176
+ kind TEXT NOT NULL,
177
+ source TEXT NOT NULL,
178
+ transaction_bytes BLOB NOT NULL,
179
+ redacted_diagnostics_json TEXT,
180
+ created_at TEXT NOT NULL,
181
+ expires_at TEXT NOT NULL
182
+ );
183
+
184
+ CREATE INDEX IF NOT EXISTS idx_live_transaction_materials_session
185
+ ON live_transaction_materials(review_session_id);
186
+
187
+ CREATE TABLE IF NOT EXISTS live_review_sessions (
188
+ id TEXT PRIMARY KEY,
189
+ token_hash TEXT NOT NULL,
190
+ status TEXT NOT NULL,
191
+ account TEXT,
192
+ pending_handoff_digest TEXT,
193
+ plans_json TEXT NOT NULL,
194
+ review_state_json TEXT,
195
+ execution_result_json TEXT,
196
+ created_at TEXT NOT NULL,
197
+ expires_at TEXT NOT NULL,
198
+ last_activity_at TEXT NOT NULL
199
+ );
200
+
201
+ CREATE TABLE IF NOT EXISTS live_private_review_artifacts (
202
+ review_session_id TEXT PRIMARY KEY
203
+ REFERENCES live_review_sessions(id) ON DELETE CASCADE,
204
+ artifacts_json TEXT NOT NULL
205
+ );
206
+
207
+ CREATE TABLE IF NOT EXISTS live_wallet_identity_sessions (
208
+ id TEXT PRIMARY KEY,
209
+ session_json TEXT NOT NULL
210
+ );
211
+
212
+ CREATE TABLE IF NOT EXISTS live_settings_sessions (
213
+ id TEXT PRIMARY KEY,
214
+ session_json TEXT NOT NULL
215
+ );
162
216
  `);
163
217
  migrateDatabase(db, currentUserVersion);
164
218
  if (db.pragma("user_version", { simple: true }) !== DB_USER_VERSION) {
@@ -0,0 +1,18 @@
1
+ export class InMemoryKeyedRecordStore {
2
+ records = new Map();
3
+ get(id) {
4
+ return this.records.get(id);
5
+ }
6
+ set(id, value) {
7
+ this.records.set(id, value);
8
+ }
9
+ delete(id) {
10
+ this.records.delete(id);
11
+ }
12
+ ids() {
13
+ return [...this.records.keys()];
14
+ }
15
+ clear() {
16
+ this.records.clear();
17
+ }
18
+ }
@@ -0,0 +1,33 @@
1
+ export class InMemorySessionRecordStore {
2
+ sessions = new Map();
3
+ get(id) {
4
+ return this.sessions.get(id);
5
+ }
6
+ set(id, session) {
7
+ this.sessions.set(id, session);
8
+ }
9
+ ids() {
10
+ return [...this.sessions.keys()];
11
+ }
12
+ clear() {
13
+ this.sessions.clear();
14
+ }
15
+ acquireHandoffLock(id, digest) {
16
+ const session = this.sessions.get(id);
17
+ if (!session) {
18
+ return false;
19
+ }
20
+ if (session.pendingHandoffDigest !== undefined && session.pendingHandoffDigest !== digest) {
21
+ return false;
22
+ }
23
+ session.pendingHandoffDigest = digest;
24
+ return true;
25
+ }
26
+ releaseHandoffLock(id) {
27
+ const session = this.sessions.get(id);
28
+ if (!session) {
29
+ return;
30
+ }
31
+ delete session.pendingHandoffDigest;
32
+ }
33
+ }
@@ -9,6 +9,7 @@ import { publicHumanReadableReviewFromEvidence } from "../action/humanReadableRe
9
9
  import { publicTransactionSimulationSummaryFromEvidence, verifyReviewTimeSimulationEvidence } from "../action/reviewTimeSimulationEvidence.js";
10
10
  import { verifySupportedHumanReadableReviewEvidence } from "../action/humanReadableReviewProjectionVerifier.js";
11
11
  import { InMemoryPrivateReviewArtifactStore } from "./privateReviewArtifacts.js";
12
+ import { InMemorySessionRecordStore } from "./sessionRecordStore.js";
12
13
  import { isFinalSessionStatus } from "./status.js";
13
14
  import { parseSuiAddress } from "../suiAddress.js";
14
15
  import { cloneLocalSession, createLocalSessionBase, DEFAULT_SESSION_TTL_MS, isLocalSessionExpired, tokenMatchesHash } from "./localSession.js";
@@ -52,9 +53,9 @@ const ALLOWED_TRANSITIONS = {
52
53
  failure: [],
53
54
  expired: []
54
55
  };
55
- export class InMemorySessionStore {
56
- sessions = new Map();
57
- privateReviewArtifacts = new InMemoryPrivateReviewArtifactStore();
56
+ export class LocalSessionStore {
57
+ sessions;
58
+ privateReviewArtifacts;
58
59
  walletIdentity;
59
60
  settings;
60
61
  ttlMs;
@@ -64,17 +65,21 @@ export class InMemorySessionStore {
64
65
  logger;
65
66
  validateAdapterLifecycle;
66
67
  constructor(options) {
68
+ this.sessions = options.sessions;
69
+ this.privateReviewArtifacts = options.artifacts;
67
70
  this.ttlMs = options.ttlMs ?? DEFAULT_SESSION_TTL_MS;
68
71
  this.walletIdentity = new WalletIdentitySessionManager({
69
72
  ttlMs: this.ttlMs,
70
73
  appendEventLog: (record) => this.appendEventLog(record),
71
74
  setActiveAccount: async (account, now, wallet) => {
72
75
  await this.activityStore.setActiveAccount(account, "wallet_identity", now, wallet);
73
- }
76
+ },
77
+ ...(options.walletIdentityStore ? { recordStore: options.walletIdentityStore } : {})
74
78
  });
75
79
  this.settings = new SettingsSessionManager({
76
80
  ttlMs: this.ttlMs,
77
- appendEventLog: (record) => this.appendEventLog(record)
81
+ appendEventLog: (record) => this.appendEventLog(record),
82
+ ...(options.settingsStore ? { recordStore: options.settingsStore } : {})
78
83
  });
79
84
  this.eventLog = options.eventLog ?? new NullEventLogSink();
80
85
  this.activityStore = options.activityStore;
@@ -118,7 +123,7 @@ export class InMemorySessionStore {
118
123
  }
119
124
  async listReviewSessions(now = new Date()) {
120
125
  const sessions = [];
121
- for (const id of this.sessions.keys()) {
126
+ for (const id of this.sessions.ids()) {
122
127
  const session = await this.getReviewSession(id, now);
123
128
  if (session) {
124
129
  sessions.push(session);
@@ -450,8 +455,7 @@ export class InMemorySessionStore {
450
455
  if (!session || session.pendingHandoffDigest === undefined) {
451
456
  return;
452
457
  }
453
- delete session.pendingHandoffDigest;
454
- this.sessions.set(id, session);
458
+ this.sessions.releaseHandoffLock(id);
455
459
  await this.appendEventLog({
456
460
  type: "handoff.cancelled",
457
461
  sessionId: id,
@@ -512,8 +516,9 @@ export class InMemorySessionStore {
512
516
  // One-transaction lock: while a handoff is outstanding, state recomputes
513
517
  // are refused so a second, different transaction cannot be signed from
514
518
  // the same session. Cleared on result recording, cancel, or material expiry.
515
- session.pendingHandoffDigest = contract.transactionMaterialCommitment;
516
- this.sessions.set(id, session);
519
+ if (!this.sessions.acquireHandoffLock(id, contract.transactionMaterialCommitment)) {
520
+ throw new SessionStoreError("handoff_unavailable", "Another signing is already in progress for this review session");
521
+ }
517
522
  await this.appendEventLog({
518
523
  type: "handoff.prepared",
519
524
  sessionId: id,
@@ -528,7 +533,7 @@ export class InMemorySessionStore {
528
533
  };
529
534
  }
530
535
  async invalidateAllLocalSessions(reason, now = new Date()) {
531
- for (const id of this.sessions.keys()) {
536
+ for (const id of this.sessions.ids()) {
532
537
  this.deleteReviewSessionTransactionMaterials(id);
533
538
  }
534
539
  this.sessions.clear();
@@ -753,6 +758,18 @@ export class InMemorySessionStore {
753
758
  return verifiedArtifacts;
754
759
  }
755
760
  }
761
+ // In-memory session store: the orchestration above with Map-backed record and
762
+ // artifact stores. Public name + constructor are unchanged so existing callers and
763
+ // the session-store contract test keep working as the regression wall.
764
+ export class InMemorySessionStore extends LocalSessionStore {
765
+ constructor(options) {
766
+ super({
767
+ ...options,
768
+ sessions: new InMemorySessionRecordStore(),
769
+ artifacts: new InMemoryPrivateReviewArtifactStore()
770
+ });
771
+ }
772
+ }
756
773
  function assertPrivateDerivedReviewStateProjections(state, privateArtifacts) {
757
774
  for (const binding of PRIVATE_DERIVED_REVIEW_FIELD_BINDINGS) {
758
775
  const publicValue = binding.getPublicState(state);
@@ -1,9 +1,11 @@
1
1
  import { cloneLocalSession, createLocalSessionBase, isLocalSessionExpired, tokenMatchesHash } from "./localSession.js";
2
+ import { InMemoryKeyedRecordStore } from "./keyedRecordStore.js";
2
3
  export class SettingsSessionManager {
3
4
  options;
4
- sessions = new Map();
5
+ sessions;
5
6
  constructor(options) {
6
7
  this.options = options;
8
+ this.sessions = options.recordStore ?? new InMemoryKeyedRecordStore();
7
9
  }
8
10
  async create(now) {
9
11
  const { base, token } = createLocalSessionBase(now, this.options.ttlMs);
@@ -0,0 +1,175 @@
1
+ import { clonePrivateReviewArtifacts } from "./privateReviewArtifacts.js";
2
+ import { walletIdentitySessionSchema } from "./walletIdentity.js";
3
+ /**
4
+ * SQLite-backed live review-session records. Holds the same SessionRecordStore
5
+ * contract as the in-memory backend so the shared LocalSessionStore orchestration
6
+ * (and every security invariant) runs unchanged. The handoff lock is a single
7
+ * conditional UPDATE so two processes can never hand off two different transactions
8
+ * for the same session.
9
+ */
10
+ export class SqliteSessionRecordStore {
11
+ db;
12
+ constructor(db) {
13
+ this.db = db;
14
+ }
15
+ get(id) {
16
+ const row = this.db
17
+ .prepare(`SELECT * FROM live_review_sessions WHERE id = ?`)
18
+ .get(id);
19
+ return row ? sessionFromRow(row) : undefined;
20
+ }
21
+ set(id, session) {
22
+ this.db
23
+ .prepare(`INSERT INTO live_review_sessions
24
+ (id, token_hash, status, account, pending_handoff_digest,
25
+ plans_json, review_state_json, execution_result_json,
26
+ created_at, expires_at, last_activity_at)
27
+ VALUES
28
+ (@id, @tokenHash, @status, @account, @pendingHandoffDigest,
29
+ @plansJson, @reviewStateJson, @executionResultJson,
30
+ @createdAt, @expiresAt, @lastActivityAt)
31
+ ON CONFLICT(id) DO UPDATE SET
32
+ token_hash = excluded.token_hash,
33
+ status = excluded.status,
34
+ account = excluded.account,
35
+ pending_handoff_digest = excluded.pending_handoff_digest,
36
+ plans_json = excluded.plans_json,
37
+ review_state_json = excluded.review_state_json,
38
+ execution_result_json = excluded.execution_result_json,
39
+ created_at = excluded.created_at,
40
+ expires_at = excluded.expires_at,
41
+ last_activity_at = excluded.last_activity_at`)
42
+ .run({
43
+ id: session.id,
44
+ tokenHash: session.tokenHash,
45
+ status: session.status,
46
+ account: session.account ?? null,
47
+ pendingHandoffDigest: session.pendingHandoffDigest ?? null,
48
+ plansJson: JSON.stringify(session.plans),
49
+ reviewStateJson: session.reviewState ? JSON.stringify(session.reviewState) : null,
50
+ executionResultJson: session.executionResult ? JSON.stringify(session.executionResult) : null,
51
+ createdAt: session.createdAt,
52
+ expiresAt: session.expiresAt,
53
+ lastActivityAt: session.lastActivityAt
54
+ });
55
+ }
56
+ ids() {
57
+ const rows = this.db.prepare(`SELECT id FROM live_review_sessions`).all();
58
+ return rows.map((row) => row.id);
59
+ }
60
+ clear() {
61
+ this.db.prepare(`DELETE FROM live_review_sessions`).run();
62
+ }
63
+ acquireHandoffLock(id, digest) {
64
+ // Atomic across processes: claim the lock only when it is free or already held
65
+ // by the same digest. A different in-flight digest leaves the row untouched.
66
+ const result = this.db
67
+ .prepare(`UPDATE live_review_sessions
68
+ SET pending_handoff_digest = ?
69
+ WHERE id = ? AND (pending_handoff_digest IS NULL OR pending_handoff_digest = ?)`)
70
+ .run(digest, id, digest);
71
+ return result.changes > 0;
72
+ }
73
+ releaseHandoffLock(id) {
74
+ this.db
75
+ .prepare(`UPDATE live_review_sessions SET pending_handoff_digest = NULL WHERE id = ?`)
76
+ .run(id);
77
+ }
78
+ }
79
+ export class SqlitePrivateReviewArtifactStore {
80
+ db;
81
+ constructor(db) {
82
+ this.db = db;
83
+ }
84
+ get(reviewSessionId) {
85
+ const row = this.db
86
+ .prepare(`SELECT artifacts_json FROM live_private_review_artifacts WHERE review_session_id = ?`)
87
+ .get(reviewSessionId);
88
+ if (!row) {
89
+ return undefined;
90
+ }
91
+ // clonePrivateReviewArtifacts re-parses/validates the evidence on read, matching
92
+ // the in-memory store's clone-on-read behaviour.
93
+ return clonePrivateReviewArtifacts(JSON.parse(row.artifacts_json));
94
+ }
95
+ set(reviewSessionId, artifacts) {
96
+ const json = JSON.stringify(clonePrivateReviewArtifacts(artifacts));
97
+ this.db
98
+ .prepare(`INSERT INTO live_private_review_artifacts (review_session_id, artifacts_json)
99
+ VALUES (?, ?)
100
+ ON CONFLICT(review_session_id) DO UPDATE SET artifacts_json = excluded.artifacts_json`)
101
+ .run(reviewSessionId, json);
102
+ }
103
+ delete(reviewSessionId) {
104
+ this.db.prepare(`DELETE FROM live_private_review_artifacts WHERE review_session_id = ?`).run(reviewSessionId);
105
+ }
106
+ clear() {
107
+ this.db.prepare(`DELETE FROM live_private_review_artifacts`).run();
108
+ }
109
+ }
110
+ /**
111
+ * SQLite-backed id-keyed store for the short-lived wallet-identity and settings
112
+ * session managers. Each record is a JSON blob keyed by id; the table name is a
113
+ * trusted constant supplied by the factories below (never user input).
114
+ */
115
+ export class SqliteKeyedRecordStore {
116
+ db;
117
+ table;
118
+ revive;
119
+ constructor(db, table, revive) {
120
+ this.db = db;
121
+ this.table = table;
122
+ this.revive = revive;
123
+ }
124
+ get(id) {
125
+ const row = this.db
126
+ .prepare(`SELECT session_json FROM ${this.table} WHERE id = ?`)
127
+ .get(id);
128
+ return row ? this.revive(JSON.parse(row.session_json)) : undefined;
129
+ }
130
+ set(id, value) {
131
+ this.db
132
+ .prepare(`INSERT INTO ${this.table} (id, session_json) VALUES (?, ?)
133
+ ON CONFLICT(id) DO UPDATE SET session_json = excluded.session_json`)
134
+ .run(id, JSON.stringify(value));
135
+ }
136
+ delete(id) {
137
+ this.db.prepare(`DELETE FROM ${this.table} WHERE id = ?`).run(id);
138
+ }
139
+ ids() {
140
+ const rows = this.db.prepare(`SELECT id FROM ${this.table}`).all();
141
+ return rows.map((row) => row.id);
142
+ }
143
+ clear() {
144
+ this.db.prepare(`DELETE FROM ${this.table}`).run();
145
+ }
146
+ }
147
+ export function createSqliteWalletIdentityRecordStore(db) {
148
+ return new SqliteKeyedRecordStore(db, "live_wallet_identity_sessions", (raw) => walletIdentitySessionSchema.parse(raw));
149
+ }
150
+ export function createSqliteSettingsRecordStore(db) {
151
+ return new SqliteKeyedRecordStore(db, "live_settings_sessions", (raw) => raw);
152
+ }
153
+ // Reconstruct the ReviewSession from columns + JSON blobs. Optional fields are
154
+ // omitted (not set to undefined) so the shape matches the in-memory store exactly.
155
+ function sessionFromRow(row) {
156
+ return {
157
+ id: row.id,
158
+ tokenHash: row.token_hash,
159
+ status: row.status,
160
+ createdAt: row.created_at,
161
+ expiresAt: row.expires_at,
162
+ lastActivityAt: row.last_activity_at,
163
+ plans: JSON.parse(row.plans_json),
164
+ ...(row.account === null ? {} : { account: row.account }),
165
+ ...(row.review_state_json === null
166
+ ? {}
167
+ : { reviewState: JSON.parse(row.review_state_json) }),
168
+ ...(row.execution_result_json === null
169
+ ? {}
170
+ : { executionResult: JSON.parse(row.execution_result_json) }),
171
+ ...(row.pending_handoff_digest === null
172
+ ? {}
173
+ : { pendingHandoffDigest: row.pending_handoff_digest })
174
+ };
175
+ }
@@ -0,0 +1,64 @@
1
+ import { buildTransactionMaterialRecord, sameHandle, toMaterialHandle } from "./transactionMaterialStore.js";
2
+ /**
3
+ * SQLite-backed transaction material store. Holds the same synchronous contract as
4
+ * InMemoryLocalTransactionMaterialStore so producers and the wallet handoff path are
5
+ * unchanged, but persists records (including the unsigned transaction BLOB) to the
6
+ * shared local database so any review-server process can read them. Validation,
7
+ * handle identity, and expiry semantics are shared via transactionMaterialStore.ts,
8
+ * so the two backends never drift. The unsigned bytes are protected at rest by the
9
+ * data directory (0700) and database file (0600) permissions set in SqliteActivityStore.
10
+ */
11
+ export class SqliteTransactionMaterialStore {
12
+ db;
13
+ constructor(db) {
14
+ this.db = db;
15
+ }
16
+ recordTransactionMaterial(input, now = new Date()) {
17
+ const record = buildTransactionMaterialRecord(input, now);
18
+ this.db
19
+ .prepare(`INSERT INTO live_transaction_materials
20
+ (material_id, review_session_id, plan_id, account, kind, source,
21
+ transaction_bytes, redacted_diagnostics_json, created_at, expires_at)
22
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
23
+ .run(record.materialId, record.reviewSessionId, record.planId, record.account, record.kind, record.source, Buffer.from(record.transactionBytes), record.redactedDiagnostics === undefined ? null : JSON.stringify(record.redactedDiagnostics), record.createdAt, record.expiresAt);
24
+ return toMaterialHandle(record);
25
+ }
26
+ getTransactionMaterial(handle, now = new Date()) {
27
+ const row = this.db
28
+ .prepare(`SELECT * FROM live_transaction_materials WHERE material_id = ?`)
29
+ .get(handle.materialId);
30
+ if (!row) {
31
+ return undefined;
32
+ }
33
+ if (Date.parse(row.expires_at) <= now.getTime()) {
34
+ this.db.prepare(`DELETE FROM live_transaction_materials WHERE material_id = ?`).run(handle.materialId);
35
+ return undefined;
36
+ }
37
+ const record = recordFromRow(row);
38
+ if (!sameHandle(record, handle)) {
39
+ return undefined;
40
+ }
41
+ return record;
42
+ }
43
+ deleteReviewSessionTransactionMaterials(reviewSessionId) {
44
+ this.db.prepare(`DELETE FROM live_transaction_materials WHERE review_session_id = ?`).run(reviewSessionId);
45
+ }
46
+ }
47
+ // Each read rebuilds a fresh record (new Uint8Array from the BLOB, parsed
48
+ // diagnostics) so a caller mutating the result never touches the stored row.
49
+ function recordFromRow(row) {
50
+ return {
51
+ materialId: row.material_id,
52
+ reviewSessionId: row.review_session_id,
53
+ planId: row.plan_id,
54
+ account: row.account,
55
+ kind: row.kind,
56
+ source: row.source,
57
+ createdAt: row.created_at,
58
+ expiresAt: row.expires_at,
59
+ transactionBytes: new Uint8Array(row.transaction_bytes),
60
+ ...(row.redacted_diagnostics_json == null
61
+ ? {}
62
+ : { redactedDiagnostics: JSON.parse(row.redacted_diagnostics_json) })
63
+ };
64
+ }
@@ -116,51 +116,68 @@ export class LocalTransactionMaterialStoreError extends Error {
116
116
  super(message);
117
117
  }
118
118
  }
119
+ // Validate the store input and build the stored record (with cloned bytes). Shared
120
+ // by every LocalTransactionMaterialStore implementation so validation never drifts
121
+ // between the in-memory and SQLite backends.
122
+ export function buildTransactionMaterialRecord(input, now = new Date()) {
123
+ const account = parseSuiAddress(input.account);
124
+ if (!account) {
125
+ throw new LocalTransactionMaterialStoreError("Invalid transaction material account");
126
+ }
127
+ if (!input.reviewSessionId) {
128
+ throw new LocalTransactionMaterialStoreError("reviewSessionId is required");
129
+ }
130
+ if (!input.planId) {
131
+ throw new LocalTransactionMaterialStoreError("planId is required");
132
+ }
133
+ if (!LOCAL_TRANSACTION_MATERIAL_KINDS.includes(input.kind)) {
134
+ throw new LocalTransactionMaterialStoreError("Invalid transaction material kind");
135
+ }
136
+ if (!LOCAL_TRANSACTION_MATERIAL_SOURCES.includes(input.source)) {
137
+ throw new LocalTransactionMaterialStoreError("Invalid transaction material source");
138
+ }
139
+ if (input.transactionBytes.byteLength === 0) {
140
+ throw new LocalTransactionMaterialStoreError("transactionBytes must not be empty");
141
+ }
142
+ const createdAt = now.toISOString();
143
+ const expiresAt = input.expiresAt.toISOString();
144
+ if (Date.parse(expiresAt) <= Date.parse(createdAt)) {
145
+ throw new LocalTransactionMaterialStoreError("expiresAt must be after createdAt");
146
+ }
147
+ return {
148
+ materialId: `txmat_${randomUUID()}`,
149
+ reviewSessionId: input.reviewSessionId,
150
+ planId: input.planId,
151
+ account,
152
+ kind: input.kind,
153
+ source: input.source,
154
+ createdAt,
155
+ expiresAt,
156
+ transactionBytes: cloneBytes(input.transactionBytes),
157
+ ...(input.redactedDiagnostics === undefined
158
+ ? {}
159
+ : { redactedDiagnostics: structuredClone(input.redactedDiagnostics) })
160
+ };
161
+ }
162
+ // Project the public (redacted) handle out of a stored record.
163
+ export function toMaterialHandle(record) {
164
+ return {
165
+ materialId: record.materialId,
166
+ reviewSessionId: record.reviewSessionId,
167
+ planId: record.planId,
168
+ account: record.account,
169
+ kind: record.kind,
170
+ source: record.source,
171
+ createdAt: record.createdAt,
172
+ expiresAt: record.expiresAt
173
+ };
174
+ }
119
175
  export class InMemoryLocalTransactionMaterialStore {
120
176
  records = new Map();
121
177
  recordTransactionMaterial(input, now = new Date()) {
122
- const account = parseSuiAddress(input.account);
123
- if (!account) {
124
- throw new LocalTransactionMaterialStoreError("Invalid transaction material account");
125
- }
126
- if (!input.reviewSessionId) {
127
- throw new LocalTransactionMaterialStoreError("reviewSessionId is required");
128
- }
129
- if (!input.planId) {
130
- throw new LocalTransactionMaterialStoreError("planId is required");
131
- }
132
- if (!LOCAL_TRANSACTION_MATERIAL_KINDS.includes(input.kind)) {
133
- throw new LocalTransactionMaterialStoreError("Invalid transaction material kind");
134
- }
135
- if (!LOCAL_TRANSACTION_MATERIAL_SOURCES.includes(input.source)) {
136
- throw new LocalTransactionMaterialStoreError("Invalid transaction material source");
137
- }
138
- if (input.transactionBytes.byteLength === 0) {
139
- throw new LocalTransactionMaterialStoreError("transactionBytes must not be empty");
140
- }
141
- const createdAt = now.toISOString();
142
- const expiresAt = input.expiresAt.toISOString();
143
- if (Date.parse(expiresAt) <= Date.parse(createdAt)) {
144
- throw new LocalTransactionMaterialStoreError("expiresAt must be after createdAt");
145
- }
146
- const handle = {
147
- materialId: `txmat_${randomUUID()}`,
148
- reviewSessionId: input.reviewSessionId,
149
- planId: input.planId,
150
- account,
151
- kind: input.kind,
152
- source: input.source,
153
- createdAt,
154
- expiresAt
155
- };
156
- this.records.set(handle.materialId, {
157
- ...handle,
158
- transactionBytes: cloneBytes(input.transactionBytes),
159
- ...(input.redactedDiagnostics === undefined
160
- ? {}
161
- : { redactedDiagnostics: structuredClone(input.redactedDiagnostics) })
162
- });
163
- return { ...handle };
178
+ const record = buildTransactionMaterialRecord(input, now);
179
+ this.records.set(record.materialId, record);
180
+ return toMaterialHandle(record);
164
181
  }
165
182
  getTransactionMaterial(handle, now = new Date()) {
166
183
  const record = this.records.get(handle.materialId);
@@ -190,7 +207,7 @@ export class InMemoryLocalTransactionMaterialStore {
190
207
  }
191
208
  }
192
209
  }
193
- function sameHandle(record, handle) {
210
+ export function sameHandle(record, handle) {
194
211
  return record.materialId === handle.materialId &&
195
212
  record.reviewSessionId === handle.reviewSessionId &&
196
213
  record.planId === handle.planId &&