@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 +21 -8
- package/dist/core/action/contractNameRegistry.js +42 -0
- package/dist/core/action/ptbVisualizationProducer.js +6 -1
- package/dist/core/action/signableAdapterContract.js +2 -1
- 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/AGENT_BEHAVIOR.md +2 -2
- package/docs/FRONTEND_POLICY.md +12 -2
- package/docs/LOCAL_DB_ARCHITECTURE.md +24 -1
- package/docs/MCP_SETUP.md +4 -3
- package/docs/MCP_TOOLS.md +2 -2
- package/docs/SDK_API.md +1 -1
- package/package.json +2 -2
- package/protocols/deepbook-v3.md +4 -4
- package/dist/review-app/walletStatus-CcojOdGy.js +0 -7
package/README.md
CHANGED
|
@@ -115,15 +115,13 @@ It also includes:
|
|
|
115
115
|
|
|
116
116
|
## Current Limits
|
|
117
117
|
|
|
118
|
-
### Not Implemented
|
|
118
|
+
### Not Implemented
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
implemented
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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(
|
|
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
|
+
}
|