From 4dd587361616fae357a39d13eb529e40a2e556b9 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 14 Aug 2026 12:40:13 +0700 Subject: [PATCH 01/32] fix(sidecar): durable-upload crash-recovery tag never matched the reconcile lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole point of PR #562's idempotency work is: if the sidecar crashes right after minting a paid Walrus blob but before its journal write lands, a retry must find and adopt that orphaned blob instead of minting a second paid one. The lookup that's supposed to find it was searching for the wrong tag. The durable-upload register step (walrus-upload-journal.ts — the only path every real /api/remember call goes through today) tagged the minted blob's on-chain metadata with memwal_migration_job, an unrelated, dead-code constant borrowed from the V1->V2 migration feature (its only reader, findOwnedBlobObjects, has zero callers anywhere in the codebase). The reconcile scan that runs on a lost journal, scanOwnerForJobBlob (walrus-query.ts), searches for memwal_job_id instead. The two could never match, so a crash at exactly the wrong moment would silently mint twice — the precise failure PR #562 exists to prevent. memwal_job_id was already the correct key, used correctly, by the legacy (non-durable) upload path — but that path is dead code today, since every current caller sets remember_job_id: Some(...), which always routes into the durable path instead. Fixes it by introducing a single shared MEMWAL_JOB_TAG_KEY constant in util.ts and having both write sites (durable + legacy) and the read site import it instead of each hardcoding the string literal, so the two sides structurally cannot drift apart again without someone deliberately un-importing the shared constant. New regression test pins the constant's value and greps all three files to confirm none hardcodes a competing 'memwal_job_id' literal. 193/193 sidecar scripts tests pass (7/7 in the directly affected files). tsc --noEmit: no new errors (one pre-existing, unrelated error in mcp/__tests__/integration.test.ts, confirmed present on unmodified dev too). --- .../sidecar-find-blob-by-job.test.ts | 44 +++++++++++++++++++ .../scripts/sidecar/routes/walrus-query.ts | 4 +- .../sidecar/routes/walrus-upload-journal.ts | 4 +- .../scripts/sidecar/routes/walrus-upload.ts | 4 +- services/server/scripts/sidecar/util.ts | 17 +++++++ 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/services/server/scripts/__tests__/sidecar-find-blob-by-job.test.ts b/services/server/scripts/__tests__/sidecar-find-blob-by-job.test.ts index 39b51519f..c24494fe0 100644 --- a/services/server/scripts/__tests__/sidecar-find-blob-by-job.test.ts +++ b/services/server/scripts/__tests__/sidecar-find-blob-by-job.test.ts @@ -1,6 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import type { Server } from "node:http"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; // Minimal env for booting the default-mode sidecar app. The find-blob-by-job // validation path returns 400 before touching the chain, so no client mocking @@ -14,6 +17,7 @@ process.env.WALRUS_PACKAGE_ID = `0x${"a".repeat(64)}`; const { createSidecarApp } = await import("../sidecar/app.js"); const { sanitizeRequestId } = await import("../sidecar/log.js"); +const { MEMWAL_JOB_TAG_KEY } = await import("../sidecar/util.js"); // The reconcile correctness hinges on the register-side tag value // (sanitizeRequestId(jobId)) equaling the query-side value (also @@ -30,6 +34,46 @@ test("a remember-job UUID survives sanitizeRequestId unchanged (tag == query)", assert.equal(sanitizeRequestId("x".repeat(129)), null); }); +// Regression: the durable upload path (walrus-upload-journal.ts) once wrote +// the crash-recovery job tag under a different key (`memwal_migration_job`, +// an unrelated dead-code constant from the V1->V2 migration feature) than +// the one scanOwnerForJobBlob() actually searches for (`memwal_job_id`) — so +// the tag written by every real /api/remember call could never be found by +// reconcile, and a crash right after mint-but-before-journal would mint a +// second paid blob on retry, silently. Both the write sites (durable + +// legacy) and the read site now import the same MEMWAL_JOB_TAG_KEY constant +// from util.ts instead of each hardcoding the string, so this can't drift +// again without deliberately un-importing the shared constant. This test +// pins the constant's value and confirms no file in sidecar/ hardcodes a +// competing literal for job-tag purposes. +test("register (write) and reconcile (read) use the identical job-tag key — no hardcoded drift", () => { + assert.equal(MEMWAL_JOB_TAG_KEY, "memwal_job_id"); + + const here = path.dirname(fileURLToPath(import.meta.url)); + const sidecarDir = path.join(here, "..", "sidecar"); + const filesToCheck = [ + path.join(sidecarDir, "routes", "walrus-upload-journal.ts"), + path.join(sidecarDir, "routes", "walrus-upload.ts"), + path.join(sidecarDir, "routes", "walrus-query.ts"), + ]; + + for (const file of filesToCheck) { + const src = readFileSync(file, "utf8"); + assert.ok( + src.includes("MEMWAL_JOB_TAG_KEY"), + `${path.basename(file)} must reference the shared MEMWAL_JOB_TAG_KEY constant, not a hardcoded literal` + ); + // The only other job-shaped tag key in this codebase is the distinct, + // separately-purposed `memwal_migration_job` (findOwnedBlobObjects in + // walrus-query.ts) — a hardcoded `"memwal_job_id"` string literal + // anywhere in these files would mean someone reintroduced the drift. + assert.ok( + !src.includes('"memwal_job_id"') && !src.includes("'memwal_job_id'"), + `${path.basename(file)} must not hardcode the "memwal_job_id" string literal — import MEMWAL_JOB_TAG_KEY instead` + ); + } +}); + async function listen(): Promise<{ server: Server; baseUrl: string }> { return await new Promise((resolve) => { const server = createSidecarApp().listen(0, "127.0.0.1", () => { diff --git a/services/server/scripts/sidecar/routes/walrus-query.ts b/services/server/scripts/sidecar/routes/walrus-query.ts index 4f9c30632..b2189a95e 100644 --- a/services/server/scripts/sidecar/routes/walrus-query.ts +++ b/services/server/scripts/sidecar/routes/walrus-query.ts @@ -17,7 +17,7 @@ import { import { getWalrusClient, refreshWalrusClientIfStale, suiClient, suiGraphqlClient } from "../clients.js"; import { requestIdFor, sanitizeRequestId, sidecarLog } from "../log.js"; import { withRpcRetry } from "../retry/rpc.js"; -import { errorMessage, mapConcurrent } from "../util.js"; +import { MEMWAL_JOB_TAG_KEY, errorMessage, mapConcurrent } from "../util.js"; /** * blob_id from chain is a big integer (U256); convert to base64url @@ -455,7 +455,7 @@ async function scanOwnerForJobBlob( const blobs = await listBlobObjectsGrpc(normalizeSuiAddress(scanOwner), blobType, Infinity); for (const blob of blobs) { const entries = await fetchBlobMetadataEntries(blob.objectId); - const jobMatches = entries.some(({ key, value }) => key === "memwal_job_id" && value === jobId); + const jobMatches = entries.some(({ key, value }) => key === MEMWAL_JOB_TAG_KEY && value === jobId); // Normalize the tagged owner before comparing — the register stores the // raw `owner` the relayer sent, which may be mixed-case / unpadded hex. const ownerMatches = entries.some( diff --git a/services/server/scripts/sidecar/routes/walrus-upload-journal.ts b/services/server/scripts/sidecar/routes/walrus-upload-journal.ts index 8534dd69e..2e7819cbe 100644 --- a/services/server/scripts/sidecar/routes/walrus-upload-journal.ts +++ b/services/server/scripts/sidecar/routes/walrus-upload-journal.ts @@ -42,7 +42,7 @@ import { NoSideEffectError, withRpcRetry, } from "../retry/rpc.js"; -import { delayInjectedResponseOnce, errorMessage, parseWalrusKeySlot } from "../util.js"; +import { MEMWAL_JOB_TAG_KEY, delayInjectedResponseOnce, errorMessage, parseWalrusKeySlot } from "../util.js"; import { DURABLE_WALLET_FALLBACK_POLICY, assertFinalizedTransactionSuccess, @@ -552,7 +552,7 @@ export function registerWalrusUploadJournalRoute(app: Express): void { ...(namespace ? { memwal_namespace: namespace } : {}), ...(targetOwner ? { memwal_owner: targetOwner } : {}), ...(packageId ? { memwal_package_id: packageId } : {}), - memwal_migration_job: jobId, + [MEMWAL_JOB_TAG_KEY]: jobId, }, }); enforceAddressBalanceCoinIntents(registerTx); diff --git a/services/server/scripts/sidecar/routes/walrus-upload.ts b/services/server/scripts/sidecar/routes/walrus-upload.ts index c51edd4d5..3ad677ea3 100644 --- a/services/server/scripts/sidecar/routes/walrus-upload.ts +++ b/services/server/scripts/sidecar/routes/walrus-upload.ts @@ -35,7 +35,7 @@ import { import { acquireWalrusUploadSlots, walrusUploadLimitSnapshot, WalrusUploadLimitError } from "../concurrency.js"; import { requestIdFor, sanitizeRequestId, sidecarLog } from "../log.js"; import { sidecarStartedAtMs, sidecarStateSnapshot } from "../state.js"; -import { dedupeAddresses, errorMessage, parseWalrusKeySlot, shortAddress, sleep, truncateForLog } from "../util.js"; +import { MEMWAL_JOB_TAG_KEY, dedupeAddresses, errorMessage, parseWalrusKeySlot, shortAddress, sleep, truncateForLog } from "../util.js"; import { isMoveAbortBalanceSplit, isMoveAbortWalDestroyZero } from "../enoki.js"; import { ADDRESS_BALANCE_WALLET_FALLBACK_POLICY, @@ -240,7 +240,7 @@ export function registerWalrusUploadRoute(app: Express): void { // Correlate the minted blob back to its remember job so a // crashed write (mint landed, response/DB-record lost) can be // reconciled — found and adopted — instead of re-minting. - ...(jobIdForLog ? { memwal_job_id: jobIdForLog } : {}), + ...(jobIdForLog ? { [MEMWAL_JOB_TAG_KEY]: jobIdForLog } : {}), }, }); diff --git a/services/server/scripts/sidecar/util.ts b/services/server/scripts/sidecar/util.ts index 170958693..133f0e47b 100644 --- a/services/server/scripts/sidecar/util.ts +++ b/services/server/scripts/sidecar/util.ts @@ -8,6 +8,23 @@ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * On-chain Blob attribute key used to tag a mint with the remember-job id + * that requested it, so a crash-recovery reconcile (after a mint lands but + * before its journal write) can find and adopt the orphaned blob instead of + * minting a second paid one on retry. + * + * Both the write side (walrus-upload-journal.ts's durable register step, + * walrus-upload.ts's legacy register step) and the read side + * (walrus-query.ts's scanOwnerForJobBlob) must import this same constant + * rather than each hardcoding the string literal — they previously didn't, + * and drifted: the durable path wrote `memwal_migration_job` (an unrelated + * constant from the V1->V2 migration feature) while the reconcile scan + * searched for `memwal_job_id`, so the two could never match and the + * durable path's crash-recovery guarantee silently did nothing. + */ +export const MEMWAL_JOB_TAG_KEY = "memwal_job_id"; + /** * Test-only lost-response window after a durable side effect has completed. * From c16f64df13eea00bc2c49b3f7965b2e0cea1d9ef Mon Sep 17 00:00:00 2001 From: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:36:12 +0700 Subject: [PATCH 02/32] test(sdk): live e2e suite against the relayer Mirrors the Python SDK's tests/test_integration.py: no-auth health, compatibility and auth-rejection checks that always run, plus authenticated remember/recall/analyze/restore coverage (and the JS-only bulk, manual-mode and embed surfaces) that skips without MEMWAL_PRIVATE_KEY / MEMWAL_ACCOUNT_ID. Authenticated writes land in a per-run sdk-e2e- namespace so the shared bench account's real namespaces stay clean, and remember waits get 120s of headroom over the SDK's 60s default (a live write measures ~44s on dev). Named *.e2e.mjs so the offline unit glob (test/**/*.test.mjs) never picks the suite up; run it via the new test:e2e script. [WALM-353] --- packages/sdk/package.json | 3 +- packages/sdk/test/e2e/live.e2e.mjs | 368 +++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/test/e2e/live.e2e.mjs diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e207e6995..9d87d605b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -27,7 +27,8 @@ "build": "tsc", "dev": "tsc --watch", "typecheck": "tsc --noEmit", - "test": "tsc && node --test \"test/**/*.test.mjs\"" + "test": "tsc && node --test \"test/**/*.test.mjs\"", + "test:e2e": "tsc && node --test \"test/e2e/*.e2e.mjs\"" }, "dependencies": { "@noble/ed25519": "^2.3.0", diff --git a/packages/sdk/test/e2e/live.e2e.mjs b/packages/sdk/test/e2e/live.e2e.mjs new file mode 100644 index 000000000..c2b3cf501 --- /dev/null +++ b/packages/sdk/test/e2e/live.e2e.mjs @@ -0,0 +1,368 @@ +/** + * E2E tests for the Walrus Memory JS SDK against a live relayer. + * + * Targets MEMWAL_SERVER_URL (default: https://relayer-staging.memory.walrus.xyz). + * The structure mirrors the Python SDK's tests/test_integration.py so the two + * suites stay comparable; `ask()` has no JS equivalent (its analogue is the + * `ai/` integration, which needs a model provider and is out of scope here). + * + * No-auth tests (always run, no env vars needed): + * - /health endpoint + * - Compatibility contract (GET /version) accepts this SDK + * - Unsigned request → 401 + * - Wrong signature → 401 + * - Expired timestamp → 401 + * - Future timestamp → 401 + * - Unregistered key → SDK error carrying the 401/403 + * + * Authenticated tests (require MEMWAL_PRIVATE_KEY + MEMWAL_ACCOUNT_ID): + * - remember() acceptance, rememberAndWait(), namespace handling + * - recall() + * - analyze() / analyzeAndWait() + * - rememberBulkAndWait() + * - embed() + manual mode (rememberManual → recallManual) + * - restore() + * - Full e2e: remember → recall → verify + * + * Usage: + * # Run only no-auth tests (no keys needed) + * pnpm --filter @mysten-incubation/memwal test:e2e + * + * # Run full suite with real credentials + * MEMWAL_PRIVATE_KEY= MEMWAL_ACCOUNT_ID=0x... \ + * pnpm --filter @mysten-incubation/memwal test:e2e + * + * # Point at a specific relayer + * MEMWAL_SERVER_URL=https://relayer.dev.memwal.ai ... + */ + +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import * as ed from "@noble/ed25519"; + +import { MemWal, MemWalCompatibilityError } from "../../dist/index.js"; +import { bytesToHex, sha256hex } from "../../dist/utils.js"; + +// ── Config ─────────────────────────────────────────────────────────────────── + +const SERVER_URL = (process.env.MEMWAL_SERVER_URL ?? "https://relayer-staging.memory.walrus.xyz").replace(/\/$/, ""); +const PRIVATE_KEY_HEX = process.env.MEMWAL_PRIVATE_KEY ?? ""; +const ACCOUNT_ID = process.env.MEMWAL_ACCOUNT_ID ?? ""; + +// A live write runs embed -> SEAL encrypt -> Walrus upload -> on-chain metadata. +// Measured around 44s against the dev relayer, so the SDK's 60s default leaves +// too little headroom to be reliable in CI. 120s matches what the SDK already +// uses for bulk pipelines. +const REMEMBER_TIMEOUT_MS = Number(process.env.MEMWAL_REMEMBER_TIMEOUT_MS ?? "120000"); + +// Every authenticated test writes into a namespace unique to this run. The bench +// account is shared, and `default` in particular is what real users get, so a +// recurring job must not leave live Walrus blobs there. +const E2E_NAMESPACE = `sdk-e2e-${randomUUID().replaceAll("-", "").slice(0, 8)}`; +const E2E_NAMESPACE_ALT = `${E2E_NAMESPACE}-alt`; + +const HAS_KEY = Boolean(PRIVATE_KEY_HEX && ACCOUNT_ID); + +// node:test skips the test (with this reason) when the value is a string. +const requiresKey = HAS_KEY ? false : "MEMWAL_PRIVATE_KEY and MEMWAL_ACCOUNT_ID not set"; + +function client(namespace = E2E_NAMESPACE) { + return MemWal.create({ + key: PRIVATE_KEY_HEX, + accountId: ACCOUNT_ID, + serverUrl: SERVER_URL, + namespace, + }); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Make a raw signed request without using the SDK (for auth rejection tests). + * Message format matches memwal.ts signedRequest(): + * "{timestamp}.{method}.{path}.{body_sha256}.{nonce}.{account_id}" + */ +async function rawSignedRequest(method, path, body, privateKey, overrides = {}) { + const bodyStr = JSON.stringify(body); + const bodySha256 = await sha256hex(bodyStr); + const timestamp = overrides.timestamp ?? Math.floor(Date.now() / 1000).toString(); + const nonce = randomUUID(); + const accountId = ACCOUNT_ID || "0x0"; + const message = `${timestamp}.${method}.${path}.${bodySha256}.${nonce}.${accountId}`; + const signature = await ed.signAsync(new TextEncoder().encode(message), privateKey); + const publicKeyHex = overrides.publicKeyHex ?? bytesToHex(await ed.getPublicKeyAsync(privateKey)); + + return fetch(`${SERVER_URL}${path}`, { + method, + headers: { + "Content-Type": "application/json", + "x-public-key": publicKeyHex, + "x-signature": bytesToHex(signature), + "x-timestamp": timestamp, + "x-nonce": nonce, + "x-account-id": accountId, + }, + body: bodyStr, + }); +} + +const REJECTION_BODY = { text: "hello", namespace: "default" }; + +// ── No-auth tests (always run) ─────────────────────────────────────────────── + +test("health returns ok with a version string", async () => { + const mw = MemWal.create({ key: "aa".repeat(32), accountId: "0x0", serverUrl: SERVER_URL }); + const result = await mw.health(); + assert.equal(result.status, "ok", `Expected 'ok', got '${result.status}'`); + assert.equal(typeof result.version, "string"); +}); + +test("compatibility contract accepts this SDK version", async () => { + // compatibility() both fetches GET /version and validates it against this + // SDK's compatibility version — a MemWalCompatibilityError here means the + // live relayer has dropped support for the SDK as released. + const mw = MemWal.create({ key: "aa".repeat(32), accountId: "0x0", serverUrl: SERVER_URL }); + const result = await mw.compatibility(); + assert.equal(typeof result.relayerVersion, "string"); + assert.equal(typeof result.apiVersion, "string"); + assert.equal(typeof result.minSupportedSdk.typescript, "string"); +}); + +test("unsigned request is rejected with 401", async () => { + const res = await fetch(`${SERVER_URL}/api/remember`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(REJECTION_BODY), + }); + assert.equal(res.status, 401, `Expected 401, got ${res.status}: ${await res.text()}`); +}); + +test("wrong signature is rejected with 401", async () => { + // Sign with key A but claim key B's public key. + const keyA = ed.utils.randomPrivateKey(); + const keyB = ed.utils.randomPrivateKey(); + const res = await rawSignedRequest("POST", "/api/remember", REJECTION_BODY, keyA, { + publicKeyHex: bytesToHex(await ed.getPublicKeyAsync(keyB)), + }); + assert.equal(res.status, 401, `Expected 401, got ${res.status}: ${await res.text()}`); +}); + +test("expired timestamp is rejected with 401", async () => { + const key = ed.utils.randomPrivateKey(); + const tenMinutesAgo = String(Math.floor(Date.now() / 1000) - 600); + const res = await rawSignedRequest("POST", "/api/remember", REJECTION_BODY, key, { + timestamp: tenMinutesAgo, + }); + assert.equal(res.status, 401, `Expected 401, got ${res.status}: ${await res.text()}`); +}); + +test("future timestamp is rejected with 401", async () => { + const key = ed.utils.randomPrivateKey(); + const tenMinutesAhead = String(Math.floor(Date.now() / 1000) + 600); + const res = await rawSignedRequest("POST", "/api/remember", REJECTION_BODY, key, { + timestamp: tenMinutesAhead, + }); + assert.equal(res.status, 401, `Expected 401, got ${res.status}: ${await res.text()}`); +}); + +test("SDK surfaces an unregistered key as an auth error", async (t) => { + const mw = MemWal.create({ + key: "bb".repeat(32), // random, not registered on-chain + accountId: "0x0", + serverUrl: SERVER_URL, + }); + try { + await mw.remember("hello"); + assert.fail("Expected remember() with an unregistered key to throw"); + } catch (err) { + if (err instanceof MemWalCompatibilityError) { + t.skip("live relayer does not expose compatibility metadata yet"); + return; + } + const status = err.status ?? 0; + assert.ok( + status === 401 || status === 403 || /\b40[13]\b/.test(String(err.message)), + `Expected a 401/403 auth rejection, got: ${err.message}`, + ); + } +}); + +// ── Authenticated tests ────────────────────────────────────────────────────── + +test("remember returns a job id and an accepted status", { skip: requiresKey }, async () => { + const mw = client(); + const result = await mw.remember("Integration test: the sky is blue"); + assert.equal(typeof result.job_id, "string"); + assert.ok(result.job_id.length > 0); + assert.ok(["pending", "running"].includes(result.status), `unexpected status: ${result.status}`); + + // One-shot status lookup on the accepted job (no polling). + const status = await mw.getRememberStatus(result.job_id); + assert.equal(status.job_id, result.job_id); + assert.ok( + ["pending", "running", "uploaded", "done"].includes(status.status), + `unexpected job status: ${status.status}`, + ); +}); + +test("rememberAndWait returns blob and owner", { skip: requiresKey }, async () => { + const mw = client(); + const result = await mw.rememberAndWait("Integration test: the sky is blue", undefined, { + timeoutMs: REMEMBER_TIMEOUT_MS, + }); + assert.equal(typeof result.id, "string"); + assert.ok(result.id.length > 0); + assert.equal(typeof result.blob_id, "string"); + assert.ok(result.blob_id.length > 0); + assert.ok(result.owner.startsWith("0x")); +}); + +test("remember uses the client namespace when none is passed", { skip: requiresKey }, async () => { + // Omitting `namespace` falls back to the one the client was built with. + // The literal `"default"` fallback is asserted in the mocked suite; proving + // it here would mean writing a live blob into the namespace real users get. + const mw = client(); + const result = await mw.rememberAndWait("Integration test: namespace fallback", undefined, { + timeoutMs: REMEMBER_TIMEOUT_MS, + }); + assert.equal(result.namespace, E2E_NAMESPACE); +}); + +test("remember honors a per-call namespace override", { skip: requiresKey }, async () => { + const mw = client(); + const result = await mw.rememberAndWait( + "Integration test: custom namespace", + E2E_NAMESPACE_ALT, + { timeoutMs: REMEMBER_TIMEOUT_MS }, + ); + assert.equal(result.namespace, E2E_NAMESPACE_ALT); +}); + +test("recall returns results with the expected fields", { skip: requiresKey }, async () => { + const mw = client(); + const result = await mw.recall({ query: "sky blue", limit: 5 }); + assert.ok(Array.isArray(result.results)); + assert.ok(result.total >= 0); + for (const memory of result.results) { + assert.equal(typeof memory.text, "string"); + assert.equal(typeof memory.blob_id, "string"); + assert.equal(typeof memory.distance, "number"); + } +}); + +test("recall respects the limit", { skip: requiresKey }, async () => { + const mw = client(); + const result = await mw.recall({ query: "test", limit: 2 }); + assert.ok(result.results.length <= 2); +}); + +test("analyze extracts facts", { skip: requiresKey }, async () => { + const mw = client(); + const result = await mw.analyze("I love hiking and my favorite food is pho."); + assert.ok(Array.isArray(result.facts)); + assert.ok(result.fact_count >= 0); + assert.ok(result.owner.startsWith("0x")); + for (const fact of result.facts) { + assert.equal(typeof fact.text, "string"); + } +}); + +test("analyzeAndWait stores every extracted fact", { skip: requiresKey }, async () => { + const mw = client(); + const result = await mw.analyzeAndWait( + "I moved to Lisbon last spring and I play tennis every Saturday.", + undefined, + { timeoutMs: REMEMBER_TIMEOUT_MS }, + ); + assert.equal(result.results.length, result.facts.length); + assert.equal(result.failed, 0, `analyze facts failed to store: ${JSON.stringify(result.results)}`); + assert.equal(result.succeeded, result.facts.length); +}); + +test("rememberBulkAndWait stores a batch", { skip: requiresKey }, async () => { + const mw = client(); + const result = await mw.rememberBulkAndWait( + [ + { text: "Bulk e2e test: I drink oat-milk coffee" }, + { text: "Bulk e2e test: my desk faces a window" }, + ], + { timeoutMs: REMEMBER_TIMEOUT_MS }, + ); + assert.equal(result.total, 2); + assert.equal(result.failed, 0, `bulk items failed: ${JSON.stringify(result.results)}`); + assert.equal(result.succeeded, 2); + for (const item of result.results) { + assert.equal(item.status, "done"); + assert.ok(item.blob_id.length > 0); + assert.equal(item.namespace, E2E_NAMESPACE); + } +}); + +test("embed returns a numeric vector", { skip: requiresKey }, async () => { + const mw = client(); + const result = await mw.embed("The quick brown fox jumps over the lazy dog"); + assert.ok(Array.isArray(result.vector)); + assert.ok(result.vector.length > 0); + assert.ok(result.vector.every((v) => Number.isFinite(v))); +}); + +test("manual mode round-trips a vector to its blob id", { skip: requiresKey }, async () => { + // Manual mode: the caller owns SEAL + Walrus, the relayer only stores the + // vector ↔ blob_id mapping — so a synthetic blob id round-trips fine and + // nothing is uploaded. Both requests transmit no SEAL credential. + const mw = client(); + const marker = randomUUID().replaceAll("-", "").slice(0, 8); + const blobId = `manual-e2e-${marker}`; + + const { vector } = await mw.embed(`Manual mode e2e test ${marker}`); + const stored = await mw.rememberManual({ blobId, vector }); + assert.equal(stored.blob_id, blobId); + assert.ok(stored.owner.startsWith("0x")); + assert.equal(stored.namespace, E2E_NAMESPACE); + + const hits = await mw.recallManual({ vector, limit: 5 }); + assert.ok(hits.total >= 1, `Expected >= 1 manual hit, got ${hits.total}`); + assert.ok( + hits.results.some((hit) => hit.blob_id === blobId), + `Expected blob '${blobId}' in hits: ${JSON.stringify(hits.results)}`, + ); +}); + +test("restore reports counts for the run namespace", { skip: requiresKey }, async () => { + // Everything this run stored is already indexed on the relayer, so restore + // has no work to do — assert the response shape rather than exact counts: + // candidate discovery is capped per-owner across namespaces (WALM-319), so + // `total` for a fresh namespace on the shared bench account is not stable. + const mw = client(); + const result = await mw.restore(E2E_NAMESPACE); + assert.equal(result.namespace, E2E_NAMESPACE); + assert.ok(Number.isInteger(result.restored) && result.restored >= 0); + assert.ok(Number.isInteger(result.skipped) && result.skipped >= 0); + assert.ok(Number.isInteger(result.total) && result.total >= 0); + assert.equal(typeof result.truncated, "boolean"); + assert.ok(result.owner.startsWith("0x")); +}); + +// ── Full flow ──────────────────────────────────────────────────────────────── + +test("full flow: remember then recall finds it", { skip: requiresKey }, async () => { + const unique = randomUUID().replaceAll("-", "").slice(0, 8); + const text = `SDK e2e test ${unique}: quantum entanglement in photonics`; + const namespace = `sdk-e2e-${unique}`; + + const mw = client(); + + // Store a distinctive memory in an isolated namespace. + const memory = await mw.rememberAndWait(text, namespace, { timeoutMs: REMEMBER_TIMEOUT_MS }); + assert.ok(memory.id.length > 0); + + // Recall — should find the stored memory. + const result = await mw.recall({ query: `quantum photonics ${unique}`, limit: 5, namespace }); + assert.ok(result.total >= 1, `Expected >= 1 result, got ${result.total}`); + assert.ok( + result.results.some((r) => r.text.includes(unique)), + `Expected unique marker '${unique}' in recalled texts: ${JSON.stringify(result.results.map((r) => r.text))}`, + ); +}); From abae31b34b2dc93634370e475ded37e7afd6f1e9 Mon Sep 17 00:00:00 2001 From: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:36:20 +0700 Subject: [PATCH 03/32] ci(sdk): unit test and e2e workflow against the dev relayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling of test-python-sdk.yml. The unit job runs the offline suite on Node 22 and 24 for every PR touching packages/sdk — closing the gap where SDK unit tests never ran in CI at all. The e2e job reuses the benchmark-dev environment credentials, is limited to dev pushes, manual dispatch and a weekly cron (PRs are excluded: fork PRs get no environment secrets, and every authenticated run writes real memories), fails loudly when credentials are missing instead of green-skipping, and uploads a junit artifact plus a run summary. The cron sits 30 minutes after the Python suite's slot so the two weekly runs don't write through the shared bench account at the same time. [WALM-353] --- .github/workflows/test-sdk.yml | 160 +++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 .github/workflows/test-sdk.yml diff --git a/.github/workflows/test-sdk.yml b/.github/workflows/test-sdk.yml new file mode 100644 index 000000000..4e162954c --- /dev/null +++ b/.github/workflows/test-sdk.yml @@ -0,0 +1,160 @@ +name: Test JS SDK + +on: + pull_request: + paths: + - 'packages/sdk/**' + - '.github/workflows/test-sdk.yml' + push: + branches: + - main + - staging + - dev + paths: + - 'packages/sdk/**' + - '.github/workflows/test-sdk.yml' + workflow_dispatch: + schedule: + # Catches relayer drift with no code change to trigger it. Offset 30 + # minutes from test-python-sdk.yml (17 9 * * 1) so the two weekly suites + # don't write through the shared bench account at the same time. + - cron: '47 9 * * 1' + +concurrency: + group: test-sdk-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + unit: + name: Unit / Node ${{ matrix.node-version }} + runs-on: ubuntu-latest + timeout-minutes: 10 + + strategy: + fail-fast: false + matrix: + # 22 is what the rest of CI runs on; 24 is the active LTS. + node-version: ['22', '24'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Typecheck, build and unit tests + run: pnpm --filter @mysten-incubation/memwal test + + e2e: + name: E2E / dev relayer + runs-on: ubuntu-latest + needs: unit + timeout-minutes: 30 + + # Dev only for now; staging and production follow once their credentials + # exist. Pull requests are excluded because environment secrets are + # withheld from fork PRs, and every authenticated run writes real memories. + if: >- + github.event_name == 'workflow_dispatch' || + github.event_name == 'schedule' || + (github.event_name == 'push' && github.ref_name == 'dev') + + # Reuses the credentials benchmark-live.yml and test-python-sdk.yml already + # rely on, so this needs no new secrets. Safe to share: the benchmark + # writes to the `benchmark` namespace while these tests use a per-run + # `sdk-e2e-`. + environment: benchmark-dev + + env: + # BENCH_DELEGATE_KEY is the delegate private key the SDK signs with, + # which test/e2e/live.e2e.mjs reads as MEMWAL_PRIVATE_KEY. + MEMWAL_PRIVATE_KEY: ${{ secrets.BENCH_DELEGATE_KEY }} + MEMWAL_ACCOUNT_ID: ${{ secrets.BENCH_ACCOUNT_ID }} + # Public URL (docs/relayer/benchmark-ci-setup.md), defaulted so a missing + # variable cannot silently point the run at the wrong relayer. + MEMWAL_SERVER_URL: ${{ vars.BENCH_SERVER_URL || 'https://relayer.dev.memwal.ai' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: pnpm + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm --filter @mysten-incubation/memwal build + + - name: Check credentials + id: config + shell: bash + run: | + set -euo pipefail + + # Without these the suite skips every authenticated test, and the + # job would report success having never exercised the relayer + # beyond /health. Fail instead of reporting a green run that + # proved nothing. + missing=0 + for name in MEMWAL_PRIVATE_KEY MEMWAL_ACCOUNT_ID; do + if [ -z "${!name:-}" ]; then + echo "::error::${name} is empty — set BENCH_DELEGATE_KEY / BENCH_ACCOUNT_ID on the benchmark-dev environment." + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + exit 1 + fi + echo "authenticated=true" >> "$GITHUB_OUTPUT" + + - name: E2E tests + id: e2e + working-directory: packages/sdk + run: | + node --test \ + --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=junit --test-reporter-destination=e2e-results.xml \ + "test/e2e/live.e2e.mjs" + + - name: Write run summary + if: always() + run: | + { + echo "## JS SDK e2e" + echo + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Relayer | \`${MEMWAL_SERVER_URL}\` |" + echo "| Authenticated | ${{ steps.config.outputs.authenticated }} |" + echo "| Result | ${{ steps.e2e.outcome }} |" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload e2e results + if: always() + uses: actions/upload-artifact@v4 + with: + name: js-sdk-e2e-${{ github.run_id }} + path: packages/sdk/e2e-results.xml + if-no-files-found: warn + retention-days: 14 From e2fb815c2ef6dde8058a9b1d34a9cce4ee428cd1 Mon Sep 17 00:00:00 2001 From: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:50:08 +0700 Subject: [PATCH 04/32] test(sdk): drop e2e coverage for two unserved SDK contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the authenticated half — which cannot run locally without the bench credentials — caught two tests that were guaranteed to go red on the first CI run: - embed() POSTs /api/embed, which is absent from the relayer's protected route table entirely. - rememberManual() sends blob_id, but RememberManualRequest requires encrypted_data and has no blob_id field, so axum 422s the call before the handler sees it. Both methods are covered only by MemWalMock today, which is why the drift went unnoticed. The suite documents the omission inline rather than deleting it silently; the SDK bug is tracked in WALM-371. Also hardens the remaining live tests: the analyze fan-out gets twice the single-write budget (N facts, one wallet-job pipeline each, on a contended shared account), the one-shot status probe tolerates a background 'failed' since it asserts acceptance rather than pipeline health, and MEMWAL_REMEMBER_TIMEOUT_MS now rejects unparseable input instead of silently becoming NaN. [WALM-353] --- packages/sdk/test/e2e/live.e2e.mjs | 68 +++++++++++++++--------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/sdk/test/e2e/live.e2e.mjs b/packages/sdk/test/e2e/live.e2e.mjs index c2b3cf501..e69445f09 100644 --- a/packages/sdk/test/e2e/live.e2e.mjs +++ b/packages/sdk/test/e2e/live.e2e.mjs @@ -20,7 +20,6 @@ * - recall() * - analyze() / analyzeAndWait() * - rememberBulkAndWait() - * - embed() + manual mode (rememberManual → recallManual) * - restore() * - Full e2e: remember → recall → verify * @@ -55,7 +54,24 @@ const ACCOUNT_ID = process.env.MEMWAL_ACCOUNT_ID ?? ""; // Measured around 44s against the dev relayer, so the SDK's 60s default leaves // too little headroom to be reliable in CI. 120s matches what the SDK already // uses for bulk pipelines. -const REMEMBER_TIMEOUT_MS = Number(process.env.MEMWAL_REMEMBER_TIMEOUT_MS ?? "120000"); +function positiveIntEnv(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive number of milliseconds, got '${raw}'`); + } + return parsed; +} + +const REMEMBER_TIMEOUT_MS = positiveIntEnv("MEMWAL_REMEMBER_TIMEOUT_MS", 120_000); + +// analyze() fans one input out into N facts, each its own full write pipeline. +// They round-robin across relayer wallet slots and usually overlap, but the +// bench account is shared, so a contended pool can push a 3-fact extraction +// past the single-write budget. Give the fan-out twice the headroom rather +// than letting a slow-but-healthy run report failed jobs. +const ANALYZE_TIMEOUT_MS = REMEMBER_TIMEOUT_MS * 2; // Every authenticated test writes into a namespace unique to this run. The bench // account is shared, and `default` in particular is what real users get, so a @@ -198,11 +214,15 @@ test("remember returns a job id and an accepted status", { skip: requiresKey }, assert.ok(result.job_id.length > 0); assert.ok(["pending", "running"].includes(result.status), `unexpected status: ${result.status}`); - // One-shot status lookup on the accepted job (no polling). + // One-shot status lookup on the accepted job (no polling). This asserts the + // acceptance contract — the job is known to the relayer and reports a real + // state — so `failed` is tolerated here: whether the background pipeline + // succeeds is what rememberAndWait covers. `not_found` is the failure that + // matters, since it means the accepted job_id addresses nothing. const status = await mw.getRememberStatus(result.job_id); assert.equal(status.job_id, result.job_id); assert.ok( - ["pending", "running", "uploaded", "done"].includes(status.status), + ["pending", "running", "uploaded", "done", "failed"].includes(status.status), `unexpected job status: ${status.status}`, ); }); @@ -274,7 +294,7 @@ test("analyzeAndWait stores every extracted fact", { skip: requiresKey }, async const result = await mw.analyzeAndWait( "I moved to Lisbon last spring and I play tennis every Saturday.", undefined, - { timeoutMs: REMEMBER_TIMEOUT_MS }, + { timeoutMs: ANALYZE_TIMEOUT_MS }, ); assert.equal(result.results.length, result.facts.length); assert.equal(result.failed, 0, `analyze facts failed to store: ${JSON.stringify(result.results)}`); @@ -296,39 +316,19 @@ test("rememberBulkAndWait stores a batch", { skip: requiresKey }, async () => { for (const item of result.results) { assert.equal(item.status, "done"); assert.ok(item.blob_id.length > 0); + // Client-side bookkeeping, not a server echo: the bulk status endpoint + // returns no namespace, so the SDK fills this in from the request. assert.equal(item.namespace, E2E_NAMESPACE); } }); -test("embed returns a numeric vector", { skip: requiresKey }, async () => { - const mw = client(); - const result = await mw.embed("The quick brown fox jumps over the lazy dog"); - assert.ok(Array.isArray(result.vector)); - assert.ok(result.vector.length > 0); - assert.ok(result.vector.every((v) => Number.isFinite(v))); -}); - -test("manual mode round-trips a vector to its blob id", { skip: requiresKey }, async () => { - // Manual mode: the caller owns SEAL + Walrus, the relayer only stores the - // vector ↔ blob_id mapping — so a synthetic blob id round-trips fine and - // nothing is uploaded. Both requests transmit no SEAL credential. - const mw = client(); - const marker = randomUUID().replaceAll("-", "").slice(0, 8); - const blobId = `manual-e2e-${marker}`; - - const { vector } = await mw.embed(`Manual mode e2e test ${marker}`); - const stored = await mw.rememberManual({ blobId, vector }); - assert.equal(stored.blob_id, blobId); - assert.ok(stored.owner.startsWith("0x")); - assert.equal(stored.namespace, E2E_NAMESPACE); - - const hits = await mw.recallManual({ vector, limit: 5 }); - assert.ok(hits.total >= 1, `Expected >= 1 manual hit, got ${hits.total}`); - assert.ok( - hits.results.some((hit) => hit.blob_id === blobId), - `Expected blob '${blobId}' in hits: ${JSON.stringify(hits.results)}`, - ); -}); +// `embed()` and the lightweight manual mode (`rememberManual` / `recallManual`) +// are deliberately NOT covered here: both SDK methods target contracts this +// relayer does not serve. `/api/embed` is absent from the protected route table +// (services/server/src/main.rs), and `RememberManualRequest` +// (services/server/src/types.rs) requires `encrypted_data` where the SDK sends +// `blob_id`, so the call 422s before reaching the handler. Tests for them would +// be guaranteed red on the first authenticated run. Tracked in WALM-371. test("restore reports counts for the run namespace", { skip: requiresKey }, async () => { // Everything this run stored is already indexed on the relayer, so restore From 43b5f772bcaa03472b0396d5c41afd708edbee8d Mon Sep 17 00:00:00 2001 From: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:05:30 +0700 Subject: [PATCH 05/32] ci(sdk): run the e2e suite against dev, staging and production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-only restriction was inherited from test-python-sdk.yml, whose comment justified it as 'staging and production follow once their credentials exist'. That premise no longer holds: benchmark-staging and benchmark-production both carry BENCH_DELEGATE_KEY, BENCH_ACCOUNT_ID and BENCH_SERVER_URL today. Each long-lived branch now tests the deployment it corresponds to — dev, staging, main -> production — and workflow_dispatch takes an explicit environment choice, following the selection pattern benchmark-live.yml already uses. Scheduled runs stay on dev, since cron fires on the default branch, so production is only reached by an explicit main push or a deliberate dispatch. Drops the hardcoded relayer URL fallback. With one environment a literal default guarded against an unset variable; with three it becomes the hazard it was meant to prevent, since a production run with a missing variable would silently exercise dev. The credential check now requires MEMWAL_SERVER_URL and fails the job without it. The suite is safe to point at production because every authenticated write is confined to a per-run sdk-e2e- namespace; the only reference to 'default' is the body of the 401-rejection tests, which never write. [WALM-353] --- .github/workflows/test-sdk.yml | 61 ++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test-sdk.yml b/.github/workflows/test-sdk.yml index 4e162954c..3e3c84e37 100644 --- a/.github/workflows/test-sdk.yml +++ b/.github/workflows/test-sdk.yml @@ -14,6 +14,16 @@ on: - 'packages/sdk/**' - '.github/workflows/test-sdk.yml' workflow_dispatch: + inputs: + target_environment: + description: Benchmark environment to run the authenticated e2e suite against + required: true + type: choice + default: dev + options: + - dev + - staging + - production schedule: # Catches relayer drift with no code change to trigger it. Offset 30 # minutes from test-python-sdk.yml (17 9 * * 1) so the two weekly suites @@ -59,33 +69,48 @@ jobs: run: pnpm --filter @mysten-incubation/memwal test e2e: - name: E2E / dev relayer + name: E2E / ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || (github.ref_name == 'main' && 'production' || github.ref_name == 'staging' && 'staging' || 'dev') }} relayer runs-on: ubuntu-latest needs: unit timeout-minutes: 30 - # Dev only for now; staging and production follow once their credentials - # exist. Pull requests are excluded because environment secrets are - # withheld from fork PRs, and every authenticated run writes real memories. + # Each long-lived branch tests the deployment it corresponds to. Pull + # requests are excluded because environment secrets are withheld from fork + # PRs, and every authenticated run writes real memories. if: >- github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || - (github.event_name == 'push' && github.ref_name == 'dev') + (github.event_name == 'push' && + (github.ref_name == 'dev' || + github.ref_name == 'staging' || + github.ref_name == 'main')) # Reuses the credentials benchmark-live.yml and test-python-sdk.yml already # rely on, so this needs no new secrets. Safe to share: the benchmark # writes to the `benchmark` namespace while these tests use a per-run # `sdk-e2e-`. - environment: benchmark-dev + # + # main maps to benchmark-production, so a push to main writes real + # memories through the live Walrus pipeline on the production benchmark + # account. That is intentional, and the per-run namespace is what keeps it + # contained — never point this job at an account holding user data, and + # never relax the namespace isolation in live.e2e.mjs. + environment: + name: benchmark-${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || (github.ref_name == 'main' && 'production' || github.ref_name == 'staging' && 'staging' || 'dev') }} env: + # Which deployment this run targets. Mirrors the `environment:` name + # above so error messages and the run summary can name it. + ENVIRONMENT_NAME: benchmark-${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || (github.ref_name == 'main' && 'production' || github.ref_name == 'staging' && 'staging' || 'dev') }} # BENCH_DELEGATE_KEY is the delegate private key the SDK signs with, # which test/e2e/live.e2e.mjs reads as MEMWAL_PRIVATE_KEY. MEMWAL_PRIVATE_KEY: ${{ secrets.BENCH_DELEGATE_KEY }} MEMWAL_ACCOUNT_ID: ${{ secrets.BENCH_ACCOUNT_ID }} - # Public URL (docs/relayer/benchmark-ci-setup.md), defaulted so a missing - # variable cannot silently point the run at the wrong relayer. - MEMWAL_SERVER_URL: ${{ vars.BENCH_SERVER_URL || 'https://relayer.dev.memwal.ai' }} + # Public URL, set per environment (docs/relayer/benchmark-ci-setup.md). + # Deliberately NOT defaulted: with three environments in play, a literal + # fallback would silently run the production job against whichever + # relayer the default names. A missing variable fails the job instead. + MEMWAL_SERVER_URL: ${{ vars.BENCH_SERVER_URL }} steps: - name: Checkout @@ -112,14 +137,15 @@ jobs: run: | set -euo pipefail - # Without these the suite skips every authenticated test, and the - # job would report success having never exercised the relayer - # beyond /health. Fail instead of reporting a green run that - # proved nothing. + # Without the credentials the suite skips every authenticated test, + # and the job would report success having never exercised the + # relayer beyond /health. Without the URL there is no safe guess to + # fall back on. Fail instead of reporting a green run that proved + # nothing, or running the wrong deployment. missing=0 - for name in MEMWAL_PRIVATE_KEY MEMWAL_ACCOUNT_ID; do + for name in MEMWAL_PRIVATE_KEY MEMWAL_ACCOUNT_ID MEMWAL_SERVER_URL; do if [ -z "${!name:-}" ]; then - echo "::error::${name} is empty — set BENCH_DELEGATE_KEY / BENCH_ACCOUNT_ID on the benchmark-dev environment." + echo "::error::${name} is empty — set BENCH_DELEGATE_KEY / BENCH_ACCOUNT_ID / BENCH_SERVER_URL on the ${ENVIRONMENT_NAME} environment." missing=1 fi done @@ -145,7 +171,8 @@ jobs: echo echo "| Field | Value |" echo "| --- | --- |" - echo "| Relayer | \`${MEMWAL_SERVER_URL}\` |" + echo "| Environment | \`${ENVIRONMENT_NAME}\` |" + echo "| Relayer | \`${MEMWAL_SERVER_URL:-}\` |" echo "| Authenticated | ${{ steps.config.outputs.authenticated }} |" echo "| Result | ${{ steps.e2e.outcome }} |" } >> "$GITHUB_STEP_SUMMARY" @@ -154,7 +181,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: js-sdk-e2e-${{ github.run_id }} + name: js-sdk-e2e-${{ env.ENVIRONMENT_NAME }}-${{ github.run_id }} path: packages/sdk/e2e-results.xml if-no-files-found: warn retention-days: 14 From e54b521fd6ebe8374f8b9208cb682ccf22b1cae9 Mon Sep 17 00:00:00 2001 From: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:57:47 +0700 Subject: [PATCH 06/32] fix(mcp): resolve credentials per project and warn before replacing an account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credentials lived in one global `~/.memwal/credentials.json`. Signing in from one project repointed every other project on the machine at a different account and delegate key, with the `label` field as the only visible signal and no warning at the point of use. Memories written in that state land on the wrong account, on immutable storage, with no delete path. A `.memwal/credentials.json` in the working directory now takes precedence over the global file, the way `.npmrc` and `.git/config` resolve. Presence-based on purpose: creating the local file is the opt-in, so a machine without one behaves exactly as before. Paths resolve per call rather than at module load, since the working directory is not knowable at import time. Replacing a DIFFERENT account now copies the outgoing file aside and reports both ids — the account replaced and the one now in use. There was no backup of any kind before, so an overwrite was unrecoverable. Same-account re-saves (a label change, a rotated delegate) are left alone rather than churning a backup per login. `saveCreds` returns what it did instead of printing directly: the persistence layer should not own user-facing output, and returning it keeps the behaviour testable without capturing stdout. Note on the recorded decision to refuse a different-account replacement unless forced: the incoming `accountId` only arrives in the browser callback, after the user approved and the delegate key was already registered on-chain. Refusing there would discard a registration that cost gas and already grants access, so this warns and backs up instead. A pre-approval notice belongs earlier in the flow, where the outgoing account and target path are already known. Refs #628 (WALM-361). --- .../gh-628-project-scoped-credentials.md | 11 ++ packages/mcp/src/auth.ts | 109 +++++++++-- packages/mcp/src/login.ts | 13 +- .../mcp/test/credential-resolution.test.mjs | 176 ++++++++++++++++++ 4 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 .changeset/gh-628-project-scoped-credentials.md create mode 100644 packages/mcp/test/credential-resolution.test.mjs diff --git a/.changeset/gh-628-project-scoped-credentials.md b/.changeset/gh-628-project-scoped-credentials.md new file mode 100644 index 000000000..bdbba3519 --- /dev/null +++ b/.changeset/gh-628-project-scoped-credentials.md @@ -0,0 +1,11 @@ +--- +"@mysten-incubation/memwal-mcp": patch +--- + +Resolve credentials per project, and stop silently replacing another account's (#628). + +Credentials lived in one global `~/.memwal/credentials.json`, so signing in from one project repointed every other project on the machine at a different account and delegate key. The only visible signal was the `label` field, and nothing warned at the point of use — memories written in that state would have landed on the wrong account, on immutable storage, with no delete path. + +A `.memwal/credentials.json` in the working directory now takes precedence over the global file, the way `.npmrc` and `.git/config` resolve. This is purely additive: a machine with no project-local file behaves exactly as before, and creating one is the opt-in. + +Signing in as a *different* account now also copies the outgoing file to `credentials.backup-.json` and prints both account ids — the one replaced and the one now in use. There was previously no backup of any kind, so an overwrite was unrecoverable. diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index a84d8ffc7..951cddd32 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -11,7 +11,15 @@ */ import { homedir } from "node:os"; import { join, dirname } from "node:path"; -import { mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync, existsSync } from "node:fs"; +import { + mkdirSync, + readFileSync, + writeFileSync, + chmodSync, + unlinkSync, + existsSync, + copyFileSync, +} from "node:fs"; export interface MemWalCredentials { /** 64-hex Ed25519 private key seed (32 bytes). NEVER log this. */ @@ -36,18 +44,44 @@ export interface MemWalCredentials { version: 1; } -const CREDS_DIR = join(homedir(), ".memwal"); -const CREDS_PATH = join(CREDS_DIR, "credentials.json"); +const CREDS_FILE = "credentials.json"; +/** Global, per-machine location. Always the fallback, and the only location + * before project-scoping existed — a machine with no project-local file keeps + * behaving exactly as it did. */ +function globalCredsPath(): string { + return join(homedir(), ".memwal", CREDS_FILE); +} + +/** Working-directory location, checked first. */ +function projectCredsPath(): string { + return join(process.cwd(), ".memwal", CREDS_FILE); +} + +/** + * Which credentials file this process should read and write. + * + * A project-local `.memwal/credentials.json` wins over the global one, the way + * `.npmrc` and `.git/config` resolve. Signing in from one project otherwise + * repoints every other project on the machine at a different account and + * delegate key, silently — memories then land on the wrong account, on + * immutable storage, with no delete path (GH #628). + * + * Presence-based on purpose: creating the local file is the opt-in, so this is + * purely additive. Resolved per call rather than at module load, because the + * working directory is not knowable at import time. + */ export function credsPath(): string { - return CREDS_PATH; + const project = projectCredsPath(); + return existsSync(project) ? project : globalCredsPath(); } /** Load credentials from disk. Returns null if missing or malformed. */ export function loadCreds(): MemWalCredentials | null { - if (!existsSync(CREDS_PATH)) return null; + const path = credsPath(); + if (!existsSync(path)) return null; try { - const raw = readFileSync(CREDS_PATH, "utf8"); + const raw = readFileSync(path, "utf8"); const parsed = JSON.parse(raw); if (!isValid(parsed)) return null; return parsed as MemWalCredentials; @@ -56,24 +90,73 @@ export function loadCreds(): MemWalCredentials | null { } } -/** Write credentials with secure (`0600`) permission. */ -export function saveCreds(creds: MemWalCredentials): void { - mkdirSync(dirname(CREDS_PATH), { recursive: true, mode: 0o700 }); - writeFileSync(CREDS_PATH, JSON.stringify(creds, null, 2), { encoding: "utf8", mode: 0o600 }); +/** + * Write credentials with secure (`0600`) permission, to whichever file + * `credsPath()` resolves to. + * + * Replacing a *different* account backs the outgoing file up first. There was + * no backup of any kind before, so an overwrite was unrecoverable — and the + * overwrite that matters is exactly the one that switches accounts (GH #628). + * Same-account rewrites (a label change, a rotated delegate) are not backed up: + * they are routine, and a backup per login would just churn the directory. + */ +export function saveCreds(creds: MemWalCredentials): SaveCredsResult { + const path = credsPath(); + const replaced = backupIfReplacingAnotherAccount(path, creds.accountId); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, JSON.stringify(creds, null, 2), { encoding: "utf8", mode: 0o600 }); // writeFileSync's `mode` argument is only honored on file creation; ensure // the permission on an existing file matches. try { - chmodSync(CREDS_PATH, 0o600); + chmodSync(path, 0o600); } catch { /* Windows etc. — best effort */ } + return { path, ...replaced }; +} + +/** What `saveCreds` did, so the caller can tell the user precisely — naming + * both accounts is the difference between a warning they can act on and the + * silent swap reported in GH #628. */ +export interface SaveCredsResult { + /** File actually written (project-local or global). */ + path: string; + /** Account whose credentials were displaced. Absent on a first sign-in or + * a same-account re-save. */ + replacedAccountId?: string; + /** Where the displaced file was copied. Absent when nothing was replaced, + * or when the copy failed. */ + backedUpTo?: string; +} + +/** Copy the current credentials aside when the incoming ones belong to a + * different account. Best effort: failing to back up must not block a login. */ +function backupIfReplacingAnotherAccount( + path: string, + incomingAccountId: string, +): { replacedAccountId?: string; backedUpTo?: string } { + if (!existsSync(path)) return {}; + const current = loadCreds(); + if (!current || current.accountId === incomingAccountId) return {}; + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backup = join(dirname(path), `credentials.backup-${stamp}.json`); + try { + copyFileSync(path, backup); + chmodSync(backup, 0o600); + return { replacedAccountId: current.accountId, backedUpTo: backup }; + } catch { + // Never block sign-in on a failed backup — but still report the + // replacement, since that is the part the user needs to know. + return { replacedAccountId: current.accountId }; + } } /** Delete credentials. No-op if the file does not exist. */ export function clearCreds(): void { - if (existsSync(CREDS_PATH)) { + const path = credsPath(); + if (existsSync(path)) { try { - unlinkSync(CREDS_PATH); + unlinkSync(path); } catch { /* swallow */ } diff --git a/packages/mcp/src/login.ts b/packages/mcp/src/login.ts index 02c779478..a44409bdf 100644 --- a/packages/mcp/src/login.ts +++ b/packages/mcp/src/login.ts @@ -403,7 +403,18 @@ export async function loginFlow(opts: LoginOptions = {}): Promise { + process.chdir(prevCwd); + process.env.HOME = prevHome; + process.env.USERPROFILE = prevProfile; + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + }); + + const auth = await import(`../dist/auth.js?walm361=${Date.now()}-${Math.random()}`); + return { auth, home, cwd }; +} + +test("a project-local credentials file takes precedence over the global one", async (t) => { + const { auth, cwd } = await sandbox(t, { + global: GLOBAL_ACCOUNT, + project: PROJECT_ACCOUNT, + }); + + assert.equal( + auth.loadCreds()?.accountId, + PROJECT_ACCOUNT, + "working-directory credentials should win", + ); + assert.equal(auth.credsPath(), join(cwd, ".memwal", "credentials.json")); +}); + +test("the global file is still used when the working directory has none", async (t) => { + const { auth, home } = await sandbox(t, { global: GLOBAL_ACCOUNT }); + + assert.equal( + auth.loadCreds()?.accountId, + GLOBAL_ACCOUNT, + "existing single-file setups must be unaffected", + ); + assert.equal(auth.credsPath(), join(home, ".memwal", "credentials.json")); +}); + +test("saveCreds writes back to the project-local file when that is the one in use", async (t) => { + const { auth, home, cwd } = await sandbox(t, { + global: GLOBAL_ACCOUNT, + project: PROJECT_ACCOUNT, + }); + + const updated = makeCreds(PROJECT_ACCOUNT, "Renamed"); + auth.saveCreds(updated); + + const projectFile = JSON.parse( + (await import("node:fs")).readFileSync(join(cwd, ".memwal", "credentials.json"), "utf8"), + ); + const globalFile = JSON.parse( + (await import("node:fs")).readFileSync(join(home, ".memwal", "credentials.json"), "utf8"), + ); + + assert.equal(projectFile.label, "Renamed", "the in-use file should be updated"); + assert.equal( + globalFile.accountId, + GLOBAL_ACCOUNT, + "the global file must not be touched when a project-local one is in use", + ); +}); + +test("replacing credentials for a different account backs up the outgoing file", async (t) => { + const { auth, home } = await sandbox(t, { global: GLOBAL_ACCOUNT }); + + auth.saveCreds(makeCreds(PROJECT_ACCOUNT, "Incoming")); + + const dir = join(home, ".memwal"); + const backups = (await import("node:fs")) + .readdirSync(dir) + .filter((f) => f.startsWith("credentials.backup")); + assert.equal(backups.length, 1, `expected one backup, saw: ${backups.join(", ")}`); + + const backed = JSON.parse( + (await import("node:fs")).readFileSync(join(dir, backups[0]), "utf8"), + ); + assert.equal( + backed.accountId, + GLOBAL_ACCOUNT, + "the backup must hold the credentials being replaced", + ); + assert.equal(existsSync(join(dir, "credentials.json")), true); +}); + +test("saveCreds reports what it replaced, so callers can warn with both account ids", async (t) => { + const { auth, home } = await sandbox(t, { global: GLOBAL_ACCOUNT }); + + const result = auth.saveCreds(makeCreds(PROJECT_ACCOUNT, "Incoming")); + + assert.equal(result.path, join(home, ".memwal", "credentials.json")); + assert.equal(result.replacedAccountId, GLOBAL_ACCOUNT); + assert.ok( + result.backedUpTo?.includes("credentials.backup"), + `expected a backup path, got ${result.backedUpTo}`, + ); +}); + +test("a same-account save reports no replacement and writes no backup", async (t) => { + const { auth, home } = await sandbox(t, { global: GLOBAL_ACCOUNT }); + + const result = auth.saveCreds(makeCreds(GLOBAL_ACCOUNT, "Relabelled")); + + assert.equal(result.replacedAccountId, undefined, "same account is not a replacement"); + assert.equal(result.backedUpTo, undefined, "routine re-saves must not churn backups"); + const dir = join(home, ".memwal"); + const backups = (await import("node:fs")) + .readdirSync(dir) + .filter((f) => f.startsWith("credentials.backup")); + assert.equal(backups.length, 0); +}); From f566d7d6ab69f718cfac5bcb1828b4a35c73666f Mon Sep 17 00:00:00 2001 From: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:01:48 +0700 Subject: [PATCH 07/32] fix(mcp): make the replacement notice testable and stop the success page lying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in the previous commit, both found by asking what was actually verified rather than what was written. The warning itself had no test. The unit tests asserted what `saveCreds` returns, not that anything is ever shown — so the user-facing half of the fix, which is the whole point of GH #628, rested on inspection. Extracted `formatReplacementNotice` as a pure function and covered both arms: a replacement names the outgoing account, the incoming one and the backup, and a first sign-in or same-account re-save stays quiet. The browser success page hardcoded "Credentials saved to ~/.memwal/credentials.json". Now that a project-local file can be the destination, that sentence was capable of naming a file the login had not touched — telling a user the wrong credential path is worse than telling them nothing, especially in the exact scenario this ticket exists to fix. It now reports the path actually written. Also verified with the real binary, which the unit tests do not cover: two projects sharing one HOME, one with a local `.memwal/` and one without, load different accounts. Same command, same environment, only the working directory differs. Refs #628 (WALM-361). --- packages/mcp/src/auth.ts | 23 +++++++++++++++ packages/mcp/src/login.ts | 24 ++++++---------- .../mcp/test/credential-resolution.test.mjs | 28 +++++++++++++++++++ 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index 951cddd32..59611359b 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -129,6 +129,29 @@ export interface SaveCredsResult { backedUpTo?: string; } +/** + * The message shown when a sign-in displaced a different account, or null when + * nothing was replaced. + * + * Both ids on purpose: "your credentials changed" is useless without knowing + * which account you left and which you are now on — that ambiguity is the whole + * of GH #628. Kept here as a pure function so the wording is testable without + * driving a browser login. + */ +export function formatReplacementNotice( + saved: SaveCredsResult, + incomingAccountId: string, +): string | null { + if (!saved.replacedAccountId) return null; + const lines = [ + `Replaced credentials for a DIFFERENT account in ${saved.path}:`, + ` was: ${saved.replacedAccountId}`, + ` now: ${incomingAccountId}`, + ]; + if (saved.backedUpTo) lines.push(` previous file backed up to ${saved.backedUpTo}`); + return lines.join("\n"); +} + /** Copy the current credentials aside when the incoming ones belong to a * different account. Best effort: failing to back up must not block a login. */ function backupIfReplacingAnotherAccount( diff --git a/packages/mcp/src/login.ts b/packages/mcp/src/login.ts index a44409bdf..d4239d03c 100644 --- a/packages/mcp/src/login.ts +++ b/packages/mcp/src/login.ts @@ -22,7 +22,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import open from "open"; import type { MemWalCredentials } from "./auth.js"; -import { saveCreds } from "./auth.js"; +import { saveCreds, formatReplacementNotice } from "./auth.js"; import { generateKeypair } from "./crypto.js"; import { log, note } from "./logger.js"; @@ -136,7 +136,10 @@ function normalizeUrl(url: string): string { return url.replace(/\/+$/, ""); } -const SUCCESS_HTML = ` +/** Built per login: with project-scoped credentials the destination is no + * longer always `~/.memwal/credentials.json`, and telling the user the wrong + * file is worse than telling them nothing. */ +const SUCCESS_HTML_TEMPLATE = (savedPath: string) => ` @@ -150,7 +153,7 @@ const SUCCESS_HTML = `

