@stelis/say-ur-intent 0.0.3 → 0.0.5

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
@@ -115,15 +115,13 @@ It also includes:
115
115
 
116
116
  ## Current Limits
117
117
 
118
- ### Not Implemented Yet (Deliberately Sequenced)
118
+ ### Not Implemented
119
119
 
120
- The items in this section are deliberately sequenced roadmap steps, not product
121
- refusals. Server-side receipt verification against chain state is not
122
- implemented yet. The full analysis page is not implemented yet. Transaction
123
- material build, contract emit, digest-gated wallet handoff, and user-controlled
124
- signing are implemented for the account-bound DeepBook swap review, which
125
- resolves its protocol through a plan-factory registry so further swap adapters
126
- attach the same way. External proposal execution is not implemented.
120
+ Server-side receipt verification against chain state is not implemented. The
121
+ full analysis page is not implemented. External proposal execution is not
122
+ implemented. Transaction material build, contract emit, digest-gated wallet
123
+ handoff, and user-controlled signing are implemented for the account-bound
124
+ DeepBook and FlowX swap review through a plan-factory registry.
127
125
 
128
126
  External proposal ingestion is implemented only for read-only local review
129
127
  sessions. It accepts structured proposal facts, rejects forbidden executable or
@@ -305,6 +303,21 @@ AI client answer behavior must be mirrored in runtime-facing instructions, resou
305
303
  - `AGENTS.md`: root repository development contract and non-negotiable product boundaries for coding agents working on this codebase.
306
304
  - `docs/AGENT_DEVELOPMENT_POLICY.md`: detailed binding development, review, documentation, source-of-truth, and completion policies for coding agents.
307
305
 
306
+ ## Contract Name Registry
307
+
308
+ The PTB visualization on the review page can show a registered Move Registry
309
+ (MVR) package name in place of a raw package address (for example
310
+ `@deepbook/core`), with a toggle back to the raw address and a copyable Mermaid
311
+ source that keeps raw addresses. The name is a package identity label only, not a
312
+ safety, trust, route-quality, or signing-readiness signal.
313
+
314
+ If you maintain a Sui DeFi protocol that has a registered MVR name and want its
315
+ package to display that name in the review graph, open a pull request adding your
316
+ mainnet package address and MVR name to
317
+ [`src/core/action/contractNameRegistry.ts`](src/core/action/contractNameRegistry.ts).
318
+ Only packages listed in this registry are relabeled; every other package keeps
319
+ its raw address.
320
+
308
321
  ## For Maintainers
309
322
 
310
323
  This section is for people operating releases, running smoke checks, changing runtime storage, or debugging startup. Normal users and MCP client users can stop at the documentation map above.
@@ -0,0 +1,42 @@
1
+ import { mainnetPackageIds } from "@mysten/deepbook-v3";
2
+ export const CONTRACT_NAME_REGISTRY = [
3
+ {
4
+ address: mainnetPackageIds.DEEPBOOK_PACKAGE_ID,
5
+ name: "@deepbook/core",
6
+ source: "deepbook_v3_sdk_mainnet_package_id"
7
+ }
8
+ ];
9
+ /**
10
+ * Normalize a Sui address or package id to `0x` + 64 lowercase hex, or return
11
+ * undefined when the input is not a well-formed Sui address. Short forms are
12
+ * left-padded with zeros so different-width forms of the same address compare
13
+ * equal.
14
+ */
15
+ function normalizeContractAddress(value) {
16
+ const lower = value.trim().toLowerCase();
17
+ const body = lower.startsWith("0x") ? lower.slice(2) : lower;
18
+ if (body.length === 0 || body.length > 64 || !/^[0-9a-f]+$/.test(body)) {
19
+ return undefined;
20
+ }
21
+ return `0x${body.padStart(64, "0")}`;
22
+ }
23
+ const NAME_BY_ADDRESS = new Map(CONTRACT_NAME_REGISTRY.flatMap((entry) => {
24
+ const normalized = normalizeContractAddress(entry.address);
25
+ return normalized === undefined ? [] : [[normalized, entry.name]];
26
+ }));
27
+ /**
28
+ * Replace every registered package address in PTB Mermaid label text with its
29
+ * registered name. Only exact normalized-address matches are replaced; unknown
30
+ * addresses are left unchanged. Mermaid node ids are synthetic (`command0`,
31
+ * `input0`, ...) and the package address only appears inside quoted label text,
32
+ * so the substitution cannot break the graph syntax.
33
+ */
34
+ export function applyContractNamesToMermaid(mermaidText) {
35
+ let text = mermaidText;
36
+ for (const [address, name] of NAME_BY_ADDRESS) {
37
+ if (text.includes(address)) {
38
+ text = text.split(address).join(name);
39
+ }
40
+ }
41
+ return text;
42
+ }
@@ -1,5 +1,6 @@
1
1
  import { Transaction } from "@mysten/sui/transactions";
2
2
  import { rawTransactionToIR, transactionIRToMermaid } from "@zktx.io/ptb-model";
3
+ import { applyContractNamesToMermaid } from "./contractNameRegistry.js";
3
4
  import { PTB_VISUALIZATION_CONTRACT_VERSION, PTB_VISUALIZATION_REQUIRED_UNSUPPORTED_USES, ptbVisualizationArtifactSchema } from "./signableAdapterContract.js";
4
5
  export const PTB_VISUALIZATION_RENDERER = {
5
6
  name: "transactionIRToMermaid",
@@ -44,7 +45,11 @@ export async function producePtbVisualizationArtifact(input) {
44
45
  },
45
46
  mermaid: {
46
47
  diagramType: "flowchart",
47
- text: mermaidText
48
+ // text keeps raw package addresses (truth/audit and copyable source);
49
+ // namedText relabels registered packages with their Move Registry name for
50
+ // the default graph, with a review-page toggle back to raw addresses.
51
+ text: mermaidText,
52
+ namedText: applyContractNamesToMermaid(mermaidText)
48
53
  },
49
54
  diagnostics: [],
50
55
  unsupportedUse: [...PTB_VISUALIZATION_REQUIRED_UNSUPPORTED_USES],
@@ -975,7 +975,8 @@ export const ptbVisualizationArtifactSchema = z.object({
975
975
  }).strict(),
976
976
  mermaid: z.object({
977
977
  diagramType: z.literal("flowchart"),
978
- text: displayTextWithoutExecutableMaterial(20_000, "mermaid.text")
978
+ text: displayTextWithoutExecutableMaterial(20_000, "mermaid.text"),
979
+ namedText: displayTextWithoutExecutableMaterial(20_000, "mermaid.namedText")
979
980
  }).strict(),
980
981
  diagnostics: z.array(z.object({
981
982
  severity: z.enum(["info", "warning", "error"]),
@@ -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
+ }