@stelis/say-ur-intent 0.0.2 → 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.
- package/dist/core/activity/sqliteActivityStore.js +36 -2
- package/dist/core/activity/sqliteActivityStoreSchema.js +54 -0
- package/dist/core/session/keyedRecordStore.js +18 -0
- package/dist/core/session/sessionRecordStore.js +33 -0
- package/dist/core/session/sessionStore.js +28 -11
- package/dist/core/session/settingsSessions.js +3 -1
- package/dist/core/session/sqliteSessionStore.js +175 -0
- package/dist/core/session/sqliteTransactionMaterialStore.js +64 -0
- package/dist/core/session/transactionMaterialStore.js +60 -43
- package/dist/core/session/walletIdentitySessions.js +4 -2
- package/dist/review-app/analysis.js +1 -1
- package/dist/review-app/mermaid-parser.core-DkwUYTPl.js +1 -1
- package/dist/review-app/review.js +32 -32
- package/dist/review-app/walletStatus-YODe5Y4P.js +7 -0
- package/dist/runtime/reviewServerAcquire.js +68 -81
- package/dist/runtime/smokeMainnetRead.js +8 -5
- package/dist/runtime/start.js +16 -16
- package/docs/FRONTEND_POLICY.md +7 -2
- package/docs/LOCAL_DB_ARCHITECTURE.md +24 -1
- package/docs/SDK_API.md +1 -1
- package/package.json +2 -2
- package/dist/review-app/walletStatus-CcojOdGy.js +0 -7
|
@@ -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(
|
|
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
|
|
56
|
-
sessions
|
|
57
|
-
privateReviewArtifacts
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
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.
|
|
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
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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 &&
|