✓ Walrus Memory MCP connected

-

Credentials saved to ~/.memwal/credentials.json.

+

Credentials saved to ${savedPath}.

You can close this tab — your MCP client will pick up the new credentials automatically.

`; @@ -404,17 +407,8 @@ export async function loginFlow(opts: LoginOptions = {}): Promise server.close(), 100); diff --git a/packages/mcp/test/credential-resolution.test.mjs b/packages/mcp/test/credential-resolution.test.mjs index 769c8312f..a25aaf494 100644 --- a/packages/mcp/test/credential-resolution.test.mjs +++ b/packages/mcp/test/credential-resolution.test.mjs @@ -174,3 +174,31 @@ test("a same-account save reports no replacement and writes no backup", async (t .filter((f) => f.startsWith("credentials.backup")); assert.equal(backups.length, 0); }); + +test("the replacement notice names both accounts and the backup", async (t) => { + const { auth } = await sandbox(t, { global: GLOBAL_ACCOUNT }); + + const notice = auth.formatReplacementNotice( + { + path: "/home/u/.memwal/credentials.json", + replacedAccountId: GLOBAL_ACCOUNT, + backedUpTo: "/home/u/.memwal/credentials.backup-2026.json", + }, + PROJECT_ACCOUNT, + ); + + assert.ok(notice, "a replacement must produce a notice"); + assert.match(notice, new RegExp(GLOBAL_ACCOUNT), "must name the account being replaced"); + assert.match(notice, new RegExp(PROJECT_ACCOUNT), "must name the incoming account"); + assert.match(notice, /credentials\.backup-2026\.json/, "must point at the backup"); +}); + +test("no notice when nothing was replaced", async (t) => { + const { auth } = await sandbox(t, { global: GLOBAL_ACCOUNT }); + + assert.equal( + auth.formatReplacementNotice({ path: "/home/u/.memwal/credentials.json" }, GLOBAL_ACCOUNT), + null, + "a first sign-in or same-account re-save must stay quiet", + ); +}); From 83f1e9132387e283a592c34530e7f845c40e6fcf Mon Sep 17 00:00:00 2001 From: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:05:52 +0700 Subject: [PATCH 08/32] fix(mcp): stop `login` deleting credentials before the new ones exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the live check against the real binary showed the replacement warning never fired from `memwal-mcp login`, and no backup was written — while the unit tests were green. `login` called `clearCreds()` up front, purely so the `loadCreds()` below would return null and the sign-in flow would run. By the time `saveCreds` executed there was no file left to compare against, so the account change was invisible and there was nothing to back up. The destructive part was never needed: forcing a fresh sign-in means ignoring what is on disk, not deleting it. `login` now passes null instead, so the old file survives until a successful sign-in replaces it. That also closes a data-loss path that predates this ticket. Anyone who ran `memwal-mcp login` and then abandoned the browser flow, or whose login timed out or failed, was left with no credentials at all and nothing to recover from — the delegate key on disk was gone before the new one was ever issued. Adds the live check that caught it (`.live.mjs`, outside the `npm test` glob). It drives the real login process end to end — its own listener, preflight, callback parsing and save — and asserts on what the process actually printed. The browser half is driven programmatically, so no wallet approval and no on-chain registration; the replaced credentials are seeded locally, which is all the account comparison keys off. 10/10 pass. Refs #628 (WALM-361). --- packages/mcp/src/index.ts | 11 +- .../test/live/credential-replacement.live.mjs | 185 ++++++++++++++++++ 2 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 packages/mcp/test/live/credential-replacement.live.mjs diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index ca50aef80..88b37aa19 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -108,9 +108,6 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise env > default. const relayerUrl = @@ -131,7 +128,13 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise (out += d.toString())); +child.stderr.on("data", (d) => (out += d.toString())); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function waitForConnectUrl(timeoutMs = 20000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const m = out.match(/https:\/\/dev\.memwal\.ai\/connect\/mcp\?[^\s]+/); + if (m) return m[0].replace(/\r/g, ""); + await sleep(200); + } + throw new Error(`no connect URL\n--- output ---\n${out}`); +} + +let failures = 0; +function check(label, fn) { + try { + fn(); + console.log(` PASS ${label}`); + } catch (err) { + failures += 1; + console.log(` FAIL ${label}\n ${err.message}`); + } +} + +try { + const connectUrl = await waitForConnectUrl(); + const url = new URL(connectUrl); + const base = `http://127.0.0.1:${url.searchParams.get("port")}`; + const headers = { origin: url.origin, "content-type": "application/json" }; + + const preflight = await fetch(`${base}/preflight`, { + method: "POST", + headers, + body: JSON.stringify({ + state: url.searchParams.get("connectState"), + publicKey: url.searchParams.get("publicKey"), + relayer: url.searchParams.get("relayer"), + }), + }); + check("the real listener accepts a valid preflight", () => + assert.equal(preflight.status, 200), + ); + + const callback = await fetch(`${base}/callback`, { + method: "POST", + headers, + body: JSON.stringify({ + state: url.searchParams.get("connectState"), + accountId: INCOMING_ACCOUNT, + walletAddress: WALLET, + packageId: PACKAGE, + }), + }); + const successPage = await callback.text(); + check("the callback is accepted", () => assert.equal(callback.status, 200)); + + // The bug this guards: the page used to hardcode ~/.memwal/credentials.json. + check("the success page names the file actually written", () => + assert.ok( + successPage.includes(credsPath), + `page did not mention ${credsPath}`, + ), + ); + + await sleep(1500); + + check("the process warned that a DIFFERENT account was replaced", () => + assert.match(out, /Replaced credentials for a DIFFERENT account/), + ); + check("the warning names the outgoing account", () => + assert.ok(out.includes(OUTGOING_ACCOUNT), "outgoing account id missing from output"), + ); + check("the warning names the incoming account", () => + assert.ok(out.includes(INCOMING_ACCOUNT), "incoming account id missing from output"), + ); + + const dir = join(home, ".memwal"); + const backups = readdirSync(dir).filter((f) => f.startsWith("credentials.backup")); + check("a backup of the replaced credentials exists on disk", () => + assert.equal(backups.length, 1, `saw: ${backups.join(", ") || "none"}`), + ); + check("the backup holds the OUTGOING account", () => + assert.equal( + JSON.parse(readFileSync(join(dir, backups[0]), "utf8")).accountId, + OUTGOING_ACCOUNT, + ), + ); + check("the live file now holds the INCOMING account", () => + assert.equal(JSON.parse(readFileSync(credsPath, "utf8")).accountId, INCOMING_ACCOUNT), + ); + check("the warning points at the backup that exists", () => + assert.ok(out.includes(backups[0]), `output did not name ${backups[0]}`), + ); +} finally { + child.kill("SIGKILL"); + rmSync(home, { recursive: true, force: true }); +} + +console.log(failures === 0 ? "\nLIVE CHECK PASSED" : `\nLIVE CHECK FAILED (${failures})`); +process.exit(failures === 0 ? 0 : 1); From 3611ec0c114776ff709a00517c56a961452ad80d Mon Sep 17 00:00:00 2001 From: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:09:18 +0700 Subject: [PATCH 09/32] docs(mcp): document credential locations, and warn before the browser step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the two acceptance criteria left open on WALM-361. Warn ahead of approval, not only after. The incoming account is unknown until the callback arrives, by which point a delegate key is already registered on-chain — so a warning that waits for both ids arrives too late to act on. The browser step is the last moment the user can back out for free, so the account currently saved and the file at risk are named before it opens. The after-the-fact notice naming both ids stays; the two answer different questions. Document the resolution order, migration, backup and recovery in the MCP reference: how the two locations resolve, how to scope a project to its own account, that migration is a no-op because creating the local file is the opt-in, what a same-account versus different-account sign-in does, and how to restore from a backup. Existing sections that hardcoded ~/.memwal — first-run behaviour, the credential-file section, memwal_logout, and two CLI table rows — now point at the resolved location instead of asserting the global one. Also notes that backups are never pruned and each holds a delegate private key, and that `.memwal/` belongs in .gitignore. Refs #628 (WALM-361). --- docs/mcp/reference.md | 69 ++++++++++++++++--- packages/mcp/src/auth.ts | 20 ++++++ packages/mcp/src/login.ts | 7 +- .../mcp/test/credential-resolution.test.mjs | 19 +++++ 4 files changed, 105 insertions(+), 10 deletions(-) diff --git a/docs/mcp/reference.md b/docs/mcp/reference.md index 653a8468d..ae3cbdc68 100644 --- a/docs/mcp/reference.md +++ b/docs/mcp/reference.md @@ -41,7 +41,7 @@ The MCP server exposes **eight tools**: six **relayer tools** (memory operations ## First-run behavior -When `~/.memwal/credentials.json` does not exist, the stdio package does **not** exit immediately if an MCP host launched it. +When no credentials file is found (see [Credential locations](#credential-locations)), the stdio package does **not** exit immediately if an MCP host launched it. Instead it starts in an auth-required mode that: @@ -111,7 +111,7 @@ Returns a one-time URL valid for **5 minutes**. If it expires, call the tool aga ### memwal_logout -Remove the saved credentials from this machine (`~/.memwal/credentials.json`). Takes no parameters. +Remove the saved credentials from this machine, from whichever file is currently in use (see [Credential locations](#credential-locations)). Takes no parameters. `memwal_logout` does **not** revoke the onchain delegate key registration, it only wipes the local file. Visit the [Walrus Memory dashboard](https://memory.walrus.xyz) to remove the delegate key from your account. @@ -121,6 +121,61 @@ Remove the saved credentials from this machine (`~/.memwal/credentials.json`). T Both session tools (`memwal_login`, `memwal_logout`) are intercepted locally by the stdio package and never reach the relayer. They read and write files on the client machine only. +## Credential locations + +Credentials resolve from two places, in order: + +1. `.memwal/credentials.json` in the **current working directory** +2. `~/.memwal/credentials.json` (global, per machine) + +The first one that exists wins, the way `.npmrc` and `.git/config` resolve. Whichever file is chosen is the one read, written, and deleted for that run. + +### Working on several accounts + +Without a project-local file, every project on the machine shares one credential. Signing in from one project silently repoints the others at a different account and delegate key, and memories written in that state land on the wrong account, on immutable storage, with no delete path. + +To scope a project to its own account, create the file inside it: + +```bash +cd ~/code/my-project +mkdir -p .memwal +memwal-mcp login # writes to the global file the first time +cp ~/.memwal/credentials.json .memwal/credentials.json +``` + +From then on, runs started from that directory use the project's credentials, and runs started anywhere else keep using the global one. + + +`.memwal/credentials.json` holds a delegate private key. Add `.memwal/` to your `.gitignore`. + + +### Migration + +Nothing to do. Creating a project-local file is the opt-in — a machine without one behaves exactly as it did before, and the global file remains the fallback indefinitely. + +### Replacing an account + +Signing in as a **different** account than the one already saved: + +- warns before the browser opens, naming the account currently saved and the file at risk +- copies the outgoing file to `credentials.backup-.json` beside it +- prints both account ids afterwards, and where the backup went + +Re-signing in as the **same** account (a label change, a rotated delegate key) overwrites in place without a backup. + +### Recovery + +To restore a replaced credential, copy the backup back over the live file: + +```bash +cd ~/.memwal # or the project's .memwal directory +ls credentials.backup-* +cp credentials.backup-.json credentials.json +chmod 600 credentials.json +``` + +Backups accumulate; they are never pruned automatically. Each holds a delegate private key, so delete the ones you no longer need. + ## CLI The stdio package accepts CLI flags and environment variables. **CLI takes precedence** when both are set. @@ -131,8 +186,8 @@ The stdio package accepts CLI flags and environment variables. **CLI takes prece | `--web-url ` | `MEMWAL_WEB_URL` | Override the dashboard URL used during login. | | `--label ` | `MEMWAL_CLIENT_LABEL` | Friendly delegate-key label shown in the Walrus Memory dashboard. | | `--namespace ` (alias `--ns`) | `MEMWAL_NAMESPACE` | Default memory namespace injected into memory tool calls that omit one. See [Default namespace](#default-namespace). | -| `--login` (or `login` subcommand) | Not applicable | Force a re-login even when credentials exist. | -| `--logout` | Not applicable | Wipe `~/.memwal/credentials.json` and exit. | +| `--login` (or `login` subcommand) | Not applicable | Force a re-login even when credentials exist. The existing file is kept until the new sign-in succeeds. | +| `--logout` | Not applicable | Delete the credentials file currently in use and exit. | | `--help`, `-h` | Not applicable | Print usage and exit. | Set `MEMWAL_MCP_DEBUG=1` to enable verbose stderr logging. @@ -169,11 +224,7 @@ Example, pin every memory call to a `work` namespace: ## Credential file -The stdio package stores credentials at: - -```text -~/.memwal/credentials.json -``` +The stdio package stores credentials in whichever file [Credential locations](#credential-locations) resolves to — a project-local `.memwal/credentials.json`, or the global `~/.memwal/credentials.json`. The file includes: diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index 59611359b..ae87e061e 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -129,6 +129,26 @@ export interface SaveCredsResult { backedUpTo?: string; } +/** + * The message shown *before* a sign-in that would overwrite existing + * credentials, or null when there is nothing to lose. + * + * Deliberately shown ahead of the browser step: that is the last moment the + * user can back out for free. The incoming account is not known until the + * callback arrives, by which point a delegate key has already been registered + * on-chain — so a warning that waits for both ids is a warning that arrives + * too late to act on. + */ +export function formatPendingSignInWarning(): string | null { + const current = loadCreds(); + if (!current) return null; + return ( + `Signing in will replace the credentials in ${credsPath()} ` + + `(currently account ${current.accountId}). ` + + `The existing file is backed up if the new sign-in is a different account.` + ); +} + /** * The message shown when a sign-in displaced a different account, or null when * nothing was replaced. diff --git a/packages/mcp/src/login.ts b/packages/mcp/src/login.ts index d4239d03c..e99179603 100644 --- a/packages/mcp/src/login.ts +++ b/packages/mcp/src/login.ts @@ -22,7 +22,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import open from "open"; import type { MemWalCredentials } from "./auth.js"; -import { saveCreds, formatReplacementNotice } from "./auth.js"; +import { saveCreds, formatReplacementNotice, formatPendingSignInWarning } from "./auth.js"; import { generateKeypair } from "./crypto.js"; import { log, note } from "./logger.js"; @@ -245,6 +245,11 @@ export async function loginFlow(opts: LoginOptions = {}): Promise { "a first sign-in or same-account re-save must stay quiet", ); }); + +test("a pending sign-in warns which account it will replace, before approval", async (t) => { + const { auth, home } = await sandbox(t, { global: GLOBAL_ACCOUNT }); + + const warning = auth.formatPendingSignInWarning(); + + assert.ok(warning, "an existing sign-in must be announced before it is replaced"); + assert.match(warning, new RegExp(GLOBAL_ACCOUNT), "must name the account at risk"); + assert.ok( + warning.includes(join(home, ".memwal", "credentials.json")), + "must name the file that will be overwritten", + ); +}); + +test("a first sign-in has nothing to warn about", async (t) => { + const { auth } = await sandbox(t, {}); + + assert.equal(auth.formatPendingSignInWarning(), null); +}); From 15598094cbd0020476154cdcfe392e9deb7c3633 Mon Sep 17 00:00:00 2001 From: Le Tien Phat <91601109+Niko1444@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:14:56 +0700 Subject: [PATCH 10/32] test(mcp): correct the portability note on the credential sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the realpath call was a macOS concern. It is not: cwd and homedir both report resolved paths on every platform, and the sandbox already sets HOME and USERPROFILE together, which is what os.homedir() needs on Windows and POSIX respectively. Nothing in these tests is POSIX-only — the reason they are unproven on Windows is that the repository has no Windows runner. --- packages/mcp/test/credential-resolution.test.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/mcp/test/credential-resolution.test.mjs b/packages/mcp/test/credential-resolution.test.mjs index 623b51a85..32b6cd1d8 100644 --- a/packages/mcp/test/credential-resolution.test.mjs +++ b/packages/mcp/test/credential-resolution.test.mjs @@ -49,9 +49,13 @@ function writeCredsAt(root, accountId, label) { /** Fresh sandbox: a HOME and a working directory, with the module re-imported * so it observes them. Returns the module plus both roots. */ async function sandbox(t, { global: globalAccount, project: projectAccount }) { - // realpath: on macOS `/var` is a symlink to `/private/var`, and - // `process.cwd()` reports the resolved form — so the raw mkdtemp path would - // never match what the module computes. + // Canonicalise both roots: `process.cwd()` and `homedir()` report resolved + // paths, so a raw mkdtemp path would not compare equal to what the module + // computes. Needed on macOS (`/var` is a symlink to `/private/var`) and + // harmless elsewhere. + // + // Both HOME and USERPROFILE are set because `os.homedir()` reads + // USERPROFILE on Windows and HOME on POSIX — this sandbox is portable. const home = realpathSync(mkdtempSync(join(tmpdir(), "memwal-creds-home-"))); const cwd = realpathSync(mkdtempSync(join(tmpdir(), "memwal-creds-cwd-"))); const prevHome = process.env.HOME; From e3fe6180699729770ed3bde45696f56587355a47 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Tue, 18 Aug 2026 16:51:22 +0700 Subject: [PATCH 11/32] fix(noter): stop bouncing authenticated users off /note and dropping the delegate-key error message sessionAtom was atomWithStorage(..., { getOnInit: false }), so every fresh page load (a window.location.href redirect after login, or a plain reload of /note) rendered once with session=null before the async post-mount hydration from sessionStorage caught up. useAuth's own effect read that stale null on the same tick, concluded "logged out", and cleared isLoading before the real session arrived, so /note's guard fired router.replace("/") on an already authenticated user. getOnInit is safe to flip to true here: nothing renders `session` directly, every page branches on the separate authAtom instead, which always starts isLoading:true on both server and client, so there is no markup for the eager read to mismatch against. Separately, connectEnoki/connectDelegateKey flipped the global authAtom.isLoading flag for the duration of their mutation, and app/page.tsx renders only while !isAuthenticated && !isLoading. That unmounted the login form mid-submit, taking its local `error` state with it before the catch block's setError() could run, so an invalid delegate key just dropped the user back on a silently collapsed form. isLoginPending (from the mutation hooks themselves) already tracks per-call pending state for the submit button, so the global flag no longer needs to move during a login attempt. --- apps/noter/package/feature/auth/hook/use-auth.ts | 9 ++------- apps/noter/package/feature/auth/state/atom.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/noter/package/feature/auth/hook/use-auth.ts b/apps/noter/package/feature/auth/hook/use-auth.ts index d16e6ea2c..511faf16c 100644 --- a/apps/noter/package/feature/auth/hook/use-auth.ts +++ b/apps/noter/package/feature/auth/hook/use-auth.ts @@ -71,11 +71,9 @@ export function useAuth() { accountId?: string; }) => { try { - setLoading(true); const result = await connectEnokiMutation.mutateAsync(params); if ("needsSetup" in result && result.needsSetup) { - setLoading(false); return result; } @@ -94,19 +92,17 @@ export function useAuth() { return result; } catch (error) { - setLoading(false); console.error("Enoki connection failed:", error); throw error; } }, - [connectEnokiMutation, setSession, setAuthenticated, setLoading] + [connectEnokiMutation, setSession, setAuthenticated] ); /** Connect with delegate key (manual key + account ID). */ const connectDelegateKey = useCallback( async (params: { privateKey: string; accountId: string }) => { try { - setLoading(true); const result = await connectDelegateKeyMutation.mutateAsync(params); setSession(result.sessionData); @@ -120,12 +116,11 @@ export function useAuth() { return result; } catch (error) { - setLoading(false); console.error("Delegate key connection failed:", error); throw error; } }, - [connectDelegateKeyMutation, setSession, setAuthenticated, setLoading] + [connectDelegateKeyMutation, setSession, setAuthenticated] ); /** Logout — clear session, auth state, and disconnect wallet (prevents autoConnect). */ diff --git a/apps/noter/package/feature/auth/state/atom.ts b/apps/noter/package/feature/auth/state/atom.ts index 8fb7b933a..76242275e 100644 --- a/apps/noter/package/feature/auth/state/atom.ts +++ b/apps/noter/package/feature/auth/state/atom.ts @@ -27,6 +27,17 @@ export const authAtom = atom({ /** * Current session data * Persisted in sessionStorage (browser only) + * + * getOnInit is true so the session is read synchronously on first render + * instead of via atomWithStorage's post-mount onMount effect. With + * getOnInit:false, every fresh page load (e.g. the window.location.href + * redirect after login, or a plain refresh of /note) renders once with + * session=null before the async hydration catches up — and useAuth's own + * effect can read that stale null first and conclude "logged out", + * bouncing an already-authenticated user back to "/". Safe to read eagerly + * here because nothing renders `session` directly: authAtom (a separate, + * plain atom that always starts isLoading:true) is what every page branches + * on, so there's no server/client markup to mismatch on. */ export const sessionAtom = atomWithStorage( STORAGE_KEYS.sessionId, @@ -41,7 +52,7 @@ export const sessionAtom = atomWithStorage( key: () => null, } as Storage) ), - { getOnInit: false } + { getOnInit: true } ); // ═══════════════════════════════════════════════════════════════ From 804e717c65f148258bbb44fb4f5fb3dfa38e6e0c Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Tue, 18 Aug 2026 16:51:36 +0700 Subject: [PATCH 12/32] fix(noter): migrate the client-side Enoki registration flow off deprecated Sui JSON-RPC Sui's public JSON-RPC fullnodes were deprecated in 2026; fullnode.testnet.sui.io now answers every JSON-RPC call with "Method not found ... migrate to gRPC or GraphQL endpoints" and no CORS header, which Chrome reports as a generic "blocked by CORS policy" failure. sui-providers.tsx's getJsonRpcFullnodeUrl and enoki-login-card.tsx's useSuiClient() both hit that dead endpoint, so registerEnokiWallets failed on mount ([enoki-login] Setup failed: TypeError: Failed to fetch) and Google sign-in never got past the landing page. dapp-kit's SuiClientProvider is still hard-typed to SuiJsonRpcClient even in the latest published version (1.1.17), so it can't be pointed at a gRPC client directly. Enoki's own `client` option and Transaction.build()'s `client` option both accept the broader ClientWithCoreApi interface instead, which SuiGrpcClient satisfies, so this bypasses SuiClientProvider only where it was actually blocking things: registerEnokiWallets now gets a standalone SuiGrpcClient (lib/sui/grpc-client.ts) instead of useSuiClientContext()'s client, and enoki-login-card.tsx's on-chain reads (registry lookup, dynamic field, transaction/event fetch) move to the gRPC client's include/mask-based API. SuiClientProvider itself stays in place for WalletProvider's wallet-standard connect/sign, which doesn't touch RPC directly. gRPC object/dynamic-field/event reads return raw BCS bytes instead of JSON-RPC's parsed `.fields`, so lib/sui/account-bcs.ts adds the BCS schemas needed to decode them, verified by decoding a live testnet account and matching its stored delegate public key, and round-tripping a registry dynamic-field lookup back to the same account id. --- .../noter/app/components/enoki-login-card.tsx | 60 ++++++++---------- apps/noter/app/components/sui-providers.tsx | 21 +++++-- apps/noter/lib/sui/account-bcs.ts | 63 +++++++++++++++++++ apps/noter/lib/sui/grpc-client.ts | 34 ++++++++++ 4 files changed, 137 insertions(+), 41 deletions(-) create mode 100644 apps/noter/lib/sui/account-bcs.ts create mode 100644 apps/noter/lib/sui/grpc-client.ts diff --git a/apps/noter/app/components/enoki-login-card.tsx b/apps/noter/app/components/enoki-login-card.tsx index 2659bdcb6..5eae0fce2 100644 --- a/apps/noter/app/components/enoki-login-card.tsx +++ b/apps/noter/app/components/enoki-login-card.tsx @@ -16,9 +16,10 @@ import { useCurrentAccount, useSignPersonalMessage, useSignTransaction, - useSuiClient, } from "@mysten/dapp-kit"; import { isEnokiWallet } from "@mysten/enoki"; +import { bcs } from "@mysten/sui/bcs"; +import type { SuiGrpcClient } from "@mysten/sui/grpc"; import { Transaction } from "@mysten/sui/transactions"; import { createSponsorAuthorization } from "@mysten-incubation/memwal"; import { Loader2 } from "lucide-react"; @@ -26,6 +27,8 @@ import { Button } from "@/shared/components/ui/button"; import { enokiConfig } from "@/lib/enoki/config"; import { useAuth } from "@/feature/auth"; import { trpc } from "@/shared/lib/trpc/client"; +import { getSuiGrpcClient } from "@/lib/sui/grpc-client"; +import { AccountCreatedBcs, AccountRegistryBcs } from "@/lib/sui/account-bcs"; type Step = | "idle" @@ -62,14 +65,14 @@ function uint8ArrayToBase64(bytes: Uint8Array): string { async function sponsoredSignAndExecute( transaction: Transaction, sender: string, - suiClient: ReturnType, + suiClient: SuiGrpcClient, signTransaction: (args: { transaction: Transaction; }) => Promise<{ signature: string }>, signPersonalMessage: (message: Uint8Array) => Promise<{ signature: string }>, ): Promise<{ digest: string }> { const kindBytes = await transaction.build({ - client: suiClient as any, + client: suiClient, onlyTransactionKind: true, }); const authorization = await createSponsorAuthorization( @@ -118,7 +121,7 @@ export function EnokiLoginCard() { const wallets = useWallets(); const { mutateAsync: connect } = useConnectWallet(); const currentAccount = useCurrentAccount(); - const suiClient = useSuiClient(); + const suiClient = getSuiGrpcClient(); const { mutateAsync: signTransaction } = useSignTransaction(); const { mutateAsync: signPersonalMessage } = useSignPersonalMessage(); const { connectEnoki } = useAuth(); @@ -197,29 +200,17 @@ export function EnokiLoginCard() { let knownAccountId: string | null = null; try { - const registryObj = await suiClient.getObject({ - id: enokiConfig.memwalRegistryId, - options: { showContent: true }, + const registryRes = await suiClient.getObject({ + objectId: enokiConfig.memwalRegistryId, + include: { content: 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; - } - } + if (registryRes.object.content) { + const registry = AccountRegistryBcs.parse(registryRes.object.content); + const dynField = await suiClient.getDynamicField({ + parentId: registry.accounts.id, + name: { type: "address", bcs: bcs.Address.serialize(address).toBytes() }, + }); + knownAccountId = bcs.Address.parse(dynField.dynamicField.value.bcs); } } catch { // Dynamic field not found → no account yet @@ -267,18 +258,17 @@ export function EnokiLoginCard() { ); await suiClient.waitForTransaction({ digest: createResult.digest }); - const txDetails = await suiClient.getTransactionBlock({ + const txResult = await suiClient.getTransaction({ digest: createResult.digest, - options: { showObjectChanges: true }, + include: { events: true }, }); - const createdObj = txDetails.objectChanges?.find( - (c) => - c.type === "created" && - "objectType" in c && - c.objectType.includes("MemWalAccount"), + const txDetails = + txResult.$kind === "Transaction" ? txResult.Transaction : txResult.FailedTransaction; + const createdEvent = txDetails.events?.find((e) => + e.eventType.endsWith("::account::AccountCreated"), ); - if (createdObj && "objectId" in createdObj) { - knownAccountId = createdObj.objectId; + if (createdEvent) { + knownAccountId = AccountCreatedBcs.parse(createdEvent.bcs).account_id; } if (!knownAccountId) { diff --git a/apps/noter/app/components/sui-providers.tsx b/apps/noter/app/components/sui-providers.tsx index 6b2137919..3ec6c0097 100644 --- a/apps/noter/app/components/sui-providers.tsx +++ b/apps/noter/app/components/sui-providers.tsx @@ -5,22 +5,31 @@ 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"; +import { getSuiGrpcClient } from "@/lib/sui/grpc-client"; 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. */ +/** + * Registers Enoki wallets (Google OAuth) with dapp-kit on mount. No-op if env + * vars are missing. + * + * Uses a standalone SuiGrpcClient rather than SuiClientProvider's client: + * dapp-kit's SuiClientProvider is hard-typed to SuiJsonRpcClient (even in the + * latest published version), and Sui's public JSON-RPC fullnodes no longer + * serve JSON-RPC — so useSuiClientContext()'s client can't be used here. + * Enoki's `client` option accepts the same ClientWithCoreApi interface a + * gRPC client satisfies, so this is otherwise a drop-in swap. + */ function RegisterEnokiWallets() { - const { client, network } = useSuiClientContext(); - useEffect(() => { + const network = enokiConfig.suiNetwork; if (!isEnokiNetwork(network)) return; if (!enokiConfig.enokiApiKey || !enokiConfig.googleClientId) return; @@ -29,12 +38,12 @@ function RegisterEnokiWallets() { providers: { google: { clientId: enokiConfig.googleClientId }, }, - client, + client: getSuiGrpcClient(), network, }); return unregister; - }, [client, network]); + }, []); return null; } diff --git a/apps/noter/lib/sui/account-bcs.ts b/apps/noter/lib/sui/account-bcs.ts new file mode 100644 index 000000000..8d8309032 --- /dev/null +++ b/apps/noter/lib/sui/account-bcs.ts @@ -0,0 +1,63 @@ +/** + * BCS schemas for reading `memwal::account`'s on-chain structs. gRPC + * object/dynamic-field/event reads return raw BCS bytes (unlike JSON-RPC's + * parsed `.fields`), so these are needed to decode on-chain state. + * + * These schemas intentionally decode only the leading fields this app reads + * (id, accounts / id, owner, delegate_keys, active) and rely on the BCS + * parser stopping there rather than erroring on trailing bytes. That's safe + * against the package this app is currently configured against, but NOT + * against services/contract/sources/account.move as it reads today — + * that source has already grown migration/import fields on AccountRegistry + * (migration_finalized, pinned_allowlist_root, expected/imported counters, + * version) and MemWalAccount (admin_quarantined, legacy_account_id, and + * more) that aren't modeled here at all. Verified correct against the + * live, currently-deployed bytecode as of this change (decoded delegate key + * bytes matched a known-good derived public key; a registry dynamic-field + * lookup round-tripped to the expected account id) — but that means the + * source has moved ahead of what's published, not that these schemas are + * future-proof. If/when that contract version is published to the package + * this app points at, these schemas need the new fields added (in order) + * or reads will silently decode wrong instead of erroring. + */ +import { bcs } from "@mysten/sui/bcs"; + +/** memwal::account::DelegateKey */ +export const DelegateKeyBcs = bcs.struct("DelegateKey", { + public_key: bcs.vector(bcs.U8), + sui_address: bcs.Address, + label: bcs.String, + created_at: bcs.U64, +}); + +/** memwal::account::MemWalAccount */ +export const MemWalAccountBcs = bcs.struct("MemWalAccount", { + id: bcs.Address, + owner: bcs.Address, + delegate_keys: bcs.vector(DelegateKeyBcs), + created_at: bcs.U64, + active: bcs.Bool, +}); + +/** + * sui::table::Table — framework struct, not defined in account.move, + * but referenced by AccountRegistry.accounts: Table. Table's + * own layout is `{ id: UID, size: u64 }`; entries live as dynamic fields on + * `id`, not inlined in this struct. + */ +export const TableBcs = bcs.struct("Table", { + id: bcs.Address, + size: bcs.U64, +}); + +/** memwal::account::AccountRegistry */ +export const AccountRegistryBcs = bcs.struct("AccountRegistry", { + id: bcs.Address, + accounts: TableBcs, +}); + +/** memwal::account::AccountCreated (event) */ +export const AccountCreatedBcs = bcs.struct("AccountCreated", { + account_id: bcs.Address, + owner: bcs.Address, +}); diff --git a/apps/noter/lib/sui/grpc-client.ts b/apps/noter/lib/sui/grpc-client.ts new file mode 100644 index 000000000..8424e3d53 --- /dev/null +++ b/apps/noter/lib/sui/grpc-client.ts @@ -0,0 +1,34 @@ +/** + * Sui gRPC client — used for Enoki's on-chain registration flow. + * + * Sui's public JSON-RPC fullnodes were deprecated in 2026 in favor of gRPC. + * @mysten/dapp-kit's SuiClientProvider/useSuiClient are still hard-typed to + * SuiJsonRpcClient (confirmed against the latest published dapp-kit, 1.1.17) + * and can't be swapped for a gRPC client, so this bypasses that provider + * entirely for the one place noter needs live chain reads: registering an + * Enoki wallet with @mysten/enoki (whose `client` option accepts the + * ClientWithCoreApi interface both SuiJsonRpcClient and SuiGrpcClient + * satisfy) and the on-chain account lookup/creation in enoki-login-card.tsx. + */ +import { SuiGrpcClient } from "@mysten/sui/grpc"; +import { enokiConfig } from "@/lib/enoki/config"; + +// Same hostnames Sui's own JSON-RPC used — gRPC-web is served from the same +// fullnode, dispatched by content-type/path rather than a separate host. +const GRPC_BASE_URLS = { + testnet: "https://fullnode.testnet.sui.io:443", + mainnet: "https://fullnode.mainnet.sui.io:443", +} as const; + +let cached: SuiGrpcClient | null = null; +let cachedNetwork: keyof typeof GRPC_BASE_URLS | null = null; + +/** Memoized SuiGrpcClient for the app's configured network. */ +export function getSuiGrpcClient(): SuiGrpcClient { + const network = enokiConfig.suiNetwork; + if (cached && cachedNetwork === network) return cached; + + cached = new SuiGrpcClient({ network, baseUrl: GRPC_BASE_URLS[network] }); + cachedNetwork = network; + return cached; +} From 7dcd91145d1995c3df8155f2bc8ad3c52edd0c08 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Tue, 18 Aug 2026 16:51:56 +0700 Subject: [PATCH 13/32] test(noter): add Playwright e2e suite and CI job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Noter had zero automated tests; the on-chain registration flow, delegate-key auth, and note CRUD were verified entirely by hand. 22 specs across app shell, auth, note lifecycle, and the memory API contract, running against a real Next.js dev server and a fresh Postgres. Mock seam for the new delegate-account binding check ------------------------------------------------------ connectDelegateKey now calls assertDelegateAccountBinding (a separate, already-merged change), which reads the claimed account off-chain via gRPC and rejects any key that isn't registered in its delegate_keys list — so a random, never-registered key/account pair can no longer reach an authenticated session the way it could before that change landed. Mirroring researcher's PR #680 pattern, delegate-account.ts now branches on lib/constants.ts's isTestEnvironment (set by playwright.config.ts passing PLAYWRIGHT=True to the webServer) and serves a fixture object from delegate-account.mock.ts instead of the gRPC read, so the real validation logic still runs meaningfully: an unknown account or an unregistered key fails the exact same way it would on-chain. Noter authenticates a fresh identity per test rather than reusing two shared identities across a whole run (researcher's approach) — with `workers: 2` and ~15 login call sites, two fixed identities would have concurrent tests collide on each other's notes. delegate-account.mock.ts and fixtures/delegate-key.ts instead generate the same 24-entry deterministic pool independently (index N -> accountId byte N repeated, privateKey byte N+0x40 repeated), and the test fixture hands out a never-yet-used entry per call, interleaved by Playwright's parallelIndex so two worker processes never claim the same one. public_key is stored base64, not hex: the binding check's parser tries fromBase64() before falling back to raw hex, and every 64-char hex string (alphabet 0-9a-f, always length-divisible-by-4) also happens to be valid-but-wrong base64, so a hex value there silently decodes to the wrong bytes instead of ever matching. The memory-write specs assert against the real relayer response for a fixture (unregistered) key, so there's no live-Walrus canary in this suite by design, same as #680 documents for researcher: the real remember -> recall round trip against production Walrus Memory stays a manual check. CI job ------ noter-e2e mirrors chatbot-e2e's shape (Postgres service container, cached Playwright browsers, report/trace upload on failure). noter-checks adds tsc --noEmit and a full `next build` so a type or build regression fails CI even on a change the e2e specs don't happen to cover. --- .github/workflows/test.yml | 125 ++++++++++++++++++ apps/noter/.gitignore | 4 + apps/noter/lib/constants.ts | 7 + apps/noter/package.json | 3 + .../feature/auth/lib/delegate-account.mock.ts | 87 ++++++++++++ .../feature/auth/lib/delegate-account.ts | 23 ++++ apps/noter/playwright.config.ts | 69 ++++++++++ apps/noter/tests/playwright/e2e/app.test.ts | 48 +++++++ apps/noter/tests/playwright/e2e/auth.test.ts | 113 ++++++++++++++++ .../noter/tests/playwright/e2e/memory.test.ts | 83 ++++++++++++ apps/noter/tests/playwright/e2e/note.test.ts | 76 +++++++++++ .../tests/playwright/fixtures/delegate-key.ts | 115 ++++++++++++++++ apps/noter/tests/playwright/global-setup.ts | 55 ++++++++ pnpm-lock.yaml | 3 + 14 files changed, 811 insertions(+) create mode 100644 apps/noter/lib/constants.ts create mode 100644 apps/noter/package/feature/auth/lib/delegate-account.mock.ts create mode 100644 apps/noter/playwright.config.ts create mode 100644 apps/noter/tests/playwright/e2e/app.test.ts create mode 100644 apps/noter/tests/playwright/e2e/auth.test.ts create mode 100644 apps/noter/tests/playwright/e2e/memory.test.ts create mode 100644 apps/noter/tests/playwright/e2e/note.test.ts create mode 100644 apps/noter/tests/playwright/fixtures/delegate-key.ts create mode 100644 apps/noter/tests/playwright/global-setup.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee6be4177..1e9b6082f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -178,6 +178,131 @@ jobs: retention-days: 14 if-no-files-found: ignore + noter-e2e: + name: Noter / Playwright E2E + runs-on: ubuntu-latest + timeout-minutes: 25 + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: noter + POSTGRES_PASSWORD: noter_secret + POSTGRES_DB: noter + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U noter" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgresql://noter:noter_secret@localhost:5432/noter + NEXT_PUBLIC_APP_URL: http://localhost:3002 + PORT: "3002" + NODE_ENV: test + # NEXT_PUBLIC_* Enoki/Sui vars are inlined at build time — placeholders + # are fine here since the e2e suite authenticates via delegate key, not + # the Google/Enoki popup flow (that needs a real OAuth session and stays + # a manual check, same as researcher's live-Walrus canary in #680). + NEXT_PUBLIC_ENOKI_API_KEY: ci-placeholder-not-used-tests-use-delegate-key + NEXT_PUBLIC_GOOGLE_CLIENT_ID: ci-placeholder-not-used-tests-use-delegate-key + NEXT_PUBLIC_SUI_NETWORK: testnet + NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6" + NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437" + NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai + # MEMWAL_E2E_PRIVATE_KEY / MEMWAL_E2E_ACCOUNT_ID intentionally unset: + # the memory-write specs that need real relayer credentials self-skip + # via test.skip() when absent. CI validates auth + note CRUD + # deterministically; the real remember round-trip against the relayer + # stays a manual check, same caveat #680 documents for researcher. + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Build SDK (workspace dep of noter) + run: pnpm build:sdk + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: pw-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pw-${{ runner.os }}- + + - name: Install Playwright (Chromium + OS deps) + run: pnpm --filter @memwal/noter playwright:install + + - name: Run Playwright E2E + run: pnpm --filter @memwal/noter test:e2e + + - name: Upload Playwright report + traces + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-noter + path: | + apps/noter/playwright-report + apps/noter/test-results + retention-days: 14 + if-no-files-found: ignore + + noter-checks: + name: Noter / Build (type-check inclusive) + runs-on: ubuntu-latest + timeout-minutes: 20 + + env: + # Dummy DB — next build type-checks route handlers but doesn't connect. + DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy + NEXT_PUBLIC_ENOKI_API_KEY: ci-placeholder-build-only + NEXT_PUBLIC_GOOGLE_CLIENT_ID: ci-placeholder-build-only + NEXT_PUBLIC_SUI_NETWORK: testnet + NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6" + NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437" + NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Build SDK (workspace dep of noter) + run: pnpm build:sdk + + - name: Type-check (tsc --noEmit) + working-directory: apps/noter + run: pnpm exec tsc --noEmit + + - name: Build (Next.js, type-check inclusive) + working-directory: apps/noter + run: pnpm exec next build + server-e2e: name: Server / E2E runs-on: ubuntu-latest diff --git a/apps/noter/.gitignore b/apps/noter/.gitignore index 5ef6a5207..15781331b 100644 --- a/apps/noter/.gitignore +++ b/apps/noter/.gitignore @@ -39,3 +39,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# playwright +/test-results/ +/playwright-report/ diff --git a/apps/noter/lib/constants.ts b/apps/noter/lib/constants.ts new file mode 100644 index 000000000..3a4885961 --- /dev/null +++ b/apps/noter/lib/constants.ts @@ -0,0 +1,7 @@ +export const isProductionEnvironment = process.env.NODE_ENV === "production"; +export const isDevelopmentEnvironment = process.env.NODE_ENV === "development"; +export const isTestEnvironment = Boolean( + process.env.PLAYWRIGHT_TEST_BASE_URL || + process.env.PLAYWRIGHT || + process.env.CI_PLAYWRIGHT +); diff --git a/apps/noter/package.json b/apps/noter/package.json index a29dd7754..ec1e2bfd7 100644 --- a/apps/noter/package.json +++ b/apps/noter/package.json @@ -15,6 +15,8 @@ "db:purge-legacy-sessions": "tsx scripts/purge-legacy-zklogin-sessions.ts", "verify:memwal": "tsx ../../scripts/verify-memwal-credentials.ts", "test:unit": "vitest run", + "playwright:install": "playwright install --with-deps chromium", + "test:e2e": "playwright test", "clean": "rm -rf .next out build" }, "dependencies": { @@ -81,6 +83,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@tailwindcss/postcss": "^4", "@types/jwt-decode": "^3.1.0", "@types/node": "^20", diff --git a/apps/noter/package/feature/auth/lib/delegate-account.mock.ts b/apps/noter/package/feature/auth/lib/delegate-account.mock.ts new file mode 100644 index 000000000..684f03aa6 --- /dev/null +++ b/apps/noter/package/feature/auth/lib/delegate-account.mock.ts @@ -0,0 +1,87 @@ +import * as ed from "@noble/ed25519"; +import { sha512 } from "@noble/hashes/sha2.js"; +import { toBase64 } from "@mysten/sui/utils"; +import { enokiConfig } from "@/lib/enoki/config"; + +if (!ed.etc.sha512Sync) { + ed.etc.sha512Sync = (...m: Uint8Array[]) => { + const h = sha512.create(); + for (const msg of m) h.update(msg); + return h.digest(); + }; +} + +function hex2(n: number): string { + return n.toString(16).padStart(2, "0"); +} + +/** + * Fixture identities for Playwright runs (lib/constants.ts isTestEnvironment). + * + * Noter's specs authenticate a fresh identity per test (unlike researcher's + * two-identity, shared-storage-state design), so this is a pool, not a pair + * — with `workers: 2` and ~15 login call sites, two fixed identities would + * have tests colliding on each other's notes. Each entry is deterministic + * (index N → accountId byte `N` repeated, privateKey byte `N + 0x40` + * repeated) so the same 24 pairs regenerate identically here and in + * tests/playwright/fixtures/delegate-key.ts — keep both in sync. + * + * Each account id maps to exactly one delegate private key, so the real + * binding validation in delegate-account.ts still runs meaningfully against + * the fabricated object: an unknown account id fails lookup, and a key that + * doesn't derive the registered public key is rejected — the same failure + * modes as the on-chain path. + */ +const FIXTURE_COUNT = 24; + +const TEST_DELEGATE_ACCOUNTS: ReadonlyArray<{ + accountId: string; + owner: string; + privateKey: string; +}> = Array.from({ length: FIXTURE_COUNT }, (_, i) => ({ + accountId: `0x${hex2(i).repeat(32)}`, + owner: `0x${hex2(i + 0x80).repeat(32)}`, + privateKey: hex2(i + 0x40).repeat(32), +})); + +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; +} + +/** + * Fabricated stand-in for the Sui gRPC getObject response, shaped exactly + * like the fields delegate-account.ts validates. Returns null for account + * ids outside the fixture list (the "object not found" case). + * + * public_key is base64, not hex: delegate-account.ts's publicKeyHex() parser + * tries fromBase64() first and only falls back to a raw-hex check if that + * throws — and every 64-char hex string (alphabet 0-9a-f, always + * length-divisible-by-4) IS valid base64, just of the wrong bytes. Real gRPC + * `include: { json: true }` responses base64-encode bytes fields (standard + * protobuf JSON mapping), so this matches the real shape rather than + * coincidentally working around the parser. + */ +export function mockDelegateAccountObject( + accountId: string, +): { type: string; json: unknown } | null { + const fixture = TEST_DELEGATE_ACCOUNTS.find( + (account) => account.accountId.toLowerCase() === accountId.toLowerCase(), + ); + if (!fixture) { + return null; + } + + const publicKey = toBase64(ed.getPublicKey(hexToBytes(fixture.privateKey))); + return { + type: `${enokiConfig.memwalPackageId}::account::MemWalAccount`, + json: { + owner: fixture.owner, + active: true, + delegate_keys: [{ public_key: publicKey }], + }, + }; +} diff --git a/apps/noter/package/feature/auth/lib/delegate-account.ts b/apps/noter/package/feature/auth/lib/delegate-account.ts index 26772f12b..26c6d1229 100644 --- a/apps/noter/package/feature/auth/lib/delegate-account.ts +++ b/apps/noter/package/feature/auth/lib/delegate-account.ts @@ -2,6 +2,7 @@ import "server-only"; import { SuiGrpcClient } from "@mysten/sui/grpc"; import { fromBase64, normalizeSuiAddress, toHex } from "@mysten/sui/utils"; +import { isTestEnvironment } from "@/lib/constants"; import { enokiConfig } from "@/lib/enoki/config"; export class DelegateAccountBindingError extends Error { @@ -111,6 +112,28 @@ export async function assertDelegateAccountBinding(input: { ); } + if (isTestEnvironment) { + // Playwright runs have no chain to read. Serve a fixture object instead + // of the gRPC fetch so the real validation below still executes — an + // unknown account or unregistered key fails the same way it would live. + const { mockDelegateAccountObject } = await import( + "./delegate-account.mock" + ); + const mocked = mockDelegateAccountObject(input.accountId); + if (!mocked) { + throw new DelegateAccountBindingError( + "Unable to verify Walrus Memory account" + ); + } + const mockError = delegateAccountBindingError(mocked.type, mocked.json, { + owner: input.owner, + publicKeyHex: input.publicKeyHex, + packageId: enokiConfig.memwalPackageId, + }); + if (mockError) throw new DelegateAccountBindingError(mockError); + return; + } + const network = enokiConfig.suiNetwork; const defaultUrl = `https://fullnode.${network}.sui.io:443`; const client = new SuiGrpcClient({ diff --git a/apps/noter/playwright.config.ts b/apps/noter/playwright.config.ts new file mode 100644 index 000000000..d030e328c --- /dev/null +++ b/apps/noter/playwright.config.ts @@ -0,0 +1,69 @@ +import { defineConfig, devices } from "@playwright/test"; +import { config } from "dotenv"; + +config({ path: ".env.local" }); + +// package.json's dev script hard-codes `next dev --port 3002` — the `--port` +// CLI flag wins over any PORT env var, so this can't be env-derived without +// going stale the moment .env.local's PORT is repurposed for something else +// (e.g. manually running noter on 5173 to match Enoki's registered OAuth +// origin, which is what broke this once already). +const PORT = "3002"; +const baseURL = `http://localhost:${PORT}`; + +const isCI = !!process.env.CI; + +export default defineConfig({ + testDir: "./tests/playwright", + outputDir: "./test-results", + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: 2, + reporter: isCI + ? [ + ["html", { open: "never", outputFolder: "playwright-report" }], + ["github"], + ["list"], + ["junit", { outputFile: "playwright-report/junit.xml" }], + ] + : [["html", { open: "never", outputFolder: "playwright-report" }], ["list"]], + + globalSetup: require.resolve("./tests/playwright/global-setup"), + + use: { + baseURL, + trace: "retain-on-failure", + video: isCI ? "retain-on-failure" : "off", + screenshot: "only-on-failure", + actionTimeout: 10_000, + // 30s to tolerate cold Next.js/Turbopack compile on 2-vCPU CI runners; + // globalSetup also warms `/` to make first-nav fast on the happy path. + navigationTimeout: 30_000, + }, + + timeout: 60_000, + expect: { timeout: 10_000 }, + + projects: [ + { + name: "e2e", + testMatch: /e2e\/.*\.test\.ts$/, + use: { ...devices["Desktop Chrome"] }, + }, + ], + + webServer: { + command: "pnpm dev", + // `/api/memory/health` answers 503 when Walrus Memory is unconfigured, which + // Playwright would read as "server not up" — gate on the landing page instead. + url: baseURL, + timeout: 120_000, + reuseExistingServer: !isCI, + stdout: "pipe", + stderr: "pipe", + // Flips lib/constants.ts's isTestEnvironment, which gates + // delegate-account.ts onto the fixture mock instead of a live gRPC read. + env: { PORT, PLAYWRIGHT: "True" }, + }, +}); diff --git a/apps/noter/tests/playwright/e2e/app.test.ts b/apps/noter/tests/playwright/e2e/app.test.ts new file mode 100644 index 000000000..0d90332a6 --- /dev/null +++ b/apps/noter/tests/playwright/e2e/app.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test"; + +test.describe("App shell", () => { + test("landing page renders both sign-in paths", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "Welcome to Noter" })).toBeVisible(); + await expect(page.getByText("AI-powered note-taking on Sui blockchain")).toBeVisible(); + await expect(page.getByRole("button", { name: /sign in with google/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /sign in with delegate key/i })).toBeVisible(); + }); + + test("delegate key form stays collapsed until requested", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByPlaceholder(/private key/i)).toBeHidden(); + + await page.getByRole("button", { name: /sign in with delegate key/i }).click(); + + await expect(page.getByPlaceholder(/account id/i)).toBeVisible(); + await expect(page.getByPlaceholder(/private key/i)).toBeVisible(); + }); + + test("private key input is masked by default", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: /sign in with delegate key/i }).click(); + + await expect(page.getByPlaceholder(/private key/i)).toHaveAttribute("type", "password"); + }); + + test("/note bounces an unauthenticated visitor back to the landing page", async ({ page }) => { + await page.goto("/note"); + + await expect(page).toHaveURL("/"); + await expect(page.getByRole("heading", { name: "Welcome to Noter" })).toBeVisible(); + }); + + test("memory health endpoint answers with a structured status", async ({ request }) => { + const response = await request.get("/api/memory/health"); + + // 200 when a server-side MEMWAL key is configured, 503 when it isn't. + expect([200, 503]).toContain(response.status()); + + const body = await response.json(); + expect(body).toHaveProperty("status"); + expect(["ok", "not_configured"]).toContain(body.status); + }); +}); diff --git a/apps/noter/tests/playwright/e2e/auth.test.ts b/apps/noter/tests/playwright/e2e/auth.test.ts new file mode 100644 index 000000000..05d66000a --- /dev/null +++ b/apps/noter/tests/playwright/e2e/auth.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "@playwright/test"; +import { + openDelegateKeyForm, + nextDelegateCredentials, + readSessionId, + signInWithDelegateKey, +} from "../fixtures/delegate-key"; + +test.describe("Delegate key authentication", () => { + test("submit stays disabled until both fields are filled", async ({ page }) => { + const { privateKey, accountId } = nextDelegateCredentials(); + await page.goto("/"); + await openDelegateKeyForm(page); + + const submit = page.getByRole("button", { name: "Sign In", exact: true }); + await expect(submit).toBeDisabled(); + + await page.getByPlaceholder(/account id/i).fill(accountId); + await expect(submit).toBeDisabled(); + + await page.getByPlaceholder(/private key/i).fill(privateKey); + await expect(submit).toBeEnabled(); + }); + + test("signs in and lands on the notes route", async ({ page }) => { + await signInWithDelegateKey(page); + + await expect(page).toHaveURL(/\/note(\/|$)/); + }); + + test("persists a session id for tRPC to authenticate with", async ({ page }) => { + await signInWithDelegateKey(page); + + const sessionId = await readSessionId(page); + expect(sessionId).toBeTruthy(); + }); + + test("session survives a reload", async ({ page }) => { + await signInWithDelegateKey(page); + const before = await readSessionId(page); + + await page.reload(); + + expect(await readSessionId(page)).toBe(before); + }); + + test("does not sign in with a key that is not 64 hex characters", async ({ page }) => { + const { accountId } = nextDelegateCredentials(); + await page.goto("/"); + await openDelegateKeyForm(page); + + await page.getByPlaceholder(/account id/i).fill(accountId); + await page.getByPlaceholder(/private key/i).fill("deadbeef"); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + + // Server-side zod guard: /^[0-9a-f]{64}$/i — no session is issued. + await page.waitForTimeout(2000); + await expect(page).toHaveURL("/"); + expect(await readSessionId(page)).toBeNull(); + }); + + test("surfaces the delegate key validation error to the user", async ({ page }) => { + const { accountId } = nextDelegateCredentials(); + await page.goto("/"); + await openDelegateKeyForm(page); + + await page.getByPlaceholder(/account id/i).fill(accountId); + await page.getByPlaceholder(/private key/i).fill("deadbeef"); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + + // Regression: useAuth.connectDelegateKey used to flip authAtom.isLoading + // for the duration of the mutation, and app/page.tsx renders + // only while `!isAuthenticated && !isLoading` — so the + // form unmounted mid-submit, taking its `error` state with it before the + // catch block could set it. connectDelegateKey/connectEnoki no longer + // touch the global loading flag; isLoginPending (from the mutation hooks) + // is what the submit button reads instead. + await expect(page.locator("p.text-destructive")).toContainText(/64 hex/i); + }); + + test("stays on /note after sign-in instead of bouncing to the landing page", async ({ page }) => { + const credentials = nextDelegateCredentials(); + + await page.goto("/"); + await openDelegateKeyForm(page); + await page.getByPlaceholder(/account id/i).fill(credentials.accountId); + await page.getByPlaceholder(/private key/i).fill(credentials.privateKey); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + + // Regression: sessionAtom used to be atomWithStorage(..., { getOnInit: + // false }), so on the hard navigation triggered by + // window.location.href = "/note" the session was still null on first + // render. useAuth's effect took the `!session && !auth.isAuthenticated` + // branch and cleared isLoading before sessionStorage had hydrated, and + // /note's guard fired router.replace("/") — bouncing an authenticated + // user back to the landing page. sessionAtom now reads sessionStorage + // synchronously on first render (getOnInit: true), so no such gap exists. + await page.waitForTimeout(3000); + await expect(page).toHaveURL(/\/note(\/|$)/); + }); + + test("two sign-ins with different keys produce different sessions", async ({ page }) => { + await signInWithDelegateKey(page); + const first = await readSessionId(page); + + await page.evaluate(() => sessionStorage.clear()); + await signInWithDelegateKey(page); + const second = await readSessionId(page); + + expect(second).toBeTruthy(); + expect(second).not.toBe(first); + }); +}); diff --git a/apps/noter/tests/playwright/e2e/memory.test.ts b/apps/noter/tests/playwright/e2e/memory.test.ts new file mode 100644 index 000000000..3643e0112 --- /dev/null +++ b/apps/noter/tests/playwright/e2e/memory.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "@playwright/test"; +import { readSessionId, signInWithDelegateKey } from "../fixtures/delegate-key"; + +const SAMPLE_TEXT = + "Harry prefers dark roast coffee and always reviews pull requests on Friday afternoons."; + +test.describe("Memory API contract", () => { + test("rejects an unauthenticated request", async ({ request }) => { + // memory-request.ts's authorizeMemoryRequest runs before any body + // parsing — no session header fails closed with 401, not the 400 the + // route used to return for a missing `text` field. + const response = await request.post("/api/memory/remember", { data: {} }); + + expect(response.status()).toBe(401); + expect((await response.json()).error).toMatch(/authentication required/i); + }); + + test("rejects a request with no text", async ({ page, request }) => { + const sessionId = await signInAndGetSessionId(page); + + const response = await request.post("/api/memory/remember", { + headers: { "x-session-id": sessionId }, + data: {}, + }); + + expect(response.status()).toBe(400); + expect((await response.json()).error).toMatch(/text is required/i); + }); + + test("rejects text shorter than the analysis threshold", async ({ page, request }) => { + const sessionId = await signInAndGetSessionId(page); + + const response = await request.post("/api/memory/remember", { + headers: { "x-session-id": sessionId }, + data: { text: "short" }, + }); + + expect(response.status()).toBe(400); + expect((await response.json()).error).toMatch(/too short/i); + }); +}); + +test.describe("Memory write", () => { + test("a fixture delegate key does not reach Walrus", async ({ page, request }) => { + const sessionId = await signInAndGetSessionId(page); + + const response = await request.post("/api/memory/remember", { + headers: { "x-session-id": sessionId }, + data: { text: SAMPLE_TEXT }, + timeout: 30_000, + }); + + // assertDelegateAccountBinding only checks the fixture pool + // (delegate-account.mock.ts, gated by isTestEnvironment) — it never + // touches the real chain, so the session resolves a key that isn't + // registered with production Walrus Memory. The route attempts a real + // write and the relayer rejects it. Either a 500 with an error or a 200 + // with no facts is a legitimate outcome — what must not happen is a + // silent claim that facts were persisted. + if (response.status() === 200) { + expect((await response.json()).count).toBe(0); + } else { + expect(response.status()).toBe(500); + expect((await response.json()).error).toBeTruthy(); + } + }); +}); + +// No "real credentials" write path here by design: isTestEnvironment is +// unconditionally true for every Playwright run (playwright.config.ts sets +// PLAYWRIGHT=True on the webServer), so connectDelegateKey's binding check +// only ever consults the fixture pool — a real, on-chain-registered key +// would fail at login before it ever reached this route. The live +// remember -> recall round trip against the production relayer stays a +// manual check, same as researcher's PR #680 documents for its live-Walrus +// canary. + +async function signInAndGetSessionId(page: Parameters[0]): Promise { + await signInWithDelegateKey(page); + const sessionId = await readSessionId(page); + if (!sessionId) throw new Error("Expected a session id after delegate-key sign-in"); + return sessionId; +} diff --git a/apps/noter/tests/playwright/e2e/note.test.ts b/apps/noter/tests/playwright/e2e/note.test.ts new file mode 100644 index 000000000..5b3655202 --- /dev/null +++ b/apps/noter/tests/playwright/e2e/note.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "@playwright/test"; +import { gotoNotes, signInWithDelegateKey } from "../fixtures/delegate-key"; + +const NOTE_URL = /\/note\/[0-9a-f-]{36}$/i; + +test.describe("Note lifecycle", () => { + test("a fresh user sees the empty state", async ({ page }) => { + await signInWithDelegateKey(page); + + await expect(page).toHaveURL(/\/note$/); + await expect(page.getByRole("heading", { name: "No notes yet" })).toBeVisible(); + await expect(page.getByRole("button", { name: /create your first note/i })).toBeVisible(); + }); + + test("creating the first note opens its editor", async ({ page }) => { + await signInWithDelegateKey(page); + + await page.getByRole("button", { name: /create your first note/i }).click(); + + await expect(page).toHaveURL(NOTE_URL); + await expect(page.locator(".note-editor-content")).toBeVisible(); + }); + + test("a created note outlives the page that created it", async ({ page }) => { + await signInWithDelegateKey(page); + await page.getByRole("button", { name: /create your first note/i }).click(); + await expect(page).toHaveURL(NOTE_URL); + + // Cold-load the notes route: the empty state must be gone and /note must + // forward to the persisted note. + await gotoNotes(page); + + await expect(page).toHaveURL(NOTE_URL); + await expect(page.getByRole("heading", { name: /no notes yet/i })).toBeHidden(); + await expect(page.locator(".note-editor-content")).toBeVisible(); + }); + + test("editor content is autosaved and survives a cold load", async ({ page }) => { + const body = `Playwright autosave check ${Date.now()}`; + + await signInWithDelegateKey(page); + await page.getByRole("button", { name: /create your first note/i }).click(); + await expect(page).toHaveURL(NOTE_URL); + + const editor = page.locator(".note-editor-content"); + await editor.click(); + + // Saves are debounced 3s (use-note.ts) from the last keystroke. Wait for + // the actual note.update response instead of a fixed timeout — a bare + // sleep race against the debounce window is exactly what flaked here. + const saved = page.waitForResponse( + (res) => res.url().includes("/api/trpc/note.update") && res.ok(), + { timeout: 10_000 }, + ); + await editor.pressSequentially(body, { delay: 10 }); + await expect(editor).toContainText(body); + await saved; + + await gotoNotes(page); + + await expect(page.locator(".note-editor-content")).toContainText(body); + }); + + test("notes belong to their own user", async ({ page }) => { + await signInWithDelegateKey(page); + await page.getByRole("button", { name: /create your first note/i }).click(); + await expect(page).toHaveURL(NOTE_URL); + + // Re-authenticate as a different delegate key — the previous note must not leak. + await page.evaluate(() => sessionStorage.clear()); + await signInWithDelegateKey(page); + + await expect(page).toHaveURL(/\/note$/); + await expect(page.getByRole("heading", { name: "No notes yet" })).toBeVisible(); + }); +}); diff --git a/apps/noter/tests/playwright/fixtures/delegate-key.ts b/apps/noter/tests/playwright/fixtures/delegate-key.ts new file mode 100644 index 000000000..07d44eccf --- /dev/null +++ b/apps/noter/tests/playwright/fixtures/delegate-key.ts @@ -0,0 +1,115 @@ +/** + * Delegate-key auth helpers. + * + * `connectDelegateKey` now calls `assertDelegateAccountBinding` (see + * package/feature/auth/lib/delegate-account.ts), which verifies the derived + * public key is registered on the claimed account — on real chain data + * outside tests, and against package/feature/auth/lib/delegate-account.mock.ts's + * fixture pool when isTestEnvironment is set (playwright.config.ts passes + * PLAYWRIGHT=True to the webServer). A random, never-registered key/account + * pair is no longer enough to reach an authenticated session, so this pulls + * from that same fixture pool instead of generating throwaway credentials. + * + * The pool (24 entries) exists because noter's specs authenticate a fresh + * identity per test — with `workers: 2` and ~15 login call sites, two fixed + * identities (researcher's pattern) would have tests colliding on each + * other's notes. Generation must match delegate-account.mock.ts exactly: + * index N → accountId byte `N` repeated, privateKey byte `N + 0x40` + * repeated — keep both in sync. + * + * A fixture key is NOT enough to write to Walrus Memory: isTestEnvironment + * is unconditionally true for every Playwright run, so the binding check + * only ever consults the fixture pool — a real, on-chain-registered key + * would fail at login before reaching the relayer. There's no live-write + * path in this suite by design; see the note in e2e/memory.test.ts. + */ +import { test, type Page } from "@playwright/test"; + +export type DelegateCredentials = { + privateKey: string; + accountId: string; +}; + +const FIXTURE_COUNT = 24; + +function hex2(n: number): string { + return n.toString(16).padStart(2, "0"); +} + +function fixtureAt(i: number): DelegateCredentials { + return { + accountId: `0x${hex2(i).repeat(32)}`, + privateKey: hex2(i + 0x40).repeat(32), + }; +} + +// Module-scoped counter, one instance per Playwright worker PROCESS (workers: +// 2 spawns separate processes, not just separate async contexts — a plain +// counter alone would restart at 0 in each, so two workers' first calls would +// both claim fixture 0). Interleave by test.info().parallelIndex so worker 0 +// claims 0, 2, 4, ... and worker 1 claims 1, 3, 5, ... — every call across +// every worker gets a distinct fixture, with no fixed per-worker range to +// exhaust. Wraps at FIXTURE_COUNT if a run needs more logins than the pool +// has — bump FIXTURE_COUNT (and the matching constant in +// delegate-account.mock.ts) if that ever fires for real. +let localCallCount = 0; + +/** A never-yet-used fixture identity from the pool this test run owns exclusively. */ +export function nextDelegateCredentials(): DelegateCredentials { + const parallelIndex = test.info().parallelIndex; + const workerCount = test.info().config.workers; + const i = (localCallCount * workerCount + parallelIndex) % FIXTURE_COUNT; + localCallCount += 1; + return fixtureAt(i); +} + +/** Open the collapsed "Sign in with delegate key" form on the landing page. */ +export async function openDelegateKeyForm(page: Page): Promise { + await page.getByRole("button", { name: /sign in with delegate key/i }).click(); + await page.getByPlaceholder(/private key/i).waitFor({ state: "visible" }); +} + +/** + * Drive the real login UI end to end and land on /note. + * Returns the credentials used so the caller can reuse them. + */ +export async function signInWithDelegateKey( + page: Page, + credentials: DelegateCredentials = nextDelegateCredentials(), +): Promise { + await page.goto("/"); + await openDelegateKeyForm(page); + + await page.getByPlaceholder(/account id/i).fill(credentials.accountId); + await page.getByPlaceholder(/private key/i).fill(credentials.privateKey); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + + // The form hard-navigates with window.location.href = "/note"; wait for that + // full page load to land. If the sessionAtom-hydration regression (guarded + // by auth.test.ts) ever comes back, this — and every test that depends on + // it — fails loudly instead of silently working around the bounce. + await page.waitForURL(/\/note(\/|$)/); + return credentials; +} + +/** Cold-load the notes route. Use instead of page.reload() when a test needs to be on /note afterwards. */ +export async function gotoNotes(page: Page): Promise { + await page.goto("/note"); +} + +/** + * Read the session id the app persists for tRPC's `x-session-id` header. + * Jotai's atomWithStorage may wrap the payload under `value`. + */ +export async function readSessionId(page: Page): Promise { + return page.evaluate(() => { + const raw = sessionStorage.getItem("zklogin:session:id"); + if (!raw) return null; + try { + const outer = JSON.parse(raw); + return (outer?.value ?? outer)?.sessionId ?? null; + } catch { + return null; + } + }); +} diff --git a/apps/noter/tests/playwright/global-setup.ts b/apps/noter/tests/playwright/global-setup.ts new file mode 100644 index 000000000..a7472d405 --- /dev/null +++ b/apps/noter/tests/playwright/global-setup.ts @@ -0,0 +1,55 @@ +/** + * Playwright global setup. + * + * Runs once before any test (after the webServer is spawned). Responsible for: + * 1. Applying Drizzle migrations so note/user/session tables exist. + * 2. Failing fast with a clear error if DATABASE_URL is missing in CI. + * 3. Warming the `/` route so the first `page.goto("/")` in the suite + * doesn't race Turbopack's lazy cold compile against navigationTimeout. + * + * Note: migrations run via `tsx package/shared/lib/db/migrate.ts` — the same + * entrypoint the Docker image uses — rather than `pnpm db:push`. drizzle-kit + * 0.31.x rejects the pinned drizzle-orm 0.45.2 ("requires newer version of + * drizzle-orm"), so every `db:*` script currently fails. + */ +import { spawnSync } from "node:child_process"; + +const MIGRATE_ENTRYPOINT = "package/shared/lib/db/migrate.ts"; + +export default async function globalSetup(): Promise { + const url = process.env.DATABASE_URL; + + if (!url) { + if (process.env.CI) { + throw new Error( + "DATABASE_URL is required in CI. Start a Postgres service container and export the URL.", + ); + } + console.warn( + "[playwright] DATABASE_URL not set — skipping migrations (local dev only). " + + "Start the local database with: cd apps/noter && docker compose up -d", + ); + } else { + console.log("[playwright] Applying Drizzle migrations..."); + const result = spawnSync("pnpm", ["exec", "tsx", MIGRATE_ENTRYPOINT], { + stdio: "inherit", + env: process.env, + }); + + if (result.status !== 0) { + throw new Error(`[playwright] Migration failed with exit code ${result.status}`); + } + } + + // Prime Next.js/Turbopack's per-route compile cache for `/`. On a cold runner + // the first `page.goto("/")` can take 15-30s (routes compile lazily on first + // hit), which exceeds navigationTimeout and flakes tests until retries kick + // in. Fetching here moves that cost into setup. + const port = process.env.PORT || "3002"; + try { + await fetch(`http://localhost:${port}/`); + console.log("[playwright] Warmed / route"); + } catch { + console.warn("[playwright] Could not warm / route — continuing anyway"); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e38c79f6..71e65283e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -680,6 +680,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@playwright/test': + specifier: ^1.50.1 + version: 1.58.2 '@tailwindcss/postcss': specifier: ^4 version: 4.2.1 From b33dbfdebe425df6fa0b701ce3256e5a66e30d85 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Tue, 18 Aug 2026 23:19:14 +0700 Subject: [PATCH 14/32] fix(ci): merge duplicate noter-checks job that broke the QA workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate PRs each added a job named noter-checks to the same workflow file — one from an already-merged auth-hardening change (vitest unit tests), one from this branch (tsc + next build). Neither touched the same lines, so the merge went through cleanly with no conflict markers, but the resulting file had two top-level jobs with the identical key. GitHub Actions rejects that outright: the workflow run failed in 0s with no job output at all, before any check even started, which is why this wasn't caught by the local `tsc`/`next build`/e2e verification — those ran the commands directly, never through the YAML that CI actually parses. python's yaml.safe_load didn't catch it locally either; it silently keeps the last duplicate key rather than erroring, which GitHub's stricter workflow parser does not do. Folded the tsc/build steps into the existing job instead of renaming to avoid the collision — one Noter CI job now covers unit tests, type-checking, and the production build, sharing one checkout/install/SDK-build sequence rather than paying for it twice. --- .github/workflows/test.yml | 62 ++++++++++++-------------------------- 1 file changed, 19 insertions(+), 43 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e9b6082f..4b17fc9fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -261,48 +261,6 @@ jobs: retention-days: 14 if-no-files-found: ignore - noter-checks: - name: Noter / Build (type-check inclusive) - runs-on: ubuntu-latest - timeout-minutes: 20 - - env: - # Dummy DB — next build type-checks route handlers but doesn't connect. - DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy - NEXT_PUBLIC_ENOKI_API_KEY: ci-placeholder-build-only - NEXT_PUBLIC_GOOGLE_CLIENT_ID: ci-placeholder-build-only - NEXT_PUBLIC_SUI_NETWORK: testnet - NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6" - NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437" - NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: pnpm - - - name: Install deps - run: pnpm install --frozen-lockfile - - - name: Build SDK (workspace dep of noter) - run: pnpm build:sdk - - - name: Type-check (tsc --noEmit) - working-directory: apps/noter - run: pnpm exec tsc --noEmit - - - name: Build (Next.js, type-check inclusive) - working-directory: apps/noter - run: pnpm exec next build - server-e2e: name: Server / E2E runs-on: ubuntu-latest @@ -489,10 +447,20 @@ jobs: run: pnpm exec next build noter-checks: - name: Noter / Unit tests + name: Noter / Unit tests + Build runs-on: ubuntu-latest timeout-minutes: 20 + env: + # Dummy DB — next build type-checks route handlers but doesn't connect. + DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy + NEXT_PUBLIC_ENOKI_API_KEY: ci-placeholder-build-only + NEXT_PUBLIC_GOOGLE_CLIENT_ID: ci-placeholder-build-only + NEXT_PUBLIC_SUI_NETWORK: testnet + NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6" + NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437" + NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai + steps: - name: Checkout uses: actions/checkout@v4 @@ -518,6 +486,14 @@ jobs: - name: Unit tests (vitest) run: pnpm --filter @memwal/noter test:unit + - name: Type-check (tsc --noEmit) + working-directory: apps/noter + run: pnpm exec tsc --noEmit + + - name: Build (Next.js, type-check inclusive) + working-directory: apps/noter + run: pnpm exec next build + server-checks: name: Server / Clippy + Unit tests runs-on: ubuntu-latest From 0ba4f97abb81df159b79eb77e2a8f7f323967bc6 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Wed, 19 Aug 2026 11:29:41 +0700 Subject: [PATCH 15/32] fix(noter): stop dapp-kit's sign hook from resolving move calls via deprecated JSON-RPC useSignTransaction pulls its client from SuiClientProvider, which is still JSON-RPC (dapp-kit's hook types are hard-wired to it). transaction.toJSON() needs that client to resolve move-call ABIs before handing the transaction to the wallet for signing, and fullnode.testnet.sui.io no longer serves CORS headers on its JSON-RPC endpoint, so the browser blocked every sponsored transaction with what looked like a CORS failure. Pre-serialize with our own gRPC client and pass the resulting string instead: dapp-kit's hook accepts transaction as Transaction | string and skips its own resolution when given a string. --- apps/noter/app/components/enoki-login-card.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/noter/app/components/enoki-login-card.tsx b/apps/noter/app/components/enoki-login-card.tsx index 5eae0fce2..89d0c58b1 100644 --- a/apps/noter/app/components/enoki-login-card.tsx +++ b/apps/noter/app/components/enoki-login-card.tsx @@ -67,7 +67,7 @@ async function sponsoredSignAndExecute( sender: string, suiClient: SuiGrpcClient, signTransaction: (args: { - transaction: Transaction; + transaction: Transaction | string; }) => Promise<{ signature: string }>, signPersonalMessage: (message: Uint8Array) => Promise<{ signature: string }>, ): Promise<{ digest: string }> { @@ -98,7 +98,13 @@ async function sponsoredSignAndExecute( const sponsored = await sponsorRes.json(); const sponsoredTx = Transaction.from(sponsored.bytes); - const { signature } = await signTransaction({ transaction: sponsoredTx }); + // dapp-kit's useSignTransaction resolves move-call ABIs via the ambient + // client from SuiClientProvider, which is JSON-RPC (deprecated, no longer + // CORS-enabled for browser origins). Pre-serializing with our gRPC client + // and handing off the resulting string short-circuits that internal + // resolution — dapp-kit passes a string through as-is. + const sponsoredTxJson = await sponsoredTx.toJSON({ client: suiClient }); + const { signature } = await signTransaction({ transaction: sponsoredTxJson }); const execRes = await fetch( `${enokiConfig.memwalServerUrl}/sponsor/execute`, @@ -217,7 +223,7 @@ export function EnokiLoginCard() { } const pubKeyBytes = Array.from(publicKeyRaw); - const sign = (args: { transaction: Transaction }) => + const sign = (args: { transaction: Transaction | string }) => signTransaction(args); if (knownAccountId) { From 8e63ad9a506f75b0785d05bf5f5ee264e7a9f90c Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:07:10 +0700 Subject: [PATCH 16/32] test(noter): harden e2e fixtures and unstick CI playwright install Share one delegate fixture pool between the gRPC mock and Playwright so they cannot drift, parse hex public keys before base64, and fail-closed isTestEnvironment in production. CI now sets PLAYWRIGHT=True, times out browser install at 8m (was hanging the 25m job), and uses an isolated Playwright cache key. --- .github/workflows/test.yml | 19 +++-- apps/noter/lib/constants.ts | 18 +++-- apps/noter/lib/sui/account-bcs.unit.test.ts | 72 +++++++++++++++++++ apps/noter/package.json | 2 +- .../feature/auth/lib/delegate-account.mock.ts | 51 ++----------- .../feature/auth/lib/delegate-account.ts | 12 +++- .../auth/lib/delegate-account.unit.test.ts | 22 ++++++ .../feature/auth/lib/delegate-fixtures.ts | 51 +++++++++++++ .../auth/lib/delegate-fixtures.unit.test.ts | 35 +++++++++ apps/noter/playwright.config.ts | 2 +- apps/noter/tests/playwright/e2e/app.test.ts | 6 +- apps/noter/tests/playwright/e2e/auth.test.ts | 5 +- .../tests/playwright/fixtures/delegate-key.ts | 67 +++++++---------- 13 files changed, 258 insertions(+), 104 deletions(-) create mode 100644 apps/noter/lib/sui/account-bcs.unit.test.ts create mode 100644 apps/noter/package/feature/auth/lib/delegate-fixtures.ts create mode 100644 apps/noter/package/feature/auth/lib/delegate-fixtures.unit.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b17fc9fc..f385926aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -202,6 +202,7 @@ jobs: NEXT_PUBLIC_APP_URL: http://localhost:3002 PORT: "3002" NODE_ENV: test + PLAYWRIGHT: "True" # NEXT_PUBLIC_* Enoki/Sui vars are inlined at build time — placeholders # are fine here since the e2e suite authenticates via delegate key, not # the Google/Enoki popup flow (that needs a real OAuth session and stays @@ -212,11 +213,10 @@ jobs: NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6" NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437" NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai - # MEMWAL_E2E_PRIVATE_KEY / MEMWAL_E2E_ACCOUNT_ID intentionally unset: - # the memory-write specs that need real relayer credentials self-skip - # via test.skip() when absent. CI validates auth + note CRUD - # deterministically; the real remember round-trip against the relayer - # stays a manual check, same caveat #680 documents for researcher. + # No live-Walrus canary in this suite: isTestEnvironment flips the + # binding check onto the fixture pool, so even a real on-chain key + # would fail at login. CI covers auth + note CRUD + the memory API + # contract; remember → recall against production stays a manual check. steps: - name: Checkout @@ -241,10 +241,15 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/ms-playwright - key: pw-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: pw-${{ runner.os }}- + # Separate from chatbot's `pw-` key so the two jobs don't race the + # same cache entry. Fall back to the shared prefix on a cold start. + key: pw-noter-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pw-noter-${{ runner.os }}- + pw-${{ runner.os }}- - name: Install Playwright (Chromium + OS deps) + timeout-minutes: 8 run: pnpm --filter @memwal/noter playwright:install - name: Run Playwright E2E diff --git a/apps/noter/lib/constants.ts b/apps/noter/lib/constants.ts index 3a4885961..d56523879 100644 --- a/apps/noter/lib/constants.ts +++ b/apps/noter/lib/constants.ts @@ -1,7 +1,15 @@ export const isProductionEnvironment = process.env.NODE_ENV === "production"; export const isDevelopmentEnvironment = process.env.NODE_ENV === "development"; -export const isTestEnvironment = Boolean( - process.env.PLAYWRIGHT_TEST_BASE_URL || - process.env.PLAYWRIGHT || - process.env.CI_PLAYWRIGHT -); + +/** + * Playwright / CI test runner only. Fail-closed in production so a leaked + * PLAYWRIGHT=True on Railway cannot flip assertDelegateAccountBinding onto + * the fixture-key mock (which would let anyone log in as 0x00… / 0x40…). + */ +export const isTestEnvironment = + process.env.NODE_ENV !== "production" && + Boolean( + process.env.PLAYWRIGHT_TEST_BASE_URL || + process.env.PLAYWRIGHT || + process.env.CI_PLAYWRIGHT + ); diff --git a/apps/noter/lib/sui/account-bcs.unit.test.ts b/apps/noter/lib/sui/account-bcs.unit.test.ts new file mode 100644 index 000000000..5bd3dc309 --- /dev/null +++ b/apps/noter/lib/sui/account-bcs.unit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + AccountCreatedBcs, + AccountRegistryBcs, + MemWalAccountBcs, +} from "./account-bcs"; + +const ID = `0x${"11".repeat(32)}`; +const OWNER = `0x${"22".repeat(32)}`; +const TABLE = `0x${"33".repeat(32)}`; + +describe("account BCS schemas", () => { + it("round-trips a MemWalAccount", () => { + const value = { + id: ID, + owner: OWNER, + delegate_keys: [], + created_at: BigInt(1), + active: true, + }; + const parsed = MemWalAccountBcs.parse(MemWalAccountBcs.serialize(value).toBytes()); + expect(parsed.id).toBe(ID); + expect(parsed.owner).toBe(OWNER); + expect(parsed.active).toBe(true); + expect(parsed.delegate_keys).toEqual([]); + }); + + it("round-trips an AccountCreated event", () => { + const parsed = AccountCreatedBcs.parse( + AccountCreatedBcs.serialize({ account_id: ID, owner: OWNER }).toBytes() + ); + expect(parsed.account_id).toBe(ID); + expect(parsed.owner).toBe(OWNER); + }); + + it("round-trips an AccountRegistry", () => { + const parsed = AccountRegistryBcs.parse( + AccountRegistryBcs.serialize({ + id: ID, + accounts: { id: TABLE, size: BigInt(1) }, + }).toBytes() + ); + expect(parsed.id).toBe(ID); + expect(parsed.accounts.id).toBe(TABLE); + }); + + it("documents leftover-byte behavior for appended contract fields", () => { + const encoded = MemWalAccountBcs.serialize({ + id: ID, + owner: OWNER, + delegate_keys: [], + created_at: BigInt(0), + active: true, + }).toBytes(); + const withTrailing = new Uint8Array(encoded.length + 2); + withTrailing.set(encoded); + withTrailing[encoded.length] = 0; + withTrailing[encoded.length + 1] = 1; + + // If this throws, the live decoder will fail closed when the published + // package grows trailing fields. If it parses, appended fields are + // ignored and only *inserted* fields would silently decode wrong. + const parseWithTrailing = () => MemWalAccountBcs.parse(withTrailing); + try { + const parsed = parseWithTrailing(); + expect(parsed.id).toBe(ID); + expect(parsed.active).toBe(true); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); +}); diff --git a/apps/noter/package.json b/apps/noter/package.json index ec1e2bfd7..f97dbd5c7 100644 --- a/apps/noter/package.json +++ b/apps/noter/package.json @@ -16,7 +16,7 @@ "verify:memwal": "tsx ../../scripts/verify-memwal-credentials.ts", "test:unit": "vitest run", "playwright:install": "playwright install --with-deps chromium", - "test:e2e": "playwright test", + "test:e2e": "PLAYWRIGHT=True playwright test", "clean": "rm -rf .next out build" }, "dependencies": { diff --git a/apps/noter/package/feature/auth/lib/delegate-account.mock.ts b/apps/noter/package/feature/auth/lib/delegate-account.mock.ts index 684f03aa6..243e32b63 100644 --- a/apps/noter/package/feature/auth/lib/delegate-account.mock.ts +++ b/apps/noter/package/feature/auth/lib/delegate-account.mock.ts @@ -2,6 +2,7 @@ import * as ed from "@noble/ed25519"; import { sha512 } from "@noble/hashes/sha2.js"; import { toBase64 } from "@mysten/sui/utils"; import { enokiConfig } from "@/lib/enoki/config"; +import { findDelegateFixture } from "./delegate-fixtures"; if (!ed.etc.sha512Sync) { ed.etc.sha512Sync = (...m: Uint8Array[]) => { @@ -11,39 +12,6 @@ if (!ed.etc.sha512Sync) { }; } -function hex2(n: number): string { - return n.toString(16).padStart(2, "0"); -} - -/** - * Fixture identities for Playwright runs (lib/constants.ts isTestEnvironment). - * - * Noter's specs authenticate a fresh identity per test (unlike researcher's - * two-identity, shared-storage-state design), so this is a pool, not a pair - * — with `workers: 2` and ~15 login call sites, two fixed identities would - * have tests colliding on each other's notes. Each entry is deterministic - * (index N → accountId byte `N` repeated, privateKey byte `N + 0x40` - * repeated) so the same 24 pairs regenerate identically here and in - * tests/playwright/fixtures/delegate-key.ts — keep both in sync. - * - * Each account id maps to exactly one delegate private key, so the real - * binding validation in delegate-account.ts still runs meaningfully against - * the fabricated object: an unknown account id fails lookup, and a key that - * doesn't derive the registered public key is rejected — the same failure - * modes as the on-chain path. - */ -const FIXTURE_COUNT = 24; - -const TEST_DELEGATE_ACCOUNTS: ReadonlyArray<{ - accountId: string; - owner: string; - privateKey: string; -}> = Array.from({ length: FIXTURE_COUNT }, (_, i) => ({ - accountId: `0x${hex2(i).repeat(32)}`, - owner: `0x${hex2(i + 0x80).repeat(32)}`, - privateKey: hex2(i + 0x40).repeat(32), -})); - function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) { @@ -55,22 +23,17 @@ function hexToBytes(hex: string): Uint8Array { /** * Fabricated stand-in for the Sui gRPC getObject response, shaped exactly * like the fields delegate-account.ts validates. Returns null for account - * ids outside the fixture list (the "object not found" case). + * ids outside the shared fixture pool (the "object not found" case). * - * public_key is base64, not hex: delegate-account.ts's publicKeyHex() parser - * tries fromBase64() first and only falls back to a raw-hex check if that - * throws — and every 64-char hex string (alphabet 0-9a-f, always - * length-divisible-by-4) IS valid base64, just of the wrong bytes. Real gRPC - * `include: { json: true }` responses base64-encode bytes fields (standard - * protobuf JSON mapping), so this matches the real shape rather than - * coincidentally working around the parser. + * public_key is base64 to match real gRPC `include: { json: true }` + * responses (standard protobuf JSON mapping for bytes). The parser now + * accepts hex as well; base64 here is about matching production shape, + * not working around the decoder. */ export function mockDelegateAccountObject( accountId: string, ): { type: string; json: unknown } | null { - const fixture = TEST_DELEGATE_ACCOUNTS.find( - (account) => account.accountId.toLowerCase() === accountId.toLowerCase(), - ); + const fixture = findDelegateFixture(accountId); if (!fixture) { return null; } diff --git a/apps/noter/package/feature/auth/lib/delegate-account.ts b/apps/noter/package/feature/auth/lib/delegate-account.ts index 26c6d1229..be6d66467 100644 --- a/apps/noter/package/feature/auth/lib/delegate-account.ts +++ b/apps/noter/package/feature/auth/lib/delegate-account.ts @@ -33,11 +33,19 @@ export async function deriveDelegatePublicKeyHex( } function publicKeyHex(value: unknown): string | null { + // Hex first: every 64-char hex string (alphabet 0-9a-f, length divisible + // by 4) is also valid base64 of the *wrong* bytes. fromBase64() will not + // throw — it just decodes garbage — so a hex-first check is required. + // Matches researcher/lib/auth/delegate-account.ts. + if (typeof value === "string" && /^[0-9a-f]{64}$/i.test(value)) { + return value.toLowerCase(); + } if (typeof value === "string") { try { - return toHex(fromBase64(value)).toLowerCase(); + const decoded = fromBase64(value); + return decoded.length === 32 ? toHex(decoded).toLowerCase() : null; } catch { - return /^[0-9a-f]{64}$/i.test(value) ? value.toLowerCase() : null; + return null; } } if ( diff --git a/apps/noter/package/feature/auth/lib/delegate-account.unit.test.ts b/apps/noter/package/feature/auth/lib/delegate-account.unit.test.ts index 06bb271e4..bc51b5944 100644 --- a/apps/noter/package/feature/auth/lib/delegate-account.unit.test.ts +++ b/apps/noter/package/feature/auth/lib/delegate-account.unit.test.ts @@ -63,4 +63,26 @@ describe("delegate account binding", () => { }) ).resolves.toMatch(/inactive/); }); + + it("accepts a hex-encoded public_key without treating it as base64", async () => { + // 64-char hex is also valid-but-wrong base64. A base64-first parser + // silently decodes the wrong bytes and rejects a registered key. + await expect( + validate({ + active: true, + owner: OWNER, + delegate_keys: [{ public_key: KEY }], + }) + ).resolves.toBeNull(); + }); + + it("rejects a different hex-encoded public_key", async () => { + await expect( + validate({ + active: true, + owner: OWNER, + delegate_keys: [{ public_key: "cd".repeat(32) }], + }) + ).resolves.toMatch(/not registered/); + }); }); diff --git a/apps/noter/package/feature/auth/lib/delegate-fixtures.ts b/apps/noter/package/feature/auth/lib/delegate-fixtures.ts new file mode 100644 index 000000000..cbd474f8b --- /dev/null +++ b/apps/noter/package/feature/auth/lib/delegate-fixtures.ts @@ -0,0 +1,51 @@ +/** + * Deterministic delegate-key identities for Playwright. + * + * Shared by the server-side gRPC mock (`delegate-account.mock.ts`) and the + * Playwright fixture (`tests/playwright/fixtures/delegate-key.ts`) so the + * two cannot drift. Index N → accountId byte `N` repeated, owner byte + * `N + 0x80` repeated, privateKey byte `N + 0x40` repeated. + * + * Sized as a pool (not a pair) because noter authenticates a fresh identity + * per test: with 2 workers and ~15 login call sites, two shared identities + * would collide on each other's notes. + */ +export const DELEGATE_FIXTURE_COUNT = 24; + +export type DelegateFixture = { + accountId: string; + owner: string; + privateKey: string; +}; + +function hex2(n: number): string { + return n.toString(16).padStart(2, "0"); +} + +export function delegateFixtureAt(index: number): DelegateFixture { + if (!Number.isInteger(index) || index < 0 || index >= DELEGATE_FIXTURE_COUNT) { + throw new Error( + `Delegate fixture index ${index} is out of range (0..${DELEGATE_FIXTURE_COUNT - 1}). ` + + `Bump DELEGATE_FIXTURE_COUNT if the suite needs more identities.` + ); + } + return { + accountId: `0x${hex2(index).repeat(32)}`, + owner: `0x${hex2(index + 0x80).repeat(32)}`, + privateKey: hex2(index + 0x40).repeat(32), + }; +} + +export const TEST_DELEGATE_ACCOUNTS: readonly DelegateFixture[] = Array.from( + { length: DELEGATE_FIXTURE_COUNT }, + (_, i) => delegateFixtureAt(i) +); + +export function findDelegateFixture( + accountId: string +): DelegateFixture | undefined { + const needle = accountId.toLowerCase(); + return TEST_DELEGATE_ACCOUNTS.find( + (account) => account.accountId.toLowerCase() === needle + ); +} diff --git a/apps/noter/package/feature/auth/lib/delegate-fixtures.unit.test.ts b/apps/noter/package/feature/auth/lib/delegate-fixtures.unit.test.ts new file mode 100644 index 000000000..7fe672cd2 --- /dev/null +++ b/apps/noter/package/feature/auth/lib/delegate-fixtures.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + DELEGATE_FIXTURE_COUNT, + TEST_DELEGATE_ACCOUNTS, + delegateFixtureAt, + findDelegateFixture, +} from "./delegate-fixtures"; + +describe("delegate fixtures", () => { + it("builds a unique deterministic pool", () => { + expect(TEST_DELEGATE_ACCOUNTS).toHaveLength(DELEGATE_FIXTURE_COUNT); + + const accountIds = new Set(TEST_DELEGATE_ACCOUNTS.map((a) => a.accountId)); + const privateKeys = new Set(TEST_DELEGATE_ACCOUNTS.map((a) => a.privateKey)); + expect(accountIds.size).toBe(DELEGATE_FIXTURE_COUNT); + expect(privateKeys.size).toBe(DELEGATE_FIXTURE_COUNT); + + expect(delegateFixtureAt(0)).toEqual({ + accountId: `0x${"00".repeat(32)}`, + owner: `0x${"80".repeat(32)}`, + privateKey: "40".repeat(32), + }); + }); + + it("looks up by account id case-insensitively", () => { + const fixture = delegateFixtureAt(1); + expect(findDelegateFixture(fixture.accountId.toUpperCase())).toEqual(fixture); + expect(findDelegateFixture(`0x${"ff".repeat(32)}`)).toBeUndefined(); + }); + + it("throws instead of wrapping past the pool", () => { + expect(() => delegateFixtureAt(DELEGATE_FIXTURE_COUNT)).toThrow(/out of range/); + expect(() => delegateFixtureAt(-1)).toThrow(/out of range/); + }); +}); diff --git a/apps/noter/playwright.config.ts b/apps/noter/playwright.config.ts index d030e328c..58d66f7bc 100644 --- a/apps/noter/playwright.config.ts +++ b/apps/noter/playwright.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: isCI, retries: isCI ? 2 : 0, - workers: 2, + workers: isCI ? 2 : undefined, reporter: isCI ? [ ["html", { open: "never", outputFolder: "playwright-report" }], diff --git a/apps/noter/tests/playwright/e2e/app.test.ts b/apps/noter/tests/playwright/e2e/app.test.ts index 0d90332a6..55d359e52 100644 --- a/apps/noter/tests/playwright/e2e/app.test.ts +++ b/apps/noter/tests/playwright/e2e/app.test.ts @@ -1,13 +1,15 @@ import { expect, test } from "@playwright/test"; test.describe("App shell", () => { - test("landing page renders both sign-in paths", async ({ page }) => { + test("landing page renders the delegate-key sign-in path", async ({ page }) => { await page.goto("/"); await expect(page.getByRole("heading", { name: "Welcome to Noter" })).toBeVisible(); await expect(page.getByText("AI-powered note-taking on Sui blockchain")).toBeVisible(); - await expect(page.getByRole("button", { name: /sign in with google/i })).toBeVisible(); await expect(page.getByRole("button", { name: /sign in with delegate key/i })).toBeVisible(); + // Google/Enoki registers in a client effect and needs a real Enoki wallet + // adapter. Placeholder CI keys may never produce one — don't fail the + // shell test on that path. The Google flow stays a manual check. }); test("delegate key form stays collapsed until requested", async ({ page }) => { diff --git a/apps/noter/tests/playwright/e2e/auth.test.ts b/apps/noter/tests/playwright/e2e/auth.test.ts index 05d66000a..f1b131dc4 100644 --- a/apps/noter/tests/playwright/e2e/auth.test.ts +++ b/apps/noter/tests/playwright/e2e/auth.test.ts @@ -42,6 +42,7 @@ test.describe("Delegate key authentication", () => { await page.reload(); expect(await readSessionId(page)).toBe(before); + await expect(page).toHaveURL(/\/note(\/|$)/); }); test("does not sign in with a key that is not 64 hex characters", async ({ page }) => { @@ -54,7 +55,7 @@ test.describe("Delegate key authentication", () => { await page.getByRole("button", { name: "Sign In", exact: true }).click(); // Server-side zod guard: /^[0-9a-f]{64}$/i — no session is issued. - await page.waitForTimeout(2000); + await expect(page.locator("p.text-destructive")).toContainText(/64 hex/i); await expect(page).toHaveURL("/"); expect(await readSessionId(page)).toBeNull(); }); @@ -95,7 +96,7 @@ test.describe("Delegate key authentication", () => { // /note's guard fired router.replace("/") — bouncing an authenticated // user back to the landing page. sessionAtom now reads sessionStorage // synchronously on first render (getOnInit: true), so no such gap exists. - await page.waitForTimeout(3000); + await page.waitForURL(/\/note(\/|$)/); await expect(page).toHaveURL(/\/note(\/|$)/); }); diff --git a/apps/noter/tests/playwright/fixtures/delegate-key.ts b/apps/noter/tests/playwright/fixtures/delegate-key.ts index 07d44eccf..d9f547009 100644 --- a/apps/noter/tests/playwright/fixtures/delegate-key.ts +++ b/apps/noter/tests/playwright/fixtures/delegate-key.ts @@ -1,66 +1,53 @@ /** * Delegate-key auth helpers. * - * `connectDelegateKey` now calls `assertDelegateAccountBinding` (see - * package/feature/auth/lib/delegate-account.ts), which verifies the derived - * public key is registered on the claimed account — on real chain data - * outside tests, and against package/feature/auth/lib/delegate-account.mock.ts's - * fixture pool when isTestEnvironment is set (playwright.config.ts passes - * PLAYWRIGHT=True to the webServer). A random, never-registered key/account - * pair is no longer enough to reach an authenticated session, so this pulls - * from that same fixture pool instead of generating throwaway credentials. + * `connectDelegateKey` calls `assertDelegateAccountBinding`, which verifies + * the derived public key is registered on the claimed account — on real + * chain data outside tests, and against the shared fixture pool + * (`package/feature/auth/lib/delegate-fixtures.ts`) when isTestEnvironment + * is set (playwright.config.ts / test:e2e pass PLAYWRIGHT=True). * - * The pool (24 entries) exists because noter's specs authenticate a fresh - * identity per test — with `workers: 2` and ~15 login call sites, two fixed - * identities (researcher's pattern) would have tests colliding on each - * other's notes. Generation must match delegate-account.mock.ts exactly: - * index N → accountId byte `N` repeated, privateKey byte `N + 0x40` - * repeated — keep both in sync. + * Identities come from that single pool so the mock and this fixture cannot + * drift. The allocator throws when the pool is exhausted rather than wrapping + * back to fixture 0 (which would collide two tests on the same notes). * * A fixture key is NOT enough to write to Walrus Memory: isTestEnvironment - * is unconditionally true for every Playwright run, so the binding check - * only ever consults the fixture pool — a real, on-chain-registered key - * would fail at login before reaching the relayer. There's no live-write - * path in this suite by design; see the note in e2e/memory.test.ts. + * is true for every Playwright-started server, so the binding check only + * ever consults the fixture pool. There's no live-write path in this suite + * by design; see the note in e2e/memory.test.ts. */ import { test, type Page } from "@playwright/test"; +import { + DELEGATE_FIXTURE_COUNT, + delegateFixtureAt, +} from "../../../package/feature/auth/lib/delegate-fixtures"; export type DelegateCredentials = { privateKey: string; accountId: string; }; -const FIXTURE_COUNT = 24; - -function hex2(n: number): string { - return n.toString(16).padStart(2, "0"); -} - -function fixtureAt(i: number): DelegateCredentials { - return { - accountId: `0x${hex2(i).repeat(32)}`, - privateKey: hex2(i + 0x40).repeat(32), - }; -} - // Module-scoped counter, one instance per Playwright worker PROCESS (workers: // 2 spawns separate processes, not just separate async contexts — a plain // counter alone would restart at 0 in each, so two workers' first calls would // both claim fixture 0). Interleave by test.info().parallelIndex so worker 0 -// claims 0, 2, 4, ... and worker 1 claims 1, 3, 5, ... — every call across -// every worker gets a distinct fixture, with no fixed per-worker range to -// exhaust. Wraps at FIXTURE_COUNT if a run needs more logins than the pool -// has — bump FIXTURE_COUNT (and the matching constant in -// delegate-account.mock.ts) if that ever fires for real. +// claims 0, 2, 4, ... and worker 1 claims 1, 3, 5, ... let localCallCount = 0; -/** A never-yet-used fixture identity from the pool this test run owns exclusively. */ +/** A never-yet-used fixture identity from the pool this worker owns exclusively. */ export function nextDelegateCredentials(): DelegateCredentials { const parallelIndex = test.info().parallelIndex; - const workerCount = test.info().config.workers; - const i = (localCallCount * workerCount + parallelIndex) % FIXTURE_COUNT; + const workerCount = Number(test.info().config.workers); + const i = localCallCount * workerCount + parallelIndex; + if (i >= DELEGATE_FIXTURE_COUNT) { + throw new Error( + `Delegate fixture pool exhausted (need index ${i}, have ${DELEGATE_FIXTURE_COUNT}). ` + + `Bump DELEGATE_FIXTURE_COUNT in package/feature/auth/lib/delegate-fixtures.ts.` + ); + } localCallCount += 1; - return fixtureAt(i); + const fixture = delegateFixtureAt(i); + return { accountId: fixture.accountId, privateKey: fixture.privateKey }; } /** Open the collapsed "Sign in with delegate key" form on the landing page. */ From a300db70a3d805c7d9d71146fb5ba208b72de5e1 Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:49:42 +0700 Subject: [PATCH 17/32] chore: drop changesets to avoid a double bump on main --- .changeset/gh-571-clock-drift-error.md | 7 ------- .changeset/gh-628-project-scoped-credentials.md | 11 ----------- 2 files changed, 18 deletions(-) delete mode 100644 .changeset/gh-571-clock-drift-error.md delete mode 100644 .changeset/gh-628-project-scoped-credentials.md diff --git a/.changeset/gh-571-clock-drift-error.md b/.changeset/gh-571-clock-drift-error.md deleted file mode 100644 index 15a9f5738..000000000 --- a/.changeset/gh-571-clock-drift-error.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@mysten-incubation/memwal": patch ---- - -Surface relayer clock-drift rejections as an actionable error (#571). - -When the relayer rejects a signed request because the client's timestamp is outside its accepted clock-drift window, it now returns `401` with an `x-auth-error: ERR_TIMESTAMP_OUT_OF_BOUNDS` header. The SDK detects this on both the Relayer and manual request paths and throws a clear error (`serverCode: "ERR_TIMESTAMP_OUT_OF_BOUNDS"`) telling the caller to synchronize the client clock — instead of an opaque `401`. Fully backward-compatible: responses without the header behave exactly as before. diff --git a/.changeset/gh-628-project-scoped-credentials.md b/.changeset/gh-628-project-scoped-credentials.md deleted file mode 100644 index bdbba3519..000000000 --- a/.changeset/gh-628-project-scoped-credentials.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@mysten-incubation/memwal-mcp": patch ---- - -Resolve credentials per project, and stop silently replacing another account's (#628). - -Credentials lived in one global `~/.memwal/credentials.json`, so signing in from one project repointed every other project on the machine at a different account and delegate key. The only visible signal was the `label` field, and nothing warned at the point of use — memories written in that state would have landed on the wrong account, on immutable storage, with no delete path. - -A `.memwal/credentials.json` in the working directory now takes precedence over the global file, the way `.npmrc` and `.git/config` resolve. This is purely additive: a machine with no project-local file behaves exactly as before, and creating one is the opt-in. - -Signing in as a *different* account now also copies the outgoing file to `credentials.backup-.json` and prints both account ids — the one replaced and the one now in use. There was previously no backup of any kind, so an overwrite was unrecoverable. From 03defa1e15b0ebce0fe1cb07ae244b2ce0c6f9ee Mon Sep 17 00:00:00 2001 From: Hoang Duc Bach <124645604+HoangDucBach@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:10:35 +0300 Subject: [PATCH 18/32] fix(frontend): WALM-326 scope the dashboard no-key notice to this browser (#719) --- apps/app/src/index.css | 55 +++++++++++++++++++++++++++++ apps/app/src/pages/Dashboard.tsx | 59 ++++++++++++++++++++++++++------ 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/apps/app/src/index.css b/apps/app/src/index.css index ea322ec40..d29c499be 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -11930,3 +11930,58 @@ h1, h2, h3 { padding: 20px; } } + +.dash-page .dash-alert--info { + min-height: 0; + align-items: flex-start; + gap: 12px; + margin-bottom: 28px !important; + padding: 14px 18px; + border: 1px solid var(--dash-panel-border); + border-radius: var(--radius-md); + background: var(--dash-panel); + color: var(--dash-subtle); +} + +.dash-page .dash-alert--info .dash-alert-icon { + width: 20px; + height: 20px; + margin-top: 2px; + color: var(--dash-yellow); +} + +.dash-page .dash-alert--info p { + color: var(--dash-subtle); + font-size: 15px; + font-weight: 400; + line-height: 1.5; +} + +.dash-page .dash-alert-link { + padding: 0; + border: 0; + background: none; + color: var(--dash-yellow); + font: inherit; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} + +.dash-page .dash-alert-link:hover { + text-decoration-thickness: 2px; +} + +.dash-page .dashboard-key-current-badge { + white-space: nowrap; +} + +@media (max-width: 640px) { + .dash-page .dash-alert--info { + padding: 12px 14px; + } + + .dash-page .dash-alert--info p { + font-size: 14px; + } +} diff --git a/apps/app/src/pages/Dashboard.tsx b/apps/app/src/pages/Dashboard.tsx index dc2968b7c..c8af1956f 100644 --- a/apps/app/src/pages/Dashboard.tsx +++ b/apps/app/src/pages/Dashboard.tsx @@ -14,7 +14,7 @@ import { useSponsoredTransaction } from '../hooks/useSponsoredTransaction' import { generateDelegateKey } from '@mysten-incubation/memwal/account' import type { WalletSigner } from '@mysten-incubation/memwal/manual' import { Link, useNavigate } from 'react-router-dom' -import { TriangleAlert, Copy, Eye, EyeOff, Trash2, RefreshCw, Plus, LogOut, Github, MessageCircle } from 'lucide-react' +import { TriangleAlert, Info, Copy, Eye, EyeOff, Trash2, RefreshCw, Plus, LogOut, Github, MessageCircle } from 'lucide-react' import { Light as SyntaxHighlighter } from 'react-syntax-highlighter' import js from 'react-syntax-highlighter/dist/esm/languages/hljs/javascript' import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python' @@ -118,6 +118,7 @@ interface OnChainDelegateKey { const MAX_DELEGATE_KEYS = 20 const MAX_DELEGATE_KEYS_MESSAGE = 'This wallet already has 20 delegate keys. Remove an old key before creating a new delegate key.' +const DELEGATE_KEYS_SECTION_ID = 'delegate-keys' const PRIVATE_KEY_ENV = 'MEMWAL_PRIVATE_KEY' const ACCOUNT_ID_ENV = 'MEMWAL_ACCOUNT_ID' const SERVER_URL_ENV = 'MEMWAL_SERVER_URL' @@ -368,7 +369,6 @@ export default function Dashboard({ const hasResolvedAccount = Boolean(effectiveAccountObjectId) const accountLookupPending = loadingAccount || (shouldResolveAccount && (!accountLookupComplete || accountLookupAddress !== address)) - const isRecoveringExistingAccount = !delegateKey && hasResolvedAccount && !previewReady const activeEnvironmentLabel = config.suiNetwork === 'mainnet' ? 'production / mainnet' : 'staging / testnet' @@ -401,12 +401,21 @@ export default function Dashboard({ const hasMaxDelegateKeys = onChainKeys.length >= MAX_DELEGATE_KEYS const isKeyListLoading = accountLookupPending || (loadingKeys && onChainKeys.length === 0) const isKeyListRefreshing = loadingKeys && onChainKeys.length > 0 + const onChainKeyCount = onChainKeys.length + const browserHasNoKey = !delegateKey && hasResolvedAccount && !previewReady + const showNoBrowserKeyNotice = browserHasNoKey && !isKeyListLoading const selectableKeyPublicKeys = useMemo(() => onChainKeys.map((key) => key.publicKey), [onChainKeys]) const selectedKeySet = useMemo(() => new Set(selectedKeyPublicKeys), [selectedKeyPublicKeys]) const selectedKeyCount = selectedKeyPublicKeys.length const keyRemovalBusy = removingSelectedKeys || Boolean(removingKey) const showKeySelectionControls = Boolean(effectiveAccountObjectId) && selectedKeyCount > 0 && !accountLookupPending + const scrollToDelegateKeys = useCallback(() => { + document + .getElementById(DELEGATE_KEYS_SECTION_ID) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, []) + useEffect(() => { setSelectedKeyPublicKeys((prev) => { const next = prev.filter((publicKey) => selectableKeyPublicKeys.includes(publicKey)) @@ -775,12 +784,30 @@ const result = await generateText({ {showDashboardSubtitle &&

{dashboardSubtitle}

} - {isRecoveringExistingAccount && ( -
-