From 4b858c4ca5a01a48de80f8f6ab666ce20563e290 Mon Sep 17 00:00:00 2001 From: Ashwin-3cS Date: Thu, 2 Apr 2026 14:19:59 +0530 Subject: [PATCH 01/21] fix(server): log redis errors in rate limit record_in_window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silent `let _ =` on `pipe.query_async` meant Redis write failures in the rate-limit recording path produced no log output, leaving operators with no signal that counters were silently dropped and rate limiting was degraded — extremely hard to diagnose under load. The check phase already warns on Redis failure (lines 241, 260, 279 in rate_limit.rs — "allowing"). This brings the record phase in line with that pattern. --- services/server/src/rate_limit.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/server/src/rate_limit.rs b/services/server/src/rate_limit.rs index e9dc490b..b58fb1ee 100644 --- a/services/server/src/rate_limit.rs +++ b/services/server/src/rate_limit.rs @@ -155,7 +155,9 @@ async fn record_in_window( } pipe.expire(key, ttl_seconds); - let _: Result<(), _> = pipe.query_async(redis).await; + if let Err(e) = pipe.query_async::<()>(redis).await { + tracing::warn!("rate limit: failed to record window for key {}: {}", key, e); + } } // ============================================================ From 94a8cc220ee5f0d81eb70e1c36d303a1f336dcb7 Mon Sep 17 00:00:00 2001 From: ducnmm Date: Thu, 2 Apr 2026 20:22:27 +0700 Subject: [PATCH 02/21] fix(sidecar): limit getDynamicField concurrency to 5 + retry on 429 --- services/server/scripts/sidecar-server.ts | 197 ++++++++++++++-------- 1 file changed, 131 insertions(+), 66 deletions(-) diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts index ca22dd8b..2af29479 100644 --- a/services/server/scripts/sidecar-server.ts +++ b/services/server/scripts/sidecar-server.ts @@ -623,6 +623,61 @@ app.post("/walrus/upload", async (req, res) => { // POST /walrus/query-blobs // Query user's Walrus Blob objects from Sui chain, filter by namespace // ============================================================ + +/** + * Fetch a dynamic field with retry + exponential backoff on 429 rate limit errors. + */ +async function getDynamicFieldWithRetry( + parentId: string, + fieldName: { type: string; value: number[] }, + maxRetries = 4, +): Promise { + let lastErr: any; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await suiClient.getDynamicFieldObject({ + parentId, + name: fieldName, + }); + } catch (err: any) { + lastErr = err; + const msg = String(err?.message || err); + // Retry on 429 (rate limit) or 503 (service unavailable) + const isRetryable = msg.includes("429") || msg.includes("503") || msg.includes("rate"); + if (!isRetryable || attempt === maxRetries - 1) throw err; + const delayMs = 250 * Math.pow(2, attempt); // 250ms, 500ms, 1000ms, 2000ms + console.warn(`[query-blobs] getDynamicField 429/503 for ${parentId}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`); + await new Promise(r => setTimeout(r, delayMs)); + } + } + throw lastErr; +} + +/** + * Run async tasks with a bounded concurrency limit. + * Avoids overwhelming Sui RPC with too many parallel calls (→ 429). + */ +async function mapConcurrent( + items: T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let index = 0; + + async function worker() { + while (true) { + const i = index++; + if (i >= items.length) break; + results[i] = await fn(items[i]); + } + } + + const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker()); + await Promise.all(workers); + return results; +} + app.post("/walrus/query-blobs", async (req, res) => { try { const { owner, namespace, packageId } = req.body; @@ -630,101 +685,111 @@ app.post("/walrus/query-blobs", async (req, res) => { return res.status(400).json({ error: "Missing required field: owner" }); } - // Walrus Blob type (derived from env-driven WALRUS_PACKAGE_ID) const WALRUS_BLOB_TYPE = `${WALRUS_PACKAGE_ID}::blob::Blob`; - // Query all Walrus Blob objects owned by the user - const blobs: { blobId: string; objectId: string; namespace: string; packageId: string }[] = []; + // Step 1: Collect all raw blob objects (paginated, each page = 1 RPC call) + type RawBlobObj = { objectId: string; rawBlobId: string | number | null }; + const rawObjs: RawBlobObj[] = []; let cursor: string | null | undefined = undefined; let hasMore = true; while (hasMore) { const result = await suiClient.getOwnedObjects({ owner, - filter: { - StructType: WALRUS_BLOB_TYPE, - }, - options: { - showContent: true, - }, + filter: { StructType: WALRUS_BLOB_TYPE }, + options: { showContent: true }, cursor: cursor ?? undefined, limit: 50, }); - for (const obj of result.data) { if (!obj.data?.content || obj.data.content.dataType !== "moveObject") continue; const fields = (obj.data.content as any).fields; if (!fields) continue; - - // Extract blob_id (may be numeric or string) and convert to base64url const rawBlobId = fields.blob_id ?? fields.blobId ?? null; - const objectId = obj.data.objectId; - - // Read metadata from dynamic field (Walrus stores metadata as dynamic field on Blob UID) - let blobNamespace = "default"; - let blobOwner = ""; - let blobPackageId = ""; - - try { - // getDynamicFieldObject: key type is "vector", value is b"metadata" - const dynField = await suiClient.getDynamicFieldObject({ - parentId: objectId, - name: { - type: "vector", - value: [109, 101, 116, 97, 100, 97, 116, 97], // b"metadata" - }, - }); + rawObjs.push({ objectId: obj.data.objectId, rawBlobId }); + } + + hasMore = result.hasNextPage; + cursor = result.nextCursor; + } + + console.log(`[query-blobs] found ${rawObjs.length} raw blob objects for owner=${owner}`); + + // Step 2: Fetch metadata for each blob with bounded concurrency (5 at a time) + // to avoid overwhelming Sui RPC and hitting 429 rate limits. + const METADATA_FIELD_NAME = { + type: "vector", + value: [109, 101, 116, 97, 100, 97, 116, 97], // b"metadata" + }; + + type BlobMeta = { + objectId: string; + rawBlobId: string | number | null; + blobNamespace: string; + blobOwner: string; + blobPackageId: string; + }; - if (dynField.data?.content && dynField.data.content.dataType === "moveObject") { - const dynFields = (dynField.data.content as any).fields; - // Path: fields.value.fields.metadata.fields.contents[] - const contents = dynFields?.value?.fields?.metadata?.fields?.contents; - if (Array.isArray(contents)) { - for (const entry of contents) { - const key = entry?.fields?.key; - const value = entry?.fields?.value; - if (key === "memwal_namespace") blobNamespace = value; - if (key === "memwal_owner") blobOwner = value; - if (key === "memwal_package_id") blobPackageId = value; - } + const metas: BlobMeta[] = await mapConcurrent(rawObjs, 5, async (obj) => { + let blobNamespace = "default"; + let blobOwner = ""; + let blobPackageId = ""; + + try { + const dynField = await getDynamicFieldWithRetry(obj.objectId, METADATA_FIELD_NAME); + + if (dynField.data?.content && dynField.data.content.dataType === "moveObject") { + const dynFields = (dynField.data.content as any).fields; + // Path: fields.value.fields.metadata.fields.contents[] + const contents = dynFields?.value?.fields?.metadata?.fields?.contents; + if (Array.isArray(contents)) { + for (const entry of contents) { + const key = entry?.fields?.key; + const value = entry?.fields?.value; + if (key === "memwal_namespace") blobNamespace = value; + if (key === "memwal_owner") blobOwner = value; + if (key === "memwal_package_id") blobPackageId = value; } } - } catch { - // No dynamic field = no metadata = use defaults } + } catch { + // No dynamic field = no metadata = use defaults + } + return { ...obj, blobNamespace, blobOwner, blobPackageId }; + }); - // Filter by namespace if specified - if (namespace && blobNamespace !== namespace) continue; - - // Filter by packageId if specified - if (packageId && blobPackageId !== packageId) continue; - - if (rawBlobId) { - // blob_id from chain is a big integer (U256) — convert to base64url (little-endian!) - let blobIdStr = String(rawBlobId); - if (/^\d+$/.test(blobIdStr) && blobIdStr.length > 20) { - try { - const bigInt = BigInt(blobIdStr); - const hex = bigInt.toString(16).padStart(64, '0'); - // Convert hex to bytes (big-endian), then REVERSE to little-endian - const bytesBE = hex.match(/.{2}/g)!.map(b => parseInt(b, 16)); - const bytesLE = new Uint8Array(bytesBE.reverse()); - blobIdStr = Buffer.from(bytesLE).toString('base64url'); - } catch { - // Keep as-is if conversion fails - } + // Step 3: Filter + convert blob IDs + const blobs: { blobId: string; objectId: string; namespace: string; packageId: string }[] = []; + + for (const meta of metas) { + // Filter by namespace if specified + if (namespace && meta.blobNamespace !== namespace) continue; + // Filter by packageId if specified + if (packageId && meta.blobPackageId !== packageId) continue; + + if (meta.rawBlobId) { + // blob_id from chain is a big integer (U256) — convert to base64url (little-endian!) + let blobIdStr = String(meta.rawBlobId); + if (/^\d+$/.test(blobIdStr) && blobIdStr.length > 20) { + try { + const bigInt = BigInt(blobIdStr); + const hex = bigInt.toString(16).padStart(64, '0'); + // Convert hex to bytes (big-endian), then REVERSE to little-endian + const bytesBE = hex.match(/.{2}/g)!.map(b => parseInt(b, 16)); + const bytesLE = new Uint8Array(bytesBE.reverse()); + blobIdStr = Buffer.from(bytesLE).toString('base64url'); + } catch { + // Keep as-is if conversion fails } - blobs.push({ blobId: blobIdStr, objectId, namespace: blobNamespace, packageId: blobPackageId }); } + blobs.push({ blobId: blobIdStr, objectId: meta.objectId, namespace: meta.blobNamespace, packageId: meta.blobPackageId }); } - - hasMore = result.hasNextPage; - cursor = result.nextCursor; } + console.log(`[query-blobs] returning ${blobs.length} blobs (filtered from ${rawObjs.length}) for owner=${owner} ns=${namespace || '*'}`); res.json({ blobs, total: blobs.length }); } catch (err: any) { console.error(`[walrus/query-blobs] error: ${err.message || err}`); From 357bc6bc64ecce9275c7f77bcb0f6de7a97f48f5 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 8 Apr 2026 20:23:24 +0700 Subject: [PATCH 03/21] =?UTF-8?q?fix(security):=20resolve=204=20=20audit?= =?UTF-8?q?=20findings=20=E2=80=94=20sidecar=20auth,=20rate-limit=20fail-o?= =?UTF-8?q?pen,=20sponsor=20endpoints,=20key=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app/src/App.tsx | 8 ++-- apps/chatbot/components/chat.tsx | 12 ++--- services/server/scripts/sidecar-server.ts | 44 ++++++++++++++---- services/server/src/main.rs | 38 +++++++++++----- services/server/src/rate_limit.rs | 24 ++++++++-- services/server/src/routes.rs | 55 ++++++++++++++++++++--- services/server/src/seal.rs | 18 ++++++-- services/server/src/types.rs | 3 ++ services/server/src/walrus.rs | 18 ++++++-- 9 files changed, 172 insertions(+), 48 deletions(-) diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index c4e8d3e8..87a5b7b9 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -39,7 +39,7 @@ const { networkConfig } = createNetworkConfig({ const queryClient = new QueryClient() // ============================================================ -// Delegate Key Context (stored in localStorage) +// Delegate Key Context (stored in sessionStorage — cleared on tab close, never persists across sessions) // ============================================================ interface DelegateKeyState { @@ -67,7 +67,7 @@ export function useDelegateKey() { function DelegateKeyProvider({ children }: { children: React.ReactNode }) { const [state, setState] = useState(() => { - const saved = localStorage.getItem('memwal_delegate') + const saved = sessionStorage.getItem('memwal_delegate') if (saved) { try { return JSON.parse(saved) } catch { /* ignore */ } } @@ -76,12 +76,12 @@ function DelegateKeyProvider({ children }: { children: React.ReactNode }) { const setDelegateKeys = useCallback((privateKey: string, publicKey: string, accountId: string) => { const next = { delegateKey: privateKey, delegatePublicKey: publicKey, accountObjectId: accountId } - localStorage.setItem('memwal_delegate', JSON.stringify(next)) + sessionStorage.setItem('memwal_delegate', JSON.stringify(next)) setState(next) }, []) const clearDelegateKeys = useCallback(() => { - localStorage.removeItem('memwal_delegate') + sessionStorage.removeItem('memwal_delegate') setState({ delegateKey: null, delegatePublicKey: null, accountObjectId: null }) }, []) diff --git a/apps/chatbot/components/chat.tsx b/apps/chatbot/components/chat.tsx index ea704003..d4133623 100644 --- a/apps/chatbot/components/chat.tsx +++ b/apps/chatbot/components/chat.tsx @@ -88,13 +88,13 @@ export function Chat({ }); const [memwalKey, setMemwalKey] = useState(() => { if (typeof window !== 'undefined') { - return localStorage.getItem('memwalKey') || ''; + return sessionStorage.getItem('memwalKey') || ''; } return ''; }); const [memwalAccountId, setMemwalAccountId] = useState(() => { if (typeof window !== 'undefined') { - return localStorage.getItem('memwalAccountId') || ''; + return sessionStorage.getItem('memwalAccountId') || ''; } return ''; }); @@ -115,18 +115,18 @@ export function Chat({ useEffect(() => { memwalKeyRef.current = memwalKey; if (memwalKey) { - localStorage.setItem('memwalKey', memwalKey); + sessionStorage.setItem('memwalKey', memwalKey); } else { - localStorage.removeItem('memwalKey'); + sessionStorage.removeItem('memwalKey'); } }, [memwalKey]); useEffect(() => { memwalAccountIdRef.current = memwalAccountId; if (memwalAccountId) { - localStorage.setItem('memwalAccountId', memwalAccountId); + sessionStorage.setItem('memwalAccountId', memwalAccountId); } else { - localStorage.removeItem('memwalAccountId'); + sessionStorage.removeItem('memwalAccountId'); } }, [memwalAccountId]); diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts index e1f9aea2..b7f7baa6 100644 --- a/services/server/scripts/sidecar-server.ts +++ b/services/server/scripts/sidecar-server.ts @@ -11,7 +11,8 @@ * GET /health → { status: "ok" } */ -import express from "express"; +import express, { Request, Response, NextFunction } from "express"; +import { timingSafeEqual } from "crypto"; import { SuiJsonRpcClient, getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; import { decodeSuiPrivateKey } from "@mysten/sui/cryptography"; @@ -273,22 +274,45 @@ async function runExclusiveBySigner(signerAddress: string, task: () => Promis const app = express(); app.use(express.json({ limit: "50mb" })); -// CORS — allow frontend (any origin) to call sponsor endpoints -app.use((_req, res, next) => { - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.header("Access-Control-Allow-Headers", "Content-Type, Authorization"); +// CORS — sidecar is called only by the co-located Rust server, never by browsers. +// Remove all CORS headers so no cross-origin access is granted. +app.use((_req: Request, res: Response, next: NextFunction) => { + res.removeHeader("Access-Control-Allow-Origin"); + res.removeHeader("Access-Control-Allow-Methods"); + res.removeHeader("Access-Control-Allow-Headers"); if (_req.method === "OPTIONS") { return res.sendStatus(204); } next(); }); -// Health check -app.get("/health", (_req, res) => { +// Health check — placed before auth middleware so it is always reachable. +app.get("/health", (_req: Request, res: Response) => { res.json({ status: "ok", uptime: process.uptime() }); }); +// Shared-secret authentication — protects all routes registered after this point. +// Set SIDECAR_SECRET in the environment; callers must send it as X-Sidecar-Secret. +// If SIDECAR_SECRET is not set (dev mode), requests are allowed but a warning is logged. +app.use((req: Request, res: Response, next: NextFunction) => { + const secret = process.env.SIDECAR_SECRET; + if (secret) { + const provided = req.headers["x-sidecar-secret"]; + const secretBuf = Buffer.from(secret); + const providedBuf = Buffer.from(typeof provided === "string" ? provided : ""); + // timingSafeEqual prevents timing side-channel attacks on the secret comparison. + // Buffers must be same length — if lengths differ it's already a mismatch. + const valid = providedBuf.length === secretBuf.length && + timingSafeEqual(providedBuf, secretBuf); + if (!valid) { + return res.status(401).json({ error: "Unauthorized" }); + } + } else { + console.warn("[sidecar] WARNING: SIDECAR_SECRET is not set — running without authentication"); + } + next(); +}); + // ============================================================ // POST /seal/encrypt // ============================================================ @@ -873,9 +897,11 @@ app.post("/sponsor/execute", async (req, res) => { // ============================================================ const PORT = parseInt(process.env.SIDECAR_PORT || "9000", 10); -app.listen(PORT, () => { +const HOST = process.env.SIDECAR_HOST || "127.0.0.1"; +app.listen(PORT, HOST, () => { console.log(JSON.stringify({ event: "sidecar_ready", + host: HOST, port: PORT, pid: process.pid, })); diff --git a/services/server/src/main.rs b/services/server/src/main.rs index f64276dd..59e0993c 100644 --- a/services/server/src/main.rs +++ b/services/server/src/main.rs @@ -7,7 +7,7 @@ mod sui; mod types; mod walrus; -use axum::{middleware, routing::{get, post}, Router}; +use axum::{extract::DefaultBodyLimit, middleware, routing::{get, post}, Router}; use std::sync::Arc; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; @@ -122,18 +122,18 @@ async fn main() { }); // Build routes - // Protected routes (require Ed25519 signature + onchain verification) - let protected_routes = Router::new() + // General protected routes (require Ed25519 signature + onchain verification). + // Body size is implicitly bounded to 1 MB by the auth middleware's to_bytes call. + // Router::layer runs middleware bottom-to-top (last added runs first). + // Keep auth outer so AuthInfo is in request extensions before rate limiting reads it. + let api_routes = Router::new() .route("/api/remember", post(routes::remember)) .route("/api/recall", post(routes::recall)) .route("/api/remember/manual", post(routes::remember_manual)) .route("/api/recall/manual", post(routes::recall_manual)) - .route("/api/analyze", post(routes::analyze)) .route("/api/ask", post(routes::ask)) .route("/api/restore", post(routes::restore)) - // Router::layer runs middleware bottom-to-top (last added runs first). - // Keep auth outer so AuthInfo is in request extensions before rate limiting reads it. .layer(middleware::from_fn_with_state( state.clone(), rate_limit::rate_limit_middleware, @@ -143,14 +143,30 @@ async fn main() { auth::verify_signature, )); - // Public routes - let public_routes = Router::new() - .route("/health", get(routes::health)) + // Sponsor proxy routes — auth-gated with a tight 16 KB body limit to + // prevent large-payload abuse of the Enoki gas sponsorship budget. + // DefaultBodyLimit is applied outermost (last layer added = first to run) + // so oversized bodies are rejected before the auth middleware reads them. + let sponsor_routes = Router::new() .route("/sponsor", post(routes::sponsor_proxy)) - .route("/sponsor/execute", post(routes::sponsor_execute_proxy)); + .route("/sponsor/execute", post(routes::sponsor_execute_proxy)) + .layer(middleware::from_fn_with_state( + state.clone(), + rate_limit::rate_limit_middleware, + )) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::verify_signature, + )) + .layer(DefaultBodyLimit::max(16_384)); + + // Public routes (no auth, no rate limit) + let public_routes = Router::new() + .route("/health", get(routes::health)); let app = Router::new() - .merge(protected_routes) + .merge(api_routes) + .merge(sponsor_routes) .merge(public_routes) .with_state(state) .layer(CorsLayer::permissive()) diff --git a/services/server/src/rate_limit.rs b/services/server/src/rate_limit.rs index b58fb1ee..2504f754 100644 --- a/services/server/src/rate_limit.rs +++ b/services/server/src/rate_limit.rs @@ -97,6 +97,8 @@ fn endpoint_weight(path: &str) -> i64 { "/api/remember/manual" => 3, // Walrus upload only (client did embed/encrypt) "/api/restore" => 3, // download + decrypt + re-embed "/api/ask" => 2, // recall + LLM + "/sponsor" => 5, // Enoki gas sponsorship — expensive on-chain operation + "/sponsor/execute" => 5, // Enoki execute — submits sponsored transaction on-chain _ => 1, // recall, recall/manual, etc. } } @@ -148,6 +150,7 @@ async fn record_in_window( ttl_seconds: i64, ) { let mut pipe = redis::pipe(); + pipe.atomic(); for i in 0..weight { // Use fractional offsets to create unique members let ts = now + i as f64 * 0.001; @@ -238,7 +241,12 @@ pub async fn rate_limit_middleware( } } Err(e) => { - tracing::error!("redis rate limit check failed (dk): {}, allowing", e); + tracing::error!("redis rate limit check failed (dk): {}", e); + return axum::response::Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("Content-Type", "application/json") + .body(axum::body::Body::from(r#"{"error":"Rate limiter unavailable"}"#)) + .unwrap(); } } @@ -257,7 +265,12 @@ pub async fn rate_limit_middleware( } } Err(e) => { - tracing::error!("redis rate limit check failed (burst): {}, allowing", e); + tracing::error!("redis rate limit check failed (burst): {}", e); + return axum::response::Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("Content-Type", "application/json") + .body(axum::body::Body::from(r#"{"error":"Rate limiter unavailable"}"#)) + .unwrap(); } } @@ -276,7 +289,12 @@ pub async fn rate_limit_middleware( } } Err(e) => { - tracing::error!("redis rate limit check failed (sustained): {}, allowing", e); + tracing::error!("redis rate limit check failed (sustained): {}", e); + return axum::response::Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("Content-Type", "application/json") + .body(axum::body::Body::from(r#"{"error":"Rate limiter unavailable"}"#)) + .unwrap(); } } diff --git a/services/server/src/routes.rs b/services/server/src/routes.rs index df8396cc..1d79a2d6 100644 --- a/services/server/src/routes.rs +++ b/services/server/src/routes.rs @@ -143,6 +143,7 @@ pub async fn remember( let embed_fut = generate_embedding(&state.http_client, &state.config, text); let encrypt_fut = seal::seal_encrypt( &state.http_client, &state.config.sidecar_url, + state.config.sidecar_secret.as_deref(), text.as_bytes(), owner, &state.config.package_id, ); let (vector_result, encrypted_result) = tokio::join!(embed_fut, encrypt_fut); @@ -155,6 +156,7 @@ pub async fn remember( .ok_or_else(|| AppError::Internal("No Sui keys configured (set SERVER_SUI_PRIVATE_KEYS or SERVER_SUI_PRIVATE_KEY)".into()))?; let upload_result = walrus::upload_blob( &state.http_client, &state.config.sidecar_url, + state.config.sidecar_secret.as_deref(), &encrypted, 50, owner, &sui_key, namespace, &state.config.package_id, ).await?; let blob_id = upload_result.blob_id; @@ -218,6 +220,7 @@ pub async fn recall( let walrus_client = &state.walrus_client; let http_client = &state.http_client; let sidecar_url = state.config.sidecar_url.clone(); + let sidecar_secret = state.config.sidecar_secret.clone(); let blob_id = hit.blob_id.clone(); let distance = hit.distance; let private_key = private_key.to_string(); @@ -239,7 +242,7 @@ pub async fn recall( } }; // Decrypt using SEAL (via sidecar HTTP) - match seal::seal_decrypt(http_client, &sidecar_url, &encrypted_data, &private_key, &package_id, &account_id).await { + match seal::seal_decrypt(http_client, &sidecar_url, sidecar_secret.as_deref(), &encrypted_data, &private_key, &package_id, &account_id).await { Ok(plaintext) => { match String::from_utf8(plaintext) { Ok(text) => Some(RecallResult { blob_id, text, distance }), @@ -321,6 +324,7 @@ pub async fn remember_manual( let upload = walrus::upload_blob( &state.http_client, &state.config.sidecar_url, + state.config.sidecar_secret.as_deref(), &encrypted_bytes, 50, owner, @@ -436,6 +440,7 @@ pub async fn analyze( let embed_fut = generate_embedding(&state.http_client, &state.config, &fact_text); let encrypt_fut = seal::seal_encrypt( &state.http_client, &state.config.sidecar_url, + state.config.sidecar_secret.as_deref(), fact_text.as_bytes(), &owner, &state.config.package_id, ); let (vector_result, encrypted_result) = tokio::join!(embed_fut, encrypt_fut); @@ -445,6 +450,7 @@ pub async fn analyze( // Upload to Walrus (via sidecar HTTP) let upload_result = walrus::upload_blob( &state.http_client, &state.config.sidecar_url, + state.config.sidecar_secret.as_deref(), &encrypted, 50, &owner, &sui_key, &namespace, &state.config.package_id, ).await?; @@ -645,6 +651,7 @@ pub async fn ask( let walrus_client = &state.walrus_client; let http_client = &state.http_client; let sidecar_url = state.config.sidecar_url.clone(); + let sidecar_secret = state.config.sidecar_secret.clone(); let blob_id = hit.blob_id.clone(); let distance = hit.distance; let private_key = private_key.to_string(); @@ -664,7 +671,7 @@ pub async fn ask( return None; } }; - match seal::seal_decrypt(http_client, &sidecar_url, &encrypted_data, &private_key, &package_id, &account_id).await { + match seal::seal_decrypt(http_client, &sidecar_url, sidecar_secret.as_deref(), &encrypted_data, &private_key, &package_id, &account_id).await { Ok(plaintext) => { match String::from_utf8(plaintext) { Ok(text) => Some(RecallResult { blob_id, text, distance }), @@ -811,6 +818,7 @@ pub async fn restore( let on_chain_blobs = walrus::query_blobs_by_owner( &state.http_client, &state.config.sidecar_url, + state.config.sidecar_secret.as_deref(), owner, Some(namespace), Some(&state.config.package_id), @@ -916,6 +924,7 @@ pub async fn restore( .map(|(blob_id, encrypted_data)| { let http_client = &state.http_client; let sidecar_url = state.config.sidecar_url.clone(); + let sidecar_secret = state.config.sidecar_secret.clone(); let private_key = private_key.clone(); // Use the package_id that was stored with this blob (supports contract upgrades) let package_id = blob_package_ids.get(&blob_id) @@ -924,7 +933,7 @@ pub async fn restore( let account_id = auth.account_id.clone(); async move { match seal::seal_decrypt( - http_client, &sidecar_url, &encrypted_data, + http_client, &sidecar_url, sidecar_secret.as_deref(), &encrypted_data, &private_key, &package_id, &account_id, ).await { Ok(plaintext) => { @@ -1008,15 +1017,31 @@ pub async fn restore( // ============================================================ /// POST /sponsor — proxy to sidecar POST /sponsor +/// +/// Requires valid auth (Ed25519 signature) — enforced by protected_routes middleware. +/// Validates that the body is well-formed JSON containing `transactionBlockKindBytes`. pub async fn sponsor_proxy( State(state): State>, body: axum::body::Bytes, ) -> Result, AppError> { + // Basic JSON validation: must parse and contain the expected field. + let parsed: serde_json::Value = serde_json::from_slice(&body) + .map_err(|_| AppError::BadRequest("Request body must be valid JSON".to_string()))?; + if parsed.get("transactionBlockKindBytes").is_none() { + return Err(AppError::BadRequest( + "Missing required field: transactionBlockKindBytes".to_string(), + )); + } + let url = format!("{}/sponsor", state.config.sidecar_url); - let resp = state.http_client + let mut req = state.http_client .post(&url) .header("Content-Type", "application/json") - .body(body.to_vec()) + .body(body.to_vec()); + if let Some(secret) = state.config.sidecar_secret.as_deref() { + req = req.header("x-sidecar-secret", secret); + } + let resp = req .send() .await .map_err(|e| AppError::Internal(format!("Sponsor proxy failed: {}", e)))?; @@ -1034,15 +1059,31 @@ pub async fn sponsor_proxy( } /// POST /sponsor/execute — proxy to sidecar POST /sponsor/execute +/// +/// Requires valid auth (Ed25519 signature) — enforced by protected_routes middleware. +/// Validates that the body is well-formed JSON containing `digest`. pub async fn sponsor_execute_proxy( State(state): State>, body: axum::body::Bytes, ) -> Result, AppError> { + // Basic JSON validation: must parse and contain the expected field. + let parsed: serde_json::Value = serde_json::from_slice(&body) + .map_err(|_| AppError::BadRequest("Request body must be valid JSON".to_string()))?; + if parsed.get("digest").is_none() { + return Err(AppError::BadRequest( + "Missing required field: digest".to_string(), + )); + } + let url = format!("{}/sponsor/execute", state.config.sidecar_url); - let resp = state.http_client + let mut req = state.http_client .post(&url) .header("Content-Type", "application/json") - .body(body.to_vec()) + .body(body.to_vec()); + if let Some(secret) = state.config.sidecar_secret.as_deref() { + req = req.header("x-sidecar-secret", secret); + } + let resp = req .send() .await .map_err(|e| AppError::Internal(format!("Sponsor execute proxy failed: {}", e)))?; diff --git a/services/server/src/seal.rs b/services/server/src/seal.rs index 93fe585c..6ea3195b 100644 --- a/services/server/src/seal.rs +++ b/services/server/src/seal.rs @@ -40,6 +40,7 @@ struct SealDecryptResponse { pub async fn seal_encrypt( client: &reqwest::Client, sidecar_url: &str, + sidecar_secret: Option<&str>, data: &[u8], owner_address: &str, package_id: &str, @@ -47,13 +48,17 @@ pub async fn seal_encrypt( let url = format!("{}/seal/encrypt", sidecar_url); let data_b64 = BASE64.encode(data); - let resp = client + let mut req = client .post(&url) .json(&SealEncryptRequest { data: data_b64, owner: owner_address.to_string(), package_id: package_id.to_string(), - }) + }); + if let Some(secret) = sidecar_secret { + req = req.header("x-sidecar-secret", secret); + } + let resp = req .send() .await .map_err(|e| { @@ -95,6 +100,7 @@ pub async fn seal_encrypt( pub async fn seal_decrypt( client: &reqwest::Client, sidecar_url: &str, + sidecar_secret: Option<&str>, encrypted_data: &[u8], private_key: &str, package_id: &str, @@ -103,14 +109,18 @@ pub async fn seal_decrypt( let url = format!("{}/seal/decrypt", sidecar_url); let data_b64 = BASE64.encode(encrypted_data); - let resp = client + let mut req = client .post(&url) .json(&SealDecryptRequest { data: data_b64, private_key: private_key.to_string(), package_id: package_id.to_string(), account_id: account_id.to_string(), - }) + }); + if let Some(secret) = sidecar_secret { + req = req.header("x-sidecar-secret", secret); + } + let resp = req .send() .await .map_err(|e| { diff --git a/services/server/src/types.rs b/services/server/src/types.rs index ad82cb8f..0675ef6f 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -78,6 +78,8 @@ pub struct Config { pub registry_id: String, /// URL of the SEAL/Walrus TS sidecar HTTP server pub sidecar_url: String, + /// Shared secret for authenticating Rust→sidecar calls (X-Sidecar-Secret header) + pub sidecar_secret: Option, /// Rate limiting configuration pub rate_limit: RateLimitConfig, } @@ -129,6 +131,7 @@ impl Config { .expect("MEMWAL_REGISTRY_ID must be set"), sidecar_url: std::env::var("SIDECAR_URL") .unwrap_or_else(|_| "http://localhost:9000".to_string()), + sidecar_secret: std::env::var("SIDECAR_SECRET").ok(), rate_limit: RateLimitConfig::from_env(), } } diff --git a/services/server/src/walrus.rs b/services/server/src/walrus.rs index 5c63a630..6df0ff2a 100644 --- a/services/server/src/walrus.rs +++ b/services/server/src/walrus.rs @@ -64,6 +64,7 @@ struct WalrusUploadResponse { pub async fn upload_blob( client: &reqwest::Client, sidecar_url: &str, + sidecar_secret: Option<&str>, data: &[u8], epochs: u64, owner_address: &str, @@ -74,7 +75,7 @@ pub async fn upload_blob( let url = format!("{}/walrus/upload", sidecar_url); let data_b64 = BASE64.encode(data); - let resp = client + let mut req = client .post(&url) .json(&WalrusUploadRequest { data: data_b64, @@ -83,7 +84,11 @@ pub async fn upload_blob( namespace: namespace.to_string(), package_id: package_id.to_string(), epochs, - }) + }); + if let Some(secret) = sidecar_secret { + req = req.header("x-sidecar-secret", secret); + } + let resp = req .send() .await .map_err(|e| { @@ -124,6 +129,7 @@ pub async fn upload_blob( pub async fn query_blobs_by_owner( client: &reqwest::Client, sidecar_url: &str, + sidecar_secret: Option<&str>, owner_address: &str, namespace: Option<&str>, package_id: Option<&str>, @@ -138,9 +144,13 @@ pub async fn query_blobs_by_owner( body["packageId"] = serde_json::json!(pkg); } - let resp = client + let mut req = client .post(&url) - .json(&body) + .json(&body); + if let Some(secret) = sidecar_secret { + req = req.header("x-sidecar-secret", secret); + } + let resp = req .send() .await .map_err(|e| { From de0ae4866d0567c99465afedfb9d03272af5f4cf Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 8 Apr 2026 20:47:22 +0700 Subject: [PATCH 04/21] =?UTF-8?q?fix(security):=20rename=20SIDECAR=5FSECRE?= =?UTF-8?q?T=E2=86=92SIDECAR=5FAUTH=5FTOKEN,=20use=20Authorization:Bearer,?= =?UTF-8?q?=20exit(1)=20if=20unset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/server/scripts/sidecar-server.ts | 33 ++++++++++++----------- services/server/src/routes.rs | 4 +-- services/server/src/walrus.rs | 4 +-- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts index b7f7baa6..41fac8ba 100644 --- a/services/server/scripts/sidecar-server.ts +++ b/services/server/scripts/sidecar-server.ts @@ -292,23 +292,24 @@ app.get("/health", (_req: Request, res: Response) => { }); // Shared-secret authentication — protects all routes registered after this point. -// Set SIDECAR_SECRET in the environment; callers must send it as X-Sidecar-Secret. -// If SIDECAR_SECRET is not set (dev mode), requests are allowed but a warning is logged. +// Set SIDECAR_AUTH_TOKEN in the environment; callers must send it as Authorization: Bearer . +// Sidecar refuses to start if SIDECAR_AUTH_TOKEN is not set. +const SIDECAR_AUTH_TOKEN = process.env.SIDECAR_AUTH_TOKEN; +if (!SIDECAR_AUTH_TOKEN) { + console.error("[sidecar] FATAL: SIDECAR_AUTH_TOKEN not set. Refusing to start without auth."); + process.exit(1); +} + app.use((req: Request, res: Response, next: NextFunction) => { - const secret = process.env.SIDECAR_SECRET; - if (secret) { - const provided = req.headers["x-sidecar-secret"]; - const secretBuf = Buffer.from(secret); - const providedBuf = Buffer.from(typeof provided === "string" ? provided : ""); - // timingSafeEqual prevents timing side-channel attacks on the secret comparison. - // Buffers must be same length — if lengths differ it's already a mismatch. - const valid = providedBuf.length === secretBuf.length && - timingSafeEqual(providedBuf, secretBuf); - if (!valid) { - return res.status(401).json({ error: "Unauthorized" }); - } - } else { - console.warn("[sidecar] WARNING: SIDECAR_SECRET is not set — running without authentication"); + const token = req.headers.authorization?.replace("Bearer ", ""); + const secretBuf = Buffer.from(SIDECAR_AUTH_TOKEN!); + const providedBuf = Buffer.from(typeof token === "string" ? token : ""); + // timingSafeEqual prevents timing side-channel attacks on the token comparison. + // Buffers must be same length — if lengths differ it's already a mismatch. + const valid = providedBuf.length === secretBuf.length && + timingSafeEqual(providedBuf, secretBuf); + if (!valid) { + return res.status(401).json({ error: "Unauthorized" }); } next(); }); diff --git a/services/server/src/routes.rs b/services/server/src/routes.rs index 1d79a2d6..5682c199 100644 --- a/services/server/src/routes.rs +++ b/services/server/src/routes.rs @@ -1039,7 +1039,7 @@ pub async fn sponsor_proxy( .header("Content-Type", "application/json") .body(body.to_vec()); if let Some(secret) = state.config.sidecar_secret.as_deref() { - req = req.header("x-sidecar-secret", secret); + req = req.header("authorization", format!("Bearer {}", secret)); } let resp = req .send() @@ -1081,7 +1081,7 @@ pub async fn sponsor_execute_proxy( .header("Content-Type", "application/json") .body(body.to_vec()); if let Some(secret) = state.config.sidecar_secret.as_deref() { - req = req.header("x-sidecar-secret", secret); + req = req.header("authorization", format!("Bearer {}", secret)); } let resp = req .send() diff --git a/services/server/src/walrus.rs b/services/server/src/walrus.rs index 6df0ff2a..d9e0ebe1 100644 --- a/services/server/src/walrus.rs +++ b/services/server/src/walrus.rs @@ -86,7 +86,7 @@ pub async fn upload_blob( epochs, }); if let Some(secret) = sidecar_secret { - req = req.header("x-sidecar-secret", secret); + req = req.header("authorization", format!("Bearer {}", secret)); } let resp = req .send() @@ -148,7 +148,7 @@ pub async fn query_blobs_by_owner( .post(&url) .json(&body); if let Some(secret) = sidecar_secret { - req = req.header("x-sidecar-secret", secret); + req = req.header("authorization", format!("Bearer {}", secret)); } let resp = req .send() From 3bc98b62cb6255e6ec878c30efe9c558ebd0f0d3 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Apr 2026 09:36:29 +0700 Subject: [PATCH 05/21] fix(security): complete HIGH-1 and HIGH-4 wiring --- apps/app/src/hooks/useSponsoredTransaction.ts | 50 +++++++--------- services/server/src/seal.rs | 4 +- services/server/src/types.rs | 2 +- .../server/tests/test_rate_limit_redis.py | 60 +++++++++++++++++++ 4 files changed, 84 insertions(+), 32 deletions(-) create mode 100644 services/server/tests/test_rate_limit_redis.py diff --git a/apps/app/src/hooks/useSponsoredTransaction.ts b/apps/app/src/hooks/useSponsoredTransaction.ts index 43f740d7..535e4152 100644 --- a/apps/app/src/hooks/useSponsoredTransaction.ts +++ b/apps/app/src/hooks/useSponsoredTransaction.ts @@ -16,12 +16,15 @@ import { useCurrentAccount, useSignTransaction, useSignAndExecuteTransaction, useSuiClient } from '@mysten/dapp-kit' import { Transaction } from '@mysten/sui/transactions' import { config } from '../config' +import { useDelegateKey } from '../App' +import { apiCall } from '../utils/api' export function useSponsoredTransaction() { const currentAccount = useCurrentAccount() const suiClient = useSuiClient() const { mutateAsync: signTransaction } = useSignTransaction() const { mutateAsync: directSignAndExecute } = useSignAndExecuteTransaction() + const { delegateKey, delegatePublicKey, accountObjectId } = useDelegateKey() const mutateAsync = async ({ transaction }: { transaction: Transaction }): Promise<{ digest: string }> => { const sender = currentAccount?.address @@ -35,44 +38,33 @@ export function useSponsoredTransaction() { }) const kindBase64 = uint8ArrayToBase64(kindBytes) - // 2. Sponsor via server (proxied to sidecar) - const sponsorRes = await fetch(`${config.memwalServerUrl}/sponsor`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - transactionBlockKindBytes: kindBase64, - sender, - }), - }) - - if (!sponsorRes.ok) { - const errText = await sponsorRes.text() - throw new Error(`Sponsor failed (${sponsorRes.status}): ${errText}`) + if (!delegateKey || !delegatePublicKey) { + throw new Error('No delegate key available for signing sponsor request') } - const sponsored = await sponsorRes.json() + // 2. Sponsor via server — signed with delegate key (required by verify_signature middleware) + const sponsored = await apiCall( + delegateKey, + config.memwalServerUrl, + '/sponsor', + { transactionBlockKindBytes: kindBase64, sender }, + accountObjectId ?? undefined, + ) // sponsored = { bytes: base64, digest: string } // 3. Sign sponsored bytes with user wallet const sponsoredTx = Transaction.from(sponsored.bytes) const { signature } = await signTransaction({ transaction: sponsoredTx }) - // 4. Execute via server (proxied to sidecar) - const execRes = await fetch(`${config.memwalServerUrl}/sponsor/execute`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - digest: sponsored.digest, - signature, - }), - }) - - if (!execRes.ok) { - const errText = await execRes.text() - throw new Error(`Sponsored execute failed (${execRes.status}): ${errText}`) - } + // 4. Execute via server — signed with delegate key + const result = await apiCall( + delegateKey, + config.memwalServerUrl, + '/sponsor/execute', + { digest: sponsored.digest, signature }, + accountObjectId ?? undefined, + ) - const result = await execRes.json() console.log(`[sponsored-tx] success, digest=${result.digest}`) return { digest: result.digest } } catch (err) { diff --git a/services/server/src/seal.rs b/services/server/src/seal.rs index 6ea3195b..e4617a5f 100644 --- a/services/server/src/seal.rs +++ b/services/server/src/seal.rs @@ -56,7 +56,7 @@ pub async fn seal_encrypt( package_id: package_id.to_string(), }); if let Some(secret) = sidecar_secret { - req = req.header("x-sidecar-secret", secret); + req = req.header("authorization", format!("Bearer {}", secret)); } let resp = req .send() @@ -118,7 +118,7 @@ pub async fn seal_decrypt( account_id: account_id.to_string(), }); if let Some(secret) = sidecar_secret { - req = req.header("x-sidecar-secret", secret); + req = req.header("authorization", format!("Bearer {}", secret)); } let resp = req .send() diff --git a/services/server/src/types.rs b/services/server/src/types.rs index 0675ef6f..0e68d140 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -131,7 +131,7 @@ impl Config { .expect("MEMWAL_REGISTRY_ID must be set"), sidecar_url: std::env::var("SIDECAR_URL") .unwrap_or_else(|_| "http://localhost:9000".to_string()), - sidecar_secret: std::env::var("SIDECAR_SECRET").ok(), + sidecar_secret: std::env::var("SIDECAR_AUTH_TOKEN").ok(), rate_limit: RateLimitConfig::from_env(), } } diff --git a/services/server/tests/test_rate_limit_redis.py b/services/server/tests/test_rate_limit_redis.py new file mode 100644 index 00000000..b5463ad7 --- /dev/null +++ b/services/server/tests/test_rate_limit_redis.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +Test: Rate limiter returns 503 when Redis is down. +Run BEFORE this script: + 1. cargo run (server must be running on port 3001) + 2. docker stop memwal-redis + +Then run: + python3 tests/test_rate_limit_redis.py +""" + +import json, hashlib, time, urllib.request, urllib.error +from nacl.signing import SigningKey +from nacl.encoding import RawEncoder + +BASE_URL = "http://localhost:3001" + +def signed_request(method, path, body, key): + body_json = json.dumps(body).encode() + body_hash = hashlib.sha256(body_json).hexdigest() + timestamp = str(int(time.time())) + message = f"{timestamp}.{method}.{path}.{body_hash}" + signed = key.sign(message.encode(), encoder=RawEncoder) + pub = key.verify_key.encode().hex() + sig = signed.signature.hex() + req = urllib.request.Request( + f"{BASE_URL}{path}", data=body_json, + headers={ + "Content-Type": "application/json", + "x-public-key": pub, + "x-signature": sig, + "x-timestamp": timestamp, + }, + method=method, + ) + try: + with urllib.request.urlopen(req) as r: + raw = r.read() + return r.status, json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + raw = e.read() + try: + body = json.loads(raw) if raw else {} + except Exception: + body = {"raw": raw.decode(errors="replace")} + return e.code, body + +key = SigningKey.generate() +body = {"text": "test memory for rate limit check"} + +print("Sending signed POST /api/remember ...") +status, resp = signed_request("POST", "/api/remember", body, key) +print(f"→ HTTP {status}: {resp}") + +if status == 503: + print("\n[PASS] Rate limiter returned 503 — Redis is down, fail-closed working correctly.") +elif status == 200: + print("\n[INFO] Got 200 — Redis is still UP. Stop it first: docker stop memwal-redis") +else: + print(f"\n[INFO] Got {status} — {resp}") From c51f35e5235417abb3f2f56b380d35a7e0bd5dcb Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Apr 2026 09:56:34 +0700 Subject: [PATCH 06/21] test(security): use env vars for delegate key in rate limit test --- .../server/tests/test_rate_limit_redis.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/services/server/tests/test_rate_limit_redis.py b/services/server/tests/test_rate_limit_redis.py index b5463ad7..772b2af8 100644 --- a/services/server/tests/test_rate_limit_redis.py +++ b/services/server/tests/test_rate_limit_redis.py @@ -7,6 +7,9 @@ Then run: python3 tests/test_rate_limit_redis.py + +Then restore: + docker start memwal-redis """ import json, hashlib, time, urllib.request, urllib.error @@ -15,7 +18,18 @@ BASE_URL = "http://localhost:3001" -def signed_request(method, path, body, key): +import os, sys + +# Set via env vars — never hardcode keys in source +PRIVATE_KEY_HEX = os.environ.get("TEST_DELEGATE_KEY") +ACCOUNT_ID = os.environ.get("TEST_ACCOUNT_ID") + +if not PRIVATE_KEY_HEX or not ACCOUNT_ID: + print("Usage: TEST_DELEGATE_KEY= TEST_ACCOUNT_ID=<0x...> python3 tests/test_rate_limit_redis.py") + sys.exit(1) + +def signed_request(method, path, body): + key = SigningKey(bytes.fromhex(PRIVATE_KEY_HEX)) body_json = json.dumps(body).encode() body_hash = hashlib.sha256(body_json).hexdigest() timestamp = str(int(time.time())) @@ -30,6 +44,7 @@ def signed_request(method, path, body, key): "x-public-key": pub, "x-signature": sig, "x-timestamp": timestamp, + "x-account-id": ACCOUNT_ID, }, method=method, ) @@ -45,16 +60,17 @@ def signed_request(method, path, body, key): body = {"raw": raw.decode(errors="replace")} return e.code, body -key = SigningKey.generate() body = {"text": "test memory for rate limit check"} print("Sending signed POST /api/remember ...") -status, resp = signed_request("POST", "/api/remember", body, key) +status, resp = signed_request("POST", "/api/remember", body) print(f"→ HTTP {status}: {resp}") if status == 503: print("\n[PASS] Rate limiter returned 503 — Redis is down, fail-closed working correctly.") elif status == 200: print("\n[INFO] Got 200 — Redis is still UP. Stop it first: docker stop memwal-redis") +elif status == 401: + print("\n[FAIL] Got 401 — auth failed. Key may be expired or not registered on-chain.") else: print(f"\n[INFO] Got {status} — {resp}") From 0579eee36315c19c7766ff3ae74e8c5869ae0ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ph=E1=BA=A1m=20Minh=20H=C3=B9ng?= <46132442+hungtranphamminh@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:08:25 +0700 Subject: [PATCH 07/21] Feat: Add Enoki zkLogin and profile page to researcher app (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(researcher): add Enoki zkLogin for wallet-less Google sign-in Add "Sign in with Google" via Enoki alongside existing Ed25519 key login. Returning users skip on-chain registration via stored DB credentials. Session cookie format unchanged — zero changes to existing getSession() consumers. * fix(researcher): address review findings for Enoki login - Derive publicKey server-side from privateKey instead of trusting client - Tighten Sui address regex from {10,} to {64} - Validate accountId format (must be 0x... Sui object ID) - Show error on Phase 1 server failure instead of falling through to Phase 2 - Throw explicit error if knownAccountId is null after create_account - Check all required env vars before rendering Google sign-in button - Remove unused publicKey from client POST body * feat(researcher): add profile page and polish login UX Add /profile page showing MemWal account info (Sui address, account ID, public key) with copy buttons and Suiscan links. Includes delegate key export for cross-app usage. Add profile link to sidebar user dropdown. Polish login page with improved copy, Sparkles icon, and cleaner collapsible layout for delegate key login. Add JSDoc comments to all new Enoki integration code. --- apps/researcher/.env.example | 10 + apps/researcher/app/(auth)/login/page.tsx | 175 +++++--- apps/researcher/app/api/auth/enoki/route.ts | 128 ++++++ .../app/api/auth/profile/export-key/route.ts | 14 + apps/researcher/app/api/auth/profile/route.ts | 25 ++ apps/researcher/app/layout.tsx | 7 +- apps/researcher/app/profile/page.tsx | 289 ++++++++++++ .../components/enoki-login-card.tsx | 424 ++++++++++++++++++ .../components/sidebar/sidebar-user-nav.tsx | 15 +- apps/researcher/components/sui-providers.tsx | 58 +++ .../db/migrations/0001_add_enoki_fields.sql | 7 + .../lib/db/migrations/meta/_journal.json | 7 + apps/researcher/lib/db/queries.ts | 75 ++++ apps/researcher/lib/db/schema.ts | 3 + apps/researcher/lib/enoki/config.ts | 12 + apps/researcher/package.json | 14 +- 16 files changed, 1189 insertions(+), 74 deletions(-) create mode 100644 apps/researcher/app/api/auth/enoki/route.ts create mode 100644 apps/researcher/app/api/auth/profile/export-key/route.ts create mode 100644 apps/researcher/app/api/auth/profile/route.ts create mode 100644 apps/researcher/app/profile/page.tsx create mode 100644 apps/researcher/components/enoki-login-card.tsx create mode 100644 apps/researcher/components/sui-providers.tsx create mode 100644 apps/researcher/lib/db/migrations/0001_add_enoki_fields.sql create mode 100644 apps/researcher/lib/enoki/config.ts diff --git a/apps/researcher/.env.example b/apps/researcher/.env.example index 55f3f491..01f25df4 100644 --- a/apps/researcher/.env.example +++ b/apps/researcher/.env.example @@ -20,3 +20,13 @@ REDIS_URL= # Vercel Blob (optional) — used for file uploads (images, PDFs) BLOB_READ_WRITE_TOKEN= + +# Enoki zkLogin (optional) — enables "Sign in with Google" +# Get Enoki API key from https://portal.enoki.mystenlabs.com +# Get Google Client ID from https://console.cloud.google.com +NEXT_PUBLIC_ENOKI_API_KEY= +NEXT_PUBLIC_GOOGLE_CLIENT_ID= +NEXT_PUBLIC_SUI_NETWORK=testnet +NEXT_PUBLIC_MEMWAL_PACKAGE_ID= +NEXT_PUBLIC_MEMWAL_REGISTRY_ID= +NEXT_PUBLIC_MEMWAL_SERVER_URL=http://localhost:9000 diff --git a/apps/researcher/app/(auth)/login/page.tsx b/apps/researcher/app/(auth)/login/page.tsx index b10b7804..9342f645 100644 --- a/apps/researcher/app/(auth)/login/page.tsx +++ b/apps/researcher/app/(auth)/login/page.tsx @@ -1,12 +1,22 @@ "use client"; -import { KeyRound, Eye, EyeOff, ArrowRight, Loader2, Lock } from "lucide-react"; +import { + KeyRound, + Eye, + EyeOff, + ArrowRight, + Loader2, + Lock, + ChevronDown, + Sparkles, +} from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "@/components/toast"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { EnokiLoginCard } from "@/components/enoki-login-card"; export default function Page() { const router = useRouter(); @@ -14,6 +24,7 @@ export default function Page() { const [accountId, setAccountId] = useState(""); const [isLoading, setIsLoading] = useState(false); const [showKey, setShowKey] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -35,7 +46,10 @@ export default function Page() { const res = await fetch("/api/auth/key", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ privateKey: trimmed, accountId: trimmedAccountId }), + body: JSON.stringify({ + privateKey: trimmed, + accountId: trimmedAccountId, + }), }); if (!res.ok) { @@ -68,86 +82,119 @@ export default function Page() { {/* Logo / Brand */}
- +

Researcher

-

- Sign in with your ed25519 key +

+ AI-powered research with long-term memory

- {/* Card */} -
-
-
- - setAccountId(e.target.value)} - placeholder="0x..." - required - value={accountId} - /> -
+ {/* Google Sign-in (Enoki) */} + + + {/* Divider */} +
+
+ or +
+
+ + {/* Advanced: Key login */} +
+ -
- -
- setPrivateKey(e.target.value)} - placeholder="Enter your private key" - required - type={showKey ? "text" : "password"} - value={privateKey} - /> - +
+
+ + -
+ +
- - - + )}
{/* Footer note */} -
+
- Encrypted session cookie + Encrypted session — your keys never leave the server
diff --git a/apps/researcher/app/api/auth/enoki/route.ts b/apps/researcher/app/api/auth/enoki/route.ts new file mode 100644 index 00000000..0591106d --- /dev/null +++ b/apps/researcher/app/api/auth/enoki/route.ts @@ -0,0 +1,128 @@ +import { createSession } from "@/lib/auth/session"; +import { + getUserBySuiAddress, + createEnokiUser, + updateEnokiUserCredentials, +} from "@/lib/db/queries"; +import * as ed from "@noble/ed25519"; +import { sha512 } from "@noble/hashes/sha512"; + +if (!ed.etc.sha512Sync) { + ed.etc.sha512Sync = (...m: Uint8Array[]) => { + const h = sha512.create(); + for (const msg of m) h.update(msg); + return h.digest(); + }; +} + +const HEX_64_REGEX = /^[0-9a-f]{64}$/i; +const SUI_ADDRESS_REGEX = /^0x[0-9a-f]{64}$/i; + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * POST /api/auth/enoki + * + * Two-phase Enoki login: + * - Phase 1 (check): { suiAddress } → returns existing user or needsSetup + * - Phase 2 (register): { suiAddress, privateKey, publicKey, accountId } → creates/updates user + session + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + const { suiAddress, privateKey, accountId } = body; + + if (!suiAddress || !SUI_ADDRESS_REGEX.test(suiAddress)) { + return Response.json( + { error: "Invalid Sui address." }, + { status: 400 }, + ); + } + + // Phase 1: Check — only suiAddress provided + if (!privateKey && !accountId) { + const existing = await getUserBySuiAddress(suiAddress); + + if (existing?.delegatePrivateKey && existing?.publicKey && existing?.accountId) { + // Returning user — recreate session from stored credentials + await createSession( + existing.id, + existing.publicKey, + existing.delegatePrivateKey, + existing.accountId, + ); + return Response.json({ success: true, needsSetup: false }); + } + + // No stored credentials — client needs to generate key + register on-chain + return Response.json({ success: true, needsSetup: true }); + } + + // Phase 2: Register — full credentials provided + if ( + !privateKey || + typeof privateKey !== "string" || + !HEX_64_REGEX.test(privateKey) + ) { + return Response.json( + { error: "Invalid private key. Expected 64 hex characters." }, + { status: 400 }, + ); + } + + if (!accountId || typeof accountId !== "string" || !SUI_ADDRESS_REGEX.test(accountId)) { + return Response.json( + { error: "Valid account ID is required (0x... format)." }, + { status: 400 }, + ); + } + + // Derive publicKey server-side from privateKey (don't trust client-submitted publicKey) + const privKeyBytes = hexToBytes(privateKey); + const derivedPubKeyBytes = ed.getPublicKey(privKeyBytes); + const derivedPublicKey = bytesToHex(derivedPubKeyBytes); + + // Check if user already exists by suiAddress (e.g. partial setup from before) + const existing = await getUserBySuiAddress(suiAddress); + + if (existing) { + // Update stored credentials + await updateEnokiUserCredentials({ + userId: existing.id, + publicKey: derivedPublicKey, + delegatePrivateKey: privateKey, + accountId, + }); + await createSession(existing.id, derivedPublicKey, privateKey, accountId); + } else { + // Create new user + const created = await createEnokiUser({ + publicKey: derivedPublicKey, + suiAddress, + delegatePrivateKey: privateKey, + accountId, + }); + await createSession(created.id, derivedPublicKey, privateKey, accountId); + } + + return Response.json({ success: true, needsSetup: false }); + } catch (error) { + console.error("[auth:enoki] Error:", error); + return Response.json( + { error: "Authentication failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/researcher/app/api/auth/profile/export-key/route.ts b/apps/researcher/app/api/auth/profile/export-key/route.ts new file mode 100644 index 00000000..63711516 --- /dev/null +++ b/apps/researcher/app/api/auth/profile/export-key/route.ts @@ -0,0 +1,14 @@ +import { getSession } from "@/lib/auth/session"; + +/** POST /api/auth/profile/export-key — Returns the delegate private key from the active session. Requires authentication. */ +export async function POST() { + const session = await getSession(); + if (!session?.user?.privateKey) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + return Response.json({ + privateKey: session.user.privateKey, + accountId: session.user.accountId, + }); +} diff --git a/apps/researcher/app/api/auth/profile/route.ts b/apps/researcher/app/api/auth/profile/route.ts new file mode 100644 index 00000000..b14e7c72 --- /dev/null +++ b/apps/researcher/app/api/auth/profile/route.ts @@ -0,0 +1,25 @@ +import { getSession } from "@/lib/auth/session"; +import { getUserById } from "@/lib/db/queries"; + +/** GET /api/auth/profile — Returns the authenticated user's profile data including auth method and MemWal account info. */ +export async function GET() { + const session = await getSession(); + if (!session?.user) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const dbUser = await getUserById(session.user.id); + if (!dbUser) { + return Response.json({ error: "User not found" }, { status: 404 }); + } + + return Response.json({ + id: dbUser.id, + email: dbUser.email, + publicKey: dbUser.publicKey, + suiAddress: dbUser.suiAddress ?? null, + accountId: dbUser.accountId ?? session.user.accountId ?? null, + authMethod: dbUser.suiAddress ? "enoki" : "key", + hasPrivateKey: !!session.user.privateKey, + }); +} diff --git a/apps/researcher/app/layout.tsx b/apps/researcher/app/layout.tsx index 16d56cb9..6c9bbda9 100644 --- a/apps/researcher/app/layout.tsx +++ b/apps/researcher/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Toaster } from "sonner"; import { ThemeProvider } from "@/components/theme-provider"; +import { SuiProviders } from "@/components/sui-providers"; import "./globals.css"; @@ -77,8 +78,10 @@ export default function RootLayout({ disableTransitionOnChange enableSystem > - - {children} + + + {children} + diff --git a/apps/researcher/app/profile/page.tsx b/apps/researcher/app/profile/page.tsx new file mode 100644 index 00000000..eb2b37a0 --- /dev/null +++ b/apps/researcher/app/profile/page.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { + ArrowLeft, + Copy, + Check, + KeyRound, + Shield, + User, + Eye, + EyeOff, + ExternalLink, +} from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; + +interface ProfileData { + id: string; + email: string; + publicKey: string | null; + suiAddress: string | null; + accountId: string | null; + authMethod: "enoki" | "key"; + hasPrivateKey: boolean; +} + +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} + +function truncateAddress(addr: string): string { + if (addr.length <= 16) return addr; + return `${addr.slice(0, 10)}...${addr.slice(-6)}`; +} + +export default function ProfilePage() { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [showKey, setShowKey] = useState(false); + const [privateKey, setPrivateKey] = useState(null); + const [keyLoading, setKeyLoading] = useState(false); + + useEffect(() => { + fetch("/api/auth/profile") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setProfile(data)) + .catch(() => setProfile(null)) + .finally(() => setLoading(false)); + }, []); + + const handleExportKey = async () => { + if (privateKey) { + setShowKey(!showKey); + return; + } + setKeyLoading(true); + try { + const res = await fetch("/api/auth/profile/export-key", { + method: "POST", + }); + if (res.ok) { + const data = await res.json(); + setPrivateKey(data.privateKey); + setShowKey(true); + } + } catch { + // silently fail + } finally { + setKeyLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!profile) { + return ( +
+

Not signed in

+ + + +
+ ); + } + + return ( +
+ {/* Background decoration */} +
+
+
+ +
+ {/* Back link */} + + + Back to chat + + + {/* Header */} +
+
+ +
+
+

Profile

+

+ {profile.authMethod === "enoki" + ? "Signed in with Google" + : "Signed in with delegate key"} +

+
+
+ + {/* Account info card */} +
+
+

+ + MemWal Account +

+
+ +
+ {profile.suiAddress && ( +
+
+

Sui Address

+

+ {truncateAddress(profile.suiAddress)} +

+
+
+ + + + +
+
+ )} + + {profile.accountId && ( +
+
+

Account ID

+

+ {truncateAddress(profile.accountId)} +

+
+
+ + + + +
+
+ )} + + {profile.publicKey && ( +
+
+

Public Key

+

+ {truncateAddress(profile.publicKey)} +

+
+ +
+ )} +
+
+ + {/* Export delegate key card */} + {profile.hasPrivateKey && ( +
+
+

+ + Delegate Key +

+
+ +
+

+ Use this key to connect other apps (chatbot, CLI, OpenClaw) to + your MemWal account. Keep it private. +

+ + {showKey && privateKey ? ( +
+
+ + {privateKey} + + +
+
+ ) : null} + + +
+
+ )} + + {/* Session actions */} +
+
+

Session active

+ +
+
+
+
+ ); +} diff --git a/apps/researcher/components/enoki-login-card.tsx b/apps/researcher/components/enoki-login-card.tsx new file mode 100644 index 00000000..70c5b1ec --- /dev/null +++ b/apps/researcher/components/enoki-login-card.tsx @@ -0,0 +1,424 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + useWallets, + useConnectWallet, + useCurrentAccount, + useSignTransaction, + useSuiClient, +} from "@mysten/dapp-kit"; +import { isEnokiWallet } from "@mysten/enoki"; +import { Transaction } from "@mysten/sui/transactions"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { enokiConfig } from "@/lib/enoki/config"; + +type Step = + | "idle" + | "connecting" + | "generating-key" + | "registering-onchain" + | "creating-session" + | "done"; + +const STEP_LABELS: Record = { + idle: "", + connecting: "Signing in with Google...", + "generating-key": "Generating delegate key...", + "registering-onchain": "Registering on-chain...", + "creating-session": "Creating session...", + done: "Redirecting...", +}; + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * Execute a transaction via Enoki gas sponsorship through the MemWal relayer. + * Builds TX kind bytes, requests sponsorship, signs with user wallet, then executes. + * @param transaction - The Sui transaction to execute + * @param sender - The sender's Sui address (Enoki zkLogin address) + */ +async function sponsoredSignAndExecute( + transaction: Transaction, + sender: string, + suiClient: ReturnType, + signTransaction: (args: { + transaction: Transaction; + }) => Promise<{ signature: string }>, +): Promise<{ digest: string }> { + const kindBytes = await transaction.build({ + client: suiClient as any, + onlyTransactionKind: true, + }); + + const sponsorRes = await fetch( + `${enokiConfig.memwalServerUrl}/sponsor`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + transactionBlockKindBytes: uint8ArrayToBase64(kindBytes), + sender, + }), + }, + ); + + if (!sponsorRes.ok) { + const errText = await sponsorRes.text(); + throw new Error(`Sponsor failed (${sponsorRes.status}): ${errText}`); + } + + const sponsored = await sponsorRes.json(); + const sponsoredTx = Transaction.from(sponsored.bytes); + const { signature } = await signTransaction({ transaction: sponsoredTx }); + + const execRes = await fetch( + `${enokiConfig.memwalServerUrl}/sponsor/execute`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ digest: sponsored.digest, signature }), + }, + ); + + if (!execRes.ok) { + const errText = await execRes.text(); + throw new Error( + `Sponsored execute failed (${execRes.status}): ${errText}`, + ); + } + + return execRes.json(); +} + +/** + * Google sign-in card using Enoki zkLogin. + * + * Handles the full flow: Google OAuth -> check returning user -> generate delegate + * key -> register on-chain (sponsored) -> create server session. + * + * Returns null if Enoki env vars are not configured. + */ +export function EnokiLoginCard() { + const router = useRouter(); + const wallets = useWallets(); + const { mutateAsync: connect } = useConnectWallet(); + const currentAccount = useCurrentAccount(); + const suiClient = useSuiClient(); + const { mutateAsync: signTransaction } = useSignTransaction(); + + const [step, setStep] = useState("idle"); + const [error, setError] = useState(""); + const setupRunningRef = useRef(false); + + const enokiWallets = wallets.filter(isEnokiWallet); + const googleWallet = enokiWallets.find((w) => w.provider === "google"); + const hasEnokiConfig = + enokiConfig.enokiApiKey && + enokiConfig.googleClientId && + enokiConfig.memwalPackageId && + enokiConfig.memwalRegistryId && + enokiConfig.memwalServerUrl; + + const [pendingSetup, setPendingSetup] = useState(false); + + const runSetup = useCallback( + async (address: string) => { + // Prevent double-run + if (setupRunningRef.current) return; + setupRunningRef.current = true; + + try { + // Phase 1: Check if returning user with stored credentials + setStep("creating-session"); + const checkRes = await fetch("/api/auth/enoki", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ suiAddress: address }), + }); + + if (!checkRes.ok) { + // Server error — don't silently fall through to Phase 2 + throw new Error("Unable to check account status. Please try again."); + } + + const checkData = await checkRes.json(); + if (!checkData.needsSetup) { + // Returning user — session created from stored credentials + setStep("done"); + router.push("/"); + router.refresh(); + return; + } + + // Phase 2: First-time user — generate key + register on-chain + // Step: Generate delegate keypair + setStep("generating-key"); + const ed = await import("@noble/ed25519"); + const { blake2b } = await import("@noble/hashes/blake2b"); + + const privateKeyRaw = new Uint8Array(32); + crypto.getRandomValues(privateKeyRaw); + const publicKeyRaw = await ed.getPublicKeyAsync(privateKeyRaw); + + const privateKeyHex = bytesToHex(privateKeyRaw); + const publicKeyHex = bytesToHex(publicKeyRaw); + + // Derive Sui address for delegate key: blake2b256(0x00 || pubkey) + const addrInput = new Uint8Array(33); + addrInput[0] = 0x00; // Ed25519 scheme flag + addrInput.set(publicKeyRaw, 1); + const addressBytes = blake2b(addrInput, { dkLen: 32 }); + const delegateSuiAddress = + "0x" + bytesToHex(new Uint8Array(addressBytes)); + + // Step: On-chain registration + setStep("registering-onchain"); + + let knownAccountId: string | null = null; + + // Check if MemWal account already exists for this address + try { + const registryObj = await suiClient.getObject({ + id: enokiConfig.memwalRegistryId, + options: { showContent: true }, + }); + if ( + registryObj?.data?.content && + "fields" in registryObj.data.content + ) { + const fields = registryObj.data.content.fields as any; + const tableId = fields?.accounts?.fields?.id?.id; + if (tableId) { + const dynField = await suiClient.getDynamicFieldObject({ + parentId: tableId, + name: { type: "address", value: address }, + }); + if ( + dynField?.data?.content && + "fields" in dynField.data.content + ) { + knownAccountId = (dynField.data.content.fields as any) + .value as string; + } + } + } + } catch { + // Dynamic field not found → no account yet + } + + const pubKeyBytes = Array.from(publicKeyRaw); + + const sign = (args: { transaction: Transaction }) => + signTransaction(args); + + if (knownAccountId) { + // Account exists — add delegate key + const tx = new Transaction(); + tx.moveCall({ + target: `${enokiConfig.memwalPackageId}::account::add_delegate_key`, + arguments: [ + tx.object(knownAccountId), + tx.pure("vector", pubKeyBytes), + tx.pure("address", delegateSuiAddress), + tx.pure("string", "Researcher"), + tx.object("0x6"), + ], + }); + const result = await sponsoredSignAndExecute( + tx, + address, + suiClient, + sign, + ); + await suiClient.waitForTransaction({ digest: result.digest }); + } else { + // Create account first + const tx = new Transaction(); + tx.moveCall({ + target: `${enokiConfig.memwalPackageId}::account::create_account`, + arguments: [ + tx.object(enokiConfig.memwalRegistryId), + tx.object("0x6"), + ], + }); + const createResult = await sponsoredSignAndExecute( + tx, + address, + suiClient, + sign, + ); + await suiClient.waitForTransaction({ + digest: createResult.digest, + }); + + // Find the created account object + const txDetails = await suiClient.getTransactionBlock({ + digest: createResult.digest, + options: { showObjectChanges: true }, + }); + const createdObj = txDetails.objectChanges?.find( + (c) => + c.type === "created" && + "objectType" in c && + c.objectType.includes("MemWalAccount"), + ); + if (createdObj && "objectId" in createdObj) { + knownAccountId = createdObj.objectId; + } + + if (!knownAccountId) { + throw new Error( + "Account created on-chain but object ID not found in transaction. Please try again.", + ); + } + + // Add delegate key + const tx2 = new Transaction(); + tx2.moveCall({ + target: `${enokiConfig.memwalPackageId}::account::add_delegate_key`, + arguments: [ + tx2.object(knownAccountId!), + tx2.pure("vector", pubKeyBytes), + tx2.pure("address", delegateSuiAddress), + tx2.pure("string", "Researcher"), + tx2.object("0x6"), + ], + }); + const addResult = await sponsoredSignAndExecute( + tx2, + address, + suiClient, + sign, + ); + await suiClient.waitForTransaction({ digest: addResult.digest }); + } + + // Step: Create server session + store credentials for returning login + setStep("creating-session"); + const res = await fetch("/api/auth/enoki", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + suiAddress: address, + privateKey: privateKeyHex, + accountId: knownAccountId!, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Session creation failed"); + } + + setStep("done"); + router.push("/"); + router.refresh(); + } catch (err) { + console.error("[enoki-login] Setup failed:", err); + setError( + err instanceof Error + ? err.message + : "Setup failed. Please try again.", + ); + setStep("idle"); + } finally { + setupRunningRef.current = false; + } + }, + [suiClient, signTransaction, router], + ); + + // When wallet connects after Google OAuth, continue the setup + useEffect(() => { + if (pendingSetup && currentAccount?.address) { + setPendingSetup(false); + runSetup(currentAccount.address); + } + }, [pendingSetup, currentAccount?.address, runSetup]); + + const handleGoogleSignIn = async () => { + if (!googleWallet) return; + + setError(""); + setStep("connecting"); + + try { + await connect({ wallet: googleWallet }); + // connect() resolves but currentAccount may not be updated yet in this + // render cycle. Set pendingSetup so the useEffect picks it up. + setPendingSetup(true); + } catch (err) { + console.error("[enoki-login] Connect failed:", err); + setError( + err instanceof Error + ? err.message + : "Google sign-in failed. Please try again.", + ); + setStep("idle"); + } + }; + + if (!hasEnokiConfig || !googleWallet) return null; + + const isProcessing = step !== "idle"; + + return ( +
+ + + {error && ( +

{error}

+ )} +
+ ); +} diff --git a/apps/researcher/components/sidebar/sidebar-user-nav.tsx b/apps/researcher/components/sidebar/sidebar-user-nav.tsx index 65dec23d..1c016384 100644 --- a/apps/researcher/components/sidebar/sidebar-user-nav.tsx +++ b/apps/researcher/components/sidebar/sidebar-user-nav.tsx @@ -1,9 +1,11 @@ "use client"; -import { ChevronUp } from "lucide-react"; +import { ChevronUp, User } from "lucide-react"; import Image from "next/image"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; +import { useDisconnectWallet } from "@mysten/dapp-kit"; import { DropdownMenu, DropdownMenuContent, @@ -22,6 +24,7 @@ type SessionUser = { id: string; email?: string; publicKey?: string }; export function SidebarUserNav({ user }: { user: SessionUser }) { const router = useRouter(); const { setTheme, resolvedTheme } = useTheme(); + const { mutateAsync: disconnectWallet } = useDisconnectWallet(); const displayName = user.publicKey ? `${user.publicKey.slice(0, 6)}...${user.publicKey.slice(-4)}` @@ -54,6 +57,12 @@ export function SidebarUserNav({ user }: { user: SessionUser }) { data-testid="user-nav-menu" side="top" > + + + + Profile + + { await fetch("/api/auth/signout", { method: "POST" }); - router.push("/login"); - router.refresh(); + await disconnectWallet().catch(() => {}); + window.location.href = "/login"; }} type="button" > diff --git a/apps/researcher/components/sui-providers.tsx b/apps/researcher/components/sui-providers.tsx new file mode 100644 index 00000000..92003d0c --- /dev/null +++ b/apps/researcher/components/sui-providers.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect } from "react"; +import { + createNetworkConfig, + SuiClientProvider, + WalletProvider, + useSuiClientContext, +} from "@mysten/dapp-kit"; +import { isEnokiNetwork, registerEnokiWallets } from "@mysten/enoki"; +import { getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { enokiConfig } from "@/lib/enoki/config"; + +const { networkConfig } = createNetworkConfig({ + testnet: { url: getJsonRpcFullnodeUrl("testnet"), network: "testnet" }, + mainnet: { url: getJsonRpcFullnodeUrl("mainnet"), network: "mainnet" }, +}); + +const queryClient = new QueryClient(); + +/** Registers Enoki wallets (Google OAuth) with dapp-kit on mount. No-op if env vars are missing. */ +function RegisterEnokiWallets() { + const { client, network } = useSuiClientContext(); + + useEffect(() => { + if (!isEnokiNetwork(network)) return; + if (!enokiConfig.enokiApiKey || !enokiConfig.googleClientId) return; + + const { unregister } = registerEnokiWallets({ + apiKey: enokiConfig.enokiApiKey, + providers: { + google: { clientId: enokiConfig.googleClientId }, + }, + client, + network, + }); + + return unregister; + }, [client, network]); + + return null; +} + +/** Sui + Enoki provider stack. Wraps children with QueryClient, SuiClient, and WalletProvider. */ +export function SuiProviders({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + ); +} diff --git a/apps/researcher/lib/db/migrations/0001_add_enoki_fields.sql b/apps/researcher/lib/db/migrations/0001_add_enoki_fields.sql new file mode 100644 index 00000000..22f39a5a --- /dev/null +++ b/apps/researcher/lib/db/migrations/0001_add_enoki_fields.sql @@ -0,0 +1,7 @@ +-- Add Enoki zkLogin fields to User table +-- suiAddress: stable Enoki wallet address (same every Google login) +-- delegatePrivateKey: stored to recreate session without new key gen +-- accountId: MemWal account object ID on Sui +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "suiAddress" varchar(128) UNIQUE; +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "delegatePrivateKey" varchar(128); +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "accountId" varchar(128); diff --git a/apps/researcher/lib/db/migrations/meta/_journal.json b/apps/researcher/lib/db/migrations/meta/_journal.json index 71dce345..e81fe819 100644 --- a/apps/researcher/lib/db/migrations/meta/_journal.json +++ b/apps/researcher/lib/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1710600000000, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1743500000000, + "tag": "0001_add_enoki_fields", + "breakpoints": true } ] } diff --git a/apps/researcher/lib/db/queries.ts b/apps/researcher/lib/db/queries.ts index 2c1facf1..06ba2dad 100644 --- a/apps/researcher/lib/db/queries.ts +++ b/apps/researcher/lib/db/queries.ts @@ -38,6 +38,19 @@ import { db } from "./drizzle"; // User queries // ============================================================ +/** Look up a user by their UUID primary key. */ +export async function getUserById(id: string): Promise { + try { + const [found] = await db.select().from(user).where(eq(user.id, id)); + return found ?? null; + } catch (_error) { + throw new ChatbotError( + "bad_request:database", + "Failed to get user by id" + ); + } +} + export async function getUserByPublicKey(publicKey: string): Promise { try { const [found] = await db.select().from(user).where(eq(user.publicKey, publicKey)); @@ -60,6 +73,68 @@ export async function createUserByPublicKey(publicKey: string): Promise { } } +/** Look up an Enoki user by their stable zkLogin Sui address. */ +export async function getUserBySuiAddress(suiAddress: string): Promise { + try { + const [found] = await db.select().from(user).where(eq(user.suiAddress, suiAddress)); + return found ?? null; + } catch (_error) { + throw new ChatbotError( + "bad_request:database", + "Failed to get user by Sui address" + ); + } +} + +/** Create a new user from Enoki zkLogin with stored delegate key credentials for returning login. */ +export async function createEnokiUser({ + publicKey, + suiAddress, + delegatePrivateKey, + accountId, +}: { + publicKey: string; + suiAddress: string; + delegatePrivateKey: string; + accountId: string; +}): Promise { + const email = `enoki-${suiAddress.slice(0, 8)}@zklogin`; + try { + const [created] = await db + .insert(user) + .values({ email, publicKey, suiAddress, delegatePrivateKey, accountId }) + .returning(); + return created; + } catch (_error) { + throw new ChatbotError("bad_request:database", "Failed to create Enoki user"); + } +} + +/** Update an existing Enoki user's delegate key credentials (e.g. after key rotation). */ +export async function updateEnokiUserCredentials({ + userId, + publicKey, + delegatePrivateKey, + accountId, +}: { + userId: string; + publicKey: string; + delegatePrivateKey: string; + accountId: string; +}): Promise { + try { + await db + .update(user) + .set({ publicKey, delegatePrivateKey, accountId }) + .where(eq(user.id, userId)); + } catch (_error) { + throw new ChatbotError( + "bad_request:database", + "Failed to update Enoki user credentials" + ); + } +} + // ============================================================ // Chat queries // ============================================================ diff --git a/apps/researcher/lib/db/schema.ts b/apps/researcher/lib/db/schema.ts index e8aaa3fa..fd743e91 100644 --- a/apps/researcher/lib/db/schema.ts +++ b/apps/researcher/lib/db/schema.ts @@ -30,6 +30,9 @@ export const user = pgTable("User", { email: varchar("email", { length: 64 }).notNull(), password: varchar("password", { length: 64 }), publicKey: varchar("publicKey", { length: 128 }).unique(), + suiAddress: varchar("suiAddress", { length: 128 }).unique(), + delegatePrivateKey: varchar("delegatePrivateKey", { length: 128 }), + accountId: varchar("accountId", { length: 128 }), }); export type User = InferSelectModel; diff --git a/apps/researcher/lib/enoki/config.ts b/apps/researcher/lib/enoki/config.ts new file mode 100644 index 00000000..980b90c5 --- /dev/null +++ b/apps/researcher/lib/enoki/config.ts @@ -0,0 +1,12 @@ +/** Enoki zkLogin configuration from NEXT_PUBLIC_* environment variables. */ +export const enokiConfig = { + enokiApiKey: process.env.NEXT_PUBLIC_ENOKI_API_KEY || "", + googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "", + suiNetwork: (process.env.NEXT_PUBLIC_SUI_NETWORK || "testnet") as + | "testnet" + | "mainnet", + memwalPackageId: process.env.NEXT_PUBLIC_MEMWAL_PACKAGE_ID || "", + memwalRegistryId: process.env.NEXT_PUBLIC_MEMWAL_REGISTRY_ID || "", + memwalServerUrl: + process.env.NEXT_PUBLIC_MEMWAL_SERVER_URL || "http://localhost:9000", +} as const; diff --git a/apps/researcher/package.json b/apps/researcher/package.json index 517ebec7..be87b331 100644 --- a/apps/researcher/package.json +++ b/apps/researcher/package.json @@ -19,20 +19,22 @@ }, "dependencies": { "@ai-sdk/gateway": "^3.0.15", - "@noble/ed25519": "^2.2.3", - "@noble/hashes": "^1.7.2", "@ai-sdk/openai": "^3.0.41", "@ai-sdk/provider": "^3.0.3", "@ai-sdk/react": "3.0.39", - "@mysten-incubation/memwal": "0.0.1", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/state": "^6.5.0", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.35.3", "@icons-pack/react-simple-icons": "^13.7.0", + "@mysten-incubation/memwal": "0.0.1", + "@mysten/dapp-kit": "^1.0.3", + "@mysten/enoki": "^1.0.4", + "@mysten/sui": "^2.6.0", + "@noble/ed25519": "^2.2.3", + "@noble/hashes": "^1.7.2", "@opentelemetry/api": "^1.9.0", - "@vercel/blob": "^0.24.1", "@opentelemetry/api-logs": "^0.200.0", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -48,6 +50,8 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@radix-ui/react-visually-hidden": "^1.1.0", + "@tanstack/react-query": "^5.90.21", + "@vercel/blob": "^0.24.1", "@xyflow/react": "^12.10.0", "ai": "6.0.37", "class-variance-authority": "^0.7.1", @@ -63,12 +67,12 @@ "fast-deep-equal": "^3.1.3", "framer-motion": "^11.3.19", "geist": "^1.3.1", + "jose": "^5.9.6", "katex": "^0.16.25", "lucide-react": "^0.446.0", "motion": "^12.23.26", "nanoid": "^5.1.3", "next": "16.0.10", - "jose": "^5.9.6", "next-themes": "^0.3.0", "orderedmap": "^2.1.1", "papaparse": "^5.5.2", From 21aa60956f676d5bb5b74440880c42476157ba35 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Apr 2026 12:53:49 +0700 Subject: [PATCH 08/21] fix(auth): gate /sponsor with Ed25519-only auth, fix SetupWizard regression --- apps/app/src/hooks/useSponsoredTransaction.ts | 32 +-- apps/app/src/pages/SetupWizard.tsx | 6 +- services/server/src/auth.rs | 84 ++++++++ services/server/src/main.rs | 2 +- services/server/tests/test_sponsor_flow.py | 182 ++++++++++++++++++ 5 files changed, 289 insertions(+), 17 deletions(-) create mode 100644 services/server/tests/test_sponsor_flow.py diff --git a/apps/app/src/hooks/useSponsoredTransaction.ts b/apps/app/src/hooks/useSponsoredTransaction.ts index 535e4152..4b14c1dd 100644 --- a/apps/app/src/hooks/useSponsoredTransaction.ts +++ b/apps/app/src/hooks/useSponsoredTransaction.ts @@ -6,11 +6,15 @@ * * Flow: * 1. Build Transaction as TransactionKind bytes - * 2. POST to sidecar /sponsor → get { bytes, digest } + * 2. POST to /sponsor → get { bytes, digest } * 3. Sign sponsored bytes with user wallet - * 4. POST to sidecar /sponsor/execute → get { digest } + * 4. POST to /sponsor/execute → get { digest } * * Falls back to direct signAndExecute if sponsor fails. + * + * signingKey / signingPublicKey: optional override used by SetupWizard before + * the delegate key is registered on-chain. /sponsor uses verify_signature_sponsor + * which verifies Ed25519 signature but skips on-chain account resolution. */ import { useCurrentAccount, useSignTransaction, useSignAndExecuteTransaction, useSuiClient } from '@mysten/dapp-kit' @@ -26,39 +30,43 @@ export function useSponsoredTransaction() { const { mutateAsync: directSignAndExecute } = useSignAndExecuteTransaction() const { delegateKey, delegatePublicKey, accountObjectId } = useDelegateKey() - const mutateAsync = async ({ transaction }: { transaction: Transaction }): Promise<{ digest: string }> => { + const mutateAsync = async ({ + transaction, + signingKey, + signingPublicKey, + }: { + transaction: Transaction + /** Key override for SetupWizard — local key before on-chain registration */ + signingKey?: string + signingPublicKey?: string + }): Promise<{ digest: string }> => { const sender = currentAccount?.address if (!sender) throw new Error('No wallet connected') try { - // 1. Build TransactionKind bytes (without gas data) const kindBytes = await transaction.build({ client: suiClient as any, onlyTransactionKind: true, }) const kindBase64 = uint8ArrayToBase64(kindBytes) - if (!delegateKey || !delegatePublicKey) { + if (!(signingKey ?? delegateKey) || !(signingPublicKey ?? delegatePublicKey)) { throw new Error('No delegate key available for signing sponsor request') } - // 2. Sponsor via server — signed with delegate key (required by verify_signature middleware) const sponsored = await apiCall( - delegateKey, + (signingKey ?? delegateKey)!, config.memwalServerUrl, '/sponsor', { transactionBlockKindBytes: kindBase64, sender }, accountObjectId ?? undefined, ) - // sponsored = { bytes: base64, digest: string } - // 3. Sign sponsored bytes with user wallet const sponsoredTx = Transaction.from(sponsored.bytes) const { signature } = await signTransaction({ transaction: sponsoredTx }) - // 4. Execute via server — signed with delegate key const result = await apiCall( - delegateKey, + (signingKey ?? delegateKey)!, config.memwalServerUrl, '/sponsor/execute', { digest: sponsored.digest, signature }, @@ -68,7 +76,6 @@ export function useSponsoredTransaction() { console.log(`[sponsored-tx] success, digest=${result.digest}`) return { digest: result.digest } } catch (err) { - // Fallback: try direct signing if sponsor fails console.warn('[sponsored-tx] sponsor failed, falling back to direct signing:', err) const result = await directSignAndExecute({ transaction }) return { digest: result.digest } @@ -78,7 +85,6 @@ export function useSponsoredTransaction() { return { mutateAsync } } -// Helper: Uint8Array → base64 function uint8ArrayToBase64(bytes: Uint8Array): string { let binary = '' for (let i = 0; i < bytes.length; i++) { diff --git a/apps/app/src/pages/SetupWizard.tsx b/apps/app/src/pages/SetupWizard.tsx index 25df17f5..4f583341 100644 --- a/apps/app/src/pages/SetupWizard.tsx +++ b/apps/app/src/pages/SetupWizard.tsx @@ -134,7 +134,7 @@ export default function SetupWizard() { ], }) - const result = await signAndExecute({ transaction: tx }) + const result = await signAndExecute({ transaction: tx, signingKey: privateKeyHex, signingPublicKey: publicKeyHex }) await suiClient.waitForTransaction({ digest: result.digest }) } else { // Step A: Create account first (now creates a shared object) @@ -149,7 +149,7 @@ export default function SetupWizard() { ], }) - const createResult = await signAndExecute({ transaction: tx }) + const createResult = await signAndExecute({ transaction: tx, signingKey: privateKeyHex, signingPublicKey: publicKeyHex }) await suiClient.waitForTransaction({ digest: createResult.digest }) // Find the created MemWalAccount object (now shared) @@ -180,7 +180,7 @@ export default function SetupWizard() { ], }) - const addResult = await signAndExecute({ transaction: tx2 }) + const addResult = await signAndExecute({ transaction: tx2, signingKey: privateKeyHex, signingPublicKey: publicKeyHex }) await suiClient.waitForTransaction({ digest: addResult.digest }) } diff --git a/services/server/src/auth.rs b/services/server/src/auth.rs index 7923ac3d..e4db7568 100644 --- a/services/server/src/auth.rs +++ b/services/server/src/auth.rs @@ -136,6 +136,90 @@ pub async fn verify_signature( Ok(next.run(request).await) } +/// Lightweight auth middleware for /sponsor and /sponsor/execute. +/// +/// Verifies the Ed25519 signature mathematically (same as verify_signature) +/// but skips on-chain account resolution. This allows SetupWizard to get +/// gasless transactions before the delegate key is registered on-chain. +/// +/// Security unchanged: +/// - Request must be signed with a valid Ed25519 key — cannot be forged +/// - Rate limiting still applies (keyed by public key) +/// - 5-minute timestamp window still enforced +/// - Unauthenticated curl still returns 401 +pub async fn verify_signature_sponsor( + State(_state): State>, + request: Request, + next: Next, +) -> Result { + let headers = request.headers(); + + let public_key_hex = headers + .get("x-public-key") + .and_then(|v| v.to_str().ok()) + .map(String::from) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let signature_hex = headers + .get("x-signature") + .and_then(|v| v.to_str().ok()) + .map(String::from) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let timestamp_str = headers + .get("x-timestamp") + .and_then(|v| v.to_str().ok()) + .map(String::from) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let delegate_key_hex = headers + .get("x-delegate-key") + .and_then(|v| v.to_str().ok()) + .map(String::from); + + let timestamp: i64 = timestamp_str.parse().map_err(|_| StatusCode::UNAUTHORIZED)?; + let now = chrono::Utc::now().timestamp(); + if (now - timestamp).abs() > 300 { + return Err(StatusCode::UNAUTHORIZED); + } + + let pk_bytes = hex::decode(&public_key_hex).map_err(|_| StatusCode::UNAUTHORIZED)?; + let pk_array: [u8; 32] = pk_bytes.try_into().map_err(|_| StatusCode::UNAUTHORIZED)?; + let verifying_key = VerifyingKey::from_bytes(&pk_array).map_err(|_| StatusCode::UNAUTHORIZED)?; + + let sig_bytes = hex::decode(&signature_hex).map_err(|_| StatusCode::UNAUTHORIZED)?; + let sig_array: [u8; 64] = sig_bytes.try_into().map_err(|_| StatusCode::UNAUTHORIZED)?; + let signature = Signature::from_bytes(&sig_array); + + let method = request.method().as_str().to_string(); + let path = request.uri().path().to_string(); + let (mut parts, body) = request.into_parts(); + let body_bytes = axum::body::to_bytes(body, 1024 * 1024) + .await + .map_err(|_| StatusCode::BAD_REQUEST)?; + + let body_hash = hex::encode(Sha256::digest(&body_bytes)); + let message = format!("{}.{}.{}.{}", timestamp_str, method, path, body_hash); + + verifying_key.verify(message.as_bytes(), &signature).map_err(|e| { + tracing::warn!("Sponsor signature verification failed: {}", e); + StatusCode::UNAUTHORIZED + })?; + + // No on-chain lookup — sponsor flow works before key is registered. + // Use public_key as owner proxy so rate limit buckets are per-key, + // not shared across all unregistered keys (which would collapse to "rate:"). + parts.extensions.insert(AuthInfo { + public_key: public_key_hex.clone(), + owner: public_key_hex, + account_id: String::new(), + delegate_key: delegate_key_hex, + }); + + let request = Request::from_parts(parts, axum::body::Body::from(body_bytes)); + Ok(next.run(request).await) +} + /// Resolve a delegate key to its account using multiple strategies: /// 1. PostgreSQL cache (fastest) /// 2. On-chain registry scan (slower, but auto-discovers) diff --git a/services/server/src/main.rs b/services/server/src/main.rs index 59e0993c..0de57d01 100644 --- a/services/server/src/main.rs +++ b/services/server/src/main.rs @@ -156,7 +156,7 @@ async fn main() { )) .layer(middleware::from_fn_with_state( state.clone(), - auth::verify_signature, + auth::verify_signature_sponsor, )) .layer(DefaultBodyLimit::max(16_384)); diff --git a/services/server/tests/test_sponsor_flow.py b/services/server/tests/test_sponsor_flow.py new file mode 100644 index 00000000..8eac5e26 --- /dev/null +++ b/services/server/tests/test_sponsor_flow.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +Test: Sponsor endpoint auth flow after HIGH-4 fix + SetupWizard regression fix. + +Prerequisites: + 1. cargo run (server on port 3001, sidecar on port 9000) + 2. SIDECAR_AUTH_TOKEN set in services/server/.env + +What this proves: + - Unauthenticated /sponsor → 401 (HIGH-4 security preserved) + - Random key (not on-chain) → auth PASSES (SetupWizard flow fixed) + - Registered key (on-chain) → auth PASSES (Dashboard flow unchanged) + - Rate limit is per-key, not shared across all sponsor requests (rate limit bucket fix) + Without fix: all unregistered keys share "rate:" → 1 attacker blocks all SetupWizard users + With fix: each key gets "rate:" → independent buckets + +Run: + python3 tests/test_sponsor_flow.py + + # With real registered key (optional): + TEST_DELEGATE_KEY= TEST_ACCOUNT_ID=<0x...> python3 tests/test_sponsor_flow.py +""" + +import hashlib, json, os, sys, time, urllib.error, urllib.request +from nacl.encoding import RawEncoder +from nacl.signing import SigningKey + +BASE = "http://localhost:3001" +PASS = "[PASS]" +FAIL = "[FAIL]" +SKIP = "[SKIP]" + + +def raw_post(url, body=None, headers=None): + data = json.dumps(body or {}).encode() + h = {"Content-Type": "application/json", **(headers or {})} + req = urllib.request.Request(url, data=data, headers=h, method="POST") + try: + with urllib.request.urlopen(req) as r: + return r.status, json.loads(r.read() or b"{}") + except urllib.error.HTTPError as e: + raw = e.read() + try: + return e.code, json.loads(raw) + except Exception: + return e.code, {"raw": raw.decode(errors="replace")} + + +def signed_post(path, body, key, account_id=None): + body_json = json.dumps(body).encode() + body_hash = hashlib.sha256(body_json).hexdigest() + ts = str(int(time.time())) + msg = f"{ts}.POST.{path}.{body_hash}" + signed = key.sign(msg.encode(), encoder=RawEncoder) + headers = { + "Content-Type": "application/json", + "x-public-key": key.verify_key.encode().hex(), + "x-signature": signed.signature.hex(), + "x-timestamp": ts, + } + if account_id: + headers["x-account-id"] = account_id + req = urllib.request.Request(f"{BASE}{path}", data=body_json, headers=headers, method="POST") + try: + with urllib.request.urlopen(req) as r: + return r.status, json.loads(r.read() or b"{}") + except urllib.error.HTTPError as e: + raw = e.read() + try: + return e.code, json.loads(raw) + except Exception: + return e.code, {"raw": raw.decode(errors="replace")} + + +print("=" * 60) +print("Sponsor Auth Flow Tests") +print("=" * 60) + +results = {} + +# ── Test 1: No auth → must 401 ───────────────────────────────── +print("\n[1] Unauthenticated /sponsor → must 401 (HIGH-4 preserved)") +status, resp = raw_post(f"{BASE}/sponsor", {"transactionBlockKindBytes": "abc", "sender": "0x1"}) +if status == 401: + print(f" {PASS} /sponsor (no auth) → {status}") + results["no-auth /sponsor"] = True +else: + print(f" {FAIL} /sponsor (no auth) → {status} {resp}") + results["no-auth /sponsor"] = False + +status, resp = raw_post(f"{BASE}/sponsor/execute", {"digest": "abc", "signature": "sig"}) +if status == 401: + print(f" {PASS} /sponsor/execute (no auth) → {status}") + results["no-auth /sponsor/execute"] = True +else: + print(f" {FAIL} /sponsor/execute (no auth) → {status} {resp}") + results["no-auth /sponsor/execute"] = False + + +# ── Test 2: Random key (not on-chain) → auth must PASS ───────── +print("\n[2] Random key (not on-chain) → auth must pass (SetupWizard flow)") +random_key = SigningKey.generate() +status, resp = signed_post("/sponsor", {"transactionBlockKindBytes": "abc", "sender": "0x1"}, random_key) +if status != 401: + print(f" {PASS} /sponsor (random key) → {status} — auth passed, key not required on-chain") + results["random-key /sponsor"] = True +else: + print(f" {FAIL} /sponsor (random key) → 401 — auth still blocking (fix not working)") + results["random-key /sponsor"] = False + + +# ── Test 3: Registered key (on-chain) → auth must PASS ───────── +print("\n[3] Registered key (on-chain) → auth must pass (Dashboard flow)") +registered_key_hex = os.environ.get("TEST_DELEGATE_KEY") +account_id = os.environ.get("TEST_ACCOUNT_ID") + +if registered_key_hex and account_id: + reg_key = SigningKey(bytes.fromhex(registered_key_hex)) + status, resp = signed_post("/sponsor", {"transactionBlockKindBytes": "abc", "sender": "0x1"}, reg_key, account_id) + if status != 401: + print(f" {PASS} /sponsor (registered key) → {status}") + results["registered-key /sponsor"] = True + else: + print(f" {FAIL} /sponsor (registered key) → 401 {resp}") + results["registered-key /sponsor"] = False +else: + print(f" {SKIP} set TEST_DELEGATE_KEY and TEST_ACCOUNT_ID to test with real key") + results["registered-key /sponsor"] = None + + +# ── Test 4: Rate limit buckets are per-key, not shared ───────── +# /sponsor weight=5, per-delegate-key limit=30 → triggers at request #7 +# Without the owner fix: all keys collapse to "rate:" (shared bucket) +# With the fix: each key gets "rate:" (independent) +# +# To run this test Redis must be reachable and keys must be fresh +# (guaranteed since we generate new random keys each run). +print("\n[4] Rate limit: per-key isolation (not shared global bucket)") +key_a = SigningKey.generate() +key_b = SigningKey.generate() +sponsor_body = {"transactionBlockKindBytes": "abc", "sender": "0x1"} + +# Exhaust key_a's per-key quota +# weight=5, per-key limit=30 → limit hit on request 7 (7×5=35 > 30) +hit_limit_at = None +for i in range(8): + status, resp = signed_post("/sponsor", sponsor_body, key_a) + if status == 429: + hit_limit_at = i + 1 + break + +if hit_limit_at is None: + print(f" {FAIL} key_a never hit 429 after 8 requests — rate limit not enforced") + results["rate-limit enforcement"] = False + results["rate-limit per-key isolation"] = False +else: + print(f" {PASS} key_a hit 429 at request #{hit_limit_at} (rate limit enforced)") + results["rate-limit enforcement"] = True + + # key_b is a fresh key — must NOT be affected by key_a's exhausted bucket + # If buckets are shared (bug): key_b → 429 + # If buckets are per-key (fix): key_b → not 429 + status_b, resp_b = signed_post("/sponsor", sponsor_body, key_b) + if status_b != 429: + print(f" {PASS} key_b unaffected by key_a's limit → {status_b} (buckets are independent)") + results["rate-limit per-key isolation"] = True + else: + print(f" {FAIL} key_b got 429 — rate limits are shared (fix: set owner=public_key in verify_signature_sponsor)") + results["rate-limit per-key isolation"] = False + + +# ── Summary ──────────────────────────────────────────────────── +print("\n" + "=" * 60) +passed = sum(1 for v in results.values() if v is True) +failed = sum(1 for v in results.values() if v is False) +skipped = sum(1 for v in results.values() if v is None) +for name, result in results.items(): + mark = PASS if result is True else FAIL if result is False else SKIP + print(f" {mark} {name}") +print(f"\n{passed} passed, {failed} failed, {skipped} skipped") +if failed: + sys.exit(1) From 81bad00dd5a5edd12886d284c3170bb4fdf5bc6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ph=E1=BA=A1m=20Minh=20H=C3=B9ng?= <46132442+hungtranphamminh@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:41:56 +0700 Subject: [PATCH 09/21] fix(docker): pass NEXT_PUBLIC env vars as build args for Enoki login (#85) NEXT_PUBLIC_* variables are inlined into the Next.js client bundle at build time. Without ARG/ENV declarations in the Dockerfile, they are empty strings in production, causing the Enoki login button to not render. Add ARG + ENV for all NEXT_PUBLIC_ENOKI/GOOGLE/MEMWAL/SUI vars in both noter and researcher Dockerfiles. Railway passes service env vars as Docker build args automatically. --- apps/noter/Dockerfile | 18 +++++++++++++++++- apps/researcher/Dockerfile | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/noter/Dockerfile b/apps/noter/Dockerfile index 35501a81..c18a44b6 100644 --- a/apps/noter/Dockerfile +++ b/apps/noter/Dockerfile @@ -37,9 +37,25 @@ WORKDIR /app/apps/noter # Next.js collects telemetry by default — disable it ENV NEXT_TELEMETRY_DISABLED=1 -# Dummy env var required for `next build` (real value injected at runtime) +# Server-only env vars — dummies for build, real values at runtime ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" +# NEXT_PUBLIC_* vars are inlined into the client bundle at build time. +# Railway passes env vars as Docker build args automatically. +ARG NEXT_PUBLIC_ENOKI_API_KEY +ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID +ARG NEXT_PUBLIC_SUI_NETWORK +ARG NEXT_PUBLIC_MEMWAL_PACKAGE_ID +ARG NEXT_PUBLIC_MEMWAL_REGISTRY_ID +ARG NEXT_PUBLIC_MEMWAL_SERVER_URL + +ENV NEXT_PUBLIC_ENOKI_API_KEY=$NEXT_PUBLIC_ENOKI_API_KEY +ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID +ENV NEXT_PUBLIC_SUI_NETWORK=$NEXT_PUBLIC_SUI_NETWORK +ENV NEXT_PUBLIC_MEMWAL_PACKAGE_ID=$NEXT_PUBLIC_MEMWAL_PACKAGE_ID +ENV NEXT_PUBLIC_MEMWAL_REGISTRY_ID=$NEXT_PUBLIC_MEMWAL_REGISTRY_ID +ENV NEXT_PUBLIC_MEMWAL_SERVER_URL=$NEXT_PUBLIC_MEMWAL_SERVER_URL + RUN npx next build # Compile migrate.ts → migrate.mjs so runtime needs no tsx/esbuild diff --git a/apps/researcher/Dockerfile b/apps/researcher/Dockerfile index 9640bc8d..6ab85976 100644 --- a/apps/researcher/Dockerfile +++ b/apps/researcher/Dockerfile @@ -27,11 +27,26 @@ COPY apps/researcher/ . # Next.js collects telemetry by default — disable it ENV NEXT_TELEMETRY_DISABLED=1 -# Build needs these env vars at build time (set dummies for `next build`) -# Real values are injected at runtime +# Server-only env vars — dummies for build, real values at runtime ENV POSTGRES_URL="postgresql://dummy:dummy@localhost:5432/dummy" ENV AUTH_SECRET="build-time-placeholder" +# NEXT_PUBLIC_* vars are inlined into the client bundle at build time. +# Railway passes env vars as Docker build args automatically. +ARG NEXT_PUBLIC_ENOKI_API_KEY +ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID +ARG NEXT_PUBLIC_SUI_NETWORK +ARG NEXT_PUBLIC_MEMWAL_PACKAGE_ID +ARG NEXT_PUBLIC_MEMWAL_REGISTRY_ID +ARG NEXT_PUBLIC_MEMWAL_SERVER_URL + +ENV NEXT_PUBLIC_ENOKI_API_KEY=$NEXT_PUBLIC_ENOKI_API_KEY +ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID +ENV NEXT_PUBLIC_SUI_NETWORK=$NEXT_PUBLIC_SUI_NETWORK +ENV NEXT_PUBLIC_MEMWAL_PACKAGE_ID=$NEXT_PUBLIC_MEMWAL_PACKAGE_ID +ENV NEXT_PUBLIC_MEMWAL_REGISTRY_ID=$NEXT_PUBLIC_MEMWAL_REGISTRY_ID +ENV NEXT_PUBLIC_MEMWAL_SERVER_URL=$NEXT_PUBLIC_MEMWAL_SERVER_URL + RUN bunx next build # ── Stage 3: Runtime ─────────────────────────────────────── From deb316b04a59b5d9ab0e0fea2fb4b8abb46a85c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ph=E1=BA=A1m=20Minh=20H=C3=B9ng?= <46132442+hungtranphamminh@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:00:07 +0700 Subject: [PATCH 10/21] Feat: Add Enoki login and clean up noter app (#82) * chore(noter): remove unused chat/AI feature and dead code Remove the entire AI chat feature (/ai routes, 23 chat files, /api/chat with 20+ crypto tools) which was never linked from the UI. Also remove dead auth components (WalletButton, UserMenu), unused editor code (CoinMentionNode, CoinMentionPlugin), and orphaned memory tRPC router. Clean up DB schema and types to remove chats/messages tables. -2,962 LOC across 38 files. * feat(noter): add Enoki zkLogin backend + per-user MemWal keys Add Enoki as auth provider with two-phase flow (returning user check + first-time registration). Add delegate key login as alternative. - tRPC: auth.connectEnoki + auth.connectDelegateKey procedures - Auth service: upsertEnokiUser, getEnokiUserBySuiAddress, createEnokiSession - tRPC context: load per-user memwalKey + memwalAccountId (replaces shared env key) - DB migration: add delegatePrivateKey + delegateAccountId + "enoki" auth method - SuiProviders wrapper added to Providers component - Auth hook simplified: connectEnoki + connectDelegateKey replace old methods * feat(noter): add Enoki login UI + fix per-user MemWal keys - EnokiLoginCard: Google sign-in with auto key gen + on-chain registration - AuthButtonGroup: replace wallet button with Enoki + collapsible delegate key - pdw-client: replace shared module-level key with per-request factory pattern - memory-detector: pass per-user key from tRPC context through to MemWal calls - memory/remember API: resolve user key from session header - Remove dead code: /api/memory/set-key, /auth/callback, shared/lib/ai (1,738 LOC) * fix(noter): clean up CoinMention imports and DB relations after chat removal Remove CoinMentionNode/CoinMentionPlugin references from editor components and index.ts. Update DB relations to remove chats/messages references. * fix(noter): add auth redirect on logout and protected note routes - Logout now hard-redirects to landing page - Note pages redirect to / when not authenticated * feat(noter): replace user profile panel with MemWal account info Replace manual MemWal key inputs with auto-populated profile showing: - Sui address with copy + Suiscan link - Account ID with copy + Suiscan link - Delegate key export (reveal/hide) - Auth method indicator - Remove dependency on deleted /api/memory/set-key endpoint * fix(noter): auto-generate note title from first line of content Notes now get their title from the first line of text when saved, instead of staying as "Untitled Note" forever. Only applies when the title hasn't been manually renamed by the user. * fix(noter): address review findings for Enoki integration - Filter getEnokiUserBySuiAddress by authMethod="enoki" to prevent cross-auth-method collisions - Skip memory extraction when no MemWal key available instead of falling through to env var fallback for unauthenticated requests - Remove dead privateKey state and handleExportKey in user-float panel * fix(noter): resolve React 18/19 type conflict and remove dead LoginButton - Add @types/react override to force v19 (dapp-kit pulls v18) - Remove dead LoginButton component (used old zkLogin login method) --- apps/noter/app/ai/[id]/page.tsx | 15 - apps/noter/app/ai/layout.tsx | 13 - apps/noter/app/ai/page.tsx | 21 - apps/noter/app/api/chat/route.ts | 141 -- apps/noter/app/api/memory/health/route.ts | 32 +- apps/noter/app/api/memory/remember/route.ts | 85 +- apps/noter/app/api/memory/set-key/route.ts | 29 - apps/noter/app/auth/callback/page.tsx | 108 -- .../noter/app/components/enoki-login-card.tsx | 354 +++++ apps/noter/app/components/providers.tsx | 9 +- apps/noter/app/components/sidebar-float.tsx | 85 - apps/noter/app/components/sui-providers.tsx | 53 + apps/noter/app/components/user-float.tsx | 271 ++-- apps/noter/app/note/[id]/page.tsx | 12 +- apps/noter/app/note/page.tsx | 10 +- apps/noter/lib/enoki/config.ts | 12 + apps/noter/package.json | 22 +- apps/noter/package/feature/auth/api/route.ts | 116 ++ .../package/feature/auth/domain/service.ts | 84 +- .../package/feature/auth/hook/use-auth.ts | 154 +- apps/noter/package/feature/auth/index.ts | 3 - .../feature/auth/ui/auth-button-group.tsx | 172 ++- .../package/feature/auth/ui/login-button.tsx | 52 - .../package/feature/auth/ui/user-menu.tsx | 85 - .../package/feature/auth/ui/wallet-button.tsx | 111 -- apps/noter/package/feature/chat/api/form.ts | 59 - apps/noter/package/feature/chat/api/input.ts | 82 - apps/noter/package/feature/chat/api/route.ts | 111 -- apps/noter/package/feature/chat/domain/ai.ts | 74 - .../package/feature/chat/domain/service.ts | 164 -- .../noter/package/feature/chat/domain/type.ts | 30 - .../package/feature/chat/hook/use-chat.ts | 136 -- apps/noter/package/feature/chat/index.ts | 57 - apps/noter/package/feature/chat/state/atom.ts | 18 - .../package/feature/chat/ui/ai-tool-ui.tsx | 69 - .../package/feature/chat/ui/chat-input.tsx | 50 - .../package/feature/chat/ui/chat-sidebar.tsx | 82 - apps/noter/package/feature/chat/ui/chat.tsx | 63 - .../package/feature/chat/ui/message-list.tsx | 72 - .../noter/package/feature/chat/ui/message.tsx | 66 - .../feature/chat/ui/model-selector.tsx | 36 - .../package/feature/chat/ui/tool-badge.tsx | 43 - .../feature/chat/ui/tools-used-bar.tsx | 63 - .../chat/ui/tools/tool-get-balances.tsx | 115 -- .../chat/ui/tools/tool-get-coin-history.tsx | 196 --- .../chat/ui/tools/tool-get-transactions.tsx | 114 -- .../chat/ui/tools/tool-get-user-info.tsx | 85 - .../chat/ui/tools/tool-show-diagram.tsx | 42 - apps/noter/package/feature/editor/index.ts | 3 - .../feature/editor/nodes/CoinMentionNode.tsx | 230 --- .../plugins/CoinMentionPlugin/index.tsx | 244 --- .../package/feature/editor/ui/chat-editor.tsx | 4 - .../feature/editor/ui/chat-renderer.tsx | 109 +- .../noter/package/feature/memory/api/route.ts | 61 - apps/noter/package/feature/memory/index.ts | 8 - apps/noter/package/feature/note/api/route.ts | 17 +- .../feature/note/lib/memory-detector.ts | 56 +- .../package/feature/note/lib/pdw-client.ts | 110 +- .../db/migrations/0002_add_enoki_fields.sql | 6 + .../shared/db/migrations/meta/_journal.json | 7 + apps/noter/package/shared/db/relations.ts | 32 - apps/noter/package/shared/db/schema.ts | 98 +- apps/noter/package/shared/db/type.ts | 28 +- apps/noter/package/shared/lib/ai/constant.ts | 11 - apps/noter/package/shared/lib/ai/index.ts | 2 - apps/noter/package/shared/lib/ai/provider.ts | 10 - .../package/shared/lib/ai/tools/index.ts | 1374 ----------------- apps/noter/package/shared/lib/trpc/init.ts | 66 +- apps/noter/package/shared/lib/trpc/router.ts | 4 - package.json | 3 +- 70 files changed, 1155 insertions(+), 5204 deletions(-) delete mode 100644 apps/noter/app/ai/[id]/page.tsx delete mode 100644 apps/noter/app/ai/layout.tsx delete mode 100644 apps/noter/app/ai/page.tsx delete mode 100644 apps/noter/app/api/chat/route.ts delete mode 100644 apps/noter/app/api/memory/set-key/route.ts delete mode 100644 apps/noter/app/auth/callback/page.tsx create mode 100644 apps/noter/app/components/enoki-login-card.tsx delete mode 100644 apps/noter/app/components/sidebar-float.tsx create mode 100644 apps/noter/app/components/sui-providers.tsx create mode 100644 apps/noter/lib/enoki/config.ts delete mode 100644 apps/noter/package/feature/auth/ui/login-button.tsx delete mode 100644 apps/noter/package/feature/auth/ui/user-menu.tsx delete mode 100644 apps/noter/package/feature/auth/ui/wallet-button.tsx delete mode 100644 apps/noter/package/feature/chat/api/form.ts delete mode 100644 apps/noter/package/feature/chat/api/input.ts delete mode 100644 apps/noter/package/feature/chat/api/route.ts delete mode 100644 apps/noter/package/feature/chat/domain/ai.ts delete mode 100644 apps/noter/package/feature/chat/domain/service.ts delete mode 100644 apps/noter/package/feature/chat/domain/type.ts delete mode 100644 apps/noter/package/feature/chat/hook/use-chat.ts delete mode 100644 apps/noter/package/feature/chat/index.ts delete mode 100644 apps/noter/package/feature/chat/state/atom.ts delete mode 100644 apps/noter/package/feature/chat/ui/ai-tool-ui.tsx delete mode 100644 apps/noter/package/feature/chat/ui/chat-input.tsx delete mode 100644 apps/noter/package/feature/chat/ui/chat-sidebar.tsx delete mode 100644 apps/noter/package/feature/chat/ui/chat.tsx delete mode 100644 apps/noter/package/feature/chat/ui/message-list.tsx delete mode 100644 apps/noter/package/feature/chat/ui/message.tsx delete mode 100644 apps/noter/package/feature/chat/ui/model-selector.tsx delete mode 100644 apps/noter/package/feature/chat/ui/tool-badge.tsx delete mode 100644 apps/noter/package/feature/chat/ui/tools-used-bar.tsx delete mode 100644 apps/noter/package/feature/chat/ui/tools/tool-get-balances.tsx delete mode 100644 apps/noter/package/feature/chat/ui/tools/tool-get-coin-history.tsx delete mode 100644 apps/noter/package/feature/chat/ui/tools/tool-get-transactions.tsx delete mode 100644 apps/noter/package/feature/chat/ui/tools/tool-get-user-info.tsx delete mode 100644 apps/noter/package/feature/chat/ui/tools/tool-show-diagram.tsx delete mode 100644 apps/noter/package/feature/editor/nodes/CoinMentionNode.tsx delete mode 100644 apps/noter/package/feature/editor/plugins/CoinMentionPlugin/index.tsx delete mode 100644 apps/noter/package/feature/memory/api/route.ts delete mode 100644 apps/noter/package/feature/memory/index.ts create mode 100644 apps/noter/package/shared/db/migrations/0002_add_enoki_fields.sql delete mode 100644 apps/noter/package/shared/lib/ai/constant.ts delete mode 100644 apps/noter/package/shared/lib/ai/index.ts delete mode 100644 apps/noter/package/shared/lib/ai/provider.ts delete mode 100644 apps/noter/package/shared/lib/ai/tools/index.ts diff --git a/apps/noter/app/ai/[id]/page.tsx b/apps/noter/app/ai/[id]/page.tsx deleted file mode 100644 index e14b84e2..00000000 --- a/apps/noter/app/ai/[id]/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ChatContainer } from "@/feature/chat/index"; - -type ChatPageProps = { - params: Promise<{ id: string }>; -}; - -export default async function ChatPage({ params }: ChatPageProps) { - const { id } = await params; - - return ( -
- -
- ); -} diff --git a/apps/noter/app/ai/layout.tsx b/apps/noter/app/ai/layout.tsx deleted file mode 100644 index 33b9d4f9..00000000 --- a/apps/noter/app/ai/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { SidebarFloat } from "@/app/components/sidebar-float"; -import { AuthGuard } from "@/feature/auth"; - -export default function AILayout({ children }: { children: React.ReactNode }) { - return ( - -
- -
{children}
-
-
- ); -} diff --git a/apps/noter/app/ai/page.tsx b/apps/noter/app/ai/page.tsx deleted file mode 100644 index 74bf2514..00000000 --- a/apps/noter/app/ai/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; -import { uuidv7 } from "uuidv7"; - -export default function NewChatPage() { - const router = useRouter(); - - useEffect(() => { - // Generate new chat ID and redirect - const chatId = uuidv7(); - router.replace(`/ai/${chatId}`); - }, [router]); - - return ( -
-

Creating new chat...

-
- ); -} diff --git a/apps/noter/app/api/chat/route.ts b/apps/noter/app/api/chat/route.ts deleted file mode 100644 index c4977d38..00000000 --- a/apps/noter/app/api/chat/route.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { streamText, convertToModelMessages, stepCountIs } from "ai"; -import { createOpenAI } from "@ai-sdk/openai"; -import { withMemWal } from "@mysten-incubation/memwal/ai"; -import { DEFAULT_MODEL } from "@/shared/lib/ai/constant"; -import { createTools } from "@/shared/lib/ai/tools"; -import { db } from "@/shared/lib/db"; -import { zkLoginSessions, walletSessions, users } from "@/shared/db/schema"; -import { eq } from "drizzle-orm"; - -// OpenRouter provider -const openrouter = createOpenAI({ - baseURL: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY || "", -}); - -/** - * Get a language model, optionally wrapped with MemWal memory layer. - * Same pattern as v2-test's getMemWalModel. - */ -function getModel(modelId: string) { - const baseModel = openrouter.chat(modelId); - - const memwalKey = process.env.MEMWAL_KEY; - if (!memwalKey) { - console.warn("[AI] MEMWAL_KEY not set — memory layer disabled"); - return baseModel; - } - - return withMemWal(baseModel, { - key: memwalKey, - accountId: process.env.MEMWAL_ACCOUNT_ID || "", - serverUrl: process.env.MEMWAL_SERVER_URL || "http://localhost:8000", - maxMemories: 5, - autoSave: true, - minRelevance: 0.3, - }); -} - -const SYSTEM_PROMPT = `You are Noter, a helpful AI assistant for Sui blockchain and cryptocurrency markets. - -You have access to the user's personal memory system powered by MemWal. Memories are automatically recalled and saved during conversations. Use the recalled memory context to provide personalized, context-aware responses. - -Tools available: - -Blockchain Tools (Sui): -- getUserInfo({confirm: true}) - Get the authenticated user's information (name, email, Sui address, etc.) -- getBalances({confirm: true}) - Get all token balances for the user on Sui blockchain (SUI and other tokens) -- getTransactions({confirm: true, limit?}) - Get recent transactions for the user (default 10, max 50) -- getOwnedObjects({confirm: true, limit?}) - Get user's owned objects (NFTs, game items, collectibles) with metadata and images (default 20, max 50) -- getStakes({confirm: true}) - Get all staking positions showing staked SUI, validators, rewards, and status -- getCoinMetadata({confirm: true, coinType}) - Get detailed metadata for any Sui coin type (name, symbol, decimals, icon) - -Market Tools - Prices & Data: -- getCryptoPrice({confirm: true, symbols}) - Get current prices for cryptocurrencies. Supports multiple coins (e.g., "BTC,ETH,SUI") -- getTrendingCoins({confirm: true, limit?}) - Get top gaining and losing coins in the last 24 hours (default 10, max 20) -- getTopCoins({confirm: true, limit?}) - Get top cryptocurrencies by market cap (default 10, max 50) -- searchCoins({confirm: true, query}) - Search for cryptocurrencies by name or symbol -- getCoinHistory({confirm: true, symbol, days?}) - Get historical price data with chart (default 7 days, max 365) -- getCoinDetails({confirm: true, coinId}) - Get detailed info about a coin (description, website, whitepaper, etc.) - -Market Tools - Analytics & Categories: -- getGlobalMarketStats({confirm: true}) - Get global crypto market stats (total market cap, BTC dominance, volume, etc.) -- getFearGreedIndex({confirm: true}) - Get crypto market sentiment index (0-100, Extreme Fear to Extreme Greed) -- getCoinCategories({confirm: true}) - Get all crypto categories (DeFi, NFT, Gaming, Layer 1, etc.) -- getCoinsByCategory({confirm: true, categoryId}) - Get all coins in a specific category (use getCoinCategories first) - -Utility Tools: -- getTime({confirm: true, timezone?}) - Get current date and time - -Educational Tools: -- showDiagram({confirm: true, diagramType}) - Display educational flow diagrams. Available types: - * "zklogin" - zkLogin OAuth authentication flow - * "nautilus" - Nautilus framework flow - * "seal" - Seal security flow - * "walrus" - Walrus storage flow - Use when users ask about these topics to show visual explanations. - -Be concise and helpful. Use tools when relevant. For blockchain queries, always use the appropriate tool. - -When users ask about Sui technologies (zkLogin, Nautilus, Seal, Walrus): -1. Explain the concept briefly -2. Call showDiagram with the appropriate type to display the visual flow -3. The diagram will automatically appear in the chat`; - -export async function POST(req: Request) { - const { messages, model = DEFAULT_MODEL } = await req.json(); - - // Get session from header - const sessionId = req.headers.get("x-session-id"); - let user = null; - - if (sessionId) { - // Try zkLogin session first - const [zkSession] = await db - .select() - .from(zkLoginSessions) - .where(eq(zkLoginSessions.id, sessionId)) - .limit(1); - - if (zkSession?.userId) { - const [foundUser] = await db - .select() - .from(users) - .where(eq(users.id, zkSession.userId)) - .limit(1); - - user = foundUser || null; - } - - // If not found in zkLogin, try wallet session - if (!user) { - const [walletSession] = await db - .select() - .from(walletSessions) - .where(eq(walletSessions.id, sessionId)) - .limit(1); - - if (walletSession?.userId) { - const [foundUser] = await db - .select() - .from(users) - .where(eq(users.id, walletSession.userId)) - .limit(1); - - user = foundUser || null; - } - } - } - - const tools = createTools(user); - - const result = streamText({ - model: getModel(model), - system: SYSTEM_PROMPT, - messages: await convertToModelMessages(messages), - tools, - stopWhen: stepCountIs(5), - }); - - return result.toUIMessageStreamResponse(); -} diff --git a/apps/noter/app/api/memory/health/route.ts b/apps/noter/app/api/memory/health/route.ts index 2777e1bf..6938c3d2 100644 --- a/apps/noter/app/api/memory/health/route.ts +++ b/apps/noter/app/api/memory/health/route.ts @@ -1,25 +1,21 @@ -/** - * Memory Health API — checks if MemWal is configured and server is reachable - */ +/** Memory Health API — checks if MemWal is configured and server is reachable. */ import { getMemWalClient } from "@/feature/note/lib/pdw-client"; export async function GET() { + try { + // Try with env fallback (no per-user key needed for health check) + const memwal = getMemWalClient(); try { - const memwal = getMemWalClient(); - // Try health check, if MemWal client exists then it's at least configured - try { - const health = await memwal.health(); - return Response.json({ ...health, status: "ok" }); - } catch { - // Client configured but server unreachable - return Response.json({ status: "ok", server: "unreachable" }); - } - } catch (error) { - // No key configured - return Response.json( - { status: "not_configured", message: error instanceof Error ? error.message : "MemWal not configured" }, - { status: 503 } - ); + const health = await memwal.health(); + return Response.json({ ...health, status: "ok" }); + } catch { + return Response.json({ status: "ok", server: "unreachable" }); } + } catch (error) { + return Response.json( + { status: "not_configured", message: error instanceof Error ? error.message : "MemWal not configured" }, + { status: 503 }, + ); + } } diff --git a/apps/noter/app/api/memory/remember/route.ts b/apps/noter/app/api/memory/remember/route.ts index 14c0f48e..9d8f4584 100644 --- a/apps/noter/app/api/memory/remember/route.ts +++ b/apps/noter/app/api/memory/remember/route.ts @@ -1,29 +1,72 @@ /** - * Memory Remember API — analyzes note text and extracts facts to MemWal - * Uses analyze() which extracts multiple facts, embeds, encrypts, and stores them server-side. + * Memory Remember API — analyzes note text and extracts facts to MemWal. + * Uses the authenticated user's delegate key from their session. */ import { extractMemories } from "@/feature/note/lib/pdw-client"; +import { db } from "@/shared/lib/db"; +import { zkLoginSessions, walletSessions, users } from "@/shared/db/schema"; +import { eq } from "drizzle-orm"; + +/** Resolve user's MemWal key from session header. */ +async function resolveUserKey(req: Request) { + const sessionId = req.headers.get("x-session-id"); + if (!sessionId) return { key: null, accountId: null }; + + // Check wallet/enoki sessions + const [session] = await db + .select() + .from(walletSessions) + .where(eq(walletSessions.id, sessionId)) + .limit(1); + + if (!session?.userId || session.expiresAt < new Date()) { + // Try zkLogin session + const [zkSession] = await db + .select() + .from(zkLoginSessions) + .where(eq(zkLoginSessions.id, sessionId)) + .limit(1); + if (!zkSession?.userId || zkSession.expiresAt < new Date()) { + return { key: null, accountId: null }; + } + const [user] = await db.select().from(users).where(eq(users.id, zkSession.userId)).limit(1); + return { + key: user?.delegatePrivateKey ?? null, + accountId: user?.delegateAccountId ?? null, + }; + } + + const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1); + return { + key: user?.delegatePrivateKey ?? null, + accountId: user?.delegateAccountId ?? null, + }; +} export async function POST(req: Request) { - try { - const { text } = await req.json(); - - if (!text || typeof text !== "string") { - return Response.json({ error: "text is required" }, { status: 400 }); - } - - if (text.trim().length < 10) { - return Response.json({ error: "Text too short to analyze" }, { status: 400 }); - } - - const facts = await extractMemories("noter", text); - return Response.json({ facts, count: facts.length }); - } catch (error) { - console.error("[memory/remember] Error:", error); - return Response.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); + try { + const { text } = await req.json(); + + if (!text || typeof text !== "string") { + return Response.json({ error: "text is required" }, { status: 400 }); + } + + if (text.trim().length < 10) { + return Response.json({ error: "Text too short to analyze" }, { status: 400 }); + } + + const { key, accountId } = await resolveUserKey(req); + if (!key || !accountId) { + return Response.json({ facts: [], count: 0 }); } + const facts = await extractMemories("noter", text, key, accountId); + return Response.json({ facts, count: facts.length }); + } catch (error) { + console.error("[memory/remember] Error:", error); + return Response.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 }, + ); + } } diff --git a/apps/noter/app/api/memory/set-key/route.ts b/apps/noter/app/api/memory/set-key/route.ts deleted file mode 100644 index 362fbcac..00000000 --- a/apps/noter/app/api/memory/set-key/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Set MemWal Key API — allows user to set their MEMWAL_KEY at runtime - */ - -import { setMemWalKey } from "@/feature/note/lib/pdw-client"; - -export async function POST(req: Request) { - try { - const { key, accountId } = await req.json(); - - if (typeof key !== "string") { - return Response.json({ error: "key must be a string" }, { status: 400 }); - } - - setMemWalKey(key || null, accountId || null); - - if (!key) { - return Response.json({ status: "cleared" }); - } - - return Response.json({ status: "ok" }); - } catch (error) { - console.error("[memory/set-key] Error:", error); - return Response.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } - ); - } -} diff --git a/apps/noter/app/auth/callback/page.tsx b/apps/noter/app/auth/callback/page.tsx deleted file mode 100644 index b02b68d5..00000000 --- a/apps/noter/app/auth/callback/page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * OAUTH CALLBACK PAGE - * Handles OAuth redirect and completes zkLogin authentication - */ - -"use client"; - -import { Suspense, useEffect, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useAuth } from "@/feature/auth"; -import { Loader2, AlertCircle } from "lucide-react"; -import { Alert, AlertDescription } from "@/shared/components/ui/alert"; - -function AuthCallbackContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { completeLogin } = useAuth(); - const [error, setError] = useState(null); - const [isComplete, setIsComplete] = useState(false); - const [hasRun, setHasRun] = useState(false); - - useEffect(() => { - // Prevent infinite loop - if (hasRun) return; - - const handleCallback = async () => { - setHasRun(true); - - try { - // Extract JWT from hash fragment (OpenID Connect response_type=id_token) - const hash = window.location.hash.substring(1); - const params = new URLSearchParams(hash); - const idToken = params.get("id_token"); - const state = params.get("state"); // This is our sessionId - - if (!idToken) { - throw new Error("No ID token received from OAuth provider"); - } - - if (!state) { - throw new Error("No session ID in OAuth callback"); - } - - // Complete the login flow - const result = await completeLogin(idToken, state); - setIsComplete(true); - - // Give React time to flush state updates to storage - await new Promise(resolve => setTimeout(resolve, 200)); - - // Redirect to home page - router.push("/"); - } catch (err) { - setError(err instanceof Error ? err.message : "Authentication failed"); - } - }; - - handleCallback(); - }, [completeLogin, router]); - - if (error) { - return ( -
-
- - - - {error} - - - -
-
- ); - } - - return ( -
- -
-

Completing authentication...

-

- Generating zero-knowledge proof -

-
-
- ); -} - -export default function AuthCallbackPage() { - return ( - - -
-

Loading...

-
-
- }> - - - ); -} diff --git a/apps/noter/app/components/enoki-login-card.tsx b/apps/noter/app/components/enoki-login-card.tsx new file mode 100644 index 00000000..885f73d8 --- /dev/null +++ b/apps/noter/app/components/enoki-login-card.tsx @@ -0,0 +1,354 @@ +"use client"; + +/** + * Google sign-in card using Enoki zkLogin. + * + * Handles the full flow: Google OAuth → check returning user → generate delegate + * key → register on-chain (sponsored) → create tRPC session. + * + * Returns null if Enoki env vars are not configured. + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + useWallets, + useConnectWallet, + useCurrentAccount, + useSignTransaction, + useSuiClient, +} from "@mysten/dapp-kit"; +import { isEnokiWallet } from "@mysten/enoki"; +import { Transaction } from "@mysten/sui/transactions"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { enokiConfig } from "@/lib/enoki/config"; +import { useAuth } from "@/feature/auth"; + +type Step = + | "idle" + | "connecting" + | "generating-key" + | "registering-onchain" + | "creating-session" + | "done"; + +const STEP_LABELS: Record = { + idle: "", + connecting: "Signing in with Google...", + "generating-key": "Generating delegate key...", + "registering-onchain": "Registering on-chain...", + "creating-session": "Creating session...", + done: "Redirecting...", +}; + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** Execute a transaction via Enoki gas sponsorship through the MemWal relayer. */ +async function sponsoredSignAndExecute( + transaction: Transaction, + sender: string, + suiClient: ReturnType, + signTransaction: (args: { + transaction: Transaction; + }) => Promise<{ signature: string }>, +): Promise<{ digest: string }> { + const kindBytes = await transaction.build({ + client: suiClient as any, + onlyTransactionKind: true, + }); + + const sponsorRes = await fetch(`${enokiConfig.memwalServerUrl}/sponsor`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + transactionBlockKindBytes: uint8ArrayToBase64(kindBytes), + sender, + }), + }); + + if (!sponsorRes.ok) { + const errText = await sponsorRes.text(); + throw new Error(`Sponsor failed (${sponsorRes.status}): ${errText}`); + } + + const sponsored = await sponsorRes.json(); + const sponsoredTx = Transaction.from(sponsored.bytes); + const { signature } = await signTransaction({ transaction: sponsoredTx }); + + const execRes = await fetch( + `${enokiConfig.memwalServerUrl}/sponsor/execute`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ digest: sponsored.digest, signature }), + }, + ); + + if (!execRes.ok) { + const errText = await execRes.text(); + throw new Error(`Sponsored execute failed (${execRes.status}): ${errText}`); + } + + return execRes.json(); +} + +export function EnokiLoginCard() { + const wallets = useWallets(); + const { mutateAsync: connect } = useConnectWallet(); + const currentAccount = useCurrentAccount(); + const suiClient = useSuiClient(); + const { mutateAsync: signTransaction } = useSignTransaction(); + const { connectEnoki } = useAuth(); + + const [step, setStep] = useState("idle"); + const [error, setError] = useState(""); + const setupRunningRef = useRef(false); + + const enokiWallets = wallets.filter(isEnokiWallet); + const googleWallet = enokiWallets.find((w) => w.provider === "google"); + const hasEnokiConfig = + enokiConfig.enokiApiKey && + enokiConfig.googleClientId && + enokiConfig.memwalPackageId && + enokiConfig.memwalRegistryId && + enokiConfig.memwalServerUrl; + + const [pendingSetup, setPendingSetup] = useState(false); + + const runSetup = useCallback( + async (address: string) => { + if (setupRunningRef.current) return; + setupRunningRef.current = true; + + try { + // Phase 1: Check returning user via tRPC + setStep("creating-session"); + const checkResult = await connectEnoki({ suiAddress: address }); + + if ("needsSetup" in checkResult && !checkResult.needsSetup) { + setStep("done"); + window.location.href = "/note"; + return; + } + + // Phase 2: First-time user — generate key + register on-chain + setStep("generating-key"); + const ed = await import("@noble/ed25519"); + const { blake2b } = await import("@noble/hashes/blake2b"); + + const privateKeyRaw = new Uint8Array(32); + crypto.getRandomValues(privateKeyRaw); + const publicKeyRaw = await ed.getPublicKeyAsync(privateKeyRaw); + + const privateKeyHex = bytesToHex(privateKeyRaw); + + // Derive Sui address for delegate key + const addrInput = new Uint8Array(33); + addrInput[0] = 0x00; + addrInput.set(publicKeyRaw, 1); + const addressBytes = blake2b(addrInput, { dkLen: 32 }); + const delegateSuiAddress = + "0x" + bytesToHex(new Uint8Array(addressBytes)); + + // On-chain registration + setStep("registering-onchain"); + let knownAccountId: string | null = null; + + try { + const registryObj = await suiClient.getObject({ + id: enokiConfig.memwalRegistryId, + options: { showContent: true }, + }); + if ( + registryObj?.data?.content && + "fields" in registryObj.data.content + ) { + const fields = registryObj.data.content.fields as any; + const tableId = fields?.accounts?.fields?.id?.id; + if (tableId) { + const dynField = await suiClient.getDynamicFieldObject({ + parentId: tableId, + name: { type: "address", value: address }, + }); + if ( + dynField?.data?.content && + "fields" in dynField.data.content + ) { + knownAccountId = (dynField.data.content.fields as any) + .value as string; + } + } + } + } catch { + // Dynamic field not found → no account yet + } + + const pubKeyBytes = Array.from(publicKeyRaw); + const sign = (args: { transaction: Transaction }) => + signTransaction(args); + + if (knownAccountId) { + const tx = new Transaction(); + tx.moveCall({ + target: `${enokiConfig.memwalPackageId}::account::add_delegate_key`, + arguments: [ + tx.object(knownAccountId), + tx.pure("vector", pubKeyBytes), + tx.pure("address", delegateSuiAddress), + tx.pure("string", "Noter"), + tx.object("0x6"), + ], + }); + const result = await sponsoredSignAndExecute(tx, address, suiClient, sign); + await suiClient.waitForTransaction({ digest: result.digest }); + } else { + const tx = new Transaction(); + tx.moveCall({ + target: `${enokiConfig.memwalPackageId}::account::create_account`, + arguments: [ + tx.object(enokiConfig.memwalRegistryId), + tx.object("0x6"), + ], + }); + const createResult = await sponsoredSignAndExecute(tx, address, suiClient, sign); + await suiClient.waitForTransaction({ digest: createResult.digest }); + + const txDetails = await suiClient.getTransactionBlock({ + digest: createResult.digest, + options: { showObjectChanges: true }, + }); + const createdObj = txDetails.objectChanges?.find( + (c) => + c.type === "created" && + "objectType" in c && + c.objectType.includes("MemWalAccount"), + ); + if (createdObj && "objectId" in createdObj) { + knownAccountId = createdObj.objectId; + } + + if (!knownAccountId) { + throw new Error("Account created but object ID not found. Please try again."); + } + + const tx2 = new Transaction(); + tx2.moveCall({ + target: `${enokiConfig.memwalPackageId}::account::add_delegate_key`, + arguments: [ + tx2.object(knownAccountId), + tx2.pure("vector", pubKeyBytes), + tx2.pure("address", delegateSuiAddress), + tx2.pure("string", "Noter"), + tx2.object("0x6"), + ], + }); + const addResult = await sponsoredSignAndExecute(tx2, address, suiClient, sign); + await suiClient.waitForTransaction({ digest: addResult.digest }); + } + + // Create session via tRPC + setStep("creating-session"); + await connectEnoki({ + suiAddress: address, + privateKey: privateKeyHex, + accountId: knownAccountId!, + }); + + setStep("done"); + window.location.href = "/note"; + } catch (err) { + console.error("[enoki-login] Setup failed:", err); + setError( + err instanceof Error ? err.message : "Setup failed. Please try again.", + ); + setStep("idle"); + } finally { + setupRunningRef.current = false; + } + }, + [suiClient, signTransaction, connectEnoki], + ); + + useEffect(() => { + if (pendingSetup && currentAccount?.address) { + setPendingSetup(false); + runSetup(currentAccount.address); + } + }, [pendingSetup, currentAccount?.address, runSetup]); + + const handleGoogleSignIn = async () => { + if (!googleWallet) return; + setError(""); + setStep("connecting"); + + try { + await connect({ wallet: googleWallet }); + setPendingSetup(true); + } catch (err) { + console.error("[enoki-login] Connect failed:", err); + setError( + err instanceof Error ? err.message : "Google sign-in failed.", + ); + setStep("idle"); + } + }; + + if (!hasEnokiConfig || !googleWallet) return null; + + const isProcessing = step !== "idle"; + + return ( +
+ + + {error && ( +

{error}

+ )} +
+ ); +} diff --git a/apps/noter/app/components/providers.tsx b/apps/noter/app/components/providers.tsx index 05cc01cb..2658e461 100644 --- a/apps/noter/app/components/providers.tsx +++ b/apps/noter/app/components/providers.tsx @@ -13,13 +13,16 @@ import { ThemeProvider } from "next-themes"; import { TooltipProvider } from "@/shared/components/ui/tooltip"; import { TRPCProvider } from "@/shared/lib/trpc/provider"; +import { SuiProviders } from "@/app/components/sui-providers"; export function Providers({ children }: { children: React.ReactNode }) { return ( - - {children} - + + + {children} + + ); } diff --git a/apps/noter/app/components/sidebar-float.tsx b/apps/noter/app/components/sidebar-float.tsx deleted file mode 100644 index 9745cb58..00000000 --- a/apps/noter/app/components/sidebar-float.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import { Button } from "@/shared/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/shared/components/ui/dropdown-menu"; -import { trpc } from "@/shared/lib/trpc/client"; -import { cn } from "@/shared/lib/utils"; -import { MessageSquare, PanelLeft, Plus, Trash2 } from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; -import { uuidv7 } from "uuidv7"; - -export function SidebarFloat() { - const router = useRouter(); - const params = useParams(); - const currentChatId = params?.id as string | undefined; - - const { data: chats, refetch } = trpc.chat.list.useQuery(); - const createChat = trpc.chat.create.useMutation({ - onSuccess: (chat) => { - refetch(); - router.push(`/ai/${chat.id}`); - }, - }); - const deleteChat = trpc.chat.delete.useMutation({ - onSuccess: () => { - refetch(); - if (currentChatId) { - router.push("/ai"); - } - }, - }); - - const handleNewChat = () => { - const id = uuidv7(); - createChat.mutate({ id }); - }; - - return ( -
- - - - - - - - New Chat - - - {chats?.map((chat) => ( - router.push(`/ai/${chat.id}`)} - > - - - {chat.title || "New Chat"} - - - - ))} - - -
- ); -} diff --git a/apps/noter/app/components/sui-providers.tsx b/apps/noter/app/components/sui-providers.tsx new file mode 100644 index 00000000..6b213791 --- /dev/null +++ b/apps/noter/app/components/sui-providers.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; +import { + createNetworkConfig, + SuiClientProvider, + WalletProvider, + useSuiClientContext, +} from "@mysten/dapp-kit"; +import { isEnokiNetwork, registerEnokiWallets } from "@mysten/enoki"; +import { getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc"; +import { enokiConfig } from "@/lib/enoki/config"; + +const { networkConfig } = createNetworkConfig({ + testnet: { url: getJsonRpcFullnodeUrl("testnet"), network: "testnet" }, + mainnet: { url: getJsonRpcFullnodeUrl("mainnet"), network: "mainnet" }, +}); + +/** Registers Enoki wallets (Google OAuth) with dapp-kit on mount. No-op if env vars are missing. */ +function RegisterEnokiWallets() { + const { client, network } = useSuiClientContext(); + + useEffect(() => { + if (!isEnokiNetwork(network)) return; + if (!enokiConfig.enokiApiKey || !enokiConfig.googleClientId) return; + + const { unregister } = registerEnokiWallets({ + apiKey: enokiConfig.enokiApiKey, + providers: { + google: { clientId: enokiConfig.googleClientId }, + }, + client, + network, + }); + + return unregister; + }, [client, network]); + + return null; +} + +/** Sui + Enoki provider stack. Does NOT include React Query — noter's TRPCProvider handles that. */ +export function SuiProviders({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/apps/noter/app/components/user-float.tsx b/apps/noter/app/components/user-float.tsx index cff12506..7c8f2aff 100644 --- a/apps/noter/app/components/user-float.tsx +++ b/apps/noter/app/components/user-float.tsx @@ -8,99 +8,56 @@ import { TooltipTrigger, } from "@/shared/components/ui/tooltip"; import { cn } from "@/shared/lib/utils"; -import { Copy, LogOut, Minus, Key, Check, X } from "lucide-react"; +import { Copy, LogOut, Minus, Check, Shield, KeyRound, Eye, EyeOff, ExternalLink } from "lucide-react"; import Image from "next/image"; -import { useState, useEffect } from "react"; +import { useState } from "react"; interface UserFloatPanelProps { className: string; onClose: () => void; } -export function UserFloatPanel({ className, onClose }: UserFloatPanelProps) { - const { user, suiAddress, logout } = useAuth(); +function CopyButton({ value, label }: { value: string; label: string }) { const [copied, setCopied] = useState(false); - const [memwalKey, setMemwalKey] = useState(""); - const [memwalAccountId, setMemwalAccountId] = useState(""); - const [memwalStatus, setMemwalStatus] = useState<"idle" | "checking" | "connected" | "error">("idle"); - - // Always check MemWal status on mount (key may come from .env or localStorage) - useEffect(() => { - const savedKey = localStorage.getItem("memwal_key"); - if (savedKey) { - setMemwalKey(savedKey); - } - const savedAccountId = localStorage.getItem("memwal_account_id"); - if (savedAccountId) { - setMemwalAccountId(savedAccountId); - } - // Always check health — server may have key from .env - checkMemwalConnection(); - }, []); - - const checkMemwalConnection = async () => { - setMemwalStatus("checking"); - try { - const res = await fetch("/api/memory/health"); - const data = await res.json().catch(() => ({})); - setMemwalStatus(res.ok && data.status === "ok" ? "connected" : "error"); - } catch { - setMemwalStatus("error"); - } - }; - const handleSaveKey = async () => { - if (!memwalKey.trim()) return; - localStorage.setItem("memwal_key", memwalKey.trim()); - if (memwalAccountId.trim()) { - localStorage.setItem("memwal_account_id", memwalAccountId.trim()); - } - - // Save key to server-side via API - try { - const res = await fetch("/api/memory/set-key", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key: memwalKey.trim(), accountId: memwalAccountId.trim() || undefined }), - }); - if (res.ok) { - setMemwalStatus("connected"); - } else { - setMemwalStatus("error"); - } - } catch { - setMemwalStatus("error"); - } + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); }; - const handleClearKey = () => { - setMemwalKey(""); - setMemwalAccountId(""); - localStorage.removeItem("memwal_key"); - localStorage.removeItem("memwal_account_id"); - setMemwalStatus("idle"); - fetch("/api/memory/set-key", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key: "", accountId: "" }), - }); - }; + return ( + + + + + {copied ? "Copied!" : `Copy ${label}`} + + ); +} - const copyAddress = () => { - if (suiAddress) { - navigator.clipboard.writeText(suiAddress); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; +function truncateAddress(addr: string): string { + if (addr.length <= 16) return addr; + return `${addr.slice(0, 8)}...${addr.slice(-6)}`; +} + +export function UserFloatPanel({ className, onClose }: UserFloatPanelProps) { + const { user, suiAddress, logout } = useAuth(); + const [showKey, setShowKey] = useState(false); const handleLogout = async () => { await logout(); onClose(); + window.location.href = "/"; }; + const authMethod = user?.authMethod === "enoki" ? "Google" : user?.authMethod === "wallet" ? "Wallet" : "Key"; + return (
+ {/* Header */}
-
- - - - - Minimize - -
+ + + + + Minimize +
{user && ( <> + {/* User info */}
{user.avatar && ( )}
-

{user.name}

-

{user.email}

+

{user.name || "User"}

+

Signed in with {authMethod}

- {suiAddress && ( -
-
- - {suiAddress.slice(0, 8)}...{suiAddress.slice(-6)} - + {/* MemWal Account Info */} +
+
+ + MemWal Account +
+ + {/* Sui Address */} + {suiAddress && ( +
+
+

Sui Address

+ {truncateAddress(suiAddress)} +
+ - {copied ? "Copied!" : "Copy address"} + View on Suiscan
-
- )} + )} - {/* MemWal Key Section */} -
-
- - MemWal Key - {memwalStatus === "connected" && ( - - - On - - )} - {memwalStatus === "error" && ( - - - Error - - )} -
-
- setMemwalKey(e.target.value)} - className="flex-1 text-xs bg-secondary px-2 py-2 rounded border border-border outline-none focus:ring-1 focus:ring-ring font-mono" - /> -
-
- setMemwalAccountId(e.target.value)} - className="flex-1 text-xs bg-secondary px-2 py-2 rounded border border-border outline-none focus:ring-1 focus:ring-ring font-mono" - /> - {(memwalKey || memwalAccountId) ? ( -
- - - - - Save key - - - - - - Clear key - + {/* Account ID */} + {user.delegateAccountId && ( +
+
+

Account ID

+ {truncateAddress(user.delegateAccountId)}
- ) : null} -
+ + + + + + View on Suiscan + +
+ )}
+ {/* Delegate Key Export */} + {user.delegatePrivateKey && ( +
+
+ + Delegate Key +
+ + {showKey && ( +
+ + {user.delegatePrivateKey} + +
+ +
+
+ )} + + +
+ )} + + {/* Logout */} + + + Sign in with delegate key + + + + + {showAdvanced && ( +
+ setAccountId(e.target.value)} + placeholder="Account ID (0x...)" + required + value={accountId} + /> +
+ setPrivateKey(e.target.value)} + placeholder="Private key (64 hex)" + required + type={showKey ? "text" : "password"} + value={privateKey} + /> + +
+ +
+ )}
- {/* Error Message */} - {walletError && ( -

{walletError}

- )} + {error &&

{error}

}
); } diff --git a/apps/noter/package/feature/auth/ui/login-button.tsx b/apps/noter/package/feature/auth/ui/login-button.tsx deleted file mode 100644 index 00142f76..00000000 --- a/apps/noter/package/feature/auth/ui/login-button.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/** - * LOGIN BUTTON COMPONENT - * Triggers OAuth zkLogin flow - */ - -"use client"; - -import { Button } from "@/shared/components/ui/button"; -import { Loader2 } from "lucide-react"; -import type { LoginButtonProps } from "../domain/type"; -import { useAuth } from "../hook/use-auth"; - -export function LoginButton({ - provider, - onSuccess, - onError, - className, -}: LoginButtonProps) { - const { login, isLoginPending } = useAuth(); - - const handleLogin = async () => { - try { - await login(provider); - onSuccess?.({} as any); // Will redirect, so this won't be called - } catch (error) { - onError?.(error instanceof Error ? error : new Error("Login failed")); - } - }; - - const providerNames = { - google: "Google", - facebook: "Facebook", - twitch: "Twitch", - } as const; - - return ( - - ); -} diff --git a/apps/noter/package/feature/auth/ui/user-menu.tsx b/apps/noter/package/feature/auth/ui/user-menu.tsx deleted file mode 100644 index 05e476f9..00000000 --- a/apps/noter/package/feature/auth/ui/user-menu.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * USER MENU COMPONENT - * Displays user info and logout option - */ - -"use client"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/shared/components/ui/dropdown-menu"; -import { Avatar } from "@/shared/components/ui/avatar"; -import { Button } from "@/shared/components/ui/button"; -import { LogOut, Wallet } from "lucide-react"; -import { useAuth } from "../hook/use-auth"; -import { truncateSuiAddress } from "../domain/zklogin"; -import type { UserMenuProps } from "../domain/type"; - -export function UserMenu({ user, onLogout }: UserMenuProps) { - const { logout, isLogoutPending } = useAuth(); - - const handleLogout = async () => { - await logout(); - onLogout?.(); - }; - - return ( - - - - - - - -
-

{user.name}

- {user.email && ( -

- {user.email} -

- )} -
-
- - - - - - - {truncateSuiAddress(user.suiAddress)} - - - - - - - - Log out - -
-
- ); -} diff --git a/apps/noter/package/feature/auth/ui/wallet-button.tsx b/apps/noter/package/feature/auth/ui/wallet-button.tsx deleted file mode 100644 index af4322fd..00000000 --- a/apps/noter/package/feature/auth/ui/wallet-button.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/** - * WALLET BUTTON COMPONENT - * Triggers wallet connection (Slush, Sui Wallet) - */ - -"use client"; - -import { Button } from "@/shared/components/ui/button"; -import { useAuth } from "../hook/use-auth"; -import { Loader2, Wallet } from "lucide-react"; -import { useState } from "react"; -import { - connectWallet, - signMessage, - generateAuthMessage, - isWalletInstalled, -} from "../lib/wallet-client"; -import { WALLET_NAMES, WALLET_INSTALL_URLS, type WalletType } from "../constant"; - -export type WalletButtonProps = { - wallet: WalletType; - className?: string; - variant?: "default" | "outline" | "ghost"; - size?: "default" | "sm" | "lg" | "icon"; -}; - -export function WalletButton({ - wallet, - className, - variant = "outline", - size = "default", -}: WalletButtonProps) { - const { connectWalletAuth, isLoginPending } = useAuth(); - const [isConnecting, setIsConnecting] = useState(false); - const [error, setError] = useState(null); - - const walletName = WALLET_NAMES[wallet]; - const installed = isWalletInstalled(wallet); - - const handleConnect = async () => { - setError(null); - setIsConnecting(true); - - try { - // 1. Connect to wallet - const account = await connectWallet(wallet); - // 2. Generate message to sign - const message = generateAuthMessage(); - - // 3. Sign message - const { signature } = await signMessage(wallet, message, account); - // 4. Authenticate with backend - await connectWalletAuth({ - walletType: wallet, - address: account.address, - signature, - message, - }); } catch (err) { - console.error(`[WalletButton] Failed to connect ${wallet}:`, err); - setError(err instanceof Error ? err.message : "Connection failed"); - } finally { - setIsConnecting(false); - } - }; - - const handleInstall = () => { - window.open(WALLET_INSTALL_URLS[wallet], "_blank"); - }; - - if (!installed) { - return ( - - ); - } - - return ( -
- - - {error && ( -

{error}

- )} -
- ); -} diff --git a/apps/noter/package/feature/chat/api/form.ts b/apps/noter/package/feature/chat/api/form.ts deleted file mode 100644 index 28e494d2..00000000 --- a/apps/noter/package/feature/chat/api/form.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * CHAT FORM SCHEMAS - * - * UI form validation schemas for chat feature. - * Derived from shared/db/type.ts using .pick().extend() pattern. - * - * Pattern: Forms use optionalId to handle both create and update modes in a single schema. - */ - -import { z } from "zod"; -import { chatInsertSchema, uuidv7Schema } from "@/shared/db/type"; - -// ══════════════════════════════════════════════════════════════ -// HELPERS -// ══════════════════════════════════════════════════════════════ - -/** Optional ID helper for create/update forms */ -const optionalId = { id: uuidv7Schema.optional() } as const; - -// ══════════════════════════════════════════════════════════════ -// FORM SCHEMAS -// ══════════════════════════════════════════════════════════════ - -/** - * Chat settings form (create/edit chat) - * Used in chat creation dialogs and settings modals - */ -export const chatFormSchema = chatInsertSchema - .pick({ title: true, model: true, systemPrompt: true, temperature: true }) - .extend({ - ...optionalId, - title: z - .string() - .min(1, "Title is required") - .max(100, "Title is too long") - .optional(), - model: z.string().min(1, "Model is required"), - systemPrompt: z.string().max(2000, "System prompt is too long").optional(), - temperature: z - .number() - .min(0, "Temperature must be at least 0") - .max(2, "Temperature must be at most 2") - .optional(), - }); - -/** - * Message input form (user message) - * Used in chat input component for sending messages - */ -export const messageFormSchema = z.object({ - content: z.string().min(1, "Message cannot be empty"), -}); - -// ══════════════════════════════════════════════════════════════ -// TYPE EXPORTS -// ══════════════════════════════════════════════════════════════ - -export type ChatFormData = z.infer; -export type MessageFormData = z.infer; diff --git a/apps/noter/package/feature/chat/api/input.ts b/apps/noter/package/feature/chat/api/input.ts deleted file mode 100644 index b744ca59..00000000 --- a/apps/noter/package/feature/chat/api/input.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Chat API Input Schemas - * - * Derived from shared/db/type.ts using .pick().extend() pattern. - * Never redefine DB fields - always derive from insertSchema. - * - * Pattern: [Entity][Action]Input (e.g., ChatCreateInput) - */ - -import { z } from "zod"; -import { - chatInsertSchema, - messageInsertSchema, - uuidv7Schema, - idInputSchema, -} from "@/shared/db/type"; - -// ══════════════════════════════════════════════════════════ -// CHAT INPUTS -// ══════════════════════════════════════════════════════════ - -/** Get single chat by ID */ -export const chatGetInput = idInputSchema; - -/** Create new chat */ -export const chatCreateInput = chatInsertSchema - .pick({ - id: true, - title: true, - model: true, - }) - .partial(); - -/** Update chat title */ -export const chatUpdateTitleInput = z.object({ - id: uuidv7Schema, - title: z.string(), -}); - -/** Delete chat by ID */ -export const chatDeleteInput = idInputSchema; - -// ══════════════════════════════════════════════════════════ -// MESSAGE INPUTS -// ══════════════════════════════════════════════════════════ - -/** Save user message */ -export const messageSaveUserInput = messageInsertSchema.pick({ - chatId: true, - content: true, -}); - -/** Save assistant message */ -export const messageSaveAssistantInput = messageInsertSchema - .pick({ - chatId: true, - parts: true, - model: true, - promptTokens: true, - completionTokens: true, - }) - .required({ - chatId: true, - parts: true, - }) - .partial({ - model: true, - promptTokens: true, - completionTokens: true, - }); - -// ══════════════════════════════════════════════════════════ -// TYPE EXPORTS -// ══════════════════════════════════════════════════════════ - -export type ChatGetInput = z.infer; -export type ChatCreateInput = z.infer; -export type ChatUpdateTitleInput = z.infer; -export type ChatDeleteInput = z.infer; - -export type MessageSaveUserInput = z.infer; -export type MessageSaveAssistantInput = z.infer; diff --git a/apps/noter/package/feature/chat/api/route.ts b/apps/noter/package/feature/chat/api/route.ts deleted file mode 100644 index b95181ec..00000000 --- a/apps/noter/package/feature/chat/api/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { router, procedure, protectedProcedure } from "@/shared/lib/trpc/init"; -import { TRPCError } from "@trpc/server"; -import { chats, messages } from "@/shared/db/schema"; -import { eq, desc, and } from "drizzle-orm"; -import { - chatGetInput, - chatCreateInput, - chatUpdateTitleInput, - chatDeleteInput, - messageSaveUserInput, - messageSaveAssistantInput, -} from "./input"; -import * as chatService from "../domain/service"; - -export const chatRouter = router({ - // List user's chats only - list: protectedProcedure.query(async ({ ctx }) => { - return ctx.db.query.chats.findMany({ - where: eq(chats.userId, ctx.userId), - orderBy: [desc(chats.createdAt)], - limit: 50, - }); - }), - - // Get single chat with ownership verification - get: protectedProcedure - .input(chatGetInput) - .query(async ({ ctx, input }) => { - const chat = await ctx.db.query.chats.findFirst({ - where: and(eq(chats.id, input.id), eq(chats.userId, ctx.userId)), - with: { - messages: { - orderBy: [messages.createdAt], - }, - }, - }); - - if (!chat) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Chat not found or access denied", - }); - } - - return chat; - }), - - // Create new chat with userId - create: protectedProcedure - .input(chatCreateInput) - .mutation(({ ctx, input }) => - chatService.createChat(ctx.db, { - id: input.id, - userId: ctx.userId, - title: input.title ?? undefined, - model: input.model ?? undefined, - }) - ), - - // Update chat title with ownership verification - updateTitle: protectedProcedure - .input(chatUpdateTitleInput) - .mutation(async ({ ctx, input }) => { - const chat = await chatService.updateChatTitle( - ctx.db, - input.id, - ctx.userId, - input.title - ); - - if (!chat) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Chat not found or access denied", - }); - } - - return chat; - }), - - // Delete chat with ownership verification - delete: protectedProcedure - .input(chatDeleteInput) - .mutation(async ({ ctx, input }) => { - await chatService.deleteChat(ctx.db, input.id, ctx.userId); - return { success: true }; - }), - - // Save user message - saveUserMessage: protectedProcedure - .input(messageSaveUserInput) - .mutation(({ ctx, input }) => - chatService.saveUserMessage(ctx.db, { - chatId: input.chatId, - content: input.content ?? "", - }) - ), - - // Save assistant message - saveAssistantMessage: protectedProcedure - .input(messageSaveAssistantInput) - .mutation(({ ctx, input }) => - chatService.saveAssistantMessage(ctx.db, { - chatId: input.chatId, - parts: input.parts ?? [], - model: input.model ?? undefined, - promptTokens: input.promptTokens ?? undefined, - completionTokens: input.completionTokens ?? undefined, - }) - ), -}); diff --git a/apps/noter/package/feature/chat/domain/ai.ts b/apps/noter/package/feature/chat/domain/ai.ts deleted file mode 100644 index 78c07f46..00000000 --- a/apps/noter/package/feature/chat/domain/ai.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * AI Chat Domain Helpers - * - * Pure utility functions for AI message processing. - * Uses AI SDK v6 types directly - no DB, no async. - * - * @see https://ai-sdk.dev/docs/reference/ai-sdk-core/ui-message - */ - -import { isToolUIPart, type ToolUIPart } from "ai"; -import type { AiMessagePart } from "@/shared/db/type"; - -// ══════════════════════════════════════════════════════════ -// AI SDK RE-EXPORTS -// ══════════════════════════════════════════════════════════ - -export { isToolUIPart }; - -// ══════════════════════════════════════════════════════════ -// Text Extraction -// ══════════════════════════════════════════════════════════ - -export function extractTextFromParts(parts: AiMessagePart[]): string { - return parts - .filter((p) => p.type === "text") - .map((p) => ("text" in p ? p.text : "")) - .join("\n"); -} - -// ══════════════════════════════════════════════════════════ -// Tool Extraction -// ══════════════════════════════════════════════════════════ - -/** - * Extract tool invocations from message parts. - * Uses AI SDK's isToolUIPart for type narrowing. - */ -export function extractToolParts(parts: AiMessagePart[] | null | undefined) { - if (!parts) return []; - return parts.filter(isToolUIPart); -} - -// ══════════════════════════════════════════════════════════ -// State Utilities -// ══════════════════════════════════════════════════════════ - -export function getToolStateIcon(state: ToolUIPart["state"]): string { - switch (state) { - case "input-streaming": - case "input-available": - return "⏳"; - case "output-available": - return "✓"; - case "output-error": - return "✗"; - default: - return "•"; - } -} - -export function getToolStateLabel(state: ToolUIPart["state"]): string { - switch (state) { - case "input-streaming": - return "Receiving..."; - case "input-available": - return "Executing..."; - case "output-available": - return "Completed"; - case "output-error": - return "Failed"; - default: - return "Unknown"; - } -} diff --git a/apps/noter/package/feature/chat/domain/service.ts b/apps/noter/package/feature/chat/domain/service.ts deleted file mode 100644 index 6bc1e2af..00000000 --- a/apps/noter/package/feature/chat/domain/service.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * CHAT SERVICE LAYER - * - * DB operations for chat feature. - * All service functions take db as first parameter (dependency injection pattern). - * Called exclusively by api/route.ts handlers. - */ - -import type { db as dbClient } from "@/shared/lib/db"; -import { chats, messages } from "@/shared/db/schema"; -import { eq, desc, and } from "drizzle-orm"; -import type { AiMessagePart } from "@/shared/db/type"; - -type DbClient = typeof dbClient; - -// ══════════════════════════════════════════════════════════════ -// CHAT MANAGEMENT -// ══════════════════════════════════════════════════════════════ - -/** - * List all chats for a user - * Ordered by most recent first - */ -export async function listUserChats(db: DbClient, userId: string) { - return db - .select() - .from(chats) - .where(eq(chats.userId, userId)) - .orderBy(desc(chats.createdAt)); -} - -/** - * Get single chat with its messages - * Returns null if chat doesn't exist or user doesn't own it - */ -export async function getChatWithMessages( - db: DbClient, - chatId: string, - userId: string -) { - const [chat] = await db - .select() - .from(chats) - .where(and(eq(chats.id, chatId), eq(chats.userId, userId))) - .limit(1); - - if (!chat) return null; - - const chatMessages = await db - .select() - .from(messages) - .where(eq(messages.chatId, chatId)) - .orderBy(messages.createdAt); - - return { ...chat, messages: chatMessages }; -} - -/** - * Create new chat for user - * Title and model are optional, will use defaults if not provided - */ -export async function createChat( - db: DbClient, - input: { - id?: string; - userId: string; - title?: string; - model?: string; - } -) { - const [chat] = await db - .insert(chats) - .values({ - id: input.id, - userId: input.userId, - title: input.title, - model: input.model, - }) - .returning(); - return chat; -} - -/** - * Update chat title - * Returns updated chat or null if not found or not owned by user - */ -export async function updateChatTitle( - db: DbClient, - chatId: string, - userId: string, - title: string -) { - const [chat] = await db - .update(chats) - .set({ title }) - .where(and(eq(chats.id, chatId), eq(chats.userId, userId))) - .returning(); - return chat || null; -} - -/** - * Delete chat and all its messages (cascade handled by DB) - */ -export async function deleteChat( - db: DbClient, - chatId: string, - userId: string -) { - await db - .delete(chats) - .where(and(eq(chats.id, chatId), eq(chats.userId, userId))); -} - -// ══════════════════════════════════════════════════════════════ -// MESSAGE MANAGEMENT -// ══════════════════════════════════════════════════════════════ - -/** - * Save user message to chat - * Returns created message - */ -export async function saveUserMessage( - db: DbClient, - input: { chatId: string; content: string } -) { - const [message] = await db - .insert(messages) - .values({ - chatId: input.chatId, - role: "user", - content: input.content, - }) - .returning(); - return message; -} - -/** - * Save assistant message with AI parts - * Includes token usage and model info - */ -export async function saveAssistantMessage( - db: DbClient, - input: { - chatId: string; - parts: AiMessagePart[]; - model?: string; - promptTokens?: number; - completionTokens?: number; - } -) { - const [message] = await db - .insert(messages) - .values({ - chatId: input.chatId, - role: "assistant", - parts: input.parts, - model: input.model, - status: "completed", - promptTokens: input.promptTokens, - completionTokens: input.completionTokens, - }) - .returning(); - return message; -} diff --git a/apps/noter/package/feature/chat/domain/type.ts b/apps/noter/package/feature/chat/domain/type.ts deleted file mode 100644 index 9530ebad..00000000 --- a/apps/noter/package/feature/chat/domain/type.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Chat Feature Domain Types - * - * UI-specific types for chat components. - * Pure TypeScript - no Zod, no runtime validation. - * - * For DB types, import from @/shared/db/type - */ - -import type { UIMessage } from "ai"; -import type { Chat, Message } from "@/shared/db/type"; - -// ══════════════════════════════════════════════════════════ -// COMPOSED TYPES -// ══════════════════════════════════════════════════════════ - -/** - * Chat with its messages loaded - */ -export type ChatWithMessages = Chat & { - messages: Message[]; -}; - -/** - * Chat message with timestamp for UI display - * Extends AI SDK's UIMessage with optional createdAt field - */ -export type ChatMessageWithTimestamp = UIMessage & { - createdAt?: Date | string; -}; diff --git a/apps/noter/package/feature/chat/hook/use-chat.ts b/apps/noter/package/feature/chat/hook/use-chat.ts deleted file mode 100644 index 50c796b0..00000000 --- a/apps/noter/package/feature/chat/hook/use-chat.ts +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; - -import { useChat as useAiChat } from "@ai-sdk/react"; -import { DefaultChatTransport, type UIMessage } from "ai"; -import { useAtomValue } from "jotai"; -import { useEffect, useRef, useCallback } from "react"; -import { modelAtom } from "../state/atom"; -import { trpc } from "@/shared/lib/trpc/client"; -import { sessionAtom } from "@/feature/auth"; // ✓ Import from barrel, not internal file - -export type UseChatOptions = { - chatId: string; -}; - -export function useChat({ chatId }: UseChatOptions) { - const model = useAtomValue(modelAtom); - const session = useAtomValue(sessionAtom); - const utils = trpc.useUtils(); - const chatCreatedRef = useRef(false); - const messagesLoadedRef = useRef(false); - - - // Fetch existing chat with messages - const { data: existingChat } = trpc.chat.get.useQuery( - { id: chatId }, - { enabled: !!chatId } - ); - - // Mutations for persistence - const createChat = trpc.chat.create.useMutation(); - const saveUserMessage = trpc.chat.saveUserMessage.useMutation(); - const saveAssistantMessage = trpc.chat.saveAssistantMessage.useMutation(); - const updateTitle = trpc.chat.updateTitle.useMutation(); - - // Ensure chat exists - const ensureChatExists = useCallback(async () => { - if (chatCreatedRef.current) return; - chatCreatedRef.current = true; - - try { - await createChat.mutateAsync({ id: chatId }); - utils.chat.list.invalidate(); - } catch { - // Chat already exists, ignore - } - }, [chatId, createChat, utils.chat.list]); - - const chat = useAiChat({ - id: chatId, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ai / @ai-sdk/react version mismatch - transport: new DefaultChatTransport({ - api: "/api/chat", - body: { model }, - headers: (): Record => { - if (session?.sessionId) { - return { "x-session-id": session.sessionId }; - } - return {}; - }, - }) as any, - onFinish: async ({ messages: allMessages }) => { - // Get the last assistant message - const lastMessage = allMessages.findLast((m) => m.role === "assistant"); - if (!lastMessage?.parts) return; - - // Save assistant message to DB - await saveAssistantMessage.mutateAsync({ - chatId, - parts: lastMessage.parts as any, - model, - }); - - // Auto-generate title from first user message if not set - const firstUserMessage = allMessages.find((m) => m.role === "user"); - if (firstUserMessage && allMessages.length <= 2) { - const text = - firstUserMessage.parts.find((p) => p.type === "text")?.text ?? ""; - if (text) { - const title = text.slice(0, 50) + (text.length > 50 ? "..." : ""); - await updateTitle.mutateAsync({ id: chatId, title }); - utils.chat.list.invalidate(); - } - } - }, - }); - - // Load existing messages from DB on mount - useEffect(() => { - if (!existingChat?.messages || messagesLoadedRef.current) return; - if (chat.messages.length > 0) return; // Don't override if already has messages - - messagesLoadedRef.current = true; - - // Convert DB messages to UIMessage format - const uiMessages: UIMessage[] = existingChat.messages.map((msg) => ({ - id: msg.id, - role: msg.role as "user" | "assistant", - parts: - msg.role === "user" - ? [{ type: "text" as const, text: msg.content ?? "" }] - : (msg.parts as UIMessage["parts"]) ?? [], - createdAt: msg.createdAt, - })); - - if (uiMessages.length > 0) { - chat.setMessages(uiMessages); - } - }, [existingChat, chat]); - - // Custom send that persists user message - const sendMessageWithPersistence = useCallback( - async (message: Parameters[0]) => { - if (!message) return; - - await ensureChatExists(); - - // Extract text from parts - const textPart = message.parts?.find((p) => p.type === "text"); - if (textPart && "text" in textPart) { - await saveUserMessage.mutateAsync({ - chatId, - content: textPart.text, - }); - } - - chat.sendMessage(message); - }, - [chat, chatId, ensureChatExists, saveUserMessage] - ); - - return { - ...chat, - sendMessage: sendMessageWithPersistence, - model, - }; -} diff --git a/apps/noter/package/feature/chat/index.ts b/apps/noter/package/feature/chat/index.ts deleted file mode 100644 index a83d5e23..00000000 --- a/apps/noter/package/feature/chat/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Domain Types -export type { ChatWithMessages, ChatMessageWithTimestamp } from "./domain/type"; - -// Domain Helpers -export { - extractTextFromParts, - extractToolParts, - isToolUIPart, - getToolStateIcon, - getToolStateLabel, -} from "./domain/ai"; - -// Form Schemas -export { - chatFormSchema, - messageFormSchema, - type ChatFormData, - type MessageFormData, -} from "./api/form"; - -// API Input Schemas -export { - chatGetInput, - chatCreateInput, - chatUpdateTitleInput, - chatDeleteInput, - messageSaveUserInput, - messageSaveAssistantInput, - type ChatGetInput, - type ChatCreateInput, - type ChatUpdateTitleInput, - type ChatDeleteInput, - type MessageSaveUserInput, - type MessageSaveAssistantInput, -} from "./api/input"; - -// State -export { - chatsAtom, - messagesFamily, - modelAtom, - activeChatIdAtom, -} from "./state/atom"; - -// Hooks -export { useChat } from "./hook/use-chat"; - -// UI -export { ChatContainer } from "./ui/chat"; -export { MessageList } from "./ui/message-list"; -export { ChatMessage } from "./ui/message"; -export { ChatInput } from "./ui/chat-input"; -export { ChatSidebar } from "./ui/chat-sidebar"; -export { ModelSelector } from "./ui/model-selector"; -export { ToolUI } from "./ui/ai-tool-ui"; -export { ToolBadge } from "./ui/tool-badge"; -export { ToolsUsedBar } from "./ui/tools-used-bar"; diff --git a/apps/noter/package/feature/chat/state/atom.ts b/apps/noter/package/feature/chat/state/atom.ts deleted file mode 100644 index 7a3b22cd..00000000 --- a/apps/noter/package/feature/chat/state/atom.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { atom } from "jotai"; -import { atomFamily } from "jotai/utils"; -import type { Chat, Message } from "@/shared/db/type"; -import { DEFAULT_MODEL } from "@/shared/lib/ai/constant"; - -/** All chats (for sidebar/history) */ -export const chatsAtom = atom([]); - -/** Messages per chat (atomFamily for isolation) */ -export const messagesFamily = atomFamily((chatId: string) => - atom([]) -); - -/** Current model selection */ -export const modelAtom = atom(DEFAULT_MODEL); - -/** Currently active chat ID */ -export const activeChatIdAtom = atom(null); diff --git a/apps/noter/package/feature/chat/ui/ai-tool-ui.tsx b/apps/noter/package/feature/chat/ui/ai-tool-ui.tsx deleted file mode 100644 index 00aed632..00000000 --- a/apps/noter/package/feature/chat/ui/ai-tool-ui.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * AI Tool UI Router - * - * Routes tool invocations to their specific UI components - */ - -import type { ToolUIPart, DynamicToolUIPart } from "ai"; -import { getToolName } from "ai"; -import { ToolGetUserInfo } from "./tools/tool-get-user-info"; -import { ToolGetBalances } from "./tools/tool-get-balances"; -import { ToolGetTransactions } from "./tools/tool-get-transactions"; -import { ToolGetCoinHistory } from "./tools/tool-get-coin-history"; -import { ToolShowDiagram } from "./tools/tool-show-diagram"; -import { ToolBadge } from "./tool-badge"; - -type ToolUIProps = { - tool: ToolUIPart | DynamicToolUIPart; -}; - -export function ToolUI({ tool }: ToolUIProps) { - const toolName = getToolName(tool as any); - const { state } = tool; - const output = "output" in tool ? tool.output : undefined; - const errorText = "errorText" in tool ? tool.errorText : undefined; - - // Show badge for non-completed states - if (state !== "output-available" && state !== "output-error") { - return ; - } - - // Error state - if (state === "output-error" && errorText) { - return ( -
-
- - {toolName} failed: - {errorText} -
-
- ); - } - - // Route to specific tool UI components for successful executions - switch (toolName) { - case "getUserInfo": - return output ? : null; - - case "getBalances": - return output ? : null; - - case "getTransactions": - return output ? : null; - - case "getCoinHistory": - return output ? : null; - - case "showDiagram": - return output ? : null; - - // Add more tool renderers here as you add tools - // case 'calculate': - // return output ? : null; - - default: - // Fallback: show generic completed badge - return ; - } -} diff --git a/apps/noter/package/feature/chat/ui/chat-input.tsx b/apps/noter/package/feature/chat/ui/chat-input.tsx deleted file mode 100644 index f859b10a..00000000 --- a/apps/noter/package/feature/chat/ui/chat-input.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Button } from "@/shared/components/ui/button"; -import { SendHorizontal } from "lucide-react"; -import { ChatEditor } from "@/feature/editor"; -import { ModelSelector } from "./model-selector"; - -type ChatInputProps = { - input: string; - setInput: (value: string) => void; - onSubmit: () => void; - isLoading?: boolean; -}; - -export function ChatInput({ - input, - setInput, - onSubmit, - isLoading, -}: ChatInputProps) { - return ( -
- {/* Input wrapper */} -
- { - if (input.trim() && !isLoading) { - onSubmit(); - } - }} - disabled={isLoading} - placeholder="Ask anything... ($ for coins, Enter to send)" - className="flex-1" - /> -
- - -
-
-
- ); -} diff --git a/apps/noter/package/feature/chat/ui/chat-sidebar.tsx b/apps/noter/package/feature/chat/ui/chat-sidebar.tsx deleted file mode 100644 index b5a49af2..00000000 --- a/apps/noter/package/feature/chat/ui/chat-sidebar.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { useRouter, useParams } from "next/navigation"; -import { trpc } from "@/shared/lib/trpc/client"; -import { Button } from "@/shared/components/ui/button"; -import { ScrollArea } from "@/shared/components/ui/scroll-area"; -import { Plus, MessageSquare, Trash2 } from "lucide-react"; -import { cn } from "@/shared/lib/utils"; -import { uuidv7 } from "uuidv7"; - -export function ChatSidebar() { - const router = useRouter(); - const params = useParams(); - const currentChatId = params?.id as string | undefined; - - const { data: chats, refetch } = trpc.chat.list.useQuery(); - const createChat = trpc.chat.create.useMutation({ - onSuccess: (chat) => { - refetch(); - router.push(`/ai/${chat.id}`); - }, - }); - const deleteChat = trpc.chat.delete.useMutation({ - onSuccess: () => { - refetch(); - if (currentChatId) { - router.push("/ai"); - } - }, - }); - - const handleNewChat = () => { - const id = uuidv7(); - createChat.mutate({ id }); - }; - - return ( -
-
- -
- - -
- {chats?.map((chat) => ( -
router.push(`/ai/${chat.id}`)} - > - - - {chat.title || "New Chat"} - - -
- ))} -
-
-
- ); -} diff --git a/apps/noter/package/feature/chat/ui/chat.tsx b/apps/noter/package/feature/chat/ui/chat.tsx deleted file mode 100644 index 7a23ee0c..00000000 --- a/apps/noter/package/feature/chat/ui/chat.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { MarketingBorder } from "@/package/shared/components/border"; -import { motion } from "framer-motion"; -import Image from "next/image"; -import { useState } from "react"; -import { useChat } from "../hook/use-chat"; -import { ChatInput } from "./chat-input"; -import { MessageList } from "./message-list"; -type ChatContainerProps = { - chatId: string; -}; - -export function ChatContainer({ chatId }: ChatContainerProps) { - const [input, setInput] = useState(""); - const { messages, sendMessage, status } = useChat({ chatId }); - - const isLoading = status === "streaming" || status === "submitted"; - - const onSubmit = () => { - if (input.trim() && !isLoading) { - sendMessage({ role: "user", parts: [{ type: "text", text: input }] }); - setInput(""); - } - }; - - return ( -
- - - Noter - - - - Noter - - -
-
- -
-
- -
-
-
- ); -} diff --git a/apps/noter/package/feature/chat/ui/message-list.tsx b/apps/noter/package/feature/chat/ui/message-list.tsx deleted file mode 100644 index 037d25f9..00000000 --- a/apps/noter/package/feature/chat/ui/message-list.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import type { UIMessage } from "ai"; -import { useEffect, useRef } from "react"; -import { ChatMessage } from "./message"; - -type MessageListProps = { - messages: UIMessage[]; - isLoading?: boolean; -}; - -export function MessageList({ messages, isLoading }: MessageListProps) { - const bottomRef = useRef(null); - const prevMessageCountRef = useRef(messages.length); - const scrollContainerRef = useRef(null); - - useEffect(() => { - // Find the scroll container on mount - if (bottomRef.current && !scrollContainerRef.current) { - scrollContainerRef.current = bottomRef.current.closest('.layout-scroll'); - } - }, []); - - useEffect(() => { - // Only scroll when a new message is added, not on content updates - const messageCountChanged = messages.length !== prevMessageCountRef.current; - prevMessageCountRef.current = messages.length; - - if (messageCountChanged && bottomRef.current) { - // Check if user is near bottom before auto-scrolling - const container = scrollContainerRef.current; - if (container) { - const threshold = 100; // px from bottom - const isNearBottom = - container.scrollHeight - container.scrollTop - container.clientHeight < threshold; - - if (isNearBottom || messages.length === 1) { - // Use instant scroll to prevent glitching during streaming - bottomRef.current.scrollIntoView({ behavior: "instant", block: "end" }); - } - } else { - // Fallback if container not found - bottomRef.current.scrollIntoView({ behavior: "instant", block: "end" }); - } - } - }, [messages]); - - if (messages.length === 0) { - return ( -
- {/*

Tell your

story

*/} - {/*

welcome

*/} -
- ); - } - - return ( -
- {messages.map((message) => ( - - ))} - {isLoading && ( -
-
- Thinking... -
-
- )} -
-
- ); -} diff --git a/apps/noter/package/feature/chat/ui/message.tsx b/apps/noter/package/feature/chat/ui/message.tsx deleted file mode 100644 index c7715fda..00000000 --- a/apps/noter/package/feature/chat/ui/message.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { ChatRenderer } from "@/feature/editor"; -import { extractTextFromParts, extractToolParts } from "../domain/ai"; -import type { ChatMessageWithTimestamp } from "../domain/type"; -import { ToolUI } from "./ai-tool-ui"; - -type ChatMessageProps = { - message: ChatMessageWithTimestamp; -}; - -export function ChatMessage({ message }: ChatMessageProps) { - const isUser = message.role === "user"; - - // User message - simple bubble - if (isUser) { - const text = extractTextFromParts(message.parts); - return ( -
-
- -
-
- ); - } - - // AI message with tool support - const text = extractTextFromParts(message.parts); - const tools = extractToolParts(message.parts); - - // Don't render if no text and no completed tools - const hasCompletedTools = tools.some((t) => t.state === "output-available"); - if (!text && !hasCompletedTools) { - return null; - } - - return ( -
- {/* Content */} -
- {/* Model badge */} -
- - {message.createdAt - ? new Date(message.createdAt).toLocaleTimeString() - : new Date().toLocaleTimeString()} - -
- - {/* Text content */} - {text && ( - - )} - - {/* Tool invocations */} - {tools.length > 0 && ( -
- {tools.map((tool) => ( - - ))} -
- )} -
-
- ); -} diff --git a/apps/noter/package/feature/chat/ui/model-selector.tsx b/apps/noter/package/feature/chat/ui/model-selector.tsx deleted file mode 100644 index 9fd978ea..00000000 --- a/apps/noter/package/feature/chat/ui/model-selector.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/components/ui/select"; -import { MODELS } from "@/shared/lib/ai/constant"; -import { useAtom } from "jotai"; -import { modelAtom } from "../state/atom"; - -export function ModelSelector() { - const [model, setModel] = useAtom(modelAtom); - - return ( - - ); -} diff --git a/apps/noter/package/feature/chat/ui/tool-badge.tsx b/apps/noter/package/feature/chat/ui/tool-badge.tsx deleted file mode 100644 index f4883777..00000000 --- a/apps/noter/package/feature/chat/ui/tool-badge.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Tool State Badge - * - * Displays tool execution state with icon and label - */ - -import type { ToolUIPart } from "ai"; -import { Badge } from "@/shared/components/ui/badge"; -import { CheckCircle, XCircle, Loader2, AlertCircle } from "lucide-react"; - -type ToolBadgeProps = { - toolName: string; - state: ToolUIPart["state"]; - className?: string; -}; - -export function ToolBadge({ toolName, state, className }: ToolBadgeProps) { - const getStateIcon = () => { - switch (state) { - case "input-streaming": - case "input-available": - return ; - case "output-available": - return ; - case "output-error": - return ; - default: - return null; - } - }; - - const getVariant = (): "default" | "secondary" | "destructive" | "outline" => { - if (state === "output-error") return "destructive"; - return "secondary"; - }; - - return ( - - {getStateIcon()} - {toolName} - - ); -} diff --git a/apps/noter/package/feature/chat/ui/tools-used-bar.tsx b/apps/noter/package/feature/chat/ui/tools-used-bar.tsx deleted file mode 100644 index 98709af3..00000000 --- a/apps/noter/package/feature/chat/ui/tools-used-bar.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Tools Used Bar - * - * Shows badges for all tool invocations with their execution state - */ - -import { Badge } from "@/shared/components/ui/badge"; -import type { DynamicToolUIPart, ToolUIPart } from "ai"; -import { getToolName } from "ai"; -import { AlertCircle, CheckCircle, Loader2, XCircle } from "lucide-react"; - -type ToolsUsedBarProps = { - toolInvocations: (ToolUIPart | DynamicToolUIPart)[]; -}; - -export function ToolsUsedBar({ toolInvocations }: ToolsUsedBarProps) { - if (toolInvocations.length === 0) return null; - - const getStateIcon = (tool: ToolUIPart | DynamicToolUIPart) => { - const Icon = (() => { - switch (tool.state) { - case "input-streaming": - case "input-available": - return Loader2; - case "output-available": - return CheckCircle; - case "output-error": - return XCircle; - default: - return null; - } - })(); - - if (!Icon) return null; - - return ( - - ); - }; - - const getVariant = ( - state: ToolUIPart["state"] - ): "default" | "secondary" | "destructive" | "outline" => { - if (state === "output-error") return "destructive"; - return "secondary"; - }; - - return ( -
- {toolInvocations.map((tool) => ( - - {getStateIcon(tool)} - {getToolName(tool as any)} - - ))} -
- ); -} diff --git a/apps/noter/package/feature/chat/ui/tools/tool-get-balances.tsx b/apps/noter/package/feature/chat/ui/tools/tool-get-balances.tsx deleted file mode 100644 index 019ac439..00000000 --- a/apps/noter/package/feature/chat/ui/tools/tool-get-balances.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * GetBalances Tool UI - * - * Displays user's Sui blockchain token balances - */ - -"use client"; - -import { Coins, Wallet } from "lucide-react"; -import type { GetBalancesOutput } from "@/shared/lib/ai/tools"; - -type ToolGetBalancesProps = { - output: GetBalancesOutput; -}; - -function formatBalance(balance: string, coinType: string): string { - // SUI has 9 decimals, most tokens have 6-9 - const decimals = coinType.includes("::sui::SUI") ? 9 : 9; - const value = Number(balance) / Math.pow(10, decimals); - - return value.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 6, - }); -} - -function getCoinName(coinType: string): string { - // Extract coin name from type - // Example: "0x2::sui::SUI" -> "SUI" - const parts = coinType.split("::"); - return parts[parts.length - 1] || "Unknown"; -} - -function shortenAddress(address: string): string { - return `${address.slice(0, 6)}...${address.slice(-4)}`; -} - -export function ToolGetBalances({ output }: ToolGetBalancesProps) { - const hasSUI = output.balances.some(b => b.coinType.includes("::sui::SUI")); - const suiBalance = output.balances.find(b => b.coinType.includes("::sui::SUI")); - const otherBalances = output.balances.filter(b => !b.coinType.includes("::sui::SUI")); - - return ( -
-
-
- - Token Balances -
- - {shortenAddress(output.address)} - -
- - {output.balances.length === 0 ? ( -
- No tokens found -
- ) : ( -
- {/* SUI Balance (primary) */} - {suiBalance && ( -
-
- - SUI -
-
-
- {formatBalance(suiBalance.totalBalance, suiBalance.coinType)} -
-
- {suiBalance.coinObjectCount} {suiBalance.coinObjectCount === 1 ? 'coin' : 'coins'} -
-
-
- )} - - {/* Other Tokens */} - {otherBalances.length > 0 && ( -
- {otherBalances.map((balance, idx) => ( -
-
- - - {getCoinName(balance.coinType)} - -
-
-
- {formatBalance(balance.totalBalance, balance.coinType)} -
-
- {balance.coinObjectCount} {balance.coinObjectCount === 1 ? 'coin' : 'coins'} -
-
-
- ))} -
- )} -
- )} - - {!hasSUI && output.balances.length === 0 && ( -
- 💡 Tip: You need SUI tokens to pay for gas fees on the Sui blockchain -
- )} -
- ); -} diff --git a/apps/noter/package/feature/chat/ui/tools/tool-get-coin-history.tsx b/apps/noter/package/feature/chat/ui/tools/tool-get-coin-history.tsx deleted file mode 100644 index b15cf036..00000000 --- a/apps/noter/package/feature/chat/ui/tools/tool-get-coin-history.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/** - * GetCoinHistory Tool UI - * - * Displays cryptocurrency historical price chart - */ - -"use client"; - -import type { GetCoinHistoryOutput } from "@/shared/lib/ai/tools"; -import { Button } from "@/shared/components/ui/button"; -import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; -import { Calendar, ChevronDown, ChevronUp, TrendingDown, TrendingUp } from "lucide-react"; -import { useState } from "react"; - -type ToolGetCoinHistoryProps = { - output: GetCoinHistoryOutput; -}; - -function formatPrice(price: number): string { - if (price >= 1000) { - return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; - } else if (price >= 1) { - return `$${price.toFixed(4)}`; - } else { - return `$${price.toFixed(6)}`; - } -} - -function formatDate(timestamp: string): string { - const date = new Date(timestamp); - return date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }); -} - -function formatDateTime(timestamp: string): string { - const date = new Date(timestamp); - return date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -function calculatePriceChange(data: GetCoinHistoryOutput["data"]): { change: number; percent: number } { - if (data.length < 2) return { change: 0, percent: 0 }; - - const firstPrice = data[0].price; - const lastPrice = data[data.length - 1].price; - const change = lastPrice - firstPrice; - const percent = (change / firstPrice) * 100; - - return { change, percent }; -} - -export function ToolGetCoinHistory({ output }: ToolGetCoinHistoryProps) { - const { symbol, name, data } = output; - const [expanded, setExpanded] = useState(false); - - if (data.length === 0) { - return ( -
-
- No historical data available for {symbol} -
-
- ); - } - - const currentPrice = data[data.length - 1].price; - const { change, percent } = calculatePriceChange(data); - const isPositive = change >= 0; - - // Format data for chart - const chartData = data.map((point) => ({ - timestamp: point.timestamp, - price: point.price, - formattedDate: formatDate(point.timestamp), - formattedDateTime: formatDateTime(point.timestamp), - })); - - return ( -
- - - {expanded && ( - <> -
-
-
-

{name}

- {symbol} -
-
- {formatPrice(currentPrice)} -
- {isPositive ? : } - {isPositive ? "+" : ""}{formatPrice(change)} - ({isPositive ? "+" : ""}{percent.toFixed(2)}%) -
-
-
-
- - {data.length} {data.length === 1 ? "day" : "days"} -
-
- - )} - - {/* Chart */} -
- - - - - { - if (value >= 1000) return `$${(value / 1000).toFixed(0)}k`; - return `$${value.toFixed(0)}`; - }} - /> - { - if (!active || !payload || !payload.length) return null; - - const data = payload[0].payload; - - return ( -
-
- {data.formattedDateTime} -
-
- {formatPrice(data.price)} -
-
- ); - }} - /> - -
-
-
- - {expanded && ( -
-
-
High
-
- {formatPrice(Math.max(...data.map((d) => d.price)))} -
-
-
-
Low
-
- {formatPrice(Math.min(...data.map((d) => d.price)))} -
-
-
-
Avg Volume
-
- ${(data.reduce((sum, d) => sum + d.volume24h, 0) / data.length / 1_000_000_000).toFixed(2)}B -
-
-
- )} -
- ); -} diff --git a/apps/noter/package/feature/chat/ui/tools/tool-get-transactions.tsx b/apps/noter/package/feature/chat/ui/tools/tool-get-transactions.tsx deleted file mode 100644 index 0373f5d8..00000000 --- a/apps/noter/package/feature/chat/ui/tools/tool-get-transactions.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/** - * GetTransactions Tool UI - * - * Displays user's recent Sui blockchain transactions - */ - -"use client"; - -import { Button } from "@/package/shared/components/ui/button"; -import type { GetTransactionsOutput } from "@/shared/lib/ai/tools"; -import { cn } from "@/shared/lib/utils"; -import { color } from "@/shared/util/color"; -import { CheckCircle2, ExternalLink, XCircle } from "lucide-react"; - -type ToolGetTransactionsProps = { - output: GetTransactionsOutput; -}; - -function formatTimestamp(timestampMs: string): string { - const date = new Date(Number(timestampMs)); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; - - return date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, - }); -} - -function shortenDigest(digest: string): string { - return `${digest.slice(0, 8)}...${digest.slice(-6)}`; -} - -function shortenAddress(address: string): string { - return `${address.slice(0, 6)}...${address.slice(-4)}`; -} - -export function ToolGetTransactions({ output }: ToolGetTransactionsProps) { - const explorerUrl = "https://suiscan.xyz/mainnet/tx"; - - return ( -
- {/*
-
- - Recent Transactions -
- {shortenAddress(output.address)} -
*/} - - {output.transactions.length === 0 ? ( -
No transactions found
- ) : ( -
- {output.transactions.map((tx) => { - const isSuccess = tx.effects.status.status === "success"; - - return ( - - -
- {shortenDigest(tx.digest)} -
- {formatTimestamp(tx.timestampMs)} - Checkpoint {tx.checkpoint} -
-
- -
- ); - })} -
- )} - {/* - {output.hasMore && ( -
- Showing recent {output.transactions.length} transactions -
- )} */} -
- ); -} diff --git a/apps/noter/package/feature/chat/ui/tools/tool-get-user-info.tsx b/apps/noter/package/feature/chat/ui/tools/tool-get-user-info.tsx deleted file mode 100644 index 939ae407..00000000 --- a/apps/noter/package/feature/chat/ui/tools/tool-get-user-info.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * GetUserInfo Tool UI - * - * Displays user information retrieved by the AI - */ - -"use client"; - -import { User, Mail, Wallet, Calendar, Shield, Clock } from "lucide-react"; -import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"; -import type { GetUserInfoOutput } from "@/shared/lib/ai/tools"; - -type ToolGetUserInfoProps = { - output: GetUserInfoOutput; -}; - -export function ToolGetUserInfo({ output }: ToolGetUserInfoProps) { - const initials = output.name - ?.split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2) || "U"; - - return ( -
-
- - User Information -
- -
- - - {initials} - - -
-
-

{output.name || "Anonymous"}

- {output.email && ( -
- - {output.email} -
- )} -
- -
-
- - Sui Address: - - {output.suiAddress.slice(0, 8)}...{output.suiAddress.slice(-6)} - -
- -
- - Auth: - - {output.authMethod === "zklogin" - ? `zkLogin (${output.provider || "unknown"})` - : `Wallet (${output.walletType || "unknown"})` - } - -
- -
- - Member since {output.memberSince} -
- - {output.lastSeenAt && ( -
- - Last active {output.lastSeenAt} -
- )} -
-
-
-
- ); -} diff --git a/apps/noter/package/feature/chat/ui/tools/tool-show-diagram.tsx b/apps/noter/package/feature/chat/ui/tools/tool-show-diagram.tsx deleted file mode 100644 index 802e61a3..00000000 --- a/apps/noter/package/feature/chat/ui/tools/tool-show-diagram.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** - * ShowDiagram Tool UI - * - * Displays educational flow diagrams - */ - -"use client"; - -import type { ShowDiagramOutput } from "@/shared/lib/ai/tools"; - -type ToolShowDiagramProps = { - output: ShowDiagramOutput; -}; - -export function ToolShowDiagram({ output }: ToolShowDiagramProps) { - return ( -
- {/* Title as link */} - - {output.title} - - - {/* SVG Diagram */} -
- {output.title} -
-
- ); -} - -// Keep the old export name for backward compatibility -export { ToolShowDiagram as ToolShowZkLoginDiagram }; diff --git a/apps/noter/package/feature/editor/index.ts b/apps/noter/package/feature/editor/index.ts index d05b683d..2e6c236d 100644 --- a/apps/noter/package/feature/editor/index.ts +++ b/apps/noter/package/feature/editor/index.ts @@ -4,9 +4,6 @@ export { ChatEditor } from './ui/chat-editor'; export { ChatRenderer } from './ui/chat-renderer'; -export { CoinMentionNode } from './nodes/CoinMentionNode'; -export { CoinMentionPlugin } from './plugins/CoinMentionPlugin'; export { default as CodeHighlightPlugin } from './plugins/CodeHighlightPlugin'; -export { COINS, searchCoins, type CoinData } from './constant'; export { CHAT_TRANSFORMERS } from './config/markdown-transformers'; export { default as ChatEditorTheme } from './themes/ChatEditorTheme'; diff --git a/apps/noter/package/feature/editor/nodes/CoinMentionNode.tsx b/apps/noter/package/feature/editor/nodes/CoinMentionNode.tsx deleted file mode 100644 index cee305b8..00000000 --- a/apps/noter/package/feature/editor/nodes/CoinMentionNode.tsx +++ /dev/null @@ -1,230 +0,0 @@ -/** - * CoinMentionNode - * - * A Lexical DecoratorNode for mentioning crypto coins. - * Triggered by $ in the editor, renders as a clickable chip with coin symbol. - * - * Uses DecoratorNode pattern similar to MemberMentionNode. - */ - -'use client'; - -import { cn } from '@/shared/lib/utils'; -import { - $applyNodeReplacement, - DecoratorNode, - type DOMConversionMap, - type DOMConversionOutput, - type DOMExportOutput, - type EditorConfig, - type LexicalNode, - type NodeKey, - type SerializedLexicalNode, - type Spread, -} from 'lexical'; -import * as React from 'react'; - -// ════════════════════════════════════════════════════════════════════════════ -// TYPES -// ════════════════════════════════════════════════════════════════════════════ - -export interface CoinMentionData { - id: string; - symbol: string; - name: string; - color?: string; -} - -export type SerializedCoinMentionNode = Spread< - { coin: CoinMentionData }, - SerializedLexicalNode ->; - -// ════════════════════════════════════════════════════════════════════════════ -// COMPONENT -// ════════════════════════════════════════════════════════════════════════════ - -interface CoinMentionComponentProps { - coin: CoinMentionData; - nodeKey: string; -} - -function CoinMentionComponent({ - coin, -}: CoinMentionComponentProps): React.ReactElement { - const handleClick = React.useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - // Future: Navigate to coin details page or open modal - }, [coin]); - - const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleClick(e as unknown as React.MouseEvent); - } - }, [handleClick]); - - return ( - - ${coin.symbol} - - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// DOM CONVERSION -// ════════════════════════════════════════════════════════════════════════════ - -function $convertCoinMentionElement( - domNode: HTMLElement -): DOMConversionOutput | null { - const coinJson = domNode.getAttribute('data-coin'); - - if (coinJson) { - try { - const coin = JSON.parse(coinJson) as CoinMentionData; - const node = $createCoinMentionNode(coin); - return { node }; - } catch { - return null; - } - } - - return null; -} - -// ════════════════════════════════════════════════════════════════════════════ -// NODE CLASS -// ════════════════════════════════════════════════════════════════════════════ - -const DEFAULT_COIN: CoinMentionData = { - id: '', - symbol: '', - name: '', -}; - -export class CoinMentionNode extends DecoratorNode { - __coin: CoinMentionData; - - static getType(): string { - return 'coin-mention'; - } - - static clone(node: CoinMentionNode): CoinMentionNode { - return new CoinMentionNode(node.__coin, node.__key); - } - - static importJSON(serializedNode: SerializedCoinMentionNode): CoinMentionNode { - return $createCoinMentionNode(serializedNode.coin); - } - - // IMPORTANT: All constructor args have defaults for Yjs compatibility - constructor(coin: CoinMentionData = DEFAULT_COIN, key?: NodeKey) { - super(key); - this.__coin = coin; - } - - exportJSON(): SerializedCoinMentionNode { - return { - ...super.exportJSON(), - coin: this.__coin, - }; - } - - createDOM(config: EditorConfig): HTMLElement { - const span = document.createElement('span'); - span.className = 'coin-mention-wrapper'; - return span; - } - - updateDOM(): false { - return false; - } - - exportDOM(): DOMExportOutput { - const element = document.createElement('span'); - element.setAttribute('data-lexical-coin-mention', 'true'); - element.setAttribute('data-coin', JSON.stringify(this.__coin)); - element.textContent = `$${this.__coin.symbol}`; - return { element }; - } - - static importDOM(): DOMConversionMap | null { - return { - span: (domNode: HTMLElement) => { - if (!domNode.hasAttribute('data-lexical-coin-mention')) { - return null; - } - return { - conversion: $convertCoinMentionElement, - priority: 1, - }; - }, - }; - } - - decorate(): React.ReactElement { - return ( - - ); - } - - isInline(): boolean { - return true; - } - - isKeyboardSelectable(): boolean { - return true; - } - - // Getters - getCoin(): CoinMentionData { - return this.__coin; - } - - getCoinId(): string { - return this.__coin.id; - } - - getCoinSymbol(): string { - return this.__coin.symbol; - } - - getCoinName(): string { - return this.__coin.name; - } - - getTextContent(): string { - return `$${this.__coin.symbol}`; - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// FACTORY & TYPE GUARD -// ════════════════════════════════════════════════════════════════════════════ - -export function $createCoinMentionNode( - coin: CoinMentionData -): CoinMentionNode { - return $applyNodeReplacement(new CoinMentionNode(coin)); -} - -export function $isCoinMentionNode( - node: LexicalNode | null | undefined -): node is CoinMentionNode { - return node instanceof CoinMentionNode; -} diff --git a/apps/noter/package/feature/editor/plugins/CoinMentionPlugin/index.tsx b/apps/noter/package/feature/editor/plugins/CoinMentionPlugin/index.tsx deleted file mode 100644 index ea5a7066..00000000 --- a/apps/noter/package/feature/editor/plugins/CoinMentionPlugin/index.tsx +++ /dev/null @@ -1,244 +0,0 @@ -/** - * CoinMentionPlugin - * - * Lexical plugin for mentioning crypto coins with $ trigger. - * Displays a typeahead menu with matching coins from the static list. - * - * Usage: - * - */ - -'use client'; - -import type { JSX } from 'react'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { - LexicalTypeaheadMenuPlugin, - MenuOption, - type MenuTextMatch, -} from '@lexical/react/LexicalTypeaheadMenuPlugin'; -import { $createTextNode, TextNode } from 'lexical'; -import { useCallback, useMemo, useState } from 'react'; -import * as ReactDOM from 'react-dom'; - -import { $createCoinMentionNode } from '../../nodes/CoinMentionNode'; -import { searchCoins, type CoinData } from '../../constant'; - -// ════════════════════════════════════════════════════════════════════════════ -// CONSTANTS -// ════════════════════════════════════════════════════════════════════════════ - -// Trigger: $ followed by characters -const CoinMentionRegex = new RegExp( - '(^|\\s)(\\$)([^$\\s]{0,50})$' -); - -const SUGGESTION_LIST_LENGTH_LIMIT = 10; - -// ════════════════════════════════════════════════════════════════════════════ -// TYPEAHEAD OPTION CLASS -// ════════════════════════════════════════════════════════════════════════════ - -class CoinTypeaheadOption extends MenuOption { - coin: CoinData; - - constructor(coin: CoinData) { - super(coin.id); - this.coin = coin; - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// MENU ITEM COMPONENT -// ════════════════════════════════════════════════════════════════════════════ - -function CoinTypeaheadMenuItem({ - index, - isSelected, - onClick, - onMouseEnter, - option, -}: { - index: number; - isSelected: boolean; - onClick: () => void; - onMouseEnter: () => void; - option: CoinTypeaheadOption; -}) { - const { coin } = option; - - return ( -
  • - - {coin.symbol} - {coin.name} -
  • - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// TRIGGER MATCHING -// ════════════════════════════════════════════════════════════════════════════ - -function checkForCoinMentionMatch(text: string): MenuTextMatch | null { - const match = CoinMentionRegex.exec(text); - - if (match !== null) { - const maybeLeadingWhitespace = match[1]; - const trigger = match[2]; // $ - const matchingString = match[3]; // search query (after $) - - return { - leadOffset: match.index + maybeLeadingWhitespace.length, - matchingString, - replaceableString: trigger + matchingString, - }; - } - - return null; -} - -// ════════════════════════════════════════════════════════════════════════════ -// PLUGIN COMPONENT -// ════════════════════════════════════════════════════════════════════════════ - -export default function CoinMentionPlugin(): JSX.Element | null { - const [editor] = useLexicalComposerContext(); - const [queryString, setQueryString] = useState(null); - - // Search matching coins - const coins = useMemo( - () => searchCoins(queryString || ''), - [queryString] - ); - - // Convert to typeahead options - const options = useMemo( - () => - coins - .map((coin) => new CoinTypeaheadOption(coin)) - .slice(0, SUGGESTION_LIST_LENGTH_LIMIT), - [coins] - ); - - // Handle selection - const onSelectOption = useCallback( - ( - selectedOption: CoinTypeaheadOption, - nodeToReplace: TextNode | null, - closeMenu: () => void - ) => { - editor.update(() => { - const { coin } = selectedOption; - const mentionNode = $createCoinMentionNode(coin); - if (nodeToReplace) { - nodeToReplace.replace(mentionNode); - } - // Insert a space after the mention and move cursor there - const spaceNode = $createTextNode(' '); - mentionNode.insertAfter(spaceNode); - spaceNode.select(); - closeMenu(); - }); - }, - [editor] - ); - - // Trigger function - const checkForMentionMatch = useCallback((text: string) => { - return checkForCoinMentionMatch(text); - }, []); - - return ( - - onQueryChange={setQueryString} - onSelectOption={onSelectOption} - triggerFn={checkForMentionMatch} - options={options} - menuRenderFn={( - anchorElementRef, - { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex } - ) => { - if (!anchorElementRef.current || coins.length === 0) { - return null; - } - - // Calculate position to ensure menu stays within viewport - const anchorRect = anchorElementRef.current.getBoundingClientRect(); - const menuHeight = Math.min(300, options.length * 40); // Approximate height - const menuWidth = 300; - - // Check if menu would go off bottom of screen - const spaceBelow = window.innerHeight - anchorRect.bottom; - const spaceAbove = anchorRect.top; - const shouldRenderAbove = spaceBelow < menuHeight && spaceAbove > spaceBelow; - - // Check if menu would go off right of screen - const spaceRight = window.innerWidth - anchorRect.left; - const shouldAlignRight = spaceRight < menuWidth; - - const style: React.CSSProperties = { - position: 'fixed', - zIndex: 9999, // Higher than chat input (which is sticky) - minWidth: '200px', - maxWidth: '300px', - maxHeight: '300px', - }; - - // Position vertically - if (shouldRenderAbove) { - style.bottom = `${window.innerHeight - anchorRect.top}px`; - } else { - style.top = `${anchorRect.bottom}px`; - } - - // Position horizontally - if (shouldAlignRight) { - style.right = `${window.innerWidth - anchorRect.right}px`; - } else { - style.left = `${anchorRect.left}px`; - } - - return ReactDOM.createPortal( -
    -
      - {options.map((option, i: number) => ( - { - setHighlightedIndex(i); - selectOptionAndCleanUp(option); - }} - onMouseEnter={() => { - setHighlightedIndex(i); - }} - key={option.key} - option={option} - /> - ))} -
    -
    , - document.body - ); - }} - /> - ); -} - -export { CoinMentionPlugin }; diff --git a/apps/noter/package/feature/editor/ui/chat-editor.tsx b/apps/noter/package/feature/editor/ui/chat-editor.tsx index 9e4e163f..4baf95e0 100644 --- a/apps/noter/package/feature/editor/ui/chat-editor.tsx +++ b/apps/noter/package/feature/editor/ui/chat-editor.tsx @@ -31,8 +31,6 @@ import { ListNode, ListItemNode } from '@lexical/list'; import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'; import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; -import { CoinMentionNode } from '../nodes/CoinMentionNode'; -import CoinMentionPlugin from '../plugins/CoinMentionPlugin'; import CodeHighlightPlugin from '../plugins/CodeHighlightPlugin'; import ChatEditorTheme from '../themes/ChatEditorTheme'; import { CHAT_TRANSFORMERS } from '../config/markdown-transformers'; @@ -44,7 +42,6 @@ import { CHAT_TRANSFORMERS } from '../config/markdown-transformers'; const editorConfig = { namespace: 'ChatEditor', nodes: [ - CoinMentionNode, HeadingNode, QuoteNode, CodeNode, @@ -202,7 +199,6 @@ export function ChatEditor({ -
    ); diff --git a/apps/noter/package/feature/editor/ui/chat-renderer.tsx b/apps/noter/package/feature/editor/ui/chat-renderer.tsx index b8331c86..46bf8c11 100644 --- a/apps/noter/package/feature/editor/ui/chat-renderer.tsx +++ b/apps/noter/package/feature/editor/ui/chat-renderer.tsx @@ -1,8 +1,7 @@ /** * Chat Renderer Component * - * Read-only Lexical renderer for displaying chat messages with coin mentions. - * Parses text and converts coin mentions ($BTC, $ETH, etc.) to CoinMentionNodes. + * Read-only Lexical renderer for displaying chat messages with markdown. */ 'use client'; @@ -20,7 +19,7 @@ import { TablePlugin } from '@lexical/react/LexicalTablePlugin'; import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'; import { HorizontalRulePlugin } from '@lexical/react/LexicalHorizontalRulePlugin'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { $getRoot, $createTextNode } from 'lexical'; +import { $getRoot } from 'lexical'; import { $convertFromMarkdownString } from '@lexical/markdown'; import { HeadingNode, QuoteNode } from '@lexical/rich-text'; import { CodeNode, CodeHighlightNode } from '@lexical/code'; @@ -29,128 +28,34 @@ import { ListNode, ListItemNode } from '@lexical/list'; import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'; import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; -import { CoinMentionNode, $createCoinMentionNode } from '../nodes/CoinMentionNode'; -import { COINS } from '../constant'; import ChatEditorTheme from '../themes/ChatEditorTheme'; import CodeHighlightPlugin from '../plugins/CodeHighlightPlugin'; import { CHAT_TRANSFORMERS } from '../config/markdown-transformers'; -// ════════════════════════════════════════════════════════════════════════════ -// CONTENT PARSER PLUGIN -// ════════════════════════════════════════════════════════════════════════════ - -/** - * Parse text and build editor state with markdown and coin mentions - */ -function parseTextToEditorState(text: string) { - const root = $getRoot(); - root.clear(); - - // First, convert markdown to Lexical nodes (including tables) - $convertFromMarkdownString(text, CHAT_TRANSFORMERS); - - // Now walk through all text nodes and replace coin mentions - const allNodes = root.getAllTextNodes(); - - for (const textNode of allNodes) { - const nodeText = textNode.getTextContent(); - const coinRegex = /\$([A-Z]{2,5})\b/g; - - let match; - const matches: Array<{ index: number; symbol: string; length: number }> = []; - - // Find all coin mentions in this text node - while ((match = coinRegex.exec(nodeText)) !== null) { - matches.push({ - index: match.index, - symbol: match[1], - length: match[0].length, - }); - } - - if (matches.length > 0) { - // Process matches in reverse to maintain indices - matches.reverse(); - - for (const matchInfo of matches) { - const coin = COINS.find( - (c) => c.symbol.toUpperCase() === matchInfo.symbol.toUpperCase() - ); - - if (coin) { - const beforeText = nodeText.slice(0, matchInfo.index); - const afterText = nodeText.slice(matchInfo.index + matchInfo.length); - - // Split the text node - const beforeNode = $createTextNode(beforeText); - const coinNode = $createCoinMentionNode(coin); - const afterNode = $createTextNode(afterText); - - // Copy formatting from original node - if (textNode.hasFormat('bold')) { - beforeNode.toggleFormat('bold'); - afterNode.toggleFormat('bold'); - } - if (textNode.hasFormat('italic')) { - beforeNode.toggleFormat('italic'); - afterNode.toggleFormat('italic'); - } - if (textNode.hasFormat('code')) { - beforeNode.toggleFormat('code'); - afterNode.toggleFormat('code'); - } - - // Replace original node with split nodes - textNode.replace(beforeNode); - if (beforeText) { - beforeNode.insertAfter(coinNode); - if (afterText) { - coinNode.insertAfter(afterNode); - } - } else { - beforeNode.replace(coinNode); - if (afterText) { - coinNode.insertAfter(afterNode); - } - } - - break; // Process one match at a time per node - } - } - } - } -} - -/** - * Plugin to update content when text changes (for streaming) - */ +/** Plugin to update content when text changes (for streaming). */ function ContentUpdatePlugin({ text }: { text: string }) { const [editor] = useLexicalComposerContext(); useEffect(() => { editor.update(() => { - parseTextToEditorState(text); + const root = $getRoot(); + root.clear(); + $convertFromMarkdownString(text, CHAT_TRANSFORMERS); }); }, [editor, text]); return null; } -// ════════════════════════════════════════════════════════════════════════════ -// MAIN COMPONENT -// ════════════════════════════════════════════════════════════════════════════ - interface ChatRendererProps { text: string; className?: string; } export function ChatRenderer({ text, className = '' }: ChatRendererProps) { - // Create config once, don't recreate on text change const rendererConfig = useMemo(() => ({ namespace: 'ChatRenderer', nodes: [ - CoinMentionNode, HeadingNode, QuoteNode, CodeNode, @@ -169,7 +74,7 @@ export function ChatRenderer({ text, className = '' }: ChatRendererProps) { console.error('Lexical renderer error:', error); }, theme: ChatEditorTheme, - }), []); // Empty deps - create once + }), []); return ( diff --git a/apps/noter/package/feature/memory/api/route.ts b/apps/noter/package/feature/memory/api/route.ts deleted file mode 100644 index 26703934..00000000 --- a/apps/noter/package/feature/memory/api/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * MEMORY ROUTER — tRPC routes for memory operations (v2 SDK) - * - * Uses MemWal SDK: recall for search, remember for save. - */ - -import { router, protectedProcedure } from "@/shared/lib/trpc/init"; -import { z } from "zod"; -import { recallMemories, rememberText } from "@/feature/note/lib/pdw-client"; - -export const memoryRouter = router({ - /** - * Search user memories using MemWal recall. - * Server handles: embed query → vector search → Walrus download → decrypt. - */ - search: protectedProcedure - .input( - z.object({ - query: z.string().min(1, "Query cannot be empty"), - limit: z.number().min(1).max(50).optional().default(10), - }) - ) - .mutation(async ({ input }) => { - try { - const result = await recallMemories(input.query, input.limit); - - const memories = result.results.map((r) => ({ - text: r.text, - distance: r.distance, - similarity: 1 - r.distance, - })); - return { - query: input.query, - memories, - count: memories.length, - }; - } catch (error) { - console.error("[memory.search] Error:", error); - return { - query: input.query, - memories: [], - count: 0, - error: "Memory search unavailable", - }; - } - }), - - /** - * Save a single memory via MemWal remember. - * Server handles: embed → encrypt → Walrus upload → store. - */ - save: protectedProcedure - .input( - z.object({ - text: z.string().min(1, "Text cannot be empty"), - }) - ) - .mutation(async ({ input }) => { - const result = await rememberText(input.text); return result; - }), -}); diff --git a/apps/noter/package/feature/memory/index.ts b/apps/noter/package/feature/memory/index.ts deleted file mode 100644 index 90c8f241..00000000 --- a/apps/noter/package/feature/memory/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * MEMORY FEATURE - * - * Memory search and retrieval using PDW Client. - * Provides semantic search over user's blockchain-saved memories. - */ - -export { memoryRouter } from "./api/route"; diff --git a/apps/noter/package/feature/note/api/route.ts b/apps/noter/package/feature/note/api/route.ts index 7272c961..4d85741c 100644 --- a/apps/noter/package/feature/note/api/route.ts +++ b/apps/noter/package/feature/note/api/route.ts @@ -113,9 +113,18 @@ export const noteRouter = router({ throw new TRPCError({ code: "FORBIDDEN", message: "Not your note" }); } - // Only set title if explicitly provided (rename). - // Auto-save (content+plainText only) should NOT overwrite user-set title. - const title = input.title ?? existing.title; + // Title priority: explicit input > auto-generated from content > existing + let title = existing.title; + if (input.title) { + // User explicitly renamed + title = input.title; + } else if (input.plainText && (existing.title === "Untitled Note" || existing.title === "New Note")) { + // Auto-generate from first line of content (only if still default title) + const firstLine = input.plainText.split("\n").find((l) => l.trim())?.trim(); + if (firstLine) { + title = firstLine.length > 60 ? firstLine.slice(0, 60) + "..." : firstLine; + } + } const [updated] = await ctx.db .update(notes) @@ -183,7 +192,7 @@ export const noteRouter = router({ const plainText = input.plainText ?? note.plainText; // Extract memories using AI - const memories = await detectMemoriesForLexical(ctx.userId, plainText); + const memories = await detectMemoriesForLexical(ctx.userId, plainText, ctx.memwalKey, ctx.memwalAccountId); // Return memory data (client will inject as Lexical nodes) return { diff --git a/apps/noter/package/feature/note/lib/memory-detector.ts b/apps/noter/package/feature/note/lib/memory-detector.ts index c2bd8ba8..63bb7e3f 100644 --- a/apps/noter/package/feature/note/lib/memory-detector.ts +++ b/apps/noter/package/feature/note/lib/memory-detector.ts @@ -21,19 +21,20 @@ export type PreparedMemory = { * Detect and prepare memories from note content. * Uses MemWal analyze (server-side LLM extraction + auto-store). */ -export const detectAndPrepareMemories = async ( +export async function detectAndPrepareMemories( userId: string, plainText: string, - editorContent: SerializedEditorState -): Promise => { - const memorySnippets = await extractMemories(userId, plainText); + editorContent: SerializedEditorState, + memwalKey?: string | null, + memwalAccountId?: string | null, +): Promise { + const memorySnippets = await extractMemories(userId, plainText, memwalKey, memwalAccountId); if (memorySnippets.length === 0) { return []; } - // Map extracted facts to editor positions - const preparedMemories = memorySnippets.map((snippet) => { + return memorySnippets.map((snippet) => { const { startOffset, endOffset } = findTextOffset(editorContent, snippet); return { extractedText: snippet, @@ -43,36 +44,27 @@ export const detectAndPrepareMemories = async ( importance: 5, }; }); +} - return preparedMemories; -}; - -/** - * Check if text contains memorable content. - */ -export const shouldSaveAsMemory = async ( +/** Check if text contains memorable content. */ +export async function shouldSaveAsMemory( userId: string, - text: string -): Promise => { - const memories = await extractMemories(userId, text); + text: string, + memwalKey?: string | null, + memwalAccountId?: string | null, +): Promise { + const memories = await extractMemories(userId, text, memwalKey, memwalAccountId); return memories.length > 0; -}; +} -/** - * Detect memories from note text for Lexical node insertion. - * Simplified: no embeddings or KG (server handles internally). - */ -export const detectMemoriesForLexical = async ( +/** Detect memories from note text for Lexical node insertion. */ +export async function detectMemoriesForLexical( userId: string, - plainText: string -): Promise< - Array<{ - text: string; - category: MemoryCategory; - importance: number; - }> -> => { - const memorySnippets = await extractMemories(userId, plainText); + plainText: string, + memwalKey?: string | null, + memwalAccountId?: string | null, +): Promise> { + const memorySnippets = await extractMemories(userId, plainText, memwalKey, memwalAccountId); if (memorySnippets.length === 0) { return []; @@ -83,4 +75,4 @@ export const detectMemoriesForLexical = async ( category: "general" as MemoryCategory, importance: 5, })); -}; +} diff --git a/apps/noter/package/feature/note/lib/pdw-client.ts b/apps/noter/package/feature/note/lib/pdw-client.ts index 9353bf0a..7bce0461 100644 --- a/apps/noter/package/feature/note/lib/pdw-client.ts +++ b/apps/noter/package/feature/note/lib/pdw-client.ts @@ -1,84 +1,80 @@ /** - * memwal CLIENT — Server-side MemWal SDK wrapper - * Uses Ed25519 delegate key auth — no wallet/zkLogin needed. - * Server handles: embed → encrypt → Walrus upload → store + * MemWal CLIENT — Server-side MemWal SDK wrapper * - * Key can be set from env var OR at runtime via setMemWalKey(). + * Creates per-request MemWal instances using the authenticated user's + * delegate key (from tRPC context). Falls back to env vars for backward + * compatibility. */ import { MemWal } from "@mysten-incubation/memwal"; -let _memwal: MemWal | null = null; -let _runtimeKey: string | null = null; -let _runtimeAccountId: string | null = null; - /** - * Set the MemWal key at runtime (from user input in profile panel). - * Clears the existing client so it gets re-created with the new key. + * Create a MemWal client for a specific user's delegate key. + * Called per-request with credentials from tRPC context. */ -export const setMemWalKey = (key: string | null, accountId?: string | null) => { - _runtimeKey = key; - _runtimeAccountId = accountId ?? null; - _memwal = null; // Force re-create on next call -}; +export function createMemWalClient(key: string, accountId: string): MemWal { + return MemWal.create({ + key, + accountId, + serverUrl: process.env.MEMWAL_SERVER_URL || "http://localhost:8000", + }); +} /** - * Get a shared MemWal client instance (server-side only). - * Priority: runtime key > MEMWAL_KEY env var. + * Get a MemWal client using provided credentials or env var fallback. + * Throws if no key is available. */ -export const getMemWalClient = (): MemWal => { - if (_memwal) return _memwal; +export function getMemWalClient( + key?: string | null, + accountId?: string | null, +): MemWal { + const resolvedKey = key || process.env.MEMWAL_KEY; + const resolvedAccountId = accountId || process.env.MEMWAL_ACCOUNT_ID; - const key = _runtimeKey || process.env.MEMWAL_KEY; - if (!key) { - throw new Error("[MemWal] No key configured — set MEMWAL_KEY in Profile or .env"); + if (!resolvedKey) { + throw new Error("[MemWal] No key configured — sign in with Enoki or set MEMWAL_KEY in .env"); } - - const accountId = _runtimeAccountId || process.env.MEMWAL_ACCOUNT_ID; - if (!accountId) { - throw new Error("[MemWal] No accountId configured — set MEMWAL_ACCOUNT_ID in Profile or .env"); + if (!resolvedAccountId) { + throw new Error("[MemWal] No accountId configured — sign in with Enoki or set MEMWAL_ACCOUNT_ID in .env"); } - _memwal = MemWal.create({ - key, - accountId, - serverUrl: process.env.MEMWAL_SERVER_URL || "http://localhost:8000", - }); - - return _memwal; -}; + return createMemWalClient(resolvedKey, resolvedAccountId); +} -/** - * Extract memories from text using MemWal analyze endpoint. - * Server uses LLM to extract facts, then stores each one. - */ -export const extractMemories = async ( +/** Extract memories from text using MemWal analyze endpoint. */ +export async function extractMemories( _userId: string, - text: string -): Promise => { + text: string, + key?: string | null, + accountId?: string | null, +): Promise { try { - const memwal = getMemWalClient(); + const memwal = getMemWalClient(key, accountId); const result = await memwal.analyze(text); - const facts = (result.facts ?? []).map((f) => f.text); - return facts; + return (result.facts ?? []).map((f) => f.text); } catch (error) { console.error("[extractMemories] Error:", error); return []; } -}; +} -/** - * Remember a single text — server handles embed + encrypt + store. - */ -export const rememberText = async (text: string) => { - const memwal = getMemWalClient(); +/** Remember a single text — server handles embed + encrypt + store. */ +export async function rememberText( + text: string, + key?: string | null, + accountId?: string | null, +) { + const memwal = getMemWalClient(key, accountId); return memwal.remember(text); -}; +} -/** - * Recall memories similar to a query — server handles search + decrypt. - */ -export const recallMemories = async (query: string, limit = 10) => { - const memwal = getMemWalClient(); +/** Recall memories similar to a query — server handles search + decrypt. */ +export async function recallMemories( + query: string, + limit = 10, + key?: string | null, + accountId?: string | null, +) { + const memwal = getMemWalClient(key, accountId); return memwal.recall(query, limit); -}; +} diff --git a/apps/noter/package/shared/db/migrations/0002_add_enoki_fields.sql b/apps/noter/package/shared/db/migrations/0002_add_enoki_fields.sql new file mode 100644 index 00000000..b0f86eed --- /dev/null +++ b/apps/noter/package/shared/db/migrations/0002_add_enoki_fields.sql @@ -0,0 +1,6 @@ +-- Add "enoki" to auth_method enum +ALTER TYPE "auth_method" ADD VALUE IF NOT EXISTS 'enoki'; + +-- Add Enoki delegate key credentials to users table +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "delegatePrivateKey" text; +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "delegateAccountId" text; diff --git a/apps/noter/package/shared/db/migrations/meta/_journal.json b/apps/noter/package/shared/db/migrations/meta/_journal.json index f0a9c297..a855507c 100644 --- a/apps/noter/package/shared/db/migrations/meta/_journal.json +++ b/apps/noter/package/shared/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1773214559127, "tag": "0001_flat_amazoness", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1743900000000, + "tag": "0002_add_enoki_fields", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/noter/package/shared/db/relations.ts b/apps/noter/package/shared/db/relations.ts index 7192665a..48062e44 100644 --- a/apps/noter/package/shared/db/relations.ts +++ b/apps/noter/package/shared/db/relations.ts @@ -3,19 +3,11 @@ * * Defines all table relationships for Drizzle ORM query builder. * Relations enable type-safe joins and eager loading. - * - * Pattern: - * - one(): Many-to-one relation (FK side) - requires fields + references - * - many(): One-to-many relation (parent side) - just table name - * - * See: https://orm.drizzle.team/docs/rqb#relations */ import { relations } from "drizzle-orm"; import { users, - chats, - messages, zkLoginSessions, walletSessions, } from "./schema"; @@ -25,34 +17,10 @@ import { // ════════════════════════════════════════════════════════════════ export const usersRelations = relations(users, ({ many }) => ({ - chats: many(chats), zkLoginSessions: many(zkLoginSessions), walletSessions: many(walletSessions), })); -// ════════════════════════════════════════════════════════════════ -// CHAT RELATIONS -// ════════════════════════════════════════════════════════════════ - -export const chatsRelations = relations(chats, ({ one, many }) => ({ - owner: one(users, { - fields: [chats.userId], - references: [users.id], - }), - messages: many(messages), -})); - -// ════════════════════════════════════════════════════════════════ -// MESSAGE RELATIONS -// ════════════════════════════════════════════════════════════════ - -export const messagesRelations = relations(messages, ({ one }) => ({ - chat: one(chats, { - fields: [messages.chatId], - references: [chats.id], - }), -})); - // ════════════════════════════════════════════════════════════════ // ZKLOGIN SESSION RELATIONS // ════════════════════════════════════════════════════════════════ diff --git a/apps/noter/package/shared/db/schema.ts b/apps/noter/package/shared/db/schema.ts index 07b5f876..fca48aaa 100644 --- a/apps/noter/package/shared/db/schema.ts +++ b/apps/noter/package/shared/db/schema.ts @@ -4,13 +4,11 @@ import { jsonb, pgEnum, pgTable, - real, text, timestamp, uuid, } from "drizzle-orm/pg-core"; import { uuidv7 } from "uuidv7"; -import type { UIMessagePart, UIDataTypes, UITools } from "ai"; /** * SCHEMA ARCHITECTURE (for AI context) @@ -44,24 +42,7 @@ import type { UIMessagePart, UIDataTypes, UITools } from "ai"; * - Both checked on session validation via isSessionExpired() helper * - Expired sessions are invalid for authentication but remain in DB for audit * - * 6. AI MESSAGE ARCHITECTURE: - * - messages table: Base message record with role ("user" | "assistant") - * - User messages: Simple text content stored in messages.content - * - AI messages: Structured parts stored in messages.parts (JSONB) - * - parts: Array of UIMessagePart (text, tool calls, tool results) - * - status: "streaming" | "awaiting-approval" | "in-progress" | "completed" | "error" - * - model, promptTokens, completionTokens tracked per message - * - AI SDK v6 integration: parts array matches Vercel AI SDK's UIMessage format - * - * 7. CHAT SYSTEM: - * - chats: One-to-many with messages (all AI conversations) - * - chats.userId: Owner of the chat (user can have multiple chats) - * - chats.model: Per-chat AI model selection (e.g., "anthropic/claude-sonnet-4") - * - chats.systemPrompt: Optional custom system prompt override - * - chats.temperature: Optional temperature setting (0-2) - * - Auto-generated titles from first message or user-provided - * - * 8. NOTES SYSTEM (Apple Notes / Notion-like with MemWal): + * 6. NOTES SYSTEM (Apple Notes / Notion-like with MemWal): * - notes: User-created notes with Lexical editor content * - notes.content: Lexical EditorState JSON for rich text editing * - notes.plainText: Extracted plain text for AI memory detection and search @@ -92,7 +73,7 @@ const basicFields = { // USERS (zkLogin + Wallet auth) // ════════════════════════════════════════════════════════════════ -export const authMethod = pgEnum("auth_method", ["zklogin", "wallet"]); +export const authMethod = pgEnum("auth_method", ["zklogin", "wallet", "enoki"]); export const users = pgTable( "users", @@ -117,6 +98,10 @@ export const users = pgTable( email: text(), avatar: text(), + // Enoki delegate key credentials (nullable, only for Enoki auth) + delegatePrivateKey: text(), + delegateAccountId: text(), + // Last active lastSeenAt: timestamp(), }, @@ -128,30 +113,9 @@ export const users = pgTable( ); // ════════════════════════════════════════════════════════════════ -// CHATS (AI conversations only) +// (REMOVED: chats table — AI chat feature was unused bloat) // ════════════════════════════════════════════════════════════════ -export const chats = pgTable( - "chats", - { - ...basicFields, - - // Owner of this chat - userId: uuid() - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - - // Display - title: text(), // Auto-generated or user-set - - // AI config (per-chat overrides) - model: text().default("anthropic/claude-sonnet-4"), - systemPrompt: text(), - temperature: real(), - }, - (t) => [index().on(t.userId), index().on(t.userId, t.createdAt)] -); - // ════════════════════════════════════════════════════════════════ // ZKLOGIN SESSIONS (ephemeral keypairs with expiration) // ════════════════════════════════════════════════════════════════ @@ -312,51 +276,5 @@ export const noteMemoryHighlights = pgTable( ); // ════════════════════════════════════════════════════════════════ -// MESSAGES +// (REMOVED: messages table — AI chat feature was unused bloat) // ════════════════════════════════════════════════════════════════ - -export const messageRole = pgEnum("message_role", ["user", "assistant"]); - -export const aiMessageStatus = pgEnum("ai_message_status", [ - "streaming", - "awaiting-approval", // Tool needs human approval - "in-progress", // Tool executing - "completed", - "error", -]); - -export type AiMessagePart = UIMessagePart; - -export const messages = pgTable( - "messages", - { - ...basicFields, - - chatId: uuid() - .references(() => chats.id, { onDelete: "cascade" }) - .notNull(), - - role: messageRole().notNull(), - - // Content: text for user, null for AI (use parts) - content: text(), - - // AI-specific (null for user messages) - parts: jsonb().$type(), - model: text(), - status: aiMessageStatus(), - - // Token usage (AI only) - promptTokens: integer(), - completionTokens: integer(), - - // Agent tracking (for multi-turn tool loops) - agentRunId: uuid(), - turnIndex: integer(), - }, - (t) => [ - index().on(t.chatId), - index().on(t.chatId, t.createdAt), - index().on(t.agentRunId), - ] -); diff --git a/apps/noter/package/shared/db/type.ts b/apps/noter/package/shared/db/type.ts index 2910ffdc..026aaa58 100644 --- a/apps/noter/package/shared/db/type.ts +++ b/apps/noter/package/shared/db/type.ts @@ -5,9 +5,7 @@ * * Naming: * User, UserInsert — Raw DB types (match table name) - * UserWithChats — 1-2 relations: be explicit - * MessageWithRelations — 3+ relations: collapse to WithRelations - * MESSAGE_ROLES / MessageRole — Enum values (SCREAMING_SNAKE) / types (PascalCase) + * AUTH_METHODS / AuthMethod — Enum values (SCREAMING_SNAKE) / types (PascalCase) * * Feature-Specific Schemas: * - Form schemas → feature/[name]/api/form.ts @@ -18,19 +16,14 @@ import { // Tables users, - chats, - messages, zkLoginSessions, walletSessions, notes, noteMemoryHighlights, // Enums - messageRole, - aiMessageStatus, authMethod, memoryStatus, // Types - type AiMessagePart, type ZkProofData, type EntityRelationship, } from "@/shared/db/schema"; @@ -55,18 +48,14 @@ type Insert = InferInsertModel; // §2 ENUMS — SCREAMING_SNAKE (runtime array) + PascalCase (type) -export const MESSAGE_ROLES = enumDef(messageRole); -export const AI_MESSAGE_STATUSES = enumDef(aiMessageStatus); export const AUTH_METHODS = enumDef(authMethod); export const MEMORY_STATUSES = enumDef(memoryStatus); -export type MessageRole = (typeof MESSAGE_ROLES)[number]; -export type AiMessageStatus = (typeof AI_MESSAGE_STATUSES)[number]; export type AuthMethod = (typeof AUTH_METHODS)[number]; export type MemoryStatus = (typeof MEMORY_STATUSES)[number]; // Re-export types for convenience -export type { AiMessagePart, ZkProofData, EntityRelationship }; +export type { ZkProofData, EntityRelationship }; // Type aliases for backward compatibility with note feature export type MemoryHighlightStatus = MemoryStatus; @@ -86,8 +75,6 @@ export type GraphRelationship = { // §3 DB ROW TYPES — User/UserInsert (Select/Insert), userSchema/userInsertSchema (Zod) const usersDef = tableDef(users); -const chatsDef = tableDef(chats); -const messagesDef = tableDef(messages); const zkLoginSessionsDef = tableDef(zkLoginSessions); const walletSessionsDef = tableDef(walletSessions); const notesDef = tableDef(notes); @@ -96,8 +83,6 @@ const noteMemoryHighlightsDef = tableDef(noteMemoryHighlights); // ─── Select types (what you get back from a query) ─── export type User = Select; -export type Chat = Select; -export type Message = Select; export type ZkLoginSession = Select; export type WalletSession = Select; export type Note = Select; @@ -107,8 +92,6 @@ export type NoteMemoryHighlight = Select; // ─── Insert types (for creating new rows) ─── export type UserInsert = Insert; -export type ChatInsert = Insert; -export type MessageInsert = Insert; export type ZkLoginSessionInsert = Insert; export type WalletSessionInsert = Insert; export type NoteInsert = Insert; @@ -119,12 +102,6 @@ export type NoteMemoryHighlightInsert = Insert; export const userSchema = usersDef.selectSchema; export const userInsertSchema = usersDef.insertSchema; -export const chatSchema = chatsDef.selectSchema; -export const chatInsertSchema = chatsDef.insertSchema; - -export const messageSchema = messagesDef.selectSchema; -export const messageInsertSchema = messagesDef.insertSchema; - export const zkLoginSessionSchema = zkLoginSessionsDef.selectSchema; export const zkLoginSessionInsertSchema = zkLoginSessionsDef.insertSchema; @@ -138,7 +115,6 @@ export const noteMemoryHighlightSchema = noteMemoryHighlightsDef.selectSchema; export const noteMemoryHighlightInsertSchema = noteMemoryHighlightsDef.insertSchema; // ─── Memory data type (extracted from Lexical MemoryHighlightNodes) ─── -// NOTE: MemoryStatus is now imported from DB enum (see §2 ENUMS above) export type MemoryCategory = | "note" diff --git a/apps/noter/package/shared/lib/ai/constant.ts b/apps/noter/package/shared/lib/ai/constant.ts deleted file mode 100644 index 85484ba3..00000000 --- a/apps/noter/package/shared/lib/ai/constant.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const DEFAULT_MODEL = "anthropic/claude-sonnet-4"; - -export const MODELS = [ - { id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4", provider: "Anthropic" }, - { id: "anthropic/claude-3.5-haiku", name: "Claude 3.5 Haiku", provider: "Anthropic" }, - { id: "openai/gpt-4o", name: "GPT-4o", provider: "OpenAI" }, - { id: "openai/gpt-4o-mini", name: "GPT-4o Mini", provider: "OpenAI" }, - { id: "google/gemini-2.0-flash-001", name: "Gemini 2.0 Flash", provider: "Google" }, -] as const; - -export type ModelId = (typeof MODELS)[number]["id"]; diff --git a/apps/noter/package/shared/lib/ai/index.ts b/apps/noter/package/shared/lib/ai/index.ts deleted file mode 100644 index 55c6a27d..00000000 --- a/apps/noter/package/shared/lib/ai/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { openrouter } from "./provider"; -export { DEFAULT_MODEL, MODELS, type ModelId } from "./constant"; diff --git a/apps/noter/package/shared/lib/ai/provider.ts b/apps/noter/package/shared/lib/ai/provider.ts deleted file mode 100644 index 29ae5d9b..00000000 --- a/apps/noter/package/shared/lib/ai/provider.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createOpenAI } from "@ai-sdk/openai"; - -export const openrouter = createOpenAI({ - apiKey: process.env.OPENROUTER_API_KEY, - baseURL: "https://openrouter.ai/api/v1", - headers: { - "HTTP-Referer": process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000", - "X-Title": "Noter", - }, -}); diff --git a/apps/noter/package/shared/lib/ai/tools/index.ts b/apps/noter/package/shared/lib/ai/tools/index.ts deleted file mode 100644 index b8e8be3c..00000000 --- a/apps/noter/package/shared/lib/ai/tools/index.ts +++ /dev/null @@ -1,1374 +0,0 @@ -import { tool, jsonSchema } from "ai"; -import type { User } from "@/shared/db/type"; -import { getSuiClient } from "@/feature/auth/lib/zklogin-client"; -import { recallMemories } from "@/feature/note/lib/pdw-client"; - -type GetTimeInput = { - confirm: boolean; - timezone?: string; -}; - -type GetTimeOutput = { - timezone: string; - formatted: string; - iso: string; - timestamp: number; -}; - -type GetUserInfoInput = { - confirm: boolean; -}; - -export type GetUserInfoOutput = { - id: string; - name: string | null; - email: string | null; - avatar: string | null; - suiAddress: string; - authMethod: "zklogin" | "wallet"; - provider: string | null; - walletType: string | null; - memberSince: string; - lastSeenAt: string | null; -}; - -type GetBalancesInput = { - confirm: boolean; -}; - -export type Balance = { - coinType: string; - coinObjectCount: number; - totalBalance: string; - lockedBalance: Record; -}; - -export type GetBalancesOutput = { - address: string; - balances: Balance[]; - totalCoins: number; -}; - -type GetTransactionsInput = { - confirm: boolean; - limit?: number; -}; - -export type Transaction = { - digest: string; - timestampMs: string; - checkpoint: string; - effects: { - status: { status: string }; - }; -}; - -export type GetTransactionsOutput = { - address: string; - transactions: Transaction[]; - hasMore: boolean; -}; - -type GetOwnedObjectsInput = { - confirm: boolean; - limit?: number; -}; - -export type OwnedObject = { - objectId: string; - version: string; - digest: string; - type: string | null; - display: { - name?: string; - description?: string; - image_url?: string; - } | null; -}; - -export type GetOwnedObjectsOutput = { - address: string; - objects: OwnedObject[]; - totalCount: number; - hasMore: boolean; -}; - -type GetStakesInput = { - confirm: boolean; -}; - -export type StakeObject = { - stakedSuiId: string; - stakeRequestEpoch: string; - stakeActiveEpoch: string; - principal: string; - status: string; - estimatedReward?: string; -}; - -export type GetStakesOutput = { - address: string; - stakes: StakeObject[]; - totalStaked: string; - totalRewards: string; -}; - -type GetCoinMetadataInput = { - confirm: boolean; - coinType: string; -}; - -export type CoinMetadataOutput = { - coinType: string; - name: string; - symbol: string; - description: string; - decimals: number; - iconUrl: string | null; -}; - -type GetCryptoPriceInput = { - confirm: boolean; - symbols: string; -}; - -export type CryptoPrice = { - symbol: string; - name: string; - price: number; - marketCap: number; - volume24h: number; - percentChange1h: number; - percentChange24h: number; - percentChange7d: number; -}; - -export type GetCryptoPriceOutput = { - coins: CryptoPrice[]; - timestamp: string; -}; - -type GetTrendingCoinsInput = { - confirm: boolean; - limit?: number; -}; - -export type TrendingCoin = { - symbol: string; - name: string; - price: number; - percentChange24h: number; - marketCap: number; - rank: number; -}; - -export type GetTrendingCoinsOutput = { - gainers: TrendingCoin[]; - losers: TrendingCoin[]; -}; - -type SearchCoinsInput = { - confirm: boolean; - query: string; -}; - -export type CoinSearchResult = { - id: number; - name: string; - symbol: string; - slug: string; - rank: number; -}; - -export type SearchCoinsOutput = { - results: CoinSearchResult[]; - totalCount: number; -}; - -type GetCoinHistoryInput = { - confirm: boolean; - symbol: string; - days?: number; -}; - -export type HistoricalDataPoint = { - timestamp: string; - price: number; - volume24h: number; - marketCap: number; -}; - -export type GetCoinHistoryOutput = { - symbol: string; - name: string; - data: HistoricalDataPoint[]; -}; - -type GetGlobalMarketStatsInput = { - confirm: boolean; -}; - -export type GlobalMarketStats = { - totalMarketCap: number; - totalVolume24h: number; - btcDominance: number; - ethDominance: number; - activeCryptocurrencies: number; - markets: number; - marketCapChangePercentage24h: number; -}; - -type GetFearGreedIndexInput = { - confirm: boolean; -}; - -export type FearGreedIndex = { - value: number; - classification: string; - timestamp: string; -}; - -type GetTopCoinsInput = { - confirm: boolean; - limit?: number; -}; - -export type TopCoin = { - id: string; - symbol: string; - name: string; - rank: number; - price: number; - marketCap: number; - volume24h: number; - percentChange24h: number; -}; - -export type GetTopCoinsOutput = { - coins: TopCoin[]; -}; - -type GetCoinCategoriesInput = { - confirm: boolean; -}; - -export type CoinCategory = { - id: string; - name: string; - marketCap: number; - marketCapChange24h: number; - volume24h: number; - topCoins: string[]; -}; - -export type GetCoinCategoriesOutput = { - categories: CoinCategory[]; -}; - -type GetCoinsByCategoryInput = { - confirm: boolean; - categoryId: string; -}; - -export type CoinInCategory = { - id: string; - symbol: string; - name: string; - rank: number; - price: number; -}; - -export type GetCoinsByCategoryOutput = { - category: string; - coins: CoinInCategory[]; -}; - -type GetCoinDetailsInput = { - confirm: boolean; - coinId: string; -}; - -export type CoinDetails = { - id: string; - symbol: string; - name: string; - description: string; - homepage: string | null; - whitepaper: string | null; - blockchain: string | null; - genesisDate: string | null; - marketCapRank: number; -}; - -type ShowDiagramInput = { - confirm: boolean; - diagramType: 'zklogin' | 'nautilus' | 'seal' | 'walrus'; -}; - -export type ShowDiagramOutput = { - diagramType: string; - title: string; - svgPath: string; - docsUrl: string; -}; - -type SearchUserMemoriesInput = { - confirm: boolean; - query: string; - limit?: number; -}; - -export type UserMemory = { - id: string; - text: string; - category: string; - importance: number; - similarity: number; - createdAt: string; - blobId?: string; -}; - -export type SearchUserMemoriesOutput = { - query: string; - memories: UserMemory[]; - count: number; - error?: string; -}; - -export const createTools = (user: User | null) => ({ - getTime: tool({ - description: - 'Get current date and time. Usage: getTime({confirm: true}) or getTime({confirm: true, timezone: "Asia/Tokyo"})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get time", - }, - timezone: { - type: "string" as const, - description: - 'Timezone (e.g., "America/New_York", "Asia/Tokyo"). Defaults to UTC.', - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async ({ timezone = "UTC" }): Promise => { - const now = new Date(); - - const formatted = now.toLocaleString("en-US", { - timeZone: timezone, - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - timeZoneName: "short", - }); - - return { - timezone, - formatted, - iso: now.toISOString(), - timestamp: now.getTime(), - }; - }, - }), - - getUserInfo: tool({ - description: - 'Get the current authenticated user information including name, email, Sui address, authentication method (zkLogin or wallet), and account details. Usage: getUserInfo({confirm: true})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get user info", - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async (): Promise => { - if (!user) { - throw new Error('User not authenticated'); - } - - const memberSince = new Date(user.createdAt).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); - - const lastSeenAt = user.lastSeenAt - ? new Date(user.lastSeenAt).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }) - : null; - - return { - id: user.id, - name: user.name, - email: user.email, - avatar: user.avatar, - suiAddress: user.suiAddress, - authMethod: user.authMethod, - provider: user.provider, - walletType: user.walletType, - memberSince, - lastSeenAt, - }; - }, - }), - - getBalances: tool({ - description: - 'Get all token balances for the authenticated user on Sui blockchain. Shows SUI and all other tokens owned. Usage: getBalances({confirm: true})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get balances", - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async (): Promise => { - if (!user) { - throw new Error('User not authenticated'); - } - - const client = getSuiClient(); - const balances = await client.getAllBalances({ owner: user.suiAddress }); - - return { - address: user.suiAddress, - balances: balances.map((b: any) => ({ - coinType: b.coinType, - coinObjectCount: b.coinObjectCount, - totalBalance: b.totalBalance, - lockedBalance: b.lockedBalance, - })), - totalCoins: balances.length, - }; - }, - }), - - getTransactions: tool({ - description: - 'Get recent transactions for the authenticated user on Sui blockchain. Shows transaction history including status and timestamp. Usage: getTransactions({confirm: true, limit: 10})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get transactions", - }, - limit: { - type: "number" as const, - description: "Number of transactions to fetch (default: 10, max: 50)", - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async ({ limit = 10 }): Promise => { - if (!user) { - throw new Error('User not authenticated'); - } - - const client = getSuiClient(); - const maxLimit = Math.min(limit, 50); - - const result = await client.queryTransactionBlocks({ - filter: { FromAddress: user.suiAddress }, - options: { - showEffects: true, - showInput: false, - showEvents: false, - showObjectChanges: false, - showBalanceChanges: false, - }, - limit: maxLimit, - order: 'descending', - }); - - return { - address: user.suiAddress, - transactions: result.data.map((tx: any) => ({ - digest: tx.digest, - timestampMs: tx.timestampMs || '0', - checkpoint: tx.checkpoint || '0', - effects: { - status: { - status: tx.effects?.status?.status || 'unknown', - }, - }, - })), - hasMore: result.hasNextPage, - }; - }, - }), - - getOwnedObjects: tool({ - description: - 'Get all objects owned by the user (NFTs, game items, collectibles, etc.). Shows object type, display metadata, and images. Usage: getOwnedObjects({confirm: true, limit: 20})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get owned objects", - }, - limit: { - type: "number" as const, - description: "Number of objects to fetch (default: 20, max: 50)", - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async ({ limit = 20 }): Promise => { - if (!user) { - throw new Error('User not authenticated'); - } - - const client = getSuiClient(); - const maxLimit = Math.min(limit, 50); - - const result = await client.getOwnedObjects({ - owner: user.suiAddress, - options: { - showType: true, - showContent: true, - showDisplay: true, - }, - limit: maxLimit, - }); - - return { - address: user.suiAddress, - objects: result.data.map((obj: any) => ({ - objectId: obj.data?.objectId || '', - version: obj.data?.version || '0', - digest: obj.data?.digest || '', - type: obj.data?.type || null, - display: obj.data?.display?.data || null, - })), - totalCount: result.data.length, - hasMore: result.hasNextPage, - }; - }, - }), - - getStakes: tool({ - description: - 'Get all staking positions for the user. Shows staked SUI amount, validator, rewards, and status. Usage: getStakes({confirm: true})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get staking positions", - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async (): Promise => { - if (!user) { - throw new Error('User not authenticated'); - } - - const client = getSuiClient(); - const stakes = await client.getStakes({ owner: user.suiAddress }); - - let totalStaked = BigInt(0); - let totalRewards = BigInt(0); - - const stakeObjects = stakes.map((stake: any) => { - const stakeObj = stake.stakes[0]; - const principal = BigInt(stakeObj?.principal || 0); - const estimatedReward = (stakeObj && 'estimatedReward' in stakeObj && stakeObj.estimatedReward) - ? BigInt(stakeObj.estimatedReward) - : BigInt(0); - - totalStaked += principal; - totalRewards += estimatedReward; - - return { - stakedSuiId: stakeObj?.stakedSuiId || '', - stakeRequestEpoch: stakeObj?.stakeRequestEpoch || '0', - stakeActiveEpoch: stakeObj?.stakeActiveEpoch || '0', - principal: principal.toString(), - status: stakeObj?.status || 'Unknown', - estimatedReward: estimatedReward.toString(), - }; - }); - - return { - address: user.suiAddress, - stakes: stakeObjects, - totalStaked: totalStaked.toString(), - totalRewards: totalRewards.toString(), - }; - }, - }), - - getCoinMetadata: tool({ - description: - 'Get detailed metadata for any coin type (name, symbol, decimals, icon). Useful for displaying coin information. Usage: getCoinMetadata({confirm: true, coinType: "0x2::sui::SUI"})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get coin metadata", - }, - coinType: { - type: "string" as const, - description: "The coin type to get metadata for (e.g., '0x2::sui::SUI')", - }, - }, - required: ["confirm", "coinType"], - additionalProperties: false, - }), - execute: async ({ coinType }): Promise => { - if (!user) { - throw new Error('User not authenticated'); - } - - const client = getSuiClient(); - const metadata = await client.getCoinMetadata({ coinType }); - - if (!metadata) { - throw new Error(`No metadata found for coin type: ${coinType}`); - } - - return { - coinType, - name: metadata.name || 'Unknown', - symbol: metadata.symbol || 'Unknown', - description: metadata.description || '', - decimals: metadata.decimals || 9, - iconUrl: metadata.iconUrl || null, - }; - }, - }), - - getCryptoPrice: tool({ - description: - 'Get current cryptocurrency prices, market cap, volume, and price changes. Supports multiple coins (comma-separated). Usage: getCryptoPrice({confirm: true, symbols: "BTC,ETH,SUI"})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get crypto prices", - }, - symbols: { - type: "string" as const, - description: "Comma-separated cryptocurrency symbols (e.g., 'BTC,ETH,SUI')", - }, - }, - required: ["confirm", "symbols"], - additionalProperties: false, - }), - execute: async ({ symbols }): Promise => { - const apiKey = process.env.COINMARKETCAP_API_KEY; - if (!apiKey) { - throw new Error('CoinMarketCap API key not configured'); - } - - const response = await fetch( - `https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=${symbols}`, - { - headers: { - 'X-CMC_PRO_API_KEY': apiKey, - 'Accept': 'application/json', - }, - } - ); - - if (!response.ok) { - throw new Error(`CoinMarketCap API error: ${response.status}`); - } - - const data = await response.json(); - - const coins: CryptoPrice[] = Object.values(data.data).map((coin: any) => ({ - symbol: coin.symbol, - name: coin.name, - price: coin.quote.USD.price, - marketCap: coin.quote.USD.market_cap, - volume24h: coin.quote.USD.volume_24h, - percentChange1h: coin.quote.USD.percent_change_1h, - percentChange24h: coin.quote.USD.percent_change_24h, - percentChange7d: coin.quote.USD.percent_change_7d, - })); - - return { - coins, - timestamp: data.status.timestamp, - }; - }, - }), - - getTrendingCoins: tool({ - description: - 'Get top gaining and losing cryptocurrencies in the last 24 hours. Shows the biggest movers in the market. Usage: getTrendingCoins({confirm: true, limit: 10})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get trending coins", - }, - limit: { - type: "number" as const, - description: "Number of top gainers/losers to return (default: 10, max: 20)", - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async ({ limit = 10 }): Promise => { - const apiKey = process.env.COINMARKETCAP_API_KEY; - if (!apiKey) { - throw new Error('CoinMarketCap API key not configured'); - } - - const maxLimit = Math.min(limit, 20); - - // Get top coins sorted by 24h change - const response = await fetch( - `https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?limit=100&sort=percent_change_24h&sort_dir=desc`, - { - headers: { - 'X-CMC_PRO_API_KEY': apiKey, - 'Accept': 'application/json', - }, - } - ); - - if (!response.ok) { - throw new Error(`CoinMarketCap API error: ${response.status}`); - } - - const data = await response.json(); - - const allCoins = data.data.map((coin: any) => ({ - symbol: coin.symbol, - name: coin.name, - price: coin.quote.USD.price, - percentChange24h: coin.quote.USD.percent_change_24h, - marketCap: coin.quote.USD.market_cap, - rank: coin.cmc_rank, - })); - - // Sort by percent change - const sorted = [...allCoins].sort((a, b) => b.percentChange24h - a.percentChange24h); - - return { - gainers: sorted.slice(0, maxLimit), - losers: sorted.slice(-maxLimit).reverse(), - }; - }, - }), - - searchCoins: tool({ - description: - 'Search for cryptocurrencies by name or symbol. Find coins to get their data. Usage: searchCoins({confirm: true, query: "bitcoin"})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to search coins", - }, - query: { - type: "string" as const, - description: "Search query (coin name or symbol)", - }, - }, - required: ["confirm", "query"], - additionalProperties: false, - }), - execute: async ({ query }): Promise => { - const apiKey = process.env.COINMARKETCAP_API_KEY; - if (!apiKey) { - throw new Error('CoinMarketCap API key not configured'); - } - - const response = await fetch( - `https://pro-api.coinmarketcap.com/v1/cryptocurrency/map?limit=5000`, - { - headers: { - 'X-CMC_PRO_API_KEY': apiKey, - 'Accept': 'application/json', - }, - } - ); - - if (!response.ok) { - throw new Error(`CoinMarketCap API error: ${response.status}`); - } - - const data = await response.json(); - - const queryLower = query.toLowerCase(); - const filtered = data.data - .filter((coin: any) => - coin.name.toLowerCase().includes(queryLower) || - coin.symbol.toLowerCase().includes(queryLower) - ) - .slice(0, 20) - .map((coin: any) => ({ - id: coin.id, - name: coin.name, - symbol: coin.symbol, - slug: coin.slug, - rank: coin.rank, - })); - - return { - results: filtered, - totalCount: filtered.length, - }; - }, - }), - - getCoinHistory: tool({ - description: - 'Get historical price data for a cryptocurrency with chart visualization. Shows price, volume, and market cap over time. Usage: getCoinHistory({confirm: true, symbol: "BTC", days: 7})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get historical data", - }, - symbol: { - type: "string" as const, - description: "Cryptocurrency symbol (e.g., 'BTC', 'ETH', 'SUI')", - }, - days: { - type: "number" as const, - description: "Number of days of history to fetch (1, 7, 30, 90, 365)", - }, - }, - required: ["confirm", "symbol"], - additionalProperties: false, - }), - execute: async ({ symbol, days = 7 }): Promise => { - const apiKey = process.env.COINGECKO_API_KEY; - const headers: Record = { - 'Accept': 'application/json', - }; - - // Add API key if available for better rate limits - if (apiKey) { - headers['x-cg-demo-api-key'] = apiKey; - } - - // Map common symbols to CoinGecko IDs - const symbolToId: Record = { - 'BTC': 'bitcoin', - 'ETH': 'ethereum', - 'SUI': 'sui', - 'SOL': 'solana', - 'BNB': 'binancecoin', - 'XRP': 'ripple', - 'ADA': 'cardano', - 'DOGE': 'dogecoin', - 'MATIC': 'matic-network', - 'DOT': 'polkadot', - 'AVAX': 'avalanche-2', - 'UNI': 'uniswap', - 'LINK': 'chainlink', - 'ATOM': 'cosmos', - 'APT': 'aptos', - }; - - const coinId = symbolToId[symbol.toUpperCase()]; - - if (!coinId) { - // Try to search for the coin - const searchResponse = await fetch( - `https://api.coingecko.com/api/v3/search?query=${symbol}`, - { headers } - ); - - if (!searchResponse.ok) { - throw new Error(`Failed to search for ${symbol}`); - } - - const searchData = await searchResponse.json(); - const foundCoin = searchData.coins?.[0]; - - if (!foundCoin) { - throw new Error(`Coin ${symbol} not found. Try common symbols like BTC, ETH, SUI`); - } - - // Use the found coin ID for the next request - const response = await fetch( - `https://api.coingecko.com/api/v3/coins/${foundCoin.id}/market_chart?vs_currency=usd&days=${days}`, - { headers } - ); - - if (!response.ok) { - throw new Error(`CoinGecko API error: ${response.status}`); - } - - const data = await response.json(); - - return { - symbol: symbol.toUpperCase(), - name: foundCoin.name, - data: data.prices.map((item: any, index: number) => ({ - timestamp: new Date(item[0]).toISOString(), - price: item[1], - volume24h: data.total_volumes[index]?.[1] || 0, - marketCap: data.market_caps[index]?.[1] || 0, - })), - }; - } - - // Fetch historical data from CoinGecko - const response = await fetch( - `https://api.coingecko.com/api/v3/coins/${coinId}/market_chart?vs_currency=usd&days=${days}`, - { headers } - ); - - if (!response.ok) { - throw new Error(`CoinGecko API error: ${response.status}`); - } - - const data = await response.json(); - - // Get coin info for the name - const infoResponse = await fetch( - `https://api.coingecko.com/api/v3/coins/${coinId}?localization=false&tickers=false&community_data=false&developer_data=false`, - { headers } - ); - - const coinInfo = infoResponse.ok ? await infoResponse.json() : null; - - return { - symbol: symbol.toUpperCase(), - name: coinInfo?.name || symbol.toUpperCase(), - data: data.prices.map((item: any, index: number) => ({ - timestamp: new Date(item[0]).toISOString(), - price: item[1], - volume24h: data.total_volumes[index]?.[1] || 0, - marketCap: data.market_caps[index]?.[1] || 0, - })), - }; - }, - }), - - getGlobalMarketStats: tool({ - description: - 'Get global cryptocurrency market statistics including total market cap, BTC dominance, 24h volume, and number of active cryptocurrencies. Usage: getGlobalMarketStats({confirm: true})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get global market stats", - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async (): Promise => { - const apiKey = process.env.COINGECKO_API_KEY; - const headers: Record = { - 'Accept': 'application/json', - }; - - if (apiKey) { - headers['x-cg-demo-api-key'] = apiKey; - } - - const response = await fetch( - 'https://api.coingecko.com/api/v3/global', - { headers } - ); - - if (!response.ok) { - throw new Error(`CoinGecko API error: ${response.status}`); - } - - const data = await response.json(); - const globalData = data.data; - - return { - totalMarketCap: globalData.total_market_cap.usd, - totalVolume24h: globalData.total_volume.usd, - btcDominance: globalData.market_cap_percentage.btc, - ethDominance: globalData.market_cap_percentage.eth, - activeCryptocurrencies: globalData.active_cryptocurrencies, - markets: globalData.markets, - marketCapChangePercentage24h: globalData.market_cap_change_percentage_24h_usd, - }; - }, - }), - - getFearGreedIndex: tool({ - description: - 'Get the crypto Fear & Greed Index (0-100) which measures market sentiment. Values: 0-24 = Extreme Fear, 25-49 = Fear, 50-74 = Greed, 75-100 = Extreme Greed. Usage: getFearGreedIndex({confirm: true})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get fear & greed index", - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async (): Promise => { - const response = await fetch('https://api.alternative.me/fng/'); - - if (!response.ok) { - throw new Error(`Fear & Greed API error: ${response.status}`); - } - - const data = await response.json(); - const indexData = data.data[0]; - - return { - value: parseInt(indexData.value), - classification: indexData.value_classification, - timestamp: new Date(parseInt(indexData.timestamp) * 1000).toISOString(), - }; - }, - }), - - getTopCoins: tool({ - description: - 'Get top cryptocurrencies by market cap with current prices, volumes, and 24h changes. Usage: getTopCoins({confirm: true, limit: 10})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get top coins", - }, - limit: { - type: "number" as const, - description: "Number of top coins to return (default: 10, max: 50)", - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async ({ limit = 10 }): Promise => { - const apiKey = process.env.COINGECKO_API_KEY; - const headers: Record = { - 'Accept': 'application/json', - }; - - if (apiKey) { - headers['x-cg-demo-api-key'] = apiKey; - } - - const maxLimit = Math.min(limit, 50); - - const response = await fetch( - `https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=${maxLimit}&page=1&sparkline=false`, - { headers } - ); - - if (!response.ok) { - throw new Error(`CoinGecko API error: ${response.status}`); - } - - const data = await response.json(); - - return { - coins: data.map((coin: any) => ({ - id: coin.id, - symbol: coin.symbol.toUpperCase(), - name: coin.name, - rank: coin.market_cap_rank, - price: coin.current_price, - marketCap: coin.market_cap, - volume24h: coin.total_volume, - percentChange24h: coin.price_change_percentage_24h, - })), - }; - }, - }), - - getCoinCategories: tool({ - description: - 'Get all cryptocurrency categories (DeFi, NFT, Gaming, Layer 1, etc.) with market cap and top coins. Usage: getCoinCategories({confirm: true})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get coin categories", - }, - }, - required: ["confirm"], - additionalProperties: false, - }), - execute: async (): Promise => { - const apiKey = process.env.COINGECKO_API_KEY; - const headers: Record = { - 'Accept': 'application/json', - }; - - if (apiKey) { - headers['x-cg-demo-api-key'] = apiKey; - } - - const response = await fetch( - 'https://api.coingecko.com/api/v3/coins/categories', - { headers } - ); - - if (!response.ok) { - throw new Error(`CoinGecko API error: ${response.status}`); - } - - const data = await response.json(); - - return { - categories: data.slice(0, 20).map((cat: any) => ({ - id: cat.id, - name: cat.name, - marketCap: cat.market_cap || 0, - marketCapChange24h: cat.market_cap_change_24h || 0, - volume24h: cat.volume_24h || 0, - topCoins: cat.top_3_coins || [], - })), - }; - }, - }), - - getCoinsByCategory: tool({ - description: - 'Get all coins in a specific category. First use getCoinCategories to find category IDs. Usage: getCoinsByCategory({confirm: true, categoryId: "layer-1"})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get coins by category", - }, - categoryId: { - type: "string" as const, - description: "Category ID (e.g., 'layer-1', 'defi', 'nft')", - }, - }, - required: ["confirm", "categoryId"], - additionalProperties: false, - }), - execute: async ({ categoryId }): Promise => { - const apiKey = process.env.COINGECKO_API_KEY; - const headers: Record = { - 'Accept': 'application/json', - }; - - if (apiKey) { - headers['x-cg-demo-api-key'] = apiKey; - } - - const response = await fetch( - `https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&category=${categoryId}&order=market_cap_desc&per_page=20&page=1&sparkline=false`, - { headers } - ); - - if (!response.ok) { - throw new Error(`CoinGecko API error: ${response.status}`); - } - - const data = await response.json(); - - return { - category: categoryId, - coins: data.map((coin: any) => ({ - id: coin.id, - symbol: coin.symbol.toUpperCase(), - name: coin.name, - rank: coin.market_cap_rank, - price: coin.current_price, - })), - }; - }, - }), - - getCoinDetails: tool({ - description: - 'Get detailed information about a cryptocurrency including description, website, whitepaper, blockchain, and social links. Usage: getCoinDetails({confirm: true, coinId: "bitcoin"})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to get coin details", - }, - coinId: { - type: "string" as const, - description: "CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'sui')", - }, - }, - required: ["confirm", "coinId"], - additionalProperties: false, - }), - execute: async ({ coinId }): Promise => { - const apiKey = process.env.COINGECKO_API_KEY; - const headers: Record = { - 'Accept': 'application/json', - }; - - if (apiKey) { - headers['x-cg-demo-api-key'] = apiKey; - } - - const response = await fetch( - `https://api.coingecko.com/api/v3/coins/${coinId}?localization=false&tickers=false&community_data=false&developer_data=false`, - { headers } - ); - - if (!response.ok) { - throw new Error(`CoinGecko API error: ${response.status}`); - } - - const data = await response.json(); - - return { - id: data.id, - symbol: data.symbol.toUpperCase(), - name: data.name, - description: data.description?.en?.substring(0, 500) || 'No description available', - homepage: data.links?.homepage?.[0] || null, - whitepaper: data.links?.whitepaper || null, - blockchain: data.asset_platform_id || null, - genesisDate: data.genesis_date || null, - marketCapRank: data.market_cap_rank || 0, - }; - }, - }), - - showDiagram: tool({ - description: - 'Display educational flow diagrams about Sui ecosystem technologies. Available diagrams: zklogin (OAuth authentication), nautilus (Sui framework), seal (security), walrus (storage). Use when users ask about these topics. Usage: showDiagram({confirm: true, diagramType: "zklogin"})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to show the diagram", - }, - diagramType: { - type: "string" as const, - enum: ["zklogin", "nautilus", "seal", "walrus"], - description: "Type of diagram to show: zklogin (authentication), nautilus (Sui framework), seal (security), walrus (storage)", - }, - }, - required: ["confirm", "diagramType"], - additionalProperties: false, - }), - execute: async ({ diagramType }): Promise => { - const diagrams = { - zklogin: { - title: 'zkLogin Authentication Flow', - svgPath: '/zk-login-flow.svg', - docsUrl: 'https://docs.sui.io/concepts/cryptography/zklogin', - }, - nautilus: { - title: 'Nautilus Framework Flow', - svgPath: '/nautilus-flow.svg', - docsUrl: 'https://docs.sui.io', - }, - seal: { - title: 'Seal Security Flow', - svgPath: '/seal-flow.svg', - docsUrl: 'https://docs.sui.io', - }, - walrus: { - title: 'Walrus Storage Flow', - svgPath: '/walrus-flow.svg', - docsUrl: 'https://docs.walrus.site', - }, - }; - - const diagram = diagrams[diagramType]; - - return { - diagramType, - title: diagram.title, - svgPath: diagram.svgPath, - docsUrl: diagram.docsUrl, - }; - }, - }), - - searchUserMemories: tool({ - description: - 'Search the user\'s saved memories using semantic search. Returns memories stored on the blockchain that are semantically similar to the query. Use this to recall user preferences, past conversations, important facts, and context from previous interactions. Usage: searchUserMemories({confirm: true, query: "what did I say about my favorite food?", limit: 5})', - inputSchema: jsonSchema({ - type: "object" as const, - properties: { - confirm: { - type: "boolean" as const, - description: "Set to true to search memories", - }, - query: { - type: "string" as const, - description: "Search query to find relevant memories (e.g., 'user preferences', 'past decisions', 'important facts')", - }, - limit: { - type: "number" as const, - description: "Maximum number of memories to return (default: 10, max: 50)", - }, - }, - required: ["confirm", "query"], - additionalProperties: false, - }), - execute: async ({ query, limit = 10 }): Promise => { - try { - const result = await recallMemories(query, Math.min(limit, 50)); - - const memories: UserMemory[] = result.results.map((r) => ({ - id: '', - text: r.text, - category: 'general', - importance: 5, - similarity: 1 - r.distance, - createdAt: new Date().toISOString(), - })); - return { - query, - memories, - count: memories.length, - }; - } catch (error) { - console.error('[Tool] searchUserMemories error:', error); - - return { - query, - memories: [], - count: 0, - error: "Memory search unavailable", - }; - } - }, - }), -}); diff --git a/apps/noter/package/shared/lib/trpc/init.ts b/apps/noter/package/shared/lib/trpc/init.ts index 5beb04fd..770c3119 100644 --- a/apps/noter/package/shared/lib/trpc/init.ts +++ b/apps/noter/package/shared/lib/trpc/init.ts @@ -2,34 +2,44 @@ import { initTRPC, TRPCError } from "@trpc/server"; import { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"; import superjson from "superjson"; import { db } from "@/shared/lib/db"; -import { zkLoginSessions, walletSessions } from "@/shared/db/schema"; +import { zkLoginSessions, walletSessions, users } from "@/shared/db/schema"; import { eq } from "drizzle-orm"; export type Context = { db: typeof db; userId: string | null; + /** Per-user MemWal delegate key (null if user has no stored key). */ + memwalKey: string | null; + /** Per-user MemWal account ID (null if user has no stored account). */ + memwalAccountId: string | null; }; -/** - * Extract session ID from request headers - * Client should send sessionId in x-session-id header - */ function getSessionIdFromRequest(req: Request): string | null { - const sessionId = req.headers.get("x-session-id"); - return sessionId; + return req.headers.get("x-session-id"); +} + +/** Load user's MemWal delegate key from the users table. Falls back to env vars. */ +async function loadUserMemwalKey(userId: string) { + try { + const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); + return { + memwalKey: user?.delegatePrivateKey ?? process.env.MEMWAL_KEY ?? null, + memwalAccountId: user?.delegateAccountId ?? process.env.MEMWAL_ACCOUNT_ID ?? null, + }; + } catch { + return { + memwalKey: process.env.MEMWAL_KEY ?? null, + memwalAccountId: process.env.MEMWAL_ACCOUNT_ID ?? null, + }; + } } -/** - * Create tRPC context with authenticated user - * Extracts sessionId from headers and validates against database - * Supports both zkLogin and wallet sessions - */ export const createContext = async ( opts: FetchCreateContextFnOptions ): Promise => { + const noAuth: Context = { db, userId: null, memwalKey: null, memwalAccountId: null }; const sessionId = getSessionIdFromRequest(opts.req); - if (!sessionId) { return { db, userId: null }; - } + if (!sessionId) return noAuth; // Try zkLogin session first const [zkSession] = await db @@ -38,28 +48,24 @@ export const createContext = async ( .where(eq(zkLoginSessions.id, sessionId)) .limit(1); - if (zkSession && zkSession.userId) { - // Check if session expired - if (zkSession.expiresAt < new Date()) { return { db, userId: null }; - } - - return { db, userId: zkSession.userId }; + if (zkSession?.userId && zkSession.expiresAt > new Date()) { + const keys = await loadUserMemwalKey(zkSession.userId); + return { db, userId: zkSession.userId, ...keys }; } - // Try wallet session + // Try wallet/enoki session const [walletSession] = await db .select() .from(walletSessions) .where(eq(walletSessions.id, sessionId)) .limit(1); - if (walletSession && walletSession.userId) { - // Check if session expired - if (walletSession.expiresAt < new Date()) { return { db, userId: null }; - } + if (walletSession?.userId && walletSession.expiresAt > new Date()) { + const keys = await loadUserMemwalKey(walletSession.userId); + return { db, userId: walletSession.userId, ...keys }; + } - return { db, userId: walletSession.userId }; - } return { db, userId: null }; + return noAuth; }; const t = initTRPC.context().create({ @@ -70,8 +76,8 @@ export const router = t.router; export const procedure = t.procedure; /** - * Protected procedure that requires authentication - * Throws UNAUTHORIZED if no valid session + * Protected procedure that requires authentication. + * Throws UNAUTHORIZED if no valid session. */ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.userId) { @@ -84,7 +90,7 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { return next({ ctx: { ...ctx, - userId: ctx.userId, // Now TypeScript knows userId is non-null + userId: ctx.userId, }, }); }); diff --git a/apps/noter/package/shared/lib/trpc/router.ts b/apps/noter/package/shared/lib/trpc/router.ts index 090b1fe0..02f6002a 100644 --- a/apps/noter/package/shared/lib/trpc/router.ts +++ b/apps/noter/package/shared/lib/trpc/router.ts @@ -1,14 +1,10 @@ import { router } from "./init"; import { authRouter } from "@/feature/auth/api/route"; import { noteRouter } from "@/feature/note/api/route"; -import { memoryRouter } from "@/feature/memory/api/route"; -import { chatRouter } from "@/feature/chat/api/route"; export const appRouter = router({ auth: authRouter, note: noteRouter, - memory: memoryRouter, - chat: chatRouter, }); export type AppRouter = typeof appRouter; diff --git a/package.json b/package.json index 1d391633..e3612aff 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "workspaces": [ "packages/*", "apps/*", - "services/*" + "services/*", + "docs" ] } From e02ad0ba12bcdbbddfce24276887cc7ae5e82141 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Apr 2026 14:51:03 +0700 Subject: [PATCH 11/21] revert(security): defer HIGH-4 to follow-up PR, keep HIGH-1/2/8 --- apps/app/src/hooks/useSponsoredTransaction.ts | 72 +++---- apps/app/src/pages/SetupWizard.tsx | 6 +- services/server/src/auth.rs | 84 -------- services/server/src/main.rs | 38 ++-- services/server/src/rate_limit.rs | 2 - services/server/src/routes.rs | 24 --- services/server/tests/test_sponsor_flow.py | 182 ------------------ 7 files changed, 51 insertions(+), 357 deletions(-) delete mode 100644 services/server/tests/test_sponsor_flow.py diff --git a/apps/app/src/hooks/useSponsoredTransaction.ts b/apps/app/src/hooks/useSponsoredTransaction.ts index 4b14c1dd..43f740d7 100644 --- a/apps/app/src/hooks/useSponsoredTransaction.ts +++ b/apps/app/src/hooks/useSponsoredTransaction.ts @@ -6,76 +6,77 @@ * * Flow: * 1. Build Transaction as TransactionKind bytes - * 2. POST to /sponsor → get { bytes, digest } + * 2. POST to sidecar /sponsor → get { bytes, digest } * 3. Sign sponsored bytes with user wallet - * 4. POST to /sponsor/execute → get { digest } + * 4. POST to sidecar /sponsor/execute → get { digest } * * Falls back to direct signAndExecute if sponsor fails. - * - * signingKey / signingPublicKey: optional override used by SetupWizard before - * the delegate key is registered on-chain. /sponsor uses verify_signature_sponsor - * which verifies Ed25519 signature but skips on-chain account resolution. */ import { useCurrentAccount, useSignTransaction, useSignAndExecuteTransaction, useSuiClient } from '@mysten/dapp-kit' import { Transaction } from '@mysten/sui/transactions' import { config } from '../config' -import { useDelegateKey } from '../App' -import { apiCall } from '../utils/api' export function useSponsoredTransaction() { const currentAccount = useCurrentAccount() const suiClient = useSuiClient() const { mutateAsync: signTransaction } = useSignTransaction() const { mutateAsync: directSignAndExecute } = useSignAndExecuteTransaction() - const { delegateKey, delegatePublicKey, accountObjectId } = useDelegateKey() - const mutateAsync = async ({ - transaction, - signingKey, - signingPublicKey, - }: { - transaction: Transaction - /** Key override for SetupWizard — local key before on-chain registration */ - signingKey?: string - signingPublicKey?: string - }): Promise<{ digest: string }> => { + const mutateAsync = async ({ transaction }: { transaction: Transaction }): Promise<{ digest: string }> => { const sender = currentAccount?.address if (!sender) throw new Error('No wallet connected') try { + // 1. Build TransactionKind bytes (without gas data) const kindBytes = await transaction.build({ client: suiClient as any, onlyTransactionKind: true, }) const kindBase64 = uint8ArrayToBase64(kindBytes) - if (!(signingKey ?? delegateKey) || !(signingPublicKey ?? delegatePublicKey)) { - throw new Error('No delegate key available for signing sponsor request') + // 2. Sponsor via server (proxied to sidecar) + const sponsorRes = await fetch(`${config.memwalServerUrl}/sponsor`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transactionBlockKindBytes: kindBase64, + sender, + }), + }) + + if (!sponsorRes.ok) { + const errText = await sponsorRes.text() + throw new Error(`Sponsor failed (${sponsorRes.status}): ${errText}`) } - const sponsored = await apiCall( - (signingKey ?? delegateKey)!, - config.memwalServerUrl, - '/sponsor', - { transactionBlockKindBytes: kindBase64, sender }, - accountObjectId ?? undefined, - ) + const sponsored = await sponsorRes.json() + // sponsored = { bytes: base64, digest: string } + // 3. Sign sponsored bytes with user wallet const sponsoredTx = Transaction.from(sponsored.bytes) const { signature } = await signTransaction({ transaction: sponsoredTx }) - const result = await apiCall( - (signingKey ?? delegateKey)!, - config.memwalServerUrl, - '/sponsor/execute', - { digest: sponsored.digest, signature }, - accountObjectId ?? undefined, - ) + // 4. Execute via server (proxied to sidecar) + const execRes = await fetch(`${config.memwalServerUrl}/sponsor/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + digest: sponsored.digest, + signature, + }), + }) + + if (!execRes.ok) { + const errText = await execRes.text() + throw new Error(`Sponsored execute failed (${execRes.status}): ${errText}`) + } + const result = await execRes.json() console.log(`[sponsored-tx] success, digest=${result.digest}`) return { digest: result.digest } } catch (err) { + // Fallback: try direct signing if sponsor fails console.warn('[sponsored-tx] sponsor failed, falling back to direct signing:', err) const result = await directSignAndExecute({ transaction }) return { digest: result.digest } @@ -85,6 +86,7 @@ export function useSponsoredTransaction() { return { mutateAsync } } +// Helper: Uint8Array → base64 function uint8ArrayToBase64(bytes: Uint8Array): string { let binary = '' for (let i = 0; i < bytes.length; i++) { diff --git a/apps/app/src/pages/SetupWizard.tsx b/apps/app/src/pages/SetupWizard.tsx index 4f583341..25df17f5 100644 --- a/apps/app/src/pages/SetupWizard.tsx +++ b/apps/app/src/pages/SetupWizard.tsx @@ -134,7 +134,7 @@ export default function SetupWizard() { ], }) - const result = await signAndExecute({ transaction: tx, signingKey: privateKeyHex, signingPublicKey: publicKeyHex }) + const result = await signAndExecute({ transaction: tx }) await suiClient.waitForTransaction({ digest: result.digest }) } else { // Step A: Create account first (now creates a shared object) @@ -149,7 +149,7 @@ export default function SetupWizard() { ], }) - const createResult = await signAndExecute({ transaction: tx, signingKey: privateKeyHex, signingPublicKey: publicKeyHex }) + const createResult = await signAndExecute({ transaction: tx }) await suiClient.waitForTransaction({ digest: createResult.digest }) // Find the created MemWalAccount object (now shared) @@ -180,7 +180,7 @@ export default function SetupWizard() { ], }) - const addResult = await signAndExecute({ transaction: tx2, signingKey: privateKeyHex, signingPublicKey: publicKeyHex }) + const addResult = await signAndExecute({ transaction: tx2 }) await suiClient.waitForTransaction({ digest: addResult.digest }) } diff --git a/services/server/src/auth.rs b/services/server/src/auth.rs index e4db7568..7923ac3d 100644 --- a/services/server/src/auth.rs +++ b/services/server/src/auth.rs @@ -136,90 +136,6 @@ pub async fn verify_signature( Ok(next.run(request).await) } -/// Lightweight auth middleware for /sponsor and /sponsor/execute. -/// -/// Verifies the Ed25519 signature mathematically (same as verify_signature) -/// but skips on-chain account resolution. This allows SetupWizard to get -/// gasless transactions before the delegate key is registered on-chain. -/// -/// Security unchanged: -/// - Request must be signed with a valid Ed25519 key — cannot be forged -/// - Rate limiting still applies (keyed by public key) -/// - 5-minute timestamp window still enforced -/// - Unauthenticated curl still returns 401 -pub async fn verify_signature_sponsor( - State(_state): State>, - request: Request, - next: Next, -) -> Result { - let headers = request.headers(); - - let public_key_hex = headers - .get("x-public-key") - .and_then(|v| v.to_str().ok()) - .map(String::from) - .ok_or(StatusCode::UNAUTHORIZED)?; - - let signature_hex = headers - .get("x-signature") - .and_then(|v| v.to_str().ok()) - .map(String::from) - .ok_or(StatusCode::UNAUTHORIZED)?; - - let timestamp_str = headers - .get("x-timestamp") - .and_then(|v| v.to_str().ok()) - .map(String::from) - .ok_or(StatusCode::UNAUTHORIZED)?; - - let delegate_key_hex = headers - .get("x-delegate-key") - .and_then(|v| v.to_str().ok()) - .map(String::from); - - let timestamp: i64 = timestamp_str.parse().map_err(|_| StatusCode::UNAUTHORIZED)?; - let now = chrono::Utc::now().timestamp(); - if (now - timestamp).abs() > 300 { - return Err(StatusCode::UNAUTHORIZED); - } - - let pk_bytes = hex::decode(&public_key_hex).map_err(|_| StatusCode::UNAUTHORIZED)?; - let pk_array: [u8; 32] = pk_bytes.try_into().map_err(|_| StatusCode::UNAUTHORIZED)?; - let verifying_key = VerifyingKey::from_bytes(&pk_array).map_err(|_| StatusCode::UNAUTHORIZED)?; - - let sig_bytes = hex::decode(&signature_hex).map_err(|_| StatusCode::UNAUTHORIZED)?; - let sig_array: [u8; 64] = sig_bytes.try_into().map_err(|_| StatusCode::UNAUTHORIZED)?; - let signature = Signature::from_bytes(&sig_array); - - let method = request.method().as_str().to_string(); - let path = request.uri().path().to_string(); - let (mut parts, body) = request.into_parts(); - let body_bytes = axum::body::to_bytes(body, 1024 * 1024) - .await - .map_err(|_| StatusCode::BAD_REQUEST)?; - - let body_hash = hex::encode(Sha256::digest(&body_bytes)); - let message = format!("{}.{}.{}.{}", timestamp_str, method, path, body_hash); - - verifying_key.verify(message.as_bytes(), &signature).map_err(|e| { - tracing::warn!("Sponsor signature verification failed: {}", e); - StatusCode::UNAUTHORIZED - })?; - - // No on-chain lookup — sponsor flow works before key is registered. - // Use public_key as owner proxy so rate limit buckets are per-key, - // not shared across all unregistered keys (which would collapse to "rate:"). - parts.extensions.insert(AuthInfo { - public_key: public_key_hex.clone(), - owner: public_key_hex, - account_id: String::new(), - delegate_key: delegate_key_hex, - }); - - let request = Request::from_parts(parts, axum::body::Body::from(body_bytes)); - Ok(next.run(request).await) -} - /// Resolve a delegate key to its account using multiple strategies: /// 1. PostgreSQL cache (fastest) /// 2. On-chain registry scan (slower, but auto-discovers) diff --git a/services/server/src/main.rs b/services/server/src/main.rs index 0de57d01..f64276dd 100644 --- a/services/server/src/main.rs +++ b/services/server/src/main.rs @@ -7,7 +7,7 @@ mod sui; mod types; mod walrus; -use axum::{extract::DefaultBodyLimit, middleware, routing::{get, post}, Router}; +use axum::{middleware, routing::{get, post}, Router}; use std::sync::Arc; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; @@ -122,18 +122,18 @@ async fn main() { }); // Build routes - // General protected routes (require Ed25519 signature + onchain verification). - // Body size is implicitly bounded to 1 MB by the auth middleware's to_bytes call. - // Router::layer runs middleware bottom-to-top (last added runs first). - // Keep auth outer so AuthInfo is in request extensions before rate limiting reads it. - let api_routes = Router::new() + // Protected routes (require Ed25519 signature + onchain verification) + let protected_routes = Router::new() .route("/api/remember", post(routes::remember)) .route("/api/recall", post(routes::recall)) .route("/api/remember/manual", post(routes::remember_manual)) .route("/api/recall/manual", post(routes::recall_manual)) + .route("/api/analyze", post(routes::analyze)) .route("/api/ask", post(routes::ask)) .route("/api/restore", post(routes::restore)) + // Router::layer runs middleware bottom-to-top (last added runs first). + // Keep auth outer so AuthInfo is in request extensions before rate limiting reads it. .layer(middleware::from_fn_with_state( state.clone(), rate_limit::rate_limit_middleware, @@ -143,30 +143,14 @@ async fn main() { auth::verify_signature, )); - // Sponsor proxy routes — auth-gated with a tight 16 KB body limit to - // prevent large-payload abuse of the Enoki gas sponsorship budget. - // DefaultBodyLimit is applied outermost (last layer added = first to run) - // so oversized bodies are rejected before the auth middleware reads them. - let sponsor_routes = Router::new() - .route("/sponsor", post(routes::sponsor_proxy)) - .route("/sponsor/execute", post(routes::sponsor_execute_proxy)) - .layer(middleware::from_fn_with_state( - state.clone(), - rate_limit::rate_limit_middleware, - )) - .layer(middleware::from_fn_with_state( - state.clone(), - auth::verify_signature_sponsor, - )) - .layer(DefaultBodyLimit::max(16_384)); - - // Public routes (no auth, no rate limit) + // Public routes let public_routes = Router::new() - .route("/health", get(routes::health)); + .route("/health", get(routes::health)) + .route("/sponsor", post(routes::sponsor_proxy)) + .route("/sponsor/execute", post(routes::sponsor_execute_proxy)); let app = Router::new() - .merge(api_routes) - .merge(sponsor_routes) + .merge(protected_routes) .merge(public_routes) .with_state(state) .layer(CorsLayer::permissive()) diff --git a/services/server/src/rate_limit.rs b/services/server/src/rate_limit.rs index 2504f754..3e533f09 100644 --- a/services/server/src/rate_limit.rs +++ b/services/server/src/rate_limit.rs @@ -97,8 +97,6 @@ fn endpoint_weight(path: &str) -> i64 { "/api/remember/manual" => 3, // Walrus upload only (client did embed/encrypt) "/api/restore" => 3, // download + decrypt + re-embed "/api/ask" => 2, // recall + LLM - "/sponsor" => 5, // Enoki gas sponsorship — expensive on-chain operation - "/sponsor/execute" => 5, // Enoki execute — submits sponsored transaction on-chain _ => 1, // recall, recall/manual, etc. } } diff --git a/services/server/src/routes.rs b/services/server/src/routes.rs index 5682c199..991a3c39 100644 --- a/services/server/src/routes.rs +++ b/services/server/src/routes.rs @@ -1017,22 +1017,10 @@ pub async fn restore( // ============================================================ /// POST /sponsor — proxy to sidecar POST /sponsor -/// -/// Requires valid auth (Ed25519 signature) — enforced by protected_routes middleware. -/// Validates that the body is well-formed JSON containing `transactionBlockKindBytes`. pub async fn sponsor_proxy( State(state): State>, body: axum::body::Bytes, ) -> Result, AppError> { - // Basic JSON validation: must parse and contain the expected field. - let parsed: serde_json::Value = serde_json::from_slice(&body) - .map_err(|_| AppError::BadRequest("Request body must be valid JSON".to_string()))?; - if parsed.get("transactionBlockKindBytes").is_none() { - return Err(AppError::BadRequest( - "Missing required field: transactionBlockKindBytes".to_string(), - )); - } - let url = format!("{}/sponsor", state.config.sidecar_url); let mut req = state.http_client .post(&url) @@ -1059,22 +1047,10 @@ pub async fn sponsor_proxy( } /// POST /sponsor/execute — proxy to sidecar POST /sponsor/execute -/// -/// Requires valid auth (Ed25519 signature) — enforced by protected_routes middleware. -/// Validates that the body is well-formed JSON containing `digest`. pub async fn sponsor_execute_proxy( State(state): State>, body: axum::body::Bytes, ) -> Result, AppError> { - // Basic JSON validation: must parse and contain the expected field. - let parsed: serde_json::Value = serde_json::from_slice(&body) - .map_err(|_| AppError::BadRequest("Request body must be valid JSON".to_string()))?; - if parsed.get("digest").is_none() { - return Err(AppError::BadRequest( - "Missing required field: digest".to_string(), - )); - } - let url = format!("{}/sponsor/execute", state.config.sidecar_url); let mut req = state.http_client .post(&url) diff --git a/services/server/tests/test_sponsor_flow.py b/services/server/tests/test_sponsor_flow.py deleted file mode 100644 index 8eac5e26..00000000 --- a/services/server/tests/test_sponsor_flow.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -""" -Test: Sponsor endpoint auth flow after HIGH-4 fix + SetupWizard regression fix. - -Prerequisites: - 1. cargo run (server on port 3001, sidecar on port 9000) - 2. SIDECAR_AUTH_TOKEN set in services/server/.env - -What this proves: - - Unauthenticated /sponsor → 401 (HIGH-4 security preserved) - - Random key (not on-chain) → auth PASSES (SetupWizard flow fixed) - - Registered key (on-chain) → auth PASSES (Dashboard flow unchanged) - - Rate limit is per-key, not shared across all sponsor requests (rate limit bucket fix) - Without fix: all unregistered keys share "rate:" → 1 attacker blocks all SetupWizard users - With fix: each key gets "rate:" → independent buckets - -Run: - python3 tests/test_sponsor_flow.py - - # With real registered key (optional): - TEST_DELEGATE_KEY= TEST_ACCOUNT_ID=<0x...> python3 tests/test_sponsor_flow.py -""" - -import hashlib, json, os, sys, time, urllib.error, urllib.request -from nacl.encoding import RawEncoder -from nacl.signing import SigningKey - -BASE = "http://localhost:3001" -PASS = "[PASS]" -FAIL = "[FAIL]" -SKIP = "[SKIP]" - - -def raw_post(url, body=None, headers=None): - data = json.dumps(body or {}).encode() - h = {"Content-Type": "application/json", **(headers or {})} - req = urllib.request.Request(url, data=data, headers=h, method="POST") - try: - with urllib.request.urlopen(req) as r: - return r.status, json.loads(r.read() or b"{}") - except urllib.error.HTTPError as e: - raw = e.read() - try: - return e.code, json.loads(raw) - except Exception: - return e.code, {"raw": raw.decode(errors="replace")} - - -def signed_post(path, body, key, account_id=None): - body_json = json.dumps(body).encode() - body_hash = hashlib.sha256(body_json).hexdigest() - ts = str(int(time.time())) - msg = f"{ts}.POST.{path}.{body_hash}" - signed = key.sign(msg.encode(), encoder=RawEncoder) - headers = { - "Content-Type": "application/json", - "x-public-key": key.verify_key.encode().hex(), - "x-signature": signed.signature.hex(), - "x-timestamp": ts, - } - if account_id: - headers["x-account-id"] = account_id - req = urllib.request.Request(f"{BASE}{path}", data=body_json, headers=headers, method="POST") - try: - with urllib.request.urlopen(req) as r: - return r.status, json.loads(r.read() or b"{}") - except urllib.error.HTTPError as e: - raw = e.read() - try: - return e.code, json.loads(raw) - except Exception: - return e.code, {"raw": raw.decode(errors="replace")} - - -print("=" * 60) -print("Sponsor Auth Flow Tests") -print("=" * 60) - -results = {} - -# ── Test 1: No auth → must 401 ───────────────────────────────── -print("\n[1] Unauthenticated /sponsor → must 401 (HIGH-4 preserved)") -status, resp = raw_post(f"{BASE}/sponsor", {"transactionBlockKindBytes": "abc", "sender": "0x1"}) -if status == 401: - print(f" {PASS} /sponsor (no auth) → {status}") - results["no-auth /sponsor"] = True -else: - print(f" {FAIL} /sponsor (no auth) → {status} {resp}") - results["no-auth /sponsor"] = False - -status, resp = raw_post(f"{BASE}/sponsor/execute", {"digest": "abc", "signature": "sig"}) -if status == 401: - print(f" {PASS} /sponsor/execute (no auth) → {status}") - results["no-auth /sponsor/execute"] = True -else: - print(f" {FAIL} /sponsor/execute (no auth) → {status} {resp}") - results["no-auth /sponsor/execute"] = False - - -# ── Test 2: Random key (not on-chain) → auth must PASS ───────── -print("\n[2] Random key (not on-chain) → auth must pass (SetupWizard flow)") -random_key = SigningKey.generate() -status, resp = signed_post("/sponsor", {"transactionBlockKindBytes": "abc", "sender": "0x1"}, random_key) -if status != 401: - print(f" {PASS} /sponsor (random key) → {status} — auth passed, key not required on-chain") - results["random-key /sponsor"] = True -else: - print(f" {FAIL} /sponsor (random key) → 401 — auth still blocking (fix not working)") - results["random-key /sponsor"] = False - - -# ── Test 3: Registered key (on-chain) → auth must PASS ───────── -print("\n[3] Registered key (on-chain) → auth must pass (Dashboard flow)") -registered_key_hex = os.environ.get("TEST_DELEGATE_KEY") -account_id = os.environ.get("TEST_ACCOUNT_ID") - -if registered_key_hex and account_id: - reg_key = SigningKey(bytes.fromhex(registered_key_hex)) - status, resp = signed_post("/sponsor", {"transactionBlockKindBytes": "abc", "sender": "0x1"}, reg_key, account_id) - if status != 401: - print(f" {PASS} /sponsor (registered key) → {status}") - results["registered-key /sponsor"] = True - else: - print(f" {FAIL} /sponsor (registered key) → 401 {resp}") - results["registered-key /sponsor"] = False -else: - print(f" {SKIP} set TEST_DELEGATE_KEY and TEST_ACCOUNT_ID to test with real key") - results["registered-key /sponsor"] = None - - -# ── Test 4: Rate limit buckets are per-key, not shared ───────── -# /sponsor weight=5, per-delegate-key limit=30 → triggers at request #7 -# Without the owner fix: all keys collapse to "rate:" (shared bucket) -# With the fix: each key gets "rate:" (independent) -# -# To run this test Redis must be reachable and keys must be fresh -# (guaranteed since we generate new random keys each run). -print("\n[4] Rate limit: per-key isolation (not shared global bucket)") -key_a = SigningKey.generate() -key_b = SigningKey.generate() -sponsor_body = {"transactionBlockKindBytes": "abc", "sender": "0x1"} - -# Exhaust key_a's per-key quota -# weight=5, per-key limit=30 → limit hit on request 7 (7×5=35 > 30) -hit_limit_at = None -for i in range(8): - status, resp = signed_post("/sponsor", sponsor_body, key_a) - if status == 429: - hit_limit_at = i + 1 - break - -if hit_limit_at is None: - print(f" {FAIL} key_a never hit 429 after 8 requests — rate limit not enforced") - results["rate-limit enforcement"] = False - results["rate-limit per-key isolation"] = False -else: - print(f" {PASS} key_a hit 429 at request #{hit_limit_at} (rate limit enforced)") - results["rate-limit enforcement"] = True - - # key_b is a fresh key — must NOT be affected by key_a's exhausted bucket - # If buckets are shared (bug): key_b → 429 - # If buckets are per-key (fix): key_b → not 429 - status_b, resp_b = signed_post("/sponsor", sponsor_body, key_b) - if status_b != 429: - print(f" {PASS} key_b unaffected by key_a's limit → {status_b} (buckets are independent)") - results["rate-limit per-key isolation"] = True - else: - print(f" {FAIL} key_b got 429 — rate limits are shared (fix: set owner=public_key in verify_signature_sponsor)") - results["rate-limit per-key isolation"] = False - - -# ── Summary ──────────────────────────────────────────────────── -print("\n" + "=" * 60) -passed = sum(1 for v in results.values() if v is True) -failed = sum(1 for v in results.values() if v is False) -skipped = sum(1 for v in results.values() if v is None) -for name, result in results.items(): - mark = PASS if result is True else FAIL if result is False else SKIP - print(f" {mark} {name}") -print(f"\n{passed} passed, {failed} failed, {skipped} skipped") -if failed: - sys.exit(1) From 79efef2eff1a66703ef0f68aef6c23f878c2f73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ph=E1=BA=A1m=20Minh=20H=C3=B9ng?= <46132442+hungtranphamminh@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:52:18 +0700 Subject: [PATCH 12/21] chore(deps): update pnpm-lock.yaml for noter enoki dependencies (#86) Lockfile was out of sync with noter package.json after adding @mysten/dapp-kit, @mysten/enoki, @noble/ed25519, @noble/hashes and bumping drizzle-orm/drizzle-kit versions. --- pnpm-lock.yaml | 129 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 101 insertions(+), 28 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae6e5569..c365d575 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -502,8 +502,14 @@ importers: '@mysten-incubation/memwal': specifier: workspace:* version: link:../../packages/sdk + '@mysten/dapp-kit': + specifier: ^1.0.3 + version: 1.0.4(@mysten/sui@2.8.0(typescript@5.9.3))(@tanstack/react-query@5.90.21(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.3)) + '@mysten/enoki': + specifier: ^1.0.4 + version: 1.0.4(@mysten/sui@2.8.0(typescript@5.9.3))(@types/react@19.2.14)(react@19.2.3) '@mysten/sui': - specifier: ^2.5.0 + specifier: ^2.6.0 version: 2.8.0(typescript@5.9.3) '@mysten/wallet-standard': specifier: ^0.15.0 @@ -511,6 +517,12 @@ importers: '@mysten/zklogin': specifier: ^0.8.1 version: 0.8.1(typescript@5.9.3) + '@noble/ed25519': + specifier: ^2.2.3 + version: 2.3.0 + '@noble/hashes': + specifier: ^1.7.2 + version: 1.8.0 '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.3) @@ -539,11 +551,11 @@ importers: specifier: ^4.1.0 version: 4.1.0 drizzle-orm: - specifier: ^0.45.1 - version: 0.45.1(@opentelemetry/api@1.9.0)(postgres@3.4.8) + specifier: ^0.45.2 + version: 0.45.2(@opentelemetry/api@1.9.0)(postgres@3.4.8) drizzle-zod: specifier: ^0.8.3 - version: 0.8.3(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(postgres@3.4.8))(zod@4.3.6) + version: 0.8.3(drizzle-orm@0.45.2(@opentelemetry/api@1.9.0)(postgres@3.4.8))(zod@4.3.6) embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) @@ -645,8 +657,8 @@ importers: specifier: ^16.4.5 version: 16.6.1 drizzle-kit: - specifier: ^0.31.9 - version: 0.31.9 + specifier: ^0.31.10 + version: 0.31.10 eslint: specifier: ^9 version: 9.39.4(jiti@2.6.1) @@ -704,6 +716,15 @@ importers: '@mysten-incubation/memwal': specifier: 0.0.1 version: 0.0.1(@mysten/seal@1.1.1(@mysten/sui@2.8.0(typescript@5.9.3)))(@mysten/sui@2.8.0(typescript@5.9.3))(@mysten/walrus@1.0.4(@mysten/sui@2.8.0(typescript@5.9.3)))(ai@6.0.37(zod@3.25.76))(zod@3.25.76) + '@mysten/dapp-kit': + specifier: ^1.0.3 + version: 1.0.4(@mysten/sui@2.8.0(typescript@5.9.3))(@tanstack/react-query@5.90.21(react@19.0.1))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.0.1)) + '@mysten/enoki': + specifier: ^1.0.4 + version: 1.0.4(@mysten/sui@2.8.0(typescript@5.9.3))(@types/react@19.2.14)(react@19.0.1) + '@mysten/sui': + specifier: ^2.6.0 + version: 2.8.0(typescript@5.9.3) '@noble/ed25519': specifier: ^2.2.3 version: 2.3.0 @@ -758,6 +779,9 @@ importers: '@radix-ui/react-visually-hidden': specifier: ^1.1.0 version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.0.1) '@vercel/blob': specifier: ^0.24.1 version: 0.24.1 @@ -6475,8 +6499,8 @@ packages: resolution: {integrity: sha512-Rcf0nYCAKizwjWQCY+d3zytyuTbDb81NcaPor+8NebESlUz1+9W3uGl0+r9FhU4Qal5Zv9j/7neXCSCe7DHzjA==} hasBin: true - drizzle-kit@0.31.9: - resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true drizzle-orm@0.34.1: @@ -6568,8 +6592,8 @@ packages: sqlite3: optional: true - drizzle-orm@0.45.1: - resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' '@cloudflare/workers-types': '>=4' @@ -13014,7 +13038,7 @@ snapshots: '@manypkg/find-root@1.1.0': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 @@ -13553,6 +13577,31 @@ snapshots: '@mysten/utils': 0.3.1 '@scure/base': 2.0.0 + '@mysten/dapp-kit@1.0.4(@mysten/sui@2.8.0(typescript@5.9.3))(@tanstack/react-query@5.90.21(react@19.0.1))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@9.0.21)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.0.1))': + dependencies: + '@mysten/slush-wallet': 1.0.3(@mysten/sui@2.8.0(typescript@5.9.3))(typescript@5.9.3) + '@mysten/sui': 2.8.0(typescript@5.9.3) + '@mysten/utils': 0.3.1 + '@mysten/wallet-standard': 0.20.1(@mysten/sui@2.8.0(typescript@5.9.3)) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.0.1) + '@tanstack/react-query': 5.90.21(react@19.0.1) + '@vanilla-extract/css': 1.18.0 + '@vanilla-extract/dynamic': 2.1.5 + '@vanilla-extract/recipes': 0.5.7(@vanilla-extract/css@1.18.0) + clsx: 2.1.1 + react: 19.0.1 + zustand: 5.0.11(@types/react@19.2.14)(immer@9.0.21)(react@19.0.1)(use-sync-external-store@1.6.0(react@19.0.1)) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - immer + - react-dom + - typescript + - use-sync-external-store + '@mysten/dapp-kit@1.0.4(@mysten/sui@2.8.0(typescript@5.9.3))(@tanstack/react-query@5.90.21(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.3))': dependencies: '@mysten/slush-wallet': 1.0.3(@mysten/sui@2.8.0(typescript@5.9.3))(typescript@5.9.3) @@ -13578,6 +13627,22 @@ snapshots: - typescript - use-sync-external-store + '@mysten/enoki@1.0.4(@mysten/sui@2.8.0(typescript@5.9.3))(@types/react@19.2.14)(react@19.0.1)': + dependencies: + '@mysten/signers': 1.0.1(@mysten/sui@2.8.0(typescript@5.9.3)) + '@mysten/sui': 2.8.0(typescript@5.9.3) + '@mysten/utils': 0.3.1 + '@mysten/wallet-standard': 0.20.1(@mysten/sui@2.8.0(typescript@5.9.3)) + '@nanostores/react': 1.0.0(nanostores@1.1.1)(react@19.0.1) + '@wallet-standard/ui': 1.0.1 + idb-keyval: 6.2.2 + nanostores: 1.1.1 + optionalDependencies: + '@types/react': 19.2.14 + react: 19.0.1 + transitivePeerDependencies: + - supports-color + '@mysten/enoki@1.0.4(@mysten/sui@2.8.0(typescript@5.9.3))(@types/react@19.2.14)(react@19.2.3)': dependencies: '@mysten/signers': 1.0.1(@mysten/sui@2.8.0(typescript@5.9.3)) @@ -13734,6 +13799,11 @@ snapshots: - '@gql.tada/vue-support' - typescript + '@nanostores/react@1.0.0(nanostores@1.1.1)(react@19.0.1)': + dependencies: + nanostores: 1.1.1 + react: 19.0.1 + '@nanostores/react@1.0.0(nanostores@1.1.1)(react@19.2.3)': dependencies: nanostores: 1.1.1 @@ -16894,6 +16964,11 @@ snapshots: '@tanstack/query-core@5.90.20': {} + '@tanstack/react-query@5.90.21(react@19.0.1)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.0.1 + '@tanstack/react-query@5.90.21(react@19.2.3)': dependencies: '@tanstack/query-core': 5.90.20 @@ -18598,14 +18673,12 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-kit@0.31.9: + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.25.12 - esbuild-register: 3.6.0(esbuild@0.25.12) - transitivePeerDependencies: - - supports-color + tsx: 4.21.0 drizzle-orm@0.34.1(@opentelemetry/api@1.9.0)(@types/react@18.3.28)(postgres@3.4.8)(react@19.0.1): optionalDependencies: @@ -18621,14 +18694,14 @@ snapshots: postgres: 3.4.8 react: 19.0.1 - drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(postgres@3.4.8): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.0)(postgres@3.4.8): optionalDependencies: '@opentelemetry/api': 1.9.0 postgres: 3.4.8 - drizzle-zod@0.8.3(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(postgres@3.4.8))(zod@4.3.6): + drizzle-zod@0.8.3(drizzle-orm@0.45.2(@opentelemetry/api@1.9.0)(postgres@3.4.8))(zod@4.3.6): dependencies: - drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(postgres@3.4.8) + drizzle-orm: 0.45.2(@opentelemetry/api@1.9.0)(postgres@3.4.8) zod: 4.3.6 dunder-proto@1.0.1: @@ -18874,13 +18947,6 @@ snapshots: transitivePeerDependencies: - supports-color - esbuild-register@3.6.0(esbuild@0.25.12): - dependencies: - debug: 4.4.3 - esbuild: 0.25.12 - transitivePeerDependencies: - - supports-color - esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -19040,7 +19106,7 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -19073,7 +19139,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -19087,7 +19153,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -24703,6 +24769,13 @@ snapshots: immer: 9.0.21 react: 19.2.3 + zustand@5.0.11(@types/react@19.2.14)(immer@9.0.21)(react@19.0.1)(use-sync-external-store@1.6.0(react@19.0.1)): + optionalDependencies: + '@types/react': 19.2.14 + immer: 9.0.21 + react: 19.0.1 + use-sync-external-store: 1.6.0(react@19.0.1) + zustand@5.0.11(@types/react@19.2.14)(immer@9.0.21)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): optionalDependencies: '@types/react': 19.2.14 From ef7dfaedbac9c83dd2d67d32cc9b010f4eff4b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ph=E1=BA=A1m=20Minh=20H=C3=B9ng?= <46132442+hungtranphamminh@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:28:57 +0700 Subject: [PATCH 13/21] chore(noter): trigger Railway deploy for lockfile sync (#88) --- apps/noter/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/noter/Dockerfile b/apps/noter/Dockerfile index c18a44b6..6ff46665 100644 --- a/apps/noter/Dockerfile +++ b/apps/noter/Dockerfile @@ -1,5 +1,5 @@ # ============================================================ -# MemWal Noter — Dockerfile +# MemWal Noter — Dockerfile (v2 — Enoki build args) # # ⚠️ BUILD FROM MONOREPO ROOT: # docker build -f apps/noter/Dockerfile -t memwal-noter . From 998af42fc14ee3732ac1f7b7a451c4aa7218722f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ph=E1=BA=A1m=20Minh=20H=C3=B9ng?= <46132442+hungtranphamminh@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:22:20 +0700 Subject: [PATCH 14/21] Feat: Replace setup wizard with inline Enoki login flow (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): replace setup wizard with inline enoki login flow Merge SetupWizard into LandingPage with two auth options via SDK Playground popover: - Enoki (Google OAuth): silent key gen + on-chain registration - Wallet (any Sui wallet): key display + confirm + registration Key changes: - Remove SetupWizard.tsx and its route - Add sessionStorage persistence for auth method across OAuth redirects - Add setupRunningRef guard against React 18 Strict Mode double execution - Add wallet disconnect detection during active setup - Clear private key from React state after successful save - Add login popover with matching neo-brutalism button styles - Update .env.example with Enoki credentials * fix(app): resolve premature auth state, hero flash, and defensive guard - Defer authMethod='wallet' until wallet actually connects via walletClickedRef, preventing stale sessionStorage on modal dismiss - Include idle state in showSetupFlow when authMethod is set, so the setup spinner renders immediately after OAuth redirect instead of briefly flashing the hero section - Add spinner for wallet idle state ("connecting wallet...") - Replace registerOnchain fallback empty string with throw to fail fast on unexpected missing account ID * fix(app): resolve code quality bot findings in LandingPage - Reorder declarations so all useCallback functions are defined before the useEffect hooks that reference them - Move walletClickedRef up with other refs - Remove dead code: redundant knownAccountId null check after the if/else branches where it is guaranteed to be set - Reorganize component into clear sections: callbacks → effects → render --- apps/app/.env.example | 11 +- apps/app/src/App.tsx | 3 +- apps/app/src/index.css | 33 ++ apps/app/src/pages/LandingPage.tsx | 663 ++++++++++++++++++++++++++--- apps/app/src/pages/SetupWizard.tsx | 384 ----------------- 5 files changed, 648 insertions(+), 446 deletions(-) delete mode 100644 apps/app/src/pages/SetupWizard.tsx diff --git a/apps/app/.env.example b/apps/app/.env.example index 8eaaa605..784c0d30 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -4,14 +4,15 @@ # To switch networks: uncomment one block, comment out the other. # ══════════════════════════════════════════════════════════════ -# Enoki API Key (get from https://portal.enoki.mystenlabs.com) -VITE_ENOKI_API_KEY= +# Enoki zkLogin — enables "Sign in with Google" +# Get Enoki API key from https://portal.enoki.mystenlabs.com +VITE_ENOKI_API_KEY=enoki_public_9aac56cf5c1e5b1254d1fa09bb6e9f0c # Google OAuth Client ID (from Google Cloud Console) -VITE_GOOGLE_CLIENT_ID= +VITE_GOOGLE_CLIENT_ID=386692102434-pn0enkrr12r5q3arflsfrrvb14rvhs10.apps.googleusercontent.com -# MemWal Server URL -VITE_MEMWAL_SERVER_URL=http://localhost:8000 +# MemWal Server URL (also handles /sponsor and /sponsor/execute for gasless tx) +VITE_MEMWAL_SERVER_URL=https://relayer.dev.memwal.ai # Docs URL (separate deployment) VITE_DOCS_URL=http://localhost:5174 diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 87a5b7b9..df8f16d6 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -21,7 +21,6 @@ import { config } from './config' import LandingPage from './pages/LandingPage' import Dashboard from './pages/Dashboard' -import SetupWizard from './pages/SetupWizard' import Playground from './pages/Playground' @@ -134,7 +133,7 @@ function AppContent() { } /> : - delegateKey ? : + delegateKey ? : } /> : diff --git a/apps/app/src/index.css b/apps/app/src/index.css index abd7d38d..ea53adcf 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -774,6 +774,39 @@ h1, h2, h3 { box-shadow: 5px 5px 0 #000000 !important; } +/* ── Login popover: ConnectButton full-width + matching style ── */ + +.lp-login-wallet-btn { + width: 100%; +} + +.lp-login-wallet-btn [class*="ConnectButton"], +.lp-login-wallet-btn div { + width: 100% !important; +} + +.lp-login-wallet-btn [class*="ConnectButton"] button, +.lp-login-wallet-btn button[data-dapp-kit] { + width: 100% !important; + background: #E8FF75 !important; + color: #000000 !important; + border: 2px solid #000000 !important; + border-radius: 10px !important; + padding: 10px 16px !important; + font-size: 0.88rem !important; + font-weight: 700 !important; + font-family: var(--font-sans) !important; + box-shadow: 3px 3px 0 #000000 !important; + transition: transform 0.15s, box-shadow 0.15s !important; + justify-content: center !important; +} + +.lp-login-wallet-btn [class*="ConnectButton"] button:hover, +.lp-login-wallet-btn button[data-dapp-kit]:hover { + transform: translate(-1px, -1px) !important; + box-shadow: 4px 4px 0 #000000 !important; +} + /* ── Responsive ── */ @media (max-width: 860px) { diff --git a/apps/app/src/pages/LandingPage.tsx b/apps/app/src/pages/LandingPage.tsx index 1c930f3a..9dc24ef3 100644 --- a/apps/app/src/pages/LandingPage.tsx +++ b/apps/app/src/pages/LandingPage.tsx @@ -1,5 +1,8 @@ /** - * Landing Page — Sign in with Google (Enoki zkLogin) or any Sui wallet + * Landing Page — Two login options via "SDK Playground" popover: + * + * 1. Sign in with Google (Enoki) — silent key gen + on-chain registration, no key display + * 2. Connect Wallet (any Sui wallet) — shows key + copy + confirm before on-chain registration */ import { @@ -7,19 +10,46 @@ import { useConnectWallet, useCurrentAccount, useWallets, + useSuiClient, } from '@mysten/dapp-kit' import { isEnokiWallet, type EnokiWallet, type AuthProvider } from '@mysten/enoki' -import { ChevronDown, Github } from 'lucide-react' -import { useRef, useState, useEffect } from 'react' +import { Transaction } from '@mysten/sui/transactions' +import { ChevronDown, Github, Copy } from 'lucide-react' +import { useRef, useState, useEffect, useCallback } from 'react' import { useNavigate } from 'react-router-dom' +import { useSponsoredTransaction } from '../hooks/useSponsoredTransaction' +import { useDelegateKey } from '../App' import { config } from '../config' import memwalLogo from '../assets/memwal-logo.svg' +type SetupStep = 'idle' | 'generating' | 'show-key' | 'onchain' | 'done' | 'error' +type AuthMethod = 'enoki' | 'wallet' | null + +// ── Persist authMethod across OAuth redirects ── +const AUTH_METHOD_KEY = 'memwal_auth_method' + +function getPersistedAuthMethod(): AuthMethod { + const val = sessionStorage.getItem(AUTH_METHOD_KEY) + if (val === 'enoki' || val === 'wallet') return val + return null +} + +function persistAuthMethod(method: AuthMethod) { + if (method) { + sessionStorage.setItem(AUTH_METHOD_KEY, method) + } else { + sessionStorage.removeItem(AUTH_METHOD_KEY) + } +} + export default function LandingPage() { const currentAccount = useCurrentAccount() const { mutate: connect } = useConnectWallet() const wallets = useWallets() const enokiWallets = wallets.filter(isEnokiWallet) + const suiClient = useSuiClient() + const { mutateAsync: signAndExecute } = useSponsoredTransaction() + const { delegateKey, setDelegateKeys } = useDelegateKey() // Find Google wallet from registered Enoki wallets const walletsByProvider = enokiWallets.reduce( @@ -29,29 +59,510 @@ export default function LandingPage() { const googleWallet = walletsByProvider.get('google') const navigate = useNavigate() - const hasEnokiConfig = config.enokiApiKey && config.googleClientId + const hasEnokiConfig = !!(config.enokiApiKey && config.googleClientId) const demoUrls = config.demoUrls + + // ── Dropdown states ── const [demoOpen, setDemoOpen] = useState(false) const demoRef = useRef(null) + const [loginOpen, setLoginOpen] = useState(false) + const loginRef = useRef(null) + + // ── Auth method tracking (restored from sessionStorage on mount for OAuth redirects) ── + const [authMethod, setAuthMethod] = useState(getPersistedAuthMethod) + + // ── Refs ── + const setupRunningRef = useRef(false) + const walletClickedRef = useRef(false) + + // ── Setup state ── + const [setupStep, setSetupStep] = useState('idle') + const [privateKeyHex, setPrivateKeyHex] = useState('') + const [publicKeyHex, setPublicKeyHex] = useState('') + const [suiAddress, setSuiAddress] = useState('') + const [copied, setCopied] = useState(false) + const [confirmed, setConfirmed] = useState(false) + const [txStatus, setTxStatus] = useState('') + const [error, setError] = useState('') + + const address = currentAccount?.address || '' + const isNewUser = !!currentAccount && !delegateKey + + // ── Sync authMethod to sessionStorage ── + const updateAuthMethod = useCallback((method: AuthMethod) => { + setAuthMethod(method) + persistAuthMethod(method) + }, []) + + // ════════════════════════════════════════════════════════════ + // Callbacks — declared before effects that reference them + // ════════════════════════════════════════════════════════════ + + // ── Generate Ed25519 keypair (returns the keys) ── + const generateKeys = useCallback(async () => { + const ed = await import('@noble/ed25519') + const { blake2b } = await import('@noble/hashes/blake2.js') + const privateKey = new Uint8Array(32) + crypto.getRandomValues(privateKey) + const publicKey = await ed.getPublicKeyAsync(privateKey) + + const privHex = Array.from(privateKey).map(b => b.toString(16).padStart(2, '0')).join('') + const pubHex = Array.from(publicKey).map(b => b.toString(16).padStart(2, '0')).join('') + + const input = new Uint8Array(33) + input[0] = 0x00 + input.set(publicKey, 1) + const addressBytes = blake2b(input, { dkLen: 32 }) + const suiAddr = '0x' + Array.from(new Uint8Array(addressBytes)).map((b: number) => b.toString(16).padStart(2, '0')).join('') + return { privHex, pubHex, suiAddr } + }, []) + + // ── Register delegate key on-chain (shared logic) ── + const registerOnchain = useCallback(async ( + ownerAddress: string, + pubKeyHex: string, + delegateSuiAddress: string, + ): Promise => { + let knownAccountId: string | null = null + + try { + const registryObj = await suiClient.getObject({ + id: config.memwalRegistryId, + options: { showContent: true }, + }) + if (registryObj?.data?.content && 'fields' in registryObj.data.content) { + const fields = registryObj.data.content.fields as any + const tableId = fields?.accounts?.fields?.id?.id + if (tableId) { + const dynField = await suiClient.getDynamicFieldObject({ + parentId: tableId, + name: { type: 'address', value: ownerAddress }, + }) + if (dynField?.data?.content && 'fields' in dynField.data.content) { + knownAccountId = (dynField.data.content.fields as any).value as string + } + } + } + } catch { + // Dynamic field not found → no account yet + } + + const pubKeyBytes = Array.from( + { length: pubKeyHex.length / 2 }, + (_, i) => parseInt(pubKeyHex.slice(i * 2, i * 2 + 2), 16) + ) + + if (knownAccountId) { + const tx = new Transaction() + tx.moveCall({ + target: `${config.memwalPackageId}::account::add_delegate_key`, + arguments: [ + tx.object(knownAccountId), + tx.pure('vector', pubKeyBytes), + tx.pure('address', delegateSuiAddress), + tx.pure('string', 'Web App'), + tx.object('0x6'), + ], + }) + const result = await signAndExecute({ transaction: tx }) + await suiClient.waitForTransaction({ digest: result.digest }) + } else { + const tx = new Transaction() + tx.moveCall({ + target: `${config.memwalPackageId}::account::create_account`, + arguments: [ + tx.object(config.memwalRegistryId), + tx.object('0x6'), + ], + }) + const createResult = await signAndExecute({ transaction: tx }) + await suiClient.waitForTransaction({ digest: createResult.digest }) + + const txDetails = await suiClient.getTransactionBlock({ + digest: createResult.digest, + options: { showObjectChanges: true }, + }) + const createdObj = txDetails.objectChanges?.find( + (c) => c.type === 'created' && + 'objectType' in c && + c.objectType.includes('MemWalAccount') + ) + if (createdObj && 'objectId' in createdObj) { + knownAccountId = createdObj.objectId + } + + if (!knownAccountId) { + throw new Error('Account created but object ID not found in transaction. Please try again.') + } + + const tx2 = new Transaction() + tx2.moveCall({ + target: `${config.memwalPackageId}::account::add_delegate_key`, + arguments: [ + tx2.object(knownAccountId), + tx2.pure('vector', pubKeyBytes), + tx2.pure('address', delegateSuiAddress), + tx2.pure('string', 'Web App'), + tx2.object('0x6'), + ], + }) + const addResult = await signAndExecute({ transaction: tx2 }) + await suiClient.waitForTransaction({ digest: addResult.digest }) + } + + return knownAccountId + }, [suiClient, signAndExecute]) + + // ── Enoki: silent key gen + register + save (no UI) ── + const runEnokiSilentSetup = useCallback(async () => { + if (setupRunningRef.current) return + setupRunningRef.current = true + + if (!address) { + setupRunningRef.current = false + return + } + + setSetupStep('onchain') + setError('') + setTxStatus('setting up your account...') + + try { + const { privHex, pubHex, suiAddr } = await generateKeys() + setTxStatus('registering delegate key...') + const accountId = await registerOnchain(address, pubHex, suiAddr) + setDelegateKeys(privHex, pubHex, accountId) + setSetupStep('done') + } catch (err: unknown) { + console.error('Enoki setup failed:', err) + const message = err instanceof Error ? err.message : 'setup failed. please try again.' + setError(message) + setSetupStep('error') + } finally { + setupRunningRef.current = false + } + }, [address, generateKeys, registerOnchain, setDelegateKeys]) + + // ── Wallet: generate keypair (shows key in UI) ── + const generateKeypair = useCallback(async () => { + if (setupRunningRef.current) return + setupRunningRef.current = true + + setSetupStep('generating') + setError('') + + try { + const { privHex, pubHex, suiAddr } = await generateKeys() + setPrivateKeyHex(privHex) + setPublicKeyHex(pubHex) + setSuiAddress(suiAddr) + setSetupStep('show-key') + } catch (err) { + console.error('Key generation failed:', err) + setError('failed to generate key. please try again.') + setSetupStep('error') + } finally { + setupRunningRef.current = false + } + }, [generateKeys]) + + // ── Wallet: register on-chain after user confirms key ── + const executeOnchain = useCallback(async () => { + if (setupRunningRef.current) return + setupRunningRef.current = true + + setSetupStep('onchain') + setError('') + setTxStatus('checking existing account...') + + try { + const accountId = await registerOnchain(address, publicKeyHex, suiAddress) + setTxStatus('delegate key registered onchain!') + setDelegateKeys(privateKeyHex, publicKeyHex, accountId) + setPrivateKeyHex('') + setSetupStep('done') + } catch (err: unknown) { + console.error('Onchain operation failed:', err) + const message = err instanceof Error ? err.message : 'transaction failed. please try again.' + setError(message) + setSetupStep('show-key') + } finally { + setupRunningRef.current = false + } + }, [address, publicKeyHex, privateKeyHex, suiAddress, registerOnchain, setDelegateKeys]) + + const copyKey = useCallback(async () => { + await navigator.clipboard.writeText(privateKeyHex) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [privateKeyHex]) + + const handleRetry = useCallback(() => { + setError('') + setSetupStep('idle') + }, []) + + // ── Button handlers ── + const handleEnokiConnect = () => { + if (!googleWallet) return + updateAuthMethod('enoki') + setLoginOpen(false) + connect({ wallet: googleWallet }) + } + + const handleWalletClick = () => { + walletClickedRef.current = true + setLoginOpen(false) + } + + // ════════════════════════════════════════════════════════════ + // Effects — all referenced callbacks are declared above + // ════════════════════════════════════════════════════════════ + + // ── Close dropdowns on outside click ── useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (demoRef.current && !demoRef.current.contains(e.target as Node)) { setDemoOpen(false) } + if (loginRef.current && !loginRef.current.contains(e.target as Node)) { + setLoginOpen(false) + } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, []) - const handleConnect = () => { - if (currentAccount) { - navigate('/dashboard') - } else if (hasEnokiConfig && googleWallet) { - connect({ wallet: googleWallet }) + // ── Returning user: auto-redirect to dashboard ── + useEffect(() => { + if (currentAccount && delegateKey) { + persistAuthMethod(null) navigate('/dashboard') } - } + }, [currentAccount, delegateKey, navigate]) + + // ── New user + Enoki: run full silent setup ── + useEffect(() => { + if (isNewUser && authMethod === 'enoki' && setupStep === 'idle') { + runEnokiSilentSetup() + } + }, [isNewUser, authMethod, setupStep, runEnokiSilentSetup]) + + // ── New user + Wallet: auto-trigger key generation (shows key UI) ── + useEffect(() => { + if (isNewUser && authMethod === 'wallet' && setupStep === 'idle') { + generateKeypair() + } + }, [isNewUser, authMethod, setupStep, generateKeypair]) + + // ── Wallet disconnect during active setup → show error ── + useEffect(() => { + if (!currentAccount && setupStep !== 'idle' && setupStep !== 'done' && setupStep !== 'error') { + setError('Wallet disconnected. Please reconnect and try again.') + setSetupStep('error') + setupRunningRef.current = false + } + }, [currentAccount, setupStep]) + + // ── Done: redirect to dashboard ── + useEffect(() => { + if (setupStep === 'done') { + persistAuthMethod(null) + const timer = setTimeout(() => navigate('/dashboard'), 1500) + return () => clearTimeout(timer) + } + }, [setupStep, navigate]) + + // ── Detect wallet connection via ConnectButton ── + useEffect(() => { + if (currentAccount && !delegateKey && authMethod === null) { + const persisted = getPersistedAuthMethod() + if (persisted) { + setAuthMethod(persisted) + } else if (walletClickedRef.current) { + walletClickedRef.current = false + updateAuthMethod('wallet') + } + } + }, [currentAccount, delegateKey, authMethod, updateAuthMethod]) + + // ════════════════════════════════════════════════════════════ + // Render + // ════════════════════════════════════════════════════════════ + + const showSetupFlow = (isNewUser && authMethod !== null) + || setupStep === 'error' + + const renderWalletSetupFlow = () => ( +
    +
    + + {setupStep === 'idle' && ( +
    +
    +

    connecting wallet...

    +
    + )} + + {setupStep === 'generating' && ( +
    +
    +

    generating keypair...

    +
    + )} + + {setupStep === 'show-key' && ( +
    +
    +

    + key generated! +

    +

    + a delegate key lets your AI apps access memwal on your behalf. +

    +
    + +
    +

    + save this private key now! it will not be shown again. + store it securely — you'll need it to configure the memwal SDK. +

    +
    + +
    +
    private key (keep secret)
    +
    {privateKeyHex}
    +
    + +
    +
    + +
    +
    + public key (shareable) +
    +
    + {publicKeyHex} +
    +
    + + {error && ( +
    + {error} +
    + )} + +
    + +
    + + +
    + )} + + {setupStep === 'onchain' && ( +
    +
    +

    {txStatus}

    +

    + please approve the transaction in your wallet +

    +
    + )} + + {setupStep === 'error' && ( +
    +

    + setup failed +

    +

    + {error} +

    + +
    + )} + + {setupStep === 'done' && ( +
    +

    + all set! +

    +

    + your delegate key has been registered onchain. loading dashboard... +

    +
    + )} + +
    +
    + ) + + const renderEnokiSetupFlow = () => ( +
    +
    + + {setupStep === 'done' ? ( +
    +

    + all set! +

    +

    + your account is ready. loading dashboard... +

    +
    + ) : setupStep === 'error' ? ( +
    +

    + setup failed +

    +

    + {error} +

    + +
    + ) : ( +
    +
    +

    {txStatus || 'setting up your account...'}

    +
    + )} + +
    +
    + ) return (
    @@ -89,69 +600,111 @@ export default function LandingPage() { )}
    )} - {currentAccount ? ( + + {currentAccount && delegateKey ? ( - ) : hasEnokiConfig && googleWallet ? ( - ) : ( - +
    + + {loginOpen && ( +
    + {hasEnokiConfig && googleWallet && ( + + )} + +
    + +
    +
    + )} +
    )}
    - {/* ── Hero ── */} -
    -
    -
    -

    Long-Term Memory
    for AI Agents

    -

    - MemWal introduces a long-term, verifiable memory layer on - Walrus, allowing agents to remember, share, and reuse - information reliably. -

    + {/* ── Hero or Setup Flow ── */} + {showSetupFlow && authMethod === 'enoki' ? renderEnokiSetupFlow() : + showSetupFlow && authMethod === 'wallet' ? renderWalletSetupFlow() : ( +
    +
    +
    +

    Long-Term Memory
    for AI Agents

    +

    + MemWal introduces a long-term, verifiable memory layer on + Walrus, allowing agents to remember, share, and reuse + information reliably. +

    -
    - {config.docsUrl && ( +
    -
    -
    - - MemWal mascot +
    + + MemWal mascot +
    -
    -
    +
    + )}
    ) } diff --git a/apps/app/src/pages/SetupWizard.tsx b/apps/app/src/pages/SetupWizard.tsx deleted file mode 100644 index 25df17f5..00000000 --- a/apps/app/src/pages/SetupWizard.tsx +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Setup Wizard — Generate delegate key + create MemWalAccount onchain - * - * Steps: - * 1. Generate Ed25519 keypair - * 2. Create MemWalAccount onchain (if not exists) - * 3. Add delegate key onchain - * 4. Save key to localStorage → proceed to Dashboard - */ - -import { useState, useCallback } from 'react' -import { - useCurrentAccount, - useDisconnectWallet, - useSuiClient, -} from '@mysten/dapp-kit' -import { Transaction } from '@mysten/sui/transactions' -import { useSponsoredTransaction } from '../hooks/useSponsoredTransaction' -import { useDelegateKey } from '../App' -import { Link } from 'react-router-dom' -import { LogOut, Copy } from 'lucide-react' -import { config } from '../config' -import memwalLogo from '../assets/memwal-logo.svg' - -type Step = 'intro' | 'generating' | 'show-key' | 'onchain' | 'done' - -export default function SetupWizard() { - const currentAccount = useCurrentAccount() - const { mutateAsync: disconnect } = useDisconnectWallet() - const { mutateAsync: signAndExecute } = useSponsoredTransaction() - const suiClient = useSuiClient() - const { setDelegateKeys } = useDelegateKey() - - const [step, setStep] = useState('intro') - const [privateKeyHex, setPrivateKeyHex] = useState('') - const [publicKeyHex, setPublicKeyHex] = useState('') - const [copied, setCopied] = useState(false) - const [confirmed, setConfirmed] = useState(false) - const [txStatus, setTxStatus] = useState('') - const [error, setError] = useState('') - const [suiAddress, setSuiAddress] = useState('') - - const address = currentAccount?.address || '' - - // -------------------------------------------------------- - // Step 1: Generate Ed25519 keypair - // -------------------------------------------------------- - const generateKeypair = useCallback(async () => { - setStep('generating') - setError('') - - try { - const ed = await import('@noble/ed25519') - const { blake2b } = await import('@noble/hashes/blake2.js') - const privateKey = new Uint8Array(32) - crypto.getRandomValues(privateKey) - const publicKey = await ed.getPublicKeyAsync(privateKey) - - const privHex = Array.from(privateKey).map(b => b.toString(16).padStart(2, '0')).join('') - const pubHex = Array.from(publicKey).map(b => b.toString(16).padStart(2, '0')).join('') - - // Derive Sui address: blake2b256(0x00 || public_key) - const input = new Uint8Array(33) - input[0] = 0x00 // Ed25519 scheme flag - input.set(publicKey, 1) - const addressBytes = blake2b(input, { dkLen: 32 }) - const suiAddr = '0x' + Array.from(new Uint8Array(addressBytes)).map((b: number) => b.toString(16).padStart(2, '0')).join('') - - setPrivateKeyHex(privHex) - setPublicKeyHex(pubHex) - setSuiAddress(suiAddr) - setStep('show-key') - } catch (err) { - console.error('Key generation failed:', err) - setError('failed to generate key. please try again.') - setStep('intro') - } - }, []) - - // -------------------------------------------------------- - // Step 2: Onchain — create account + add delegate key - // -------------------------------------------------------- - const executeOnchain = useCallback(async () => { - setStep('onchain') - setError('') - - try { - // Check if user already has a MemWalAccount via registry lookup - setTxStatus('checking existing account...') - let knownAccountId: string | null = null - - try { - // First, get the registry object to find the Table's inner ID - // (Move Table stores dynamic fields on its own UID, not the parent's) - const registryObj = await suiClient.getObject({ - id: config.memwalRegistryId, - options: { showContent: true }, - }) - if (registryObj?.data?.content && 'fields' in registryObj.data.content) { - const fields = registryObj.data.content.fields as any - const tableId = fields?.accounts?.fields?.id?.id - if (tableId) { - const dynField = await suiClient.getDynamicFieldObject({ - parentId: tableId, - name: { type: 'address', value: address }, - }) - if (dynField?.data?.content && 'fields' in dynField.data.content) { - knownAccountId = (dynField.data.content.fields as any).value as string - } - } - } - } catch { - // Dynamic field not found → no account yet - } - - const pubKeyBytes = Array.from( - { length: publicKeyHex.length / 2 }, - (_, i) => parseInt(publicKeyHex.slice(i * 2, i * 2 + 2), 16) - ) - - if (knownAccountId) { - // Account exists — add user delegate key - setTxStatus('account found! adding delegate key...') - const tx = new Transaction() - - tx.moveCall({ - target: `${config.memwalPackageId}::account::add_delegate_key`, - arguments: [ - tx.object(knownAccountId), - tx.pure('vector', pubKeyBytes), - tx.pure('address', suiAddress), - tx.pure('string', 'Web App'), - tx.object('0x6'), - ], - }) - - const result = await signAndExecute({ transaction: tx }) - await suiClient.waitForTransaction({ digest: result.digest }) - } else { - // Step A: Create account first (now creates a shared object) - setTxStatus('creating account...') - const tx = new Transaction() - - tx.moveCall({ - target: `${config.memwalPackageId}::account::create_account`, - arguments: [ - tx.object(config.memwalRegistryId), - tx.object('0x6'), - ], - }) - - const createResult = await signAndExecute({ transaction: tx }) - await suiClient.waitForTransaction({ digest: createResult.digest }) - - // Find the created MemWalAccount object (now shared) - const txDetails = await suiClient.getTransactionBlock({ - digest: createResult.digest, - options: { showObjectChanges: true }, - }) - const createdObj = txDetails.objectChanges?.find( - (c) => c.type === 'created' && - 'objectType' in c && - c.objectType.includes('MemWalAccount') - ) - if (createdObj && 'objectId' in createdObj) { - knownAccountId = createdObj.objectId - } - - // Step B: Add user's delegate key - setTxStatus('adding delegate key...') - const tx2 = new Transaction() - tx2.moveCall({ - target: `${config.memwalPackageId}::account::add_delegate_key`, - arguments: [ - tx2.object(knownAccountId!), - tx2.pure('vector', pubKeyBytes), - tx2.pure('address', suiAddress), - tx2.pure('string', 'Web App'), - tx2.object('0x6'), - ], - }) - - const addResult = await signAndExecute({ transaction: tx2 }) - await suiClient.waitForTransaction({ digest: addResult.digest }) - } - - setTxStatus('delegate key registered onchain!') - - // Save to localStorage (including account object ID) - setDelegateKeys(privateKeyHex, publicKeyHex, knownAccountId || '') - setStep('done') - } catch (err: unknown) { - console.error('Onchain operation failed:', err) - const message = err instanceof Error ? err.message : 'transaction failed. please try again.' - setError(message) - setStep('show-key') // Go back to key display - } - }, [address, publicKeyHex, privateKeyHex, suiAddress, suiClient, signAndExecute, setDelegateKeys]) - - const copyKey = useCallback(async () => { - await navigator.clipboard.writeText(privateKeyHex) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }, [privateKeyHex]) - - return ( - <> - - -
    -
    - - {/* ===== Step 1: Intro ===== */} - {step === 'intro' && ( -
    - -

    - create your delegate key -

    -

    - a delegate key lets your AI apps access memwal on your behalf. - it's a lightweight Ed25519 keypair — separate from your wallet. -

    - -
    -
    - -
    - low risk -

    - cannot access funds or sign Sui transactions -

    -
    -
    -
    -
    - revocable -

    - remove anytime from your memwal dashboard -

    -
    -
    -
    -
    - onchain registration -

    - key is verified on Sui blockchain for maximum security -

    -
    -
    -
    - - -
    - )} - - {/* ===== Generating ===== */} - {step === 'generating' && ( -
    -
    -

    generating keypair...

    -
    - )} - - {/* ===== Step 2: Show Key ===== */} - {step === 'show-key' && ( -
    -
    - -

    - key generated! -

    -
    - -
    -

    - save this private key now! it will not be shown again. - store it securely — you'll need it to configure the memwal SDK. -

    -
    - -
    -
    private key (keep secret)
    -
    {privateKeyHex}
    -
    - -
    -
    - -
    -
    - public key (shareable) -
    -
    - {publicKeyHex} -
    -
    - - {error && ( -
    - {error} -
    - )} - -
    - -
    - - -
    - )} - - {/* ===== Step 3: Onchain tx in progress ===== */} - {step === 'onchain' && ( -
    -
    -

    {txStatus}

    -

    - please approve the transaction in your wallet -

    -
    - )} - - {/* ===== Done ===== */} - {step === 'done' && ( -
    -

    - all set! -

    -

    - your delegate key has been registered onchain. loading dashboard... -

    -
    - )} - -
    -
    - - ) -} From ff72a8faf92bfe706de64c598db2d685441b54c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ph=E1=BA=A1m=20Minh=20H=C3=B9ng?= <46132442+hungtranphamminh@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:14:21 +0700 Subject: [PATCH 15/21] Fix: Prevent wallet connect modal from closing prematurely (#89) * fix(app): prevent wallet connect modal from closing prematurely ConnectButton's wallet selection modal renders as a portal outside the login popover. Two issues caused wallet connect to fail: - handleWalletClick closed the popover immediately, unmounting the ConnectButton before its modal could open - Outside-click handler closed the popover when user clicked inside the wallet modal (which is outside loginRef) Fix: keep popover open during wallet flow (walletClickedRef guards the outside-click handler), close only after wallet connects. * fix(app): restore SetupWizard intro page and auth-aware key generation Restore the "create your delegate key" intro page at /dashboard for users who connected but have no delegate key yet. Both Enoki and Wallet flows now see the intro before key generation. After clicking "generate delegate key": - Enoki: silent key gen + on-chain registration with contextual status text ("this may take a few seconds...") - Wallet: show key + copy + confirm + on-chain registration with wallet approval prompt Simplify LandingPage back to login popover + redirect to /dashboard. * fix(app): update stale comments and remove auto-redirect for returning users - Fix SetupWizard doc comment to reflect both flows showing keys - Remove "(wallet only)" from show-key step comment - Remove aggressive auto-redirect on currentAccount, matching original behavior where returning users stay on landing page and navigate to dashboard via explicit button click --- apps/app/src/App.tsx | 3 +- apps/app/src/pages/LandingPage.tsx | 608 ++++------------------------- apps/app/src/pages/SetupWizard.tsx | 437 +++++++++++++++++++++ 3 files changed, 516 insertions(+), 532 deletions(-) create mode 100644 apps/app/src/pages/SetupWizard.tsx diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index df8f16d6..87a5b7b9 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -21,6 +21,7 @@ import { config } from './config' import LandingPage from './pages/LandingPage' import Dashboard from './pages/Dashboard' +import SetupWizard from './pages/SetupWizard' import Playground from './pages/Playground' @@ -133,7 +134,7 @@ function AppContent() { } /> : - delegateKey ? : + delegateKey ? : } /> : diff --git a/apps/app/src/pages/LandingPage.tsx b/apps/app/src/pages/LandingPage.tsx index 9dc24ef3..895420d7 100644 --- a/apps/app/src/pages/LandingPage.tsx +++ b/apps/app/src/pages/LandingPage.tsx @@ -1,8 +1,11 @@ /** * Landing Page — Two login options via "SDK Playground" popover: * - * 1. Sign in with Google (Enoki) — silent key gen + on-chain registration, no key display - * 2. Connect Wallet (any Sui wallet) — shows key + copy + confirm before on-chain registration + * 1. Sign in with Google (Enoki) + * 2. Connect Wallet (any Sui wallet) + * + * After login, redirects to /dashboard where SetupWizard handles + * delegate key generation if needed. */ import { @@ -10,30 +13,19 @@ import { useConnectWallet, useCurrentAccount, useWallets, - useSuiClient, } from '@mysten/dapp-kit' import { isEnokiWallet, type EnokiWallet, type AuthProvider } from '@mysten/enoki' -import { Transaction } from '@mysten/sui/transactions' -import { ChevronDown, Github, Copy } from 'lucide-react' +import { ChevronDown, Github } from 'lucide-react' import { useRef, useState, useEffect, useCallback } from 'react' import { useNavigate } from 'react-router-dom' -import { useSponsoredTransaction } from '../hooks/useSponsoredTransaction' import { useDelegateKey } from '../App' import { config } from '../config' import memwalLogo from '../assets/memwal-logo.svg' -type SetupStep = 'idle' | 'generating' | 'show-key' | 'onchain' | 'done' | 'error' type AuthMethod = 'enoki' | 'wallet' | null -// ── Persist authMethod across OAuth redirects ── const AUTH_METHOD_KEY = 'memwal_auth_method' -function getPersistedAuthMethod(): AuthMethod { - const val = sessionStorage.getItem(AUTH_METHOD_KEY) - if (val === 'enoki' || val === 'wallet') return val - return null -} - function persistAuthMethod(method: AuthMethod) { if (method) { sessionStorage.setItem(AUTH_METHOD_KEY, method) @@ -42,16 +34,19 @@ function persistAuthMethod(method: AuthMethod) { } } +function getPersistedAuthMethod(): AuthMethod { + const val = sessionStorage.getItem(AUTH_METHOD_KEY) + if (val === 'enoki' || val === 'wallet') return val + return null +} + export default function LandingPage() { const currentAccount = useCurrentAccount() const { mutate: connect } = useConnectWallet() const wallets = useWallets() const enokiWallets = wallets.filter(isEnokiWallet) - const suiClient = useSuiClient() - const { mutateAsync: signAndExecute } = useSponsoredTransaction() - const { delegateKey, setDelegateKeys } = useDelegateKey() + const { delegateKey } = useDelegateKey() - // Find Google wallet from registered Enoki wallets const walletsByProvider = enokiWallets.reduce( (map, wallet) => map.set(wallet.provider, wallet), new Map(), @@ -68,265 +63,16 @@ export default function LandingPage() { const [loginOpen, setLoginOpen] = useState(false) const loginRef = useRef(null) - // ── Auth method tracking (restored from sessionStorage on mount for OAuth redirects) ── - const [authMethod, setAuthMethod] = useState(getPersistedAuthMethod) - - // ── Refs ── - const setupRunningRef = useRef(false) + // ── Track wallet click for ConnectButton flow ── const walletClickedRef = useRef(false) - // ── Setup state ── - const [setupStep, setSetupStep] = useState('idle') - const [privateKeyHex, setPrivateKeyHex] = useState('') - const [publicKeyHex, setPublicKeyHex] = useState('') - const [suiAddress, setSuiAddress] = useState('') - const [copied, setCopied] = useState(false) - const [confirmed, setConfirmed] = useState(false) - const [txStatus, setTxStatus] = useState('') - const [error, setError] = useState('') - - const address = currentAccount?.address || '' - const isNewUser = !!currentAccount && !delegateKey - - // ── Sync authMethod to sessionStorage ── - const updateAuthMethod = useCallback((method: AuthMethod) => { - setAuthMethod(method) - persistAuthMethod(method) - }, []) - - // ════════════════════════════════════════════════════════════ - // Callbacks — declared before effects that reference them - // ════════════════════════════════════════════════════════════ - - // ── Generate Ed25519 keypair (returns the keys) ── - const generateKeys = useCallback(async () => { - const ed = await import('@noble/ed25519') - const { blake2b } = await import('@noble/hashes/blake2.js') - const privateKey = new Uint8Array(32) - crypto.getRandomValues(privateKey) - const publicKey = await ed.getPublicKeyAsync(privateKey) - - const privHex = Array.from(privateKey).map(b => b.toString(16).padStart(2, '0')).join('') - const pubHex = Array.from(publicKey).map(b => b.toString(16).padStart(2, '0')).join('') - - const input = new Uint8Array(33) - input[0] = 0x00 - input.set(publicKey, 1) - const addressBytes = blake2b(input, { dkLen: 32 }) - const suiAddr = '0x' + Array.from(new Uint8Array(addressBytes)).map((b: number) => b.toString(16).padStart(2, '0')).join('') - - return { privHex, pubHex, suiAddr } - }, []) - - // ── Register delegate key on-chain (shared logic) ── - const registerOnchain = useCallback(async ( - ownerAddress: string, - pubKeyHex: string, - delegateSuiAddress: string, - ): Promise => { - let knownAccountId: string | null = null - - try { - const registryObj = await suiClient.getObject({ - id: config.memwalRegistryId, - options: { showContent: true }, - }) - if (registryObj?.data?.content && 'fields' in registryObj.data.content) { - const fields = registryObj.data.content.fields as any - const tableId = fields?.accounts?.fields?.id?.id - if (tableId) { - const dynField = await suiClient.getDynamicFieldObject({ - parentId: tableId, - name: { type: 'address', value: ownerAddress }, - }) - if (dynField?.data?.content && 'fields' in dynField.data.content) { - knownAccountId = (dynField.data.content.fields as any).value as string - } - } - } - } catch { - // Dynamic field not found → no account yet - } - - const pubKeyBytes = Array.from( - { length: pubKeyHex.length / 2 }, - (_, i) => parseInt(pubKeyHex.slice(i * 2, i * 2 + 2), 16) - ) - - if (knownAccountId) { - const tx = new Transaction() - tx.moveCall({ - target: `${config.memwalPackageId}::account::add_delegate_key`, - arguments: [ - tx.object(knownAccountId), - tx.pure('vector', pubKeyBytes), - tx.pure('address', delegateSuiAddress), - tx.pure('string', 'Web App'), - tx.object('0x6'), - ], - }) - const result = await signAndExecute({ transaction: tx }) - await suiClient.waitForTransaction({ digest: result.digest }) - } else { - const tx = new Transaction() - tx.moveCall({ - target: `${config.memwalPackageId}::account::create_account`, - arguments: [ - tx.object(config.memwalRegistryId), - tx.object('0x6'), - ], - }) - const createResult = await signAndExecute({ transaction: tx }) - await suiClient.waitForTransaction({ digest: createResult.digest }) - - const txDetails = await suiClient.getTransactionBlock({ - digest: createResult.digest, - options: { showObjectChanges: true }, - }) - const createdObj = txDetails.objectChanges?.find( - (c) => c.type === 'created' && - 'objectType' in c && - c.objectType.includes('MemWalAccount') - ) - if (createdObj && 'objectId' in createdObj) { - knownAccountId = createdObj.objectId - } - - if (!knownAccountId) { - throw new Error('Account created but object ID not found in transaction. Please try again.') - } - - const tx2 = new Transaction() - tx2.moveCall({ - target: `${config.memwalPackageId}::account::add_delegate_key`, - arguments: [ - tx2.object(knownAccountId), - tx2.pure('vector', pubKeyBytes), - tx2.pure('address', delegateSuiAddress), - tx2.pure('string', 'Web App'), - tx2.object('0x6'), - ], - }) - const addResult = await signAndExecute({ transaction: tx2 }) - await suiClient.waitForTransaction({ digest: addResult.digest }) - } - - return knownAccountId - }, [suiClient, signAndExecute]) - - // ── Enoki: silent key gen + register + save (no UI) ── - const runEnokiSilentSetup = useCallback(async () => { - if (setupRunningRef.current) return - setupRunningRef.current = true - - if (!address) { - setupRunningRef.current = false - return - } - - setSetupStep('onchain') - setError('') - setTxStatus('setting up your account...') - - try { - const { privHex, pubHex, suiAddr } = await generateKeys() - setTxStatus('registering delegate key...') - const accountId = await registerOnchain(address, pubHex, suiAddr) - setDelegateKeys(privHex, pubHex, accountId) - setSetupStep('done') - } catch (err: unknown) { - console.error('Enoki setup failed:', err) - const message = err instanceof Error ? err.message : 'setup failed. please try again.' - setError(message) - setSetupStep('error') - } finally { - setupRunningRef.current = false - } - }, [address, generateKeys, registerOnchain, setDelegateKeys]) - - // ── Wallet: generate keypair (shows key in UI) ── - const generateKeypair = useCallback(async () => { - if (setupRunningRef.current) return - setupRunningRef.current = true - - setSetupStep('generating') - setError('') - - try { - const { privHex, pubHex, suiAddr } = await generateKeys() - setPrivateKeyHex(privHex) - setPublicKeyHex(pubHex) - setSuiAddress(suiAddr) - setSetupStep('show-key') - } catch (err) { - console.error('Key generation failed:', err) - setError('failed to generate key. please try again.') - setSetupStep('error') - } finally { - setupRunningRef.current = false - } - }, [generateKeys]) - - // ── Wallet: register on-chain after user confirms key ── - const executeOnchain = useCallback(async () => { - if (setupRunningRef.current) return - setupRunningRef.current = true - - setSetupStep('onchain') - setError('') - setTxStatus('checking existing account...') - - try { - const accountId = await registerOnchain(address, publicKeyHex, suiAddress) - setTxStatus('delegate key registered onchain!') - setDelegateKeys(privateKeyHex, publicKeyHex, accountId) - setPrivateKeyHex('') - setSetupStep('done') - } catch (err: unknown) { - console.error('Onchain operation failed:', err) - const message = err instanceof Error ? err.message : 'transaction failed. please try again.' - setError(message) - setSetupStep('show-key') - } finally { - setupRunningRef.current = false - } - }, [address, publicKeyHex, privateKeyHex, suiAddress, registerOnchain, setDelegateKeys]) - - const copyKey = useCallback(async () => { - await navigator.clipboard.writeText(privateKeyHex) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }, [privateKeyHex]) - - const handleRetry = useCallback(() => { - setError('') - setSetupStep('idle') - }, []) - - // ── Button handlers ── - const handleEnokiConnect = () => { - if (!googleWallet) return - updateAuthMethod('enoki') - setLoginOpen(false) - connect({ wallet: googleWallet }) - } - - const handleWalletClick = () => { - walletClickedRef.current = true - setLoginOpen(false) - } - - // ════════════════════════════════════════════════════════════ - // Effects — all referenced callbacks are declared above - // ════════════════════════════════════════════════════════════ - // ── Close dropdowns on outside click ── useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (demoRef.current && !demoRef.current.contains(e.target as Node)) { setDemoOpen(false) } - if (loginRef.current && !loginRef.current.contains(e.target as Node)) { + if (loginRef.current && !loginRef.current.contains(e.target as Node) && !walletClickedRef.current) { setLoginOpen(false) } } @@ -334,235 +80,36 @@ export default function LandingPage() { return () => document.removeEventListener('mousedown', handleClickOutside) }, []) - // ── Returning user: auto-redirect to dashboard ── - useEffect(() => { - if (currentAccount && delegateKey) { - persistAuthMethod(null) - navigate('/dashboard') - } - }, [currentAccount, delegateKey, navigate]) - - // ── New user + Enoki: run full silent setup ── - useEffect(() => { - if (isNewUser && authMethod === 'enoki' && setupStep === 'idle') { - runEnokiSilentSetup() - } - }, [isNewUser, authMethod, setupStep, runEnokiSilentSetup]) - - // ── New user + Wallet: auto-trigger key generation (shows key UI) ── - useEffect(() => { - if (isNewUser && authMethod === 'wallet' && setupStep === 'idle') { - generateKeypair() - } - }, [isNewUser, authMethod, setupStep, generateKeypair]) - - // ── Wallet disconnect during active setup → show error ── - useEffect(() => { - if (!currentAccount && setupStep !== 'idle' && setupStep !== 'done' && setupStep !== 'error') { - setError('Wallet disconnected. Please reconnect and try again.') - setSetupStep('error') - setupRunningRef.current = false - } - }, [currentAccount, setupStep]) - - // ── Done: redirect to dashboard ── - useEffect(() => { - if (setupStep === 'done') { - persistAuthMethod(null) - const timer = setTimeout(() => navigate('/dashboard'), 1500) - return () => clearTimeout(timer) - } - }, [setupStep, navigate]) - // ── Detect wallet connection via ConnectButton ── + const updateAuthMethod = useCallback((method: AuthMethod) => { + persistAuthMethod(method) + }, []) + useEffect(() => { - if (currentAccount && !delegateKey && authMethod === null) { + if (currentAccount && !delegateKey) { const persisted = getPersistedAuthMethod() - if (persisted) { - setAuthMethod(persisted) - } else if (walletClickedRef.current) { + if (!persisted && walletClickedRef.current) { walletClickedRef.current = false + setLoginOpen(false) updateAuthMethod('wallet') } + // Navigate to dashboard/setup after connection + navigate('/dashboard') } - }, [currentAccount, delegateKey, authMethod, updateAuthMethod]) - - // ════════════════════════════════════════════════════════════ - // Render - // ════════════════════════════════════════════════════════════ - - const showSetupFlow = (isNewUser && authMethod !== null) - || setupStep === 'error' - - const renderWalletSetupFlow = () => ( -
    -
    - - {setupStep === 'idle' && ( -
    -
    -

    connecting wallet...

    -
    - )} - - {setupStep === 'generating' && ( -
    -
    -

    generating keypair...

    -
    - )} - - {setupStep === 'show-key' && ( -
    -
    -

    - key generated! -

    -

    - a delegate key lets your AI apps access memwal on your behalf. -

    -
    - -
    -

    - save this private key now! it will not be shown again. - store it securely — you'll need it to configure the memwal SDK. -

    -
    - -
    -
    private key (keep secret)
    -
    {privateKeyHex}
    -
    - -
    -
    - -
    -
    - public key (shareable) -
    -
    - {publicKeyHex} -
    -
    - - {error && ( -
    - {error} -
    - )} + }, [currentAccount, delegateKey, updateAuthMethod, navigate]) -
    - -
    - - -
    - )} - - {setupStep === 'onchain' && ( -
    -
    -

    {txStatus}

    -

    - please approve the transaction in your wallet -

    -
    - )} - - {setupStep === 'error' && ( -
    -

    - setup failed -

    -

    - {error} -

    - -
    - )} - - {setupStep === 'done' && ( -
    -

    - all set! -

    -

    - your delegate key has been registered onchain. loading dashboard... -

    -
    - )} - -
    -
    - ) - - const renderEnokiSetupFlow = () => ( -
    -
    - - {setupStep === 'done' ? ( -
    -

    - all set! -

    -

    - your account is ready. loading dashboard... -

    -
    - ) : setupStep === 'error' ? ( -
    -

    - setup failed -

    -

    - {error} -

    - -
    - ) : ( -
    -
    -

    {txStatus || 'setting up your account...'}

    -
    - )} + // ── Button handlers ── + const handleEnokiConnect = () => { + if (!googleWallet) return + updateAuthMethod('enoki') + setLoginOpen(false) + connect({ wallet: googleWallet }) + } -
    -
    - ) + const handleWalletClick = () => { + walletClickedRef.current = true + updateAuthMethod('wallet') + } return (
    @@ -574,6 +121,7 @@ export default function LandingPage() {
    + {/* Demo dropdown */} {demoUrls.length > 0 && (
    - {/* ── Hero or Setup Flow ── */} - {showSetupFlow && authMethod === 'enoki' ? renderEnokiSetupFlow() : - showSetupFlow && authMethod === 'wallet' ? renderWalletSetupFlow() : ( -
    -
    -
    -

    Long-Term Memory
    for AI Agents

    -

    - MemWal introduces a long-term, verifiable memory layer on - Walrus, allowing agents to remember, share, and reuse - information reliably. -

    + {/* ── Hero ── */} +
    +
    +
    +

    Long-Term Memory
    for AI Agents

    +

    + MemWal introduces a long-term, verifiable memory layer on + Walrus, allowing agents to remember, share, and reuse + information reliably. +

    -
    - {config.docsUrl && ( - - Documentation - - )} +
    + {config.docsUrl && ( - GitHub + Documentation -
    + )} + + GitHub +
    +
    -
    - - MemWal mascot -
    +
    + + MemWal mascot
    -
    - )} +
    +
    ) } diff --git a/apps/app/src/pages/SetupWizard.tsx b/apps/app/src/pages/SetupWizard.tsx new file mode 100644 index 00000000..78a55d05 --- /dev/null +++ b/apps/app/src/pages/SetupWizard.tsx @@ -0,0 +1,437 @@ +/** + * Setup Wizard — Generate delegate key + create MemWalAccount onchain + * + * Steps: + * 1. Intro — explain delegate keys, "generate delegate key" button + * 2. Generate Ed25519 keypair → show key + copy + confirm (both flows) + * 3. On-chain registration (Enoki: sponsored/silent, Wallet: user approves) + * 4. Save key to sessionStorage → redirect to Dashboard + */ + +import { useState, useCallback, useEffect, useRef } from 'react' +import { + useCurrentAccount, + useDisconnectWallet, + useSuiClient, +} from '@mysten/dapp-kit' +import { Transaction } from '@mysten/sui/transactions' +import { useSponsoredTransaction } from '../hooks/useSponsoredTransaction' +import { useDelegateKey } from '../App' +import { Link, useNavigate } from 'react-router-dom' +import { LogOut, Copy } from 'lucide-react' +import { config } from '../config' +import memwalLogo from '../assets/memwal-logo.svg' + +type Step = 'intro' | 'generating' | 'show-key' | 'onchain' | 'done' | 'error' + +const AUTH_METHOD_KEY = 'memwal_auth_method' + +function getPersistedAuthMethod(): string | null { + return sessionStorage.getItem(AUTH_METHOD_KEY) +} + +export default function SetupWizard() { + const currentAccount = useCurrentAccount() + const { mutateAsync: disconnect } = useDisconnectWallet() + const { mutateAsync: signAndExecute } = useSponsoredTransaction() + const suiClient = useSuiClient() + const { setDelegateKeys } = useDelegateKey() + const navigate = useNavigate() + + const [step, setStep] = useState('intro') + const [privateKeyHex, setPrivateKeyHex] = useState('') + const [publicKeyHex, setPublicKeyHex] = useState('') + const [copied, setCopied] = useState(false) + const [confirmed, setConfirmed] = useState(false) + const [txStatus, setTxStatus] = useState('') + const [error, setError] = useState('') + const [suiAddress, setSuiAddress] = useState('') + + const setupRunningRef = useRef(false) + const address = currentAccount?.address || '' + const isEnoki = getPersistedAuthMethod() === 'enoki' + + // ── Done: redirect to dashboard ── + useEffect(() => { + if (step === 'done') { + sessionStorage.removeItem(AUTH_METHOD_KEY) + const timer = setTimeout(() => navigate('/dashboard'), 1500) + return () => clearTimeout(timer) + } + }, [step, navigate]) + + // ── Generate Ed25519 keypair (shared) ── + const generateKeys = useCallback(async () => { + const ed = await import('@noble/ed25519') + const { blake2b } = await import('@noble/hashes/blake2.js') + const privateKey = new Uint8Array(32) + crypto.getRandomValues(privateKey) + const publicKey = await ed.getPublicKeyAsync(privateKey) + + const privHex = Array.from(privateKey).map(b => b.toString(16).padStart(2, '0')).join('') + const pubHex = Array.from(publicKey).map(b => b.toString(16).padStart(2, '0')).join('') + + const input = new Uint8Array(33) + input[0] = 0x00 + input.set(publicKey, 1) + const addressBytes = blake2b(input, { dkLen: 32 }) + const suiAddr = '0x' + Array.from(new Uint8Array(addressBytes)).map((b: number) => b.toString(16).padStart(2, '0')).join('') + + return { privHex, pubHex, suiAddr } + }, []) + + // ── Register delegate key on-chain (shared) ── + const registerOnchain = useCallback(async ( + ownerAddress: string, + pubKeyHex: string, + delegateSuiAddress: string, + ): Promise => { + let knownAccountId: string | null = null + + try { + const registryObj = await suiClient.getObject({ + id: config.memwalRegistryId, + options: { showContent: true }, + }) + if (registryObj?.data?.content && 'fields' in registryObj.data.content) { + const fields = registryObj.data.content.fields as any + const tableId = fields?.accounts?.fields?.id?.id + if (tableId) { + const dynField = await suiClient.getDynamicFieldObject({ + parentId: tableId, + name: { type: 'address', value: ownerAddress }, + }) + if (dynField?.data?.content && 'fields' in dynField.data.content) { + knownAccountId = (dynField.data.content.fields as any).value as string + } + } + } + } catch { + // Dynamic field not found → no account yet + } + + const pubKeyBytes = Array.from( + { length: pubKeyHex.length / 2 }, + (_, i) => parseInt(pubKeyHex.slice(i * 2, i * 2 + 2), 16) + ) + + if (knownAccountId) { + setTxStatus('account found! adding delegate key...') + const tx = new Transaction() + tx.moveCall({ + target: `${config.memwalPackageId}::account::add_delegate_key`, + arguments: [ + tx.object(knownAccountId), + tx.pure('vector', pubKeyBytes), + tx.pure('address', delegateSuiAddress), + tx.pure('string', 'Web App'), + tx.object('0x6'), + ], + }) + const result = await signAndExecute({ transaction: tx }) + await suiClient.waitForTransaction({ digest: result.digest }) + } else { + setTxStatus('creating account...') + const tx = new Transaction() + tx.moveCall({ + target: `${config.memwalPackageId}::account::create_account`, + arguments: [ + tx.object(config.memwalRegistryId), + tx.object('0x6'), + ], + }) + const createResult = await signAndExecute({ transaction: tx }) + await suiClient.waitForTransaction({ digest: createResult.digest }) + + const txDetails = await suiClient.getTransactionBlock({ + digest: createResult.digest, + options: { showObjectChanges: true }, + }) + const createdObj = txDetails.objectChanges?.find( + (c) => c.type === 'created' && + 'objectType' in c && + c.objectType.includes('MemWalAccount') + ) + if (createdObj && 'objectId' in createdObj) { + knownAccountId = createdObj.objectId + } + + if (!knownAccountId) { + throw new Error('Account created but object ID not found in transaction. Please try again.') + } + + setTxStatus('adding delegate key...') + const tx2 = new Transaction() + tx2.moveCall({ + target: `${config.memwalPackageId}::account::add_delegate_key`, + arguments: [ + tx2.object(knownAccountId), + tx2.pure('vector', pubKeyBytes), + tx2.pure('address', delegateSuiAddress), + tx2.pure('string', 'Web App'), + tx2.object('0x6'), + ], + }) + const addResult = await signAndExecute({ transaction: tx2 }) + await suiClient.waitForTransaction({ digest: addResult.digest }) + } + + return knownAccountId! + }, [suiClient, signAndExecute]) + + // ── "Generate delegate key" button handler ── + const handleGenerate = useCallback(async () => { + if (setupRunningRef.current) return + setupRunningRef.current = true + + setStep('generating') + setError('') + + try { + const { privHex, pubHex, suiAddr } = await generateKeys() + setPrivateKeyHex(privHex) + setPublicKeyHex(pubHex) + setSuiAddress(suiAddr) + setStep('show-key') + } catch (err) { + console.error('Setup failed:', err) + const message = err instanceof Error ? err.message : 'setup failed. please try again.' + setError(message) + setStep('error') + } finally { + setupRunningRef.current = false + } + }, [generateKeys]) + + // ── Wallet: register on-chain after user confirms key ── + const executeOnchain = useCallback(async () => { + if (setupRunningRef.current) return + setupRunningRef.current = true + + setStep('onchain') + setError('') + setTxStatus('checking existing account...') + + try { + const accountId = await registerOnchain(address, publicKeyHex, suiAddress) + setTxStatus('delegate key registered onchain!') + setDelegateKeys(privateKeyHex, publicKeyHex, accountId) + setPrivateKeyHex('') + setStep('done') + } catch (err: unknown) { + console.error('Onchain operation failed:', err) + const message = err instanceof Error ? err.message : 'transaction failed. please try again.' + setError(message) + setStep('show-key') + } finally { + setupRunningRef.current = false + } + }, [address, publicKeyHex, privateKeyHex, suiAddress, registerOnchain, setDelegateKeys]) + + const copyKey = useCallback(async () => { + await navigator.clipboard.writeText(privateKeyHex) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [privateKeyHex]) + + const handleRetry = useCallback(() => { + setError('') + setStep('intro') + }, []) + + return ( + <> + + +
    +
    + + {/* ===== Step 1: Intro ===== */} + {step === 'intro' && ( +
    + +

    + create your delegate key +

    +

    + a delegate key lets your AI apps access memwal on your behalf. + it's a lightweight Ed25519 keypair — separate from your wallet. +

    + +
    +
    + +
    + low risk +

    + cannot access funds or sign Sui transactions +

    +
    +
    +
    +
    + revocable +

    + remove anytime from your memwal dashboard +

    +
    +
    +
    +
    + onchain registration +

    + key is verified on Sui blockchain for maximum security +

    +
    +
    +
    + + +
    + )} + + {/* ===== Generating ===== */} + {step === 'generating' && ( +
    +
    +

    generating keypair...

    +
    + )} + + {/* ===== Step 2: Show Key ===== */} + {step === 'show-key' && ( +
    +
    + +

    + key generated! +

    +
    + +
    +

    + save this private key now! it will not be shown again. + store it securely — you'll need it to configure the memwal SDK. +

    +
    + +
    +
    private key (keep secret)
    +
    {privateKeyHex}
    +
    + +
    +
    + +
    +
    + public key (shareable) +
    +
    + {publicKeyHex} +
    +
    + + {error && ( +
    + {error} +
    + )} + +
    + +
    + + +
    + )} + + {/* ===== Onchain tx in progress ===== */} + {step === 'onchain' && ( +
    +
    +

    {txStatus}

    +

    + {isEnoki + ? 'this may take a few seconds...' + : 'please approve the transaction in your wallet'} +

    +
    + )} + + {/* ===== Error ===== */} + {step === 'error' && ( +
    +

    + setup failed +

    +

    + {error} +

    + +
    + )} + + {/* ===== Done ===== */} + {step === 'done' && ( +
    +

    + all set! +

    +

    + your delegate key has been registered onchain. loading dashboard... +

    +
    + )} + +
    +
    + + ) +} From bee09b67ae9da06fa34ae92811e6039f230c3209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Ph=E1=BA=A1m=20Minh=20H=C3=B9ng?= <46132442+hungtranphamminh@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:23:08 +0700 Subject: [PATCH 16/21] fix(noter): disconnect Enoki wallet on logout to prevent autoConnect (#92) Logout only cleared the app session but did not disconnect the dapp-kit wallet. On revisiting the login page, autoConnect restored the Enoki wallet session and the user appeared still logged in. Add useDisconnectWallet call in the logout function so dapp-kit clears its stored wallet state, preventing autoConnect from restoring the session after logout. --- .../noter/package/feature/auth/hook/use-auth.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/noter/package/feature/auth/hook/use-auth.ts b/apps/noter/package/feature/auth/hook/use-auth.ts index e3167bb7..49553d42 100644 --- a/apps/noter/package/feature/auth/hook/use-auth.ts +++ b/apps/noter/package/feature/auth/hook/use-auth.ts @@ -6,6 +6,7 @@ "use client"; import { useCallback, useEffect } from "react"; +import { useDisconnectWallet } from "@mysten/dapp-kit"; import { useAtom, useSetAtom } from "jotai"; import { authAtom, @@ -23,6 +24,9 @@ export function useAuth() { const clearAuth = useSetAtom(clearAuthAtom); const setLoading = useSetAtom(setLoadingAtom); + // Wallet disconnect (clears dapp-kit autoConnect state) + const { mutateAsync: disconnectWallet } = useDisconnectWallet(); + // tRPC mutations const connectEnokiMutation = trpc.auth.connectEnoki.useMutation(); const connectDelegateKeyMutation = trpc.auth.connectDelegateKey.useMutation(); @@ -118,18 +122,23 @@ export function useAuth() { [connectDelegateKeyMutation, setSession, setAuthenticated, setLoading] ); - /** Logout — clear session and auth state. */ + /** Logout — clear session, auth state, and disconnect wallet (prevents autoConnect). */ const logout = useCallback(async () => { try { if (session?.sessionId) { await logoutMutation.mutateAsync({ sessionId: session.sessionId }); } - clearAuth(); } catch (error) { console.error("Logout failed:", error); - clearAuth(); } - }, [session, logoutMutation, clearAuth]); + // Always disconnect wallet and clear auth, even if tRPC logout fails + try { + await disconnectWallet(); + } catch { + // Wallet may already be disconnected + } + clearAuth(); + }, [session, logoutMutation, disconnectWallet, clearAuth]); return { ...auth, From 85379cc41703fc9eb2abcf4b9f2b1f6e401d628b Mon Sep 17 00:00:00 2001 From: ducnmm Date: Fri, 10 Apr 2026 10:23:23 +0700 Subject: [PATCH 17/21] Revert "Merge pull request #84 from MystenLabs/fix/security-p0-four-issues" This reverts commit 42d6e0a027edf2e5fdf986caabcab2f4afade722, reversing changes made to deb316b04a59b5d9ab0e0fea2fb4b8abb46a85c7. --- apps/app/src/App.tsx | 8 +- apps/chatbot/components/chat.tsx | 12 +-- services/server/scripts/sidecar-server.ts | 45 +++-------- services/server/src/rate_limit.rs | 22 +----- services/server/src/routes.rs | 31 ++------ services/server/src/seal.rs | 18 +---- services/server/src/types.rs | 3 - services/server/src/walrus.rs | 18 +---- .../server/tests/test_rate_limit_redis.py | 76 ------------------- 9 files changed, 37 insertions(+), 196 deletions(-) delete mode 100644 services/server/tests/test_rate_limit_redis.py diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 87a5b7b9..c4e8d3e8 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -39,7 +39,7 @@ const { networkConfig } = createNetworkConfig({ const queryClient = new QueryClient() // ============================================================ -// Delegate Key Context (stored in sessionStorage — cleared on tab close, never persists across sessions) +// Delegate Key Context (stored in localStorage) // ============================================================ interface DelegateKeyState { @@ -67,7 +67,7 @@ export function useDelegateKey() { function DelegateKeyProvider({ children }: { children: React.ReactNode }) { const [state, setState] = useState(() => { - const saved = sessionStorage.getItem('memwal_delegate') + const saved = localStorage.getItem('memwal_delegate') if (saved) { try { return JSON.parse(saved) } catch { /* ignore */ } } @@ -76,12 +76,12 @@ function DelegateKeyProvider({ children }: { children: React.ReactNode }) { const setDelegateKeys = useCallback((privateKey: string, publicKey: string, accountId: string) => { const next = { delegateKey: privateKey, delegatePublicKey: publicKey, accountObjectId: accountId } - sessionStorage.setItem('memwal_delegate', JSON.stringify(next)) + localStorage.setItem('memwal_delegate', JSON.stringify(next)) setState(next) }, []) const clearDelegateKeys = useCallback(() => { - sessionStorage.removeItem('memwal_delegate') + localStorage.removeItem('memwal_delegate') setState({ delegateKey: null, delegatePublicKey: null, accountObjectId: null }) }, []) diff --git a/apps/chatbot/components/chat.tsx b/apps/chatbot/components/chat.tsx index d4133623..ea704003 100644 --- a/apps/chatbot/components/chat.tsx +++ b/apps/chatbot/components/chat.tsx @@ -88,13 +88,13 @@ export function Chat({ }); const [memwalKey, setMemwalKey] = useState(() => { if (typeof window !== 'undefined') { - return sessionStorage.getItem('memwalKey') || ''; + return localStorage.getItem('memwalKey') || ''; } return ''; }); const [memwalAccountId, setMemwalAccountId] = useState(() => { if (typeof window !== 'undefined') { - return sessionStorage.getItem('memwalAccountId') || ''; + return localStorage.getItem('memwalAccountId') || ''; } return ''; }); @@ -115,18 +115,18 @@ export function Chat({ useEffect(() => { memwalKeyRef.current = memwalKey; if (memwalKey) { - sessionStorage.setItem('memwalKey', memwalKey); + localStorage.setItem('memwalKey', memwalKey); } else { - sessionStorage.removeItem('memwalKey'); + localStorage.removeItem('memwalKey'); } }, [memwalKey]); useEffect(() => { memwalAccountIdRef.current = memwalAccountId; if (memwalAccountId) { - sessionStorage.setItem('memwalAccountId', memwalAccountId); + localStorage.setItem('memwalAccountId', memwalAccountId); } else { - sessionStorage.removeItem('memwalAccountId'); + localStorage.removeItem('memwalAccountId'); } }, [memwalAccountId]); diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts index 41fac8ba..e1f9aea2 100644 --- a/services/server/scripts/sidecar-server.ts +++ b/services/server/scripts/sidecar-server.ts @@ -11,8 +11,7 @@ * GET /health → { status: "ok" } */ -import express, { Request, Response, NextFunction } from "express"; -import { timingSafeEqual } from "crypto"; +import express from "express"; import { SuiJsonRpcClient, getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; import { decodeSuiPrivateKey } from "@mysten/sui/cryptography"; @@ -274,46 +273,22 @@ async function runExclusiveBySigner(signerAddress: string, task: () => Promis const app = express(); app.use(express.json({ limit: "50mb" })); -// CORS — sidecar is called only by the co-located Rust server, never by browsers. -// Remove all CORS headers so no cross-origin access is granted. -app.use((_req: Request, res: Response, next: NextFunction) => { - res.removeHeader("Access-Control-Allow-Origin"); - res.removeHeader("Access-Control-Allow-Methods"); - res.removeHeader("Access-Control-Allow-Headers"); +// CORS — allow frontend (any origin) to call sponsor endpoints +app.use((_req, res, next) => { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.header("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (_req.method === "OPTIONS") { return res.sendStatus(204); } next(); }); -// Health check — placed before auth middleware so it is always reachable. -app.get("/health", (_req: Request, res: Response) => { +// Health check +app.get("/health", (_req, res) => { res.json({ status: "ok", uptime: process.uptime() }); }); -// Shared-secret authentication — protects all routes registered after this point. -// Set SIDECAR_AUTH_TOKEN in the environment; callers must send it as Authorization: Bearer . -// Sidecar refuses to start if SIDECAR_AUTH_TOKEN is not set. -const SIDECAR_AUTH_TOKEN = process.env.SIDECAR_AUTH_TOKEN; -if (!SIDECAR_AUTH_TOKEN) { - console.error("[sidecar] FATAL: SIDECAR_AUTH_TOKEN not set. Refusing to start without auth."); - process.exit(1); -} - -app.use((req: Request, res: Response, next: NextFunction) => { - const token = req.headers.authorization?.replace("Bearer ", ""); - const secretBuf = Buffer.from(SIDECAR_AUTH_TOKEN!); - const providedBuf = Buffer.from(typeof token === "string" ? token : ""); - // timingSafeEqual prevents timing side-channel attacks on the token comparison. - // Buffers must be same length — if lengths differ it's already a mismatch. - const valid = providedBuf.length === secretBuf.length && - timingSafeEqual(providedBuf, secretBuf); - if (!valid) { - return res.status(401).json({ error: "Unauthorized" }); - } - next(); -}); - // ============================================================ // POST /seal/encrypt // ============================================================ @@ -898,11 +873,9 @@ app.post("/sponsor/execute", async (req, res) => { // ============================================================ const PORT = parseInt(process.env.SIDECAR_PORT || "9000", 10); -const HOST = process.env.SIDECAR_HOST || "127.0.0.1"; -app.listen(PORT, HOST, () => { +app.listen(PORT, () => { console.log(JSON.stringify({ event: "sidecar_ready", - host: HOST, port: PORT, pid: process.pid, })); diff --git a/services/server/src/rate_limit.rs b/services/server/src/rate_limit.rs index 3e533f09..b58fb1ee 100644 --- a/services/server/src/rate_limit.rs +++ b/services/server/src/rate_limit.rs @@ -148,7 +148,6 @@ async fn record_in_window( ttl_seconds: i64, ) { let mut pipe = redis::pipe(); - pipe.atomic(); for i in 0..weight { // Use fractional offsets to create unique members let ts = now + i as f64 * 0.001; @@ -239,12 +238,7 @@ pub async fn rate_limit_middleware( } } Err(e) => { - tracing::error!("redis rate limit check failed (dk): {}", e); - return axum::response::Response::builder() - .status(StatusCode::SERVICE_UNAVAILABLE) - .header("Content-Type", "application/json") - .body(axum::body::Body::from(r#"{"error":"Rate limiter unavailable"}"#)) - .unwrap(); + tracing::error!("redis rate limit check failed (dk): {}, allowing", e); } } @@ -263,12 +257,7 @@ pub async fn rate_limit_middleware( } } Err(e) => { - tracing::error!("redis rate limit check failed (burst): {}", e); - return axum::response::Response::builder() - .status(StatusCode::SERVICE_UNAVAILABLE) - .header("Content-Type", "application/json") - .body(axum::body::Body::from(r#"{"error":"Rate limiter unavailable"}"#)) - .unwrap(); + tracing::error!("redis rate limit check failed (burst): {}, allowing", e); } } @@ -287,12 +276,7 @@ pub async fn rate_limit_middleware( } } Err(e) => { - tracing::error!("redis rate limit check failed (sustained): {}", e); - return axum::response::Response::builder() - .status(StatusCode::SERVICE_UNAVAILABLE) - .header("Content-Type", "application/json") - .body(axum::body::Body::from(r#"{"error":"Rate limiter unavailable"}"#)) - .unwrap(); + tracing::error!("redis rate limit check failed (sustained): {}, allowing", e); } } diff --git a/services/server/src/routes.rs b/services/server/src/routes.rs index 991a3c39..df8396cc 100644 --- a/services/server/src/routes.rs +++ b/services/server/src/routes.rs @@ -143,7 +143,6 @@ pub async fn remember( let embed_fut = generate_embedding(&state.http_client, &state.config, text); let encrypt_fut = seal::seal_encrypt( &state.http_client, &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), text.as_bytes(), owner, &state.config.package_id, ); let (vector_result, encrypted_result) = tokio::join!(embed_fut, encrypt_fut); @@ -156,7 +155,6 @@ pub async fn remember( .ok_or_else(|| AppError::Internal("No Sui keys configured (set SERVER_SUI_PRIVATE_KEYS or SERVER_SUI_PRIVATE_KEY)".into()))?; let upload_result = walrus::upload_blob( &state.http_client, &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), &encrypted, 50, owner, &sui_key, namespace, &state.config.package_id, ).await?; let blob_id = upload_result.blob_id; @@ -220,7 +218,6 @@ pub async fn recall( let walrus_client = &state.walrus_client; let http_client = &state.http_client; let sidecar_url = state.config.sidecar_url.clone(); - let sidecar_secret = state.config.sidecar_secret.clone(); let blob_id = hit.blob_id.clone(); let distance = hit.distance; let private_key = private_key.to_string(); @@ -242,7 +239,7 @@ pub async fn recall( } }; // Decrypt using SEAL (via sidecar HTTP) - match seal::seal_decrypt(http_client, &sidecar_url, sidecar_secret.as_deref(), &encrypted_data, &private_key, &package_id, &account_id).await { + match seal::seal_decrypt(http_client, &sidecar_url, &encrypted_data, &private_key, &package_id, &account_id).await { Ok(plaintext) => { match String::from_utf8(plaintext) { Ok(text) => Some(RecallResult { blob_id, text, distance }), @@ -324,7 +321,6 @@ pub async fn remember_manual( let upload = walrus::upload_blob( &state.http_client, &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), &encrypted_bytes, 50, owner, @@ -440,7 +436,6 @@ pub async fn analyze( let embed_fut = generate_embedding(&state.http_client, &state.config, &fact_text); let encrypt_fut = seal::seal_encrypt( &state.http_client, &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), fact_text.as_bytes(), &owner, &state.config.package_id, ); let (vector_result, encrypted_result) = tokio::join!(embed_fut, encrypt_fut); @@ -450,7 +445,6 @@ pub async fn analyze( // Upload to Walrus (via sidecar HTTP) let upload_result = walrus::upload_blob( &state.http_client, &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), &encrypted, 50, &owner, &sui_key, &namespace, &state.config.package_id, ).await?; @@ -651,7 +645,6 @@ pub async fn ask( let walrus_client = &state.walrus_client; let http_client = &state.http_client; let sidecar_url = state.config.sidecar_url.clone(); - let sidecar_secret = state.config.sidecar_secret.clone(); let blob_id = hit.blob_id.clone(); let distance = hit.distance; let private_key = private_key.to_string(); @@ -671,7 +664,7 @@ pub async fn ask( return None; } }; - match seal::seal_decrypt(http_client, &sidecar_url, sidecar_secret.as_deref(), &encrypted_data, &private_key, &package_id, &account_id).await { + match seal::seal_decrypt(http_client, &sidecar_url, &encrypted_data, &private_key, &package_id, &account_id).await { Ok(plaintext) => { match String::from_utf8(plaintext) { Ok(text) => Some(RecallResult { blob_id, text, distance }), @@ -818,7 +811,6 @@ pub async fn restore( let on_chain_blobs = walrus::query_blobs_by_owner( &state.http_client, &state.config.sidecar_url, - state.config.sidecar_secret.as_deref(), owner, Some(namespace), Some(&state.config.package_id), @@ -924,7 +916,6 @@ pub async fn restore( .map(|(blob_id, encrypted_data)| { let http_client = &state.http_client; let sidecar_url = state.config.sidecar_url.clone(); - let sidecar_secret = state.config.sidecar_secret.clone(); let private_key = private_key.clone(); // Use the package_id that was stored with this blob (supports contract upgrades) let package_id = blob_package_ids.get(&blob_id) @@ -933,7 +924,7 @@ pub async fn restore( let account_id = auth.account_id.clone(); async move { match seal::seal_decrypt( - http_client, &sidecar_url, sidecar_secret.as_deref(), &encrypted_data, + http_client, &sidecar_url, &encrypted_data, &private_key, &package_id, &account_id, ).await { Ok(plaintext) => { @@ -1022,14 +1013,10 @@ pub async fn sponsor_proxy( body: axum::body::Bytes, ) -> Result, AppError> { let url = format!("{}/sponsor", state.config.sidecar_url); - let mut req = state.http_client + let resp = state.http_client .post(&url) .header("Content-Type", "application/json") - .body(body.to_vec()); - if let Some(secret) = state.config.sidecar_secret.as_deref() { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req + .body(body.to_vec()) .send() .await .map_err(|e| AppError::Internal(format!("Sponsor proxy failed: {}", e)))?; @@ -1052,14 +1039,10 @@ pub async fn sponsor_execute_proxy( body: axum::body::Bytes, ) -> Result, AppError> { let url = format!("{}/sponsor/execute", state.config.sidecar_url); - let mut req = state.http_client + let resp = state.http_client .post(&url) .header("Content-Type", "application/json") - .body(body.to_vec()); - if let Some(secret) = state.config.sidecar_secret.as_deref() { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req + .body(body.to_vec()) .send() .await .map_err(|e| AppError::Internal(format!("Sponsor execute proxy failed: {}", e)))?; diff --git a/services/server/src/seal.rs b/services/server/src/seal.rs index e4617a5f..93fe585c 100644 --- a/services/server/src/seal.rs +++ b/services/server/src/seal.rs @@ -40,7 +40,6 @@ struct SealDecryptResponse { pub async fn seal_encrypt( client: &reqwest::Client, sidecar_url: &str, - sidecar_secret: Option<&str>, data: &[u8], owner_address: &str, package_id: &str, @@ -48,17 +47,13 @@ pub async fn seal_encrypt( let url = format!("{}/seal/encrypt", sidecar_url); let data_b64 = BASE64.encode(data); - let mut req = client + let resp = client .post(&url) .json(&SealEncryptRequest { data: data_b64, owner: owner_address.to_string(), package_id: package_id.to_string(), - }); - if let Some(secret) = sidecar_secret { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req + }) .send() .await .map_err(|e| { @@ -100,7 +95,6 @@ pub async fn seal_encrypt( pub async fn seal_decrypt( client: &reqwest::Client, sidecar_url: &str, - sidecar_secret: Option<&str>, encrypted_data: &[u8], private_key: &str, package_id: &str, @@ -109,18 +103,14 @@ pub async fn seal_decrypt( let url = format!("{}/seal/decrypt", sidecar_url); let data_b64 = BASE64.encode(encrypted_data); - let mut req = client + let resp = client .post(&url) .json(&SealDecryptRequest { data: data_b64, private_key: private_key.to_string(), package_id: package_id.to_string(), account_id: account_id.to_string(), - }); - if let Some(secret) = sidecar_secret { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req + }) .send() .await .map_err(|e| { diff --git a/services/server/src/types.rs b/services/server/src/types.rs index 0e68d140..ad82cb8f 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -78,8 +78,6 @@ pub struct Config { pub registry_id: String, /// URL of the SEAL/Walrus TS sidecar HTTP server pub sidecar_url: String, - /// Shared secret for authenticating Rust→sidecar calls (X-Sidecar-Secret header) - pub sidecar_secret: Option, /// Rate limiting configuration pub rate_limit: RateLimitConfig, } @@ -131,7 +129,6 @@ impl Config { .expect("MEMWAL_REGISTRY_ID must be set"), sidecar_url: std::env::var("SIDECAR_URL") .unwrap_or_else(|_| "http://localhost:9000".to_string()), - sidecar_secret: std::env::var("SIDECAR_AUTH_TOKEN").ok(), rate_limit: RateLimitConfig::from_env(), } } diff --git a/services/server/src/walrus.rs b/services/server/src/walrus.rs index d9e0ebe1..5c63a630 100644 --- a/services/server/src/walrus.rs +++ b/services/server/src/walrus.rs @@ -64,7 +64,6 @@ struct WalrusUploadResponse { pub async fn upload_blob( client: &reqwest::Client, sidecar_url: &str, - sidecar_secret: Option<&str>, data: &[u8], epochs: u64, owner_address: &str, @@ -75,7 +74,7 @@ pub async fn upload_blob( let url = format!("{}/walrus/upload", sidecar_url); let data_b64 = BASE64.encode(data); - let mut req = client + let resp = client .post(&url) .json(&WalrusUploadRequest { data: data_b64, @@ -84,11 +83,7 @@ pub async fn upload_blob( namespace: namespace.to_string(), package_id: package_id.to_string(), epochs, - }); - if let Some(secret) = sidecar_secret { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req + }) .send() .await .map_err(|e| { @@ -129,7 +124,6 @@ pub async fn upload_blob( pub async fn query_blobs_by_owner( client: &reqwest::Client, sidecar_url: &str, - sidecar_secret: Option<&str>, owner_address: &str, namespace: Option<&str>, package_id: Option<&str>, @@ -144,13 +138,9 @@ pub async fn query_blobs_by_owner( body["packageId"] = serde_json::json!(pkg); } - let mut req = client + let resp = client .post(&url) - .json(&body); - if let Some(secret) = sidecar_secret { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req + .json(&body) .send() .await .map_err(|e| { diff --git a/services/server/tests/test_rate_limit_redis.py b/services/server/tests/test_rate_limit_redis.py deleted file mode 100644 index 772b2af8..00000000 --- a/services/server/tests/test_rate_limit_redis.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -""" -Test: Rate limiter returns 503 when Redis is down. -Run BEFORE this script: - 1. cargo run (server must be running on port 3001) - 2. docker stop memwal-redis - -Then run: - python3 tests/test_rate_limit_redis.py - -Then restore: - docker start memwal-redis -""" - -import json, hashlib, time, urllib.request, urllib.error -from nacl.signing import SigningKey -from nacl.encoding import RawEncoder - -BASE_URL = "http://localhost:3001" - -import os, sys - -# Set via env vars — never hardcode keys in source -PRIVATE_KEY_HEX = os.environ.get("TEST_DELEGATE_KEY") -ACCOUNT_ID = os.environ.get("TEST_ACCOUNT_ID") - -if not PRIVATE_KEY_HEX or not ACCOUNT_ID: - print("Usage: TEST_DELEGATE_KEY= TEST_ACCOUNT_ID=<0x...> python3 tests/test_rate_limit_redis.py") - sys.exit(1) - -def signed_request(method, path, body): - key = SigningKey(bytes.fromhex(PRIVATE_KEY_HEX)) - body_json = json.dumps(body).encode() - body_hash = hashlib.sha256(body_json).hexdigest() - timestamp = str(int(time.time())) - message = f"{timestamp}.{method}.{path}.{body_hash}" - signed = key.sign(message.encode(), encoder=RawEncoder) - pub = key.verify_key.encode().hex() - sig = signed.signature.hex() - req = urllib.request.Request( - f"{BASE_URL}{path}", data=body_json, - headers={ - "Content-Type": "application/json", - "x-public-key": pub, - "x-signature": sig, - "x-timestamp": timestamp, - "x-account-id": ACCOUNT_ID, - }, - method=method, - ) - try: - with urllib.request.urlopen(req) as r: - raw = r.read() - return r.status, json.loads(raw) if raw else {} - except urllib.error.HTTPError as e: - raw = e.read() - try: - body = json.loads(raw) if raw else {} - except Exception: - body = {"raw": raw.decode(errors="replace")} - return e.code, body - -body = {"text": "test memory for rate limit check"} - -print("Sending signed POST /api/remember ...") -status, resp = signed_request("POST", "/api/remember", body) -print(f"→ HTTP {status}: {resp}") - -if status == 503: - print("\n[PASS] Rate limiter returned 503 — Redis is down, fail-closed working correctly.") -elif status == 200: - print("\n[INFO] Got 200 — Redis is still UP. Stop it first: docker stop memwal-redis") -elif status == 401: - print("\n[FAIL] Got 401 — auth failed. Key may be expired or not registered on-chain.") -else: - print(f"\n[INFO] Got {status} — {resp}") From a1a063924a70c9d7edd1b2002fcdc15bfca763c0 Mon Sep 17 00:00:00 2001 From: ducnmm Date: Tue, 14 Apr 2026 10:43:12 +0700 Subject: [PATCH 18/21] feat: pass delegate_public_key to walrus metadata on upload --- services/server/scripts/sidecar-server.ts | 23 ++++++++++++++++++++--- services/server/src/routes.rs | 5 +++++ services/server/src/walrus.rs | 10 +++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts index e1f9aea2..55bd38ec 100644 --- a/services/server/scripts/sidecar-server.ts +++ b/services/server/scripts/sidecar-server.ts @@ -508,6 +508,7 @@ app.post("/walrus/upload", async (req, res) => { owner, namespace, packageId, + delegatePublicKey, epochs = DEFAULT_WALRUS_EPOCHS, } = req.body; if (!data || !privateKey) { @@ -611,6 +612,19 @@ app.post("/walrus/upload", async (req, res) => { }); } + // Set memwal_delegate_key + if (delegatePublicKey) { + metaTx.moveCall({ + target: `${WALRUS_PKG}::blob::insert_or_update_metadata_pair`, + arguments: [ + blobArg, + metaTx.pure.string("memwal_delegate_key"), + metaTx.pure.string(delegatePublicKey), + ], + typeArguments: [], + }); + } + // Transfer blob to user metaTx.transferObjects([blobArg], owner); @@ -744,12 +758,14 @@ app.post("/walrus/query-blobs", async (req, res) => { blobNamespace: string; blobOwner: string; blobPackageId: string; + blobDelegateKey: string; }; const metas: BlobMeta[] = await mapConcurrent(rawObjs, 5, async (obj) => { let blobNamespace = "default"; let blobOwner = ""; let blobPackageId = ""; + let blobDelegateKey = ""; try { const dynField = await getDynamicFieldWithRetry(obj.objectId, METADATA_FIELD_NAME); @@ -765,6 +781,7 @@ app.post("/walrus/query-blobs", async (req, res) => { if (key === "memwal_namespace") blobNamespace = value; if (key === "memwal_owner") blobOwner = value; if (key === "memwal_package_id") blobPackageId = value; + if (key === "memwal_delegate_key") blobDelegateKey = value; } } } @@ -772,11 +789,11 @@ app.post("/walrus/query-blobs", async (req, res) => { // No dynamic field = no metadata = use defaults } - return { ...obj, blobNamespace, blobOwner, blobPackageId }; + return { ...obj, blobNamespace, blobOwner, blobPackageId, blobDelegateKey }; }); // Step 3: Filter + convert blob IDs - const blobs: { blobId: string; objectId: string; namespace: string; packageId: string }[] = []; + const blobs: { blobId: string; objectId: string; namespace: string; packageId: string; delegateKey: string }[] = []; for (const meta of metas) { // Filter by namespace if specified @@ -799,7 +816,7 @@ app.post("/walrus/query-blobs", async (req, res) => { // Keep as-is if conversion fails } } - blobs.push({ blobId: blobIdStr, objectId: meta.objectId, namespace: meta.blobNamespace, packageId: meta.blobPackageId }); + blobs.push({ blobId: blobIdStr, objectId: meta.objectId, namespace: meta.blobNamespace, packageId: meta.blobPackageId, delegateKey: meta.blobDelegateKey }); } } diff --git a/services/server/src/routes.rs b/services/server/src/routes.rs index df8396cc..81b34c52 100644 --- a/services/server/src/routes.rs +++ b/services/server/src/routes.rs @@ -156,6 +156,7 @@ pub async fn remember( let upload_result = walrus::upload_blob( &state.http_client, &state.config.sidecar_url, &encrypted, 50, owner, &sui_key, namespace, &state.config.package_id, + Some(&auth.public_key), ).await?; let blob_id = upload_result.blob_id; @@ -327,6 +328,7 @@ pub async fn remember_manual( &sui_key, namespace, &state.config.package_id, + Some(&auth.public_key), ) .await?; @@ -420,10 +422,12 @@ pub async fn analyze( // Step 2: Process all facts concurrently (embed + encrypt → upload → store) // Each fact gets its own key from the pool so sidecar can upload them in parallel // (different signer addresses bypass the per-signer serialization lock). + let auth_pubkey_base = auth.public_key.clone(); let tasks: Vec<_> = facts.iter().map(|fact_text| { let state = Arc::clone(&state); let owner = owner.clone(); let fact_text = fact_text.clone(); + let auth_pubkey = auth_pubkey_base.clone(); // Pick the next key in round-robin order at task construction time. // Convert to owned String *before* async move so we don't borrow-then-move `state`. let sui_key: Result = state.key_pool.next() @@ -446,6 +450,7 @@ pub async fn analyze( let upload_result = walrus::upload_blob( &state.http_client, &state.config.sidecar_url, &encrypted, 50, &owner, &sui_key, &namespace, &state.config.package_id, + Some(&auth_pubkey), ).await?; // Store in Vector DB with namespace diff --git a/services/server/src/walrus.rs b/services/server/src/walrus.rs index 5c63a630..f3686e37 100644 --- a/services/server/src/walrus.rs +++ b/services/server/src/walrus.rs @@ -44,6 +44,8 @@ struct WalrusUploadRequest { namespace: String, package_id: String, epochs: u64, + #[serde(rename = "delegatePublicKey", skip_serializing_if = "Option::is_none")] + delegate_public_key: Option, } #[derive(serde::Deserialize)] @@ -70,6 +72,7 @@ pub async fn upload_blob( sui_private_key: &str, namespace: &str, package_id: &str, + delegate_public_key: Option<&str>, ) -> Result { let url = format!("{}/walrus/upload", sidecar_url); let data_b64 = BASE64.encode(data); @@ -83,7 +86,12 @@ pub async fn upload_blob( namespace: namespace.to_string(), package_id: package_id.to_string(), epochs, - }) + delegate_public_key: delegate_public_key.map(|s| s.to_string()), + }); + if let Some(secret) = sidecar_secret { + req = req.header("authorization", format!("Bearer {}", secret)); + } + let resp = req .send() .await .map_err(|e| { From 1aafba4113be5960aee08437809fc04835e5b016 Mon Sep 17 00:00:00 2001 From: ducnmm Date: Tue, 14 Apr 2026 11:03:25 +0700 Subject: [PATCH 19/21] docs: clarify self-hosting personas, namespace isolation, and rate points --- docs/relayer/overview.md | 23 ++++++++++++++++++++- docs/relayer/self-hosting.md | 40 ++++++++++++++++++++++++++++++------ docs/sdk/usage.md | 2 +- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/relayer/overview.md b/docs/relayer/overview.md index 82cffa90..7b75cf40 100644 --- a/docs/relayer/overview.md +++ b/docs/relayer/overview.md @@ -61,12 +61,33 @@ The sidecar is started automatically when the Rust server boots and communicates For the `analyze` endpoint (which stores multiple facts concurrently), the relayer supports a pool of Sui private keys (`SERVER_SUI_PRIVATE_KEYS`). Each concurrent Walrus upload uses a different key from the pool in round-robin order, bypassing per-signer serialization and enabling parallel uploads. +## Rate Limiting & Abuse Prevention + +To prevent spam and ensure stability, the relayer implements a cost-weighted, multi-layered rate limiting system backed by a Redis sliding window. + +### Cost-Weighted Points +Because endpoints have different computational and storage costs, they consume varying amounts of "points": +- **Heavy endpoints** (e.g., `/api/analyze` which does LLM extraction, embedding, encryption, and walrus upload) = **10 points** +- `/api/remember` (embed, encrypt, upload) = **5 points** +- `/api/restore` and `/api/remember/manual` = **3 points** +- `/api/ask` (recall + LLM answering) = **2 points** +- **Simple endpoints** (e.g., `/api/recall`) = **1 point** + +### Types of Limits & Terminology +1. **Per Account (User)**: The "Account" or "User" refers to the Sui address of the actual user (identified by `auth.owner`). Account limits are: + - **60 points / minute** (burst limit) + - **500 points / hour** (sustained limit) +2. **Per Delegate Key (Instance)**: A "Delegate Key" is the throwaway ed25519 keypair running directly on the client instance (e.g., in a browser extension or a specific device). To mitigate the risk if a specific ephemeral delegate key is compromised, each key is independently limited to **30 points / minute**. +3. **Storage Quota**: Each account is limited to a total of **1 GB** of Walrus blob storage. + +For self-hosted deployments, *all* of these limits and quotas can be fully configured via environment variables. See [Self-Hosting](/relayer/self-hosting) for configuration details. + ## Single-Instance Design Each relayer deployment is tied to a single MemWal package ID (`MEMWAL_PACKAGE_ID`). The package ID is used for SEAL encryption key derivation and Walrus blob metadata. Queries in the vector database are scoped by `owner + namespace`, while the package ID provides cross-deployment isolation at the encryption layer. -The current relayer does not support multi-tenancy across multiple package IDs. If you deploy a separate MemWal contract, you need to run a separate relayer instance with its own database. +The current relayer only supports a single active package ID at a time. If you deploy a separate MemWal contract, you need to run a separate relayer instance with its own database. ## Trust Boundary diff --git a/docs/relayer/self-hosting.md b/docs/relayer/self-hosting.md index ce3292bd..d1a94b82 100644 --- a/docs/relayer/self-hosting.md +++ b/docs/relayer/self-hosting.md @@ -6,14 +6,32 @@ Self-hosting means running your own relayer — either pointing at an existing M The managed relayer provided by Walrus Foundation is a reference implementation. You can also build your own implementation that fits the same API surface with custom logic. This guide covers how to run the reference implementation as your own self-hosted relayer. -## When to Self-Host +## Personas & When to Self-Host -The most common reasons are removing the trust assumption on a third-party relayer or running your own MemWal instance entirely: +There are two primary personas who typically self-host the relayer: -- **Control the trust boundary** — a self-hosted relayer keeps plaintext, encryption, and embedding under your own control -- **Run your own MemWal instance** — deploy your own contract with a separate package ID, SEAL encryption keys, and data isolation -- **Choose your own embedding provider** — use your own OpenAI-compatible API and credentials -- **Guarantee availability** — the managed relayer is a beta service with no SLA +1. **Builders & Teams**: Self-hosting for their own agentic needs or internal team usage, keeping the trust boundary, encryption, and embeddings under their control. +2. **Infra Operators / Managed Service Providers (MSPs)**: Hosting the relayer as a reliable platform or service for *other* external development teams and agentic builders. + +The most common reasons to self-host include: + +- **Control the trust boundary** — keeping plaintext, encryption, and embedding under your own control rather than trusting a third-party. +- **Run your own MemWal instance** — deploying your own contract with a separate package ID, SEAL encryption keys, and hard data isolation. +- **Choose your own embedding provider** — using your own OpenAI-compatible API and credentials. +- **Guarantee availability** — the managed relayer is a beta service with no SLA. + +## Data Isolation (Namespaces) + +With the current architecture, MemWal isolates data strictly by **User (Owner address)** and **Namespace**. +Because the relayer inherently scopes all vector searches and storage operations by `owner + namespace`, multiple agents or applications can safely share the same relayer deployment simply by using different namespaces or operating under different delegate keys. + +## Horizontal Scaling + +If you are a Managed Service Provider or need to handle high agentic throughput, you can horizontally scale your hosted relayer natively. To run multiple instances of the relayer behind a load balancer for the *same* account/package ID: + +1. Point all relayer instances to the **same PostgreSQL database**. +2. Supply the **same `SERVER_SUI_PRIVATE_KEYS` pool** to all instances so they can seamlessly execute concurrent Walrus uploads. +3. Configure the **same Redis cluster** (`REDIS_URL`) across all nodes so that the rate limiter sliding window accurately tracks global user quotas across your deployment. ## What Runs @@ -67,6 +85,16 @@ curl http://localhost:8000/health - `OPENAI_API_KEY` — enables real embeddings (falls back to mock embeddings without it) - `OPENAI_API_BASE` — point to an OpenAI-compatible provider like OpenRouter +### Rate Limits & Storage (Optional) + +By default, the relayer enforces rate limits and storage quotas via Redis to prevent abuse. You can customize these limits: + +- `RATE_LIMIT_REQUESTS_PER_MINUTE` — max burst weighted-requests per minute per user (default: 60) +- `RATE_LIMIT_REQUESTS_PER_HOUR` — max sustained weighted-requests per hour per user (default: 500) +- `RATE_LIMIT_DELEGATE_KEY_PER_MINUTE` — max weighted-requests per minute per delegate key (default: 30) +- `RATE_LIMIT_STORAGE_BYTES` — max storage per user in bytes (default: 1 GB, `1073741824`) +- `REDIS_URL` — required to track sliding windows for rate limits (default: `redis://localhost:6379`) + ### Defaults - `PORT` defaults to `8000` diff --git a/docs/sdk/usage.md b/docs/sdk/usage.md index e3a1f0f8..dfa2e516 100644 --- a/docs/sdk/usage.md +++ b/docs/sdk/usage.md @@ -13,7 +13,7 @@ MemWal exposes three entry points: ## Namespace Rules -- Set a default namespace in `create(...)` when one app or tenant uses one boundary +- Set a default namespace in `create(...)` when one app or agent uses one boundary - Pass `namespace` per call when one client needs multiple boundaries - If omitted, namespace falls back to client config, then to `"default"` From 05ab6766a3b30d581d7a80ed62f2c1103074f171 Mon Sep 17 00:00:00 2001 From: ducnmm Date: Tue, 14 Apr 2026 11:26:47 +0700 Subject: [PATCH 20/21] fix: remove sidecar_secret logic not yet in dev --- services/server/src/walrus.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/services/server/src/walrus.rs b/services/server/src/walrus.rs index f3686e37..58d45705 100644 --- a/services/server/src/walrus.rs +++ b/services/server/src/walrus.rs @@ -87,11 +87,7 @@ pub async fn upload_blob( package_id: package_id.to_string(), epochs, delegate_public_key: delegate_public_key.map(|s| s.to_string()), - }); - if let Some(secret) = sidecar_secret { - req = req.header("authorization", format!("Bearer {}", secret)); - } - let resp = req + }) .send() .await .map_err(|e| { From 25cf1e55041f998b14c095cc8da10ecb9a4142d1 Mon Sep 17 00:00:00 2001 From: ducnmm Date: Wed, 15 Apr 2026 06:16:51 +0700 Subject: [PATCH 21/21] feat: rename memwal_delegate_key to memwal_agent_id --- services/server/scripts/sidecar-server.ts | 22 +++++++++++----------- services/server/src/walrus.rs | 8 ++++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts index 55bd38ec..050b09c4 100644 --- a/services/server/scripts/sidecar-server.ts +++ b/services/server/scripts/sidecar-server.ts @@ -508,7 +508,7 @@ app.post("/walrus/upload", async (req, res) => { owner, namespace, packageId, - delegatePublicKey, + agentId, epochs = DEFAULT_WALRUS_EPOCHS, } = req.body; if (!data || !privateKey) { @@ -612,14 +612,14 @@ app.post("/walrus/upload", async (req, res) => { }); } - // Set memwal_delegate_key - if (delegatePublicKey) { + // Set memwal_agent_id + if (agentId) { metaTx.moveCall({ target: `${WALRUS_PKG}::blob::insert_or_update_metadata_pair`, arguments: [ blobArg, - metaTx.pure.string("memwal_delegate_key"), - metaTx.pure.string(delegatePublicKey), + metaTx.pure.string("memwal_agent_id"), + metaTx.pure.string(agentId), ], typeArguments: [], }); @@ -758,14 +758,14 @@ app.post("/walrus/query-blobs", async (req, res) => { blobNamespace: string; blobOwner: string; blobPackageId: string; - blobDelegateKey: string; + blobAgentId: string; }; const metas: BlobMeta[] = await mapConcurrent(rawObjs, 5, async (obj) => { let blobNamespace = "default"; let blobOwner = ""; let blobPackageId = ""; - let blobDelegateKey = ""; + let blobAgentId = ""; try { const dynField = await getDynamicFieldWithRetry(obj.objectId, METADATA_FIELD_NAME); @@ -781,7 +781,7 @@ app.post("/walrus/query-blobs", async (req, res) => { if (key === "memwal_namespace") blobNamespace = value; if (key === "memwal_owner") blobOwner = value; if (key === "memwal_package_id") blobPackageId = value; - if (key === "memwal_delegate_key") blobDelegateKey = value; + if (key === "memwal_agent_id") blobAgentId = value; } } } @@ -789,11 +789,11 @@ app.post("/walrus/query-blobs", async (req, res) => { // No dynamic field = no metadata = use defaults } - return { ...obj, blobNamespace, blobOwner, blobPackageId, blobDelegateKey }; + return { ...obj, blobNamespace, blobOwner, blobPackageId, blobAgentId }; }); // Step 3: Filter + convert blob IDs - const blobs: { blobId: string; objectId: string; namespace: string; packageId: string; delegateKey: string }[] = []; + const blobs: { blobId: string; objectId: string; namespace: string; packageId: string; agentId: string }[] = []; for (const meta of metas) { // Filter by namespace if specified @@ -816,7 +816,7 @@ app.post("/walrus/query-blobs", async (req, res) => { // Keep as-is if conversion fails } } - blobs.push({ blobId: blobIdStr, objectId: meta.objectId, namespace: meta.blobNamespace, packageId: meta.blobPackageId, delegateKey: meta.blobDelegateKey }); + blobs.push({ blobId: blobIdStr, objectId: meta.objectId, namespace: meta.blobNamespace, packageId: meta.blobPackageId, agentId: meta.blobAgentId }); } } diff --git a/services/server/src/walrus.rs b/services/server/src/walrus.rs index 58d45705..23489a99 100644 --- a/services/server/src/walrus.rs +++ b/services/server/src/walrus.rs @@ -44,8 +44,8 @@ struct WalrusUploadRequest { namespace: String, package_id: String, epochs: u64, - #[serde(rename = "delegatePublicKey", skip_serializing_if = "Option::is_none")] - delegate_public_key: Option, + #[serde(rename = "agentId", skip_serializing_if = "Option::is_none")] + agent_id: Option, } #[derive(serde::Deserialize)] @@ -72,7 +72,7 @@ pub async fn upload_blob( sui_private_key: &str, namespace: &str, package_id: &str, - delegate_public_key: Option<&str>, + agent_id: Option<&str>, ) -> Result { let url = format!("{}/walrus/upload", sidecar_url); let data_b64 = BASE64.encode(data); @@ -86,7 +86,7 @@ pub async fn upload_blob( namespace: namespace.to_string(), package_id: package_id.to_string(), epochs, - delegate_public_key: delegate_public_key.map(|s| s.to_string()), + agent_id: agent_id.map(|s| s.to_string()), }) .send() .await