@echomem/mcp 1.4.20 → 1.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/crypto.js CHANGED
@@ -95,6 +95,10 @@ export async function decrypt(ciphertext, key) {
95
95
  const plainBuffer = await subtle.decrypt({ name: ALGORITHM, iv }, key, data);
96
96
  return new TextDecoder().decode(plainBuffer);
97
97
  }
98
+ /** Create a server-verifiable token for a newly configured encryption key. */
99
+ export async function createVerification(key) {
100
+ return encrypt(VERIFICATION_PLAINTEXT, key);
101
+ }
98
102
  /** Verify a key against a stored verification token (current + legacy plaintexts). */
99
103
  export async function verifyKey(key, token) {
100
104
  try {
@@ -1,4 +1,5 @@
1
- import { deriveKey, exportKeyToBase64, importKeyFromBase64, decrypt, verifyKey, saltFromBase64 } from "./crypto.js";
1
+ import { createVerification, deriveKey, exportKeyToBase64, generateSalt, importKeyFromBase64, decrypt, saltFromBase64, saltToBase64, verifyKey, } from "./crypto.js";
2
+ const DEFAULT_KEY_ITERATIONS = 600_000;
2
3
  /** GET the account's encryption status from the EchoMem backend using the bridge's authed client. */
3
4
  export async function fetchEncryptionConfig(axios) {
4
5
  // Bounded: this is on the startup/tool-listing path; an unbounded call on a flaky network would hang
@@ -21,11 +22,22 @@ export async function deriveAndVerifyKey(passphrase, config) {
21
22
  if (!config.enabled || !config.salt || !config.verification)
22
23
  return null;
23
24
  const salt = saltFromBase64(config.salt);
24
- const key = await deriveKey(passphrase, salt, config.iterations ?? 600_000);
25
+ const key = await deriveKey(passphrase, salt, config.iterations ?? DEFAULT_KEY_ITERATIONS);
25
26
  if (!(await verifyKey(key, config.verification)))
26
27
  return null;
27
28
  return exportKeyToBase64(key);
28
29
  }
30
+ /** Create a fresh account encryption config locally. The passphrase/key never leaves the device. */
31
+ export async function setupNewEncryptionKey(passphrase, iterations = DEFAULT_KEY_ITERATIONS) {
32
+ const salt = generateSalt();
33
+ const key = await deriveKey(passphrase, salt, iterations);
34
+ return {
35
+ keyBase64: await exportKeyToBase64(key),
36
+ saltBase64: saltToBase64(salt),
37
+ verification: await createVerification(key),
38
+ iterations,
39
+ };
40
+ }
29
41
  /** Verify an already-exported base64 key against the server's verification token. */
30
42
  export async function verifyKeyB64(keyBase64, config) {
31
43
  if (!config.enabled || !config.verification)
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { EventLogger, hashText } from "./events.js";
10
10
  import { buildReportText } from "./report.js";
11
11
  import { contextHealthMarkdown, recomposeCapsuleMarkdown } from "./hud/api.js";
12
12
  import { createHash, randomUUID } from "node:crypto";
13
- import { fetchEncryptionConfig, decryptMemoryFields } from "./encryption.js";
13
+ import { fetchEncryptionConfig, decryptMemoryFields, verifyKeyB64, } from "./encryption.js";
14
14
  import { runCli } from "./setup.js";
15
15
  import { MCP_PACKAGE_VERSION, MCP_SERVER_INSTRUCTIONS } from "./package-metadata.js";
16
16
  import { clearBillingAlert, writeBillingAlert } from "./billing-alert.js";
@@ -23,6 +23,9 @@ class NoTokenError extends Error {
23
23
  /** Thrown when an encrypted account has no usable key — the model gets an unlock nudge, never ciphertext. */
24
24
  class LockedError extends Error {
25
25
  }
26
+ /** Thrown when the bridge cannot prove whether the account is encrypted. */
27
+ class EncryptionStatusUnavailableError extends Error {
28
+ }
26
29
  function stringifyErrorValue(value) {
27
30
  if (value == null) {
28
31
  return "";
@@ -121,7 +124,7 @@ function formatUpgradeRequiredResult(error) {
121
124
  pricingUrl,
122
125
  });
123
126
  return [
124
- "ACTION REQUIRED: tell the user to start a 7-day Pro trial or choose a subscription at this exact URL:",
127
+ "ACTION REQUIRED: tell the user to start a 14-day Pro trial or choose a subscription at this exact URL:",
125
128
  pricingUrl,
126
129
  "When replying, include the exact URL above. Do not reply only with \"connect\" or \"upgrade\".",
127
130
  "",
@@ -130,6 +133,15 @@ function formatUpgradeRequiredResult(error) {
130
133
  code ? `Reason: ${code}.` : "",
131
134
  ].filter(Boolean).join("\n");
132
135
  }
136
+ function formatReconnectRequiredResult(error) {
137
+ if (!axios.isAxiosError(error) || error.response?.status !== 401)
138
+ return null;
139
+ return [
140
+ "🔌 EchoMem's saved login is no longer accepted (Unauthorized, HTTP 401).",
141
+ "Action required from the user: run `echomem-mcp login` in a terminal to reconnect this device, then retry this action.",
142
+ "No editor restart is needed. Keep all credentials out of chat.",
143
+ ].join("\n");
144
+ }
133
145
  /** Map a thrown error to the telemetry error_kind taxonomy. */
134
146
  function classifyError(error) {
135
147
  if (error instanceof NoTokenError)
@@ -564,8 +576,8 @@ class EchoMemApiClient {
564
576
  if (!this.encConfigPromise) {
565
577
  this.encConfigPromise = fetchEncryptionConfig(this.axios).catch((error) => {
566
578
  this.encConfigPromise = null; // allow retry next call
567
- console.error(`[encryption] config fetch failed: ${describeError(error)} — proceeding as unencrypted`);
568
- return { enabled: false };
579
+ console.error(`[encryption] config fetch failed: ${describeError(error)} — refusing memory access`);
580
+ throw new EncryptionStatusUnavailableError("EchoMem could not verify the account encryption state");
569
581
  });
570
582
  }
571
583
  return this.encConfigPromise;
@@ -576,18 +588,20 @@ class EchoMemApiClient {
576
588
  * handing the model ciphertext. For unencrypted accounts it returns `{ enabled: false }`.
577
589
  */
578
590
  async encState() {
579
- // A stored key ONLY exists because the user unlocked an ENCRYPTED vault, so treat it as
580
- // authoritative: a flaky/failed `/account/encryption` fetch (which defaults to enabled:false) must
581
- // NOT downgrade an unlocked encrypted account to plaintext — that causes a 422 on save and leaks
582
- // ciphertext on read. So if we hold a usable key, we're encrypted-and-unlocked, period.
583
- const key = this.store.getKey();
584
- if (key)
585
- return { enabled: true, key };
586
- // No usable key: consult the server to tell "unencrypted" apart from "encrypted but locked/expired".
587
591
  const cfg = await this.getEncryptionConfig();
588
- if (cfg.enabled)
592
+ if (!cfg.enabled)
593
+ return { enabled: false };
594
+ const key = this.store.getKey();
595
+ if (!key)
589
596
  throw new LockedError("EchoMem vault locked");
590
- return { enabled: false };
597
+ if (!(await verifyKeyB64(key, cfg))) {
598
+ // A file-backed stale key can be removed safely; environment-provided
599
+ // keys remain under the caller's control and are ignored until corrected.
600
+ if (!process.env.ECHO_ENCRYPTION_KEY)
601
+ this.store.clearKey();
602
+ throw new LockedError("EchoMem vault key is invalid");
603
+ }
604
+ return { enabled: true, key };
591
605
  }
592
606
  pruneDeleteConfirmations(now = Date.now()) {
593
607
  for (const [memoryId, confirmation] of this.deleteConfirmations) {
@@ -608,13 +622,6 @@ class EchoMemApiClient {
608
622
  }
609
623
  return this.whoamiCache;
610
624
  }
611
- cleanMemoryDescription(desc) {
612
- if (!desc)
613
- return "";
614
- // The tuned snapshot wraps text as "Here is a memory: …Content: <text>".
615
- const idx = desc.indexOf("Content:");
616
- return (idx >= 0 ? desc.slice(idx + "Content:".length) : desc).trim();
617
- }
618
625
  async fetchMemoryById(id, enc) {
619
626
  try {
620
627
  const response = await this.axios.get(`/api/extension/memories/${encodeURIComponent(id)}`, {
@@ -630,34 +637,6 @@ class EchoMemApiClient {
630
637
  throw error;
631
638
  }
632
639
  }
633
- async searchMemoriesRaw(query, opts, enc, retrievalOnly = false, trace) {
634
- const endpoint = "/api/extension/memories/search";
635
- let response;
636
- try {
637
- response = await this.axios.post(endpoint, {
638
- query,
639
- k: opts.limit,
640
- similarityThreshold: opts.threshold,
641
- timeFrameDays: opts.timeFrameDays,
642
- requestId: opts.requestId,
643
- sessionId: this.sessionId,
644
- });
645
- trace?.({ requestId: opts.requestId, endpoint, httpStatus: response.status });
646
- }
647
- catch (error) {
648
- trace?.({
649
- requestId: opts.requestId,
650
- endpoint,
651
- httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
652
- errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
653
- });
654
- throw error;
655
- }
656
- const data = enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
657
- return retrievalOnly && isRecord(data)
658
- ? { ...data, retrievalOnly: true, tuned: false }
659
- : data;
660
- }
661
640
  async searchMemoriesRetrieved(query, opts, enc, trace) {
662
641
  const endpoint = "/api/extension/memories/deep-search/candidates";
663
642
  let response;
@@ -732,48 +711,6 @@ class EchoMemApiClient {
732
711
  memories,
733
712
  };
734
713
  }
735
- // Tuned recall. Routes through echo-mem-chrome's AUTHENTICATED deep-search proxy
736
- // (the same route the Chrome extension uses): it verifies our ec_ token, derives
737
- // user_id server-side, and forwards to dg-web's two-phase retriever. The bridge
738
- // never hits dg-web directly, so no new auth is needed and no other caller is
739
- // disrupted. NOTE: encrypted users still get server-side plaintext only; the
740
- // phase-1 + bridge-local-decrypt + phase-2 flow (spec §11) is the remaining work.
741
- async searchMemoriesTuned(query, requestId, trace) {
742
- const endpoint = "/api/extension/memories/deep-search";
743
- let response;
744
- try {
745
- response = await this.axios.post(endpoint, {
746
- query,
747
- requestId,
748
- logicalRequestId: requestId,
749
- sessionId: this.sessionId,
750
- userTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
751
- });
752
- trace?.({ requestId, endpoint, httpStatus: response.status });
753
- }
754
- catch (error) {
755
- trace?.({
756
- requestId,
757
- endpoint,
758
- httpStatus: axios.isAxiosError(error) ? error.response?.status : undefined,
759
- errorCode: axios.isAxiosError(error) ? errorCodeFrom(error.response?.data) : undefined,
760
- });
761
- throw error;
762
- }
763
- const data = response.data ?? {};
764
- if (data.success === false) {
765
- trace?.({ requestId, endpoint, httpStatus: response.status, errorCode: errorCodeFrom(data) });
766
- throw new Error(`deep-search proxy error: ${data.error || "unknown"}`);
767
- }
768
- const primary = (data.retrievedMemorySnapshot?.primary ?? []);
769
- const memories = primary.map((m) => ({
770
- key: String(m.key ?? ""),
771
- description: this.cleanMemoryDescription(String(m.description ?? "")),
772
- time: m.time,
773
- similarity: typeof m.similarity_score === "number" ? m.similarity_score : undefined,
774
- }));
775
- return { success: true, tuned: true, answer: String(data.answer || data.response || "").trim(), memories };
776
- }
777
714
  async searchMemories(args, trace) {
778
715
  const parsed = searchMemoriesSchema.parse(args ?? {});
779
716
  const query = parsed.query?.trim();
@@ -800,18 +737,11 @@ class EchoMemApiClient {
800
737
  timeFrameDays: parsed.timeFrameDays,
801
738
  requestId: randomUUID(),
802
739
  };
803
- if (!parsed.includeAnswer) {
804
- return this.searchMemoriesRetrieved(query, searchOpts, enc, trace);
805
- }
806
- // Encrypted account: the server can't synthesize over plaintext it doesn't hold, so use the raw
807
- // retriever (returns ciphertext) and decrypt locally — zero-knowledge preserved end-to-end.
808
- if (enc.enabled) {
809
- return await this.searchMemoriesRaw(query, searchOpts, enc, false, trace);
810
- }
811
- // A synthesized answer is metered through the legacy route. Do not fall
812
- // back to raw retrieval after a ledger outage: that would return a result
813
- // without a durable usage fact.
814
- return this.searchMemoriesTuned(query, searchOpts.requestId, trace);
740
+ // MCP recall is retrieval-only. `includeAnswer` remains accepted by the
741
+ // parser for older clients, but it must never select an EchoMem/dg-web LLM
742
+ // path. The MCP host model receives the ranked memories and writes the
743
+ // final answer itself.
744
+ return this.searchMemoriesRetrieved(query, searchOpts, enc, trace);
815
745
  }
816
746
  /** Decrypt the model-visible fields on a `{ memories: [...] }` response locally. */
817
747
  async decryptResult(data, key) {
@@ -1227,12 +1157,31 @@ class EchoMemMCPServer {
1227
1157
  ],
1228
1158
  };
1229
1159
  }
1160
+ if (error instanceof EncryptionStatusUnavailableError) {
1161
+ return {
1162
+ content: [{
1163
+ type: "text",
1164
+ text: [
1165
+ "⚠️ EchoMem could not verify whether this account's vault is encrypted.",
1166
+ "No memory data was returned. Retry this action after the service recovers.",
1167
+ ].join("\n"),
1168
+ }],
1169
+ isError: true,
1170
+ };
1171
+ }
1230
1172
  if (error instanceof ZodError) {
1231
1173
  throw new McpError(ErrorCode.InvalidParams, `Invalid arguments: ${error.message}`);
1232
1174
  }
1233
1175
  if (error instanceof McpError) {
1234
1176
  throw error;
1235
1177
  }
1178
+ const reconnectRequired = formatReconnectRequiredResult(error);
1179
+ if (reconnectRequired) {
1180
+ return {
1181
+ content: [{ type: "text", text: reconnectRequired }],
1182
+ isError: true,
1183
+ };
1184
+ }
1236
1185
  const upgradeRequired = formatUpgradeRequiredResult(error);
1237
1186
  if (upgradeRequired) {
1238
1187
  return {
@@ -1345,20 +1294,6 @@ class EchoMemMCPServer {
1345
1294
  .join("\n\n");
1346
1295
  return { content: [{ type: "text", text: `Retrieved ${memories.length} memories:\n\n${formattedResults}` }] };
1347
1296
  }
1348
- // Tuned two-phase path: synthesized brief + ranked source memories.
1349
- if (result?.tuned) {
1350
- const { answer, memories } = result;
1351
- if (!answer && !memories?.length) {
1352
- return { content: [{ type: "text", text: "No relevant memories found." }] };
1353
- }
1354
- const sources = (memories || [])
1355
- .map((m, idx) => `[${idx + 1}] ${m.key}${typeof m.similarity === "number" ? ` (sim ${m.similarity.toFixed(3)})` : ""}\n${m.description}`)
1356
- .join("\n\n");
1357
- const text = [answer && `Recall:\n${answer}`, sources && `---\nSource memories:\n\n${sources}`]
1358
- .filter(Boolean)
1359
- .join("\n\n");
1360
- return { content: [{ type: "text", text }] };
1361
- }
1362
1297
  // Fallback: untuned / time-range shape.
1363
1298
  const { success, memories, error } = result;
1364
1299
  if (!success)
package/dist/migrate.js CHANGED
@@ -525,6 +525,37 @@ export async function cancelQueuedImportSession(client, sessionId) {
525
525
  throw new Error("IMPORT_SESSION_ID_REQUIRED");
526
526
  await client.patch(`/api/extension/import-sessions/${encodeURIComponent(sessionId)}`, { action: "cancel" });
527
527
  }
528
+ async function findRecoverableImportSession(client, sessions, bareConversationId, signal) {
529
+ const items = sessions.map((session) => ({
530
+ conversationId: bareConversationId(session),
531
+ platform: session.source,
532
+ sourceDate: session.firstTs,
533
+ }));
534
+ const response = await client.post("/api/extension/import-sessions/status", { items }, signal ? { signal } : undefined);
535
+ const data = response.data && typeof response.data === "object"
536
+ ? response.data
537
+ : {};
538
+ const required = new Set(sessions.map((session) => mapKey(session.source, bareConversationId(session))));
539
+ for (const candidate of data.recoverableSessions ?? []) {
540
+ const id = typeof candidate.id === "string" ? candidate.id : "";
541
+ const jobs = Array.isArray(candidate.jobs)
542
+ ? candidate.jobs
543
+ .filter((job) => typeof job.id === "string"
544
+ && typeof job.platform === "string"
545
+ && typeof job.conversation_id === "string")
546
+ .map((job) => ({
547
+ id: job.id,
548
+ platform: job.platform,
549
+ conversation_id: job.conversation_id,
550
+ }))
551
+ : [];
552
+ const covered = new Set(jobs.map((job) => mapKey(job.platform, job.conversation_id)));
553
+ if (id && required.size === jobs.length && [...required].every((key) => covered.has(key))) {
554
+ return { id, concurrency: MIGRATE_CONCURRENCY, jobs };
555
+ }
556
+ }
557
+ return null;
558
+ }
528
559
  function isRecord(value) {
529
560
  return typeof value === "object" && value !== null && !Array.isArray(value);
530
561
  }
@@ -984,7 +1015,13 @@ export async function startMigration(opts) {
984
1015
  throw codedError("FORBIDDEN_SCOPE");
985
1016
  const data = responseData(e);
986
1017
  const max = Number(data.maxConversations);
987
- if (responseStatus(e) === 422 && data.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
1018
+ if (responseStatus(e) === 409 && data.error === "IMPORT_ALREADY_IN_PROGRESS") {
1019
+ const recovered = await findRecoverableImportSession(client, toImport, bareId, opts.signal);
1020
+ if (!recovered)
1021
+ throw e;
1022
+ session = recovered;
1023
+ }
1024
+ else if (responseStatus(e) === 422 && data.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
988
1025
  capped = max;
989
1026
  toImport = opts.pending.slice(0, max);
990
1027
  try {
@@ -23,8 +23,6 @@ export const SETUP_PAGE_CLIENT_CORE = String.raw ` var params = new URLSear
23
23
  var progressPollRunId = 0; // invalidates older progress loops when extraction resumes
24
24
  var dashMounted = false; // dashboard shell (incl. plate iframe) mounted once; persists across /stats polls
25
25
  var reportMounted = false; // report shell (city iframe) mounted once; loading is an overlay on it, not a separate page
26
- var authUrl = "";
27
- var switchAccountUrl = "";
28
26
  var workspacePath = "";
29
27
  var billingStatus = null;
30
28
  var billingStatusLoading = false;
@@ -34,8 +32,9 @@ export const SETUP_PAGE_CLIENT_CORE = String.raw ` var params = new URLSear
34
32
  var sessionPickerQuery = "";
35
33
  var sessionPickerSource = "all";
36
34
  var billingPollTimer = null;
37
- var authWindow = null;
38
- var connectionPollStarted = false;
35
+ var localAuthEmail = "";
36
+ var localAuthTermsAccepted = false;
37
+ var localAuthAgeConfirmed = false;
39
38
  var statsPollStarted = false;
40
39
  var reportPollStarted = false;
41
40
  var NIGHT_HOURS = { 22: 1, 23: 1, 0: 1, 1: 1, 2: 1, 3: 1 };
@@ -142,10 +141,6 @@ export const SETUP_PAGE_CLIENT_CORE = String.raw ` var params = new URLSear
142
141
  if (code === "NOT_LOGGED_IN") return "Echo on this machine is not signed in yet. Sign in again and retry.";
143
142
  return code || "Something went wrong. Retry in a moment.";
144
143
  }
145
- function closeAuthWindow() {
146
- try { if (authWindow && !authWindow.closed) authWindow.close(); } catch (_) {}
147
- authWindow = null;
148
- }
149
144
  function notifyOpenerConnected() {
150
145
  try {
151
146
  if (window.opener && window.opener !== window && !window.opener.closed) {
@@ -156,24 +151,197 @@ export const SETUP_PAGE_CLIENT_CORE = String.raw ` var params = new URLSear
156
151
  } catch (_) {}
157
152
  return false;
158
153
  }
159
- function openAuthWindow(url) {
160
- var targetUrl = url || authUrl;
161
- if (!targetUrl) return false;
154
+ function localAuthLegalHtml() {
155
+ return '<div class="localAuthLegal">' +
156
+ '<label><input type="checkbox" id="localAuthAge" ' + (localAuthAgeConfirmed ? "checked" : "") + ' />' +
157
+ '<span>I confirm that I am at least 18 years old.</span></label>' +
158
+ '<label><input type="checkbox" id="localAuthTerms" ' + (localAuthTermsAccepted ? "checked" : "") + ' />' +
159
+ '<span>I have read and agree to the Echo <a href="https://echoknows.com/terms-of-use" target="_blank" rel="noopener noreferrer">Terms of Use</a>, <a href="https://echoknows.com/memory-terms" target="_blank" rel="noopener noreferrer">Memory Usage Terms</a>, and <a href="https://echoknows.com/privacy-policy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>. I understand Echo uses subprocessors listed <a href="https://echoknows.com/subprocessor-list" target="_blank" rel="noopener noreferrer">here</a>.</span></label>' +
160
+ '</div>';
161
+ }
162
+ function captureLocalAuthConsents() {
163
+ var terms = document.getElementById("localAuthTerms");
164
+ var age = document.getElementById("localAuthAge");
165
+ localAuthTermsAccepted = !!(terms && terms.checked);
166
+ localAuthAgeConfirmed = !!(age && age.checked);
167
+ }
168
+ function setLocalAuthStatus(message, tone) {
169
+ var node = document.getElementById("localAuthStatus");
170
+ if (!node) return;
171
+ node.textContent = message || "";
172
+ node.classList.toggle("is-error", tone === "error");
173
+ }
174
+ function updateLocalAuthSendButton() {
175
+ captureLocalAuthConsents();
176
+ var email = document.getElementById("localAuthEmail");
177
+ var button = document.getElementById("localAuthSend");
178
+ if (!button) return;
179
+ var value = email && email.value ? String(email.value).trim() : "";
180
+ button.disabled = !value || !localAuthTermsAccepted || !localAuthAgeConfirmed;
181
+ }
182
+ function renderLocalLogin(message) {
183
+ setCityMode(false);
184
+ setScanMode(false);
185
+ setExtractMode(false);
186
+ setReadyMode(true);
187
+ setHead("Connect EchoMem", "Local login");
188
+ app.className = "localAuthStage";
189
+ app.innerHTML =
190
+ '<section class="localAuthCard">' +
191
+ '<div class="localAuthLead">' +
192
+ '<img src="/hud-assets/echo-face-cutout.png" alt="" />' +
193
+ '<p class="consentEyebrow">EchoMem account / local login</p>' +
194
+ '<h2 class="siteHeadline">Sign in without leaving this device.</h2>' +
195
+ '<p>Echo sends a one-time code to your email. The browser stays on localhost; your device token and encryption key are stored only in the local EchoMem keystore.</p>' +
196
+ '</div>' +
197
+ '<form class="localAuthForm" id="localAuthEmailForm">' +
198
+ '<label class="localAuthField"><span>Email</span><input id="localAuthEmail" type="email" autocomplete="email" placeholder="you@example.com" value="' + esc(localAuthEmail) + '" /></label>' +
199
+ localAuthLegalHtml() +
200
+ '<button class="primary" id="localAuthSend" type="submit">Send code</button>' +
201
+ '<p class="localAuthStatus' + (message ? " is-error" : "") + '" id="localAuthStatus" role="status" aria-live="polite">' + esc(message || "") + '</p>' +
202
+ '</form>' +
203
+ '</section>';
204
+ var form = document.getElementById("localAuthEmailForm");
205
+ if (form) form.onsubmit = function (event) { event.preventDefault(); void sendLocalOtp(); };
206
+ var email = document.getElementById("localAuthEmail");
207
+ if (email) {
208
+ email.oninput = function () { localAuthEmail = String(email.value || ""); updateLocalAuthSendButton(); };
209
+ email.focus();
210
+ }
211
+ var terms = document.getElementById("localAuthTerms");
212
+ var age = document.getElementById("localAuthAge");
213
+ if (terms) terms.onchange = updateLocalAuthSendButton;
214
+ if (age) age.onchange = updateLocalAuthSendButton;
215
+ updateLocalAuthSendButton();
216
+ }
217
+ async function sendLocalOtp() {
218
+ var email = document.getElementById("localAuthEmail");
219
+ localAuthEmail = email && email.value ? String(email.value).trim().toLowerCase() : "";
220
+ captureLocalAuthConsents();
221
+ if (!localAuthEmail) { setLocalAuthStatus("Enter your email.", "error"); return; }
222
+ if (!localAuthAgeConfirmed || !localAuthTermsAccepted) {
223
+ setLocalAuthStatus("Confirm your age and accept Echo's terms before continuing.", "error");
224
+ return;
225
+ }
226
+ var button = document.getElementById("localAuthSend");
227
+ if (button) { button.disabled = true; button.textContent = "Sending..."; }
228
+ setLocalAuthStatus("Sending verification code...");
162
229
  try {
163
- authWindow = window.open(targetUrl, "echomem-signin-" + nonce, "popup=yes,width=560,height=760");
164
- if (!authWindow) return false;
165
- authWindow.focus();
166
- return true;
167
- } catch (_) { return false; }
168
- }
169
- function showPopupFallback(message, url) {
170
- Array.prototype.forEach.call(document.querySelectorAll("[data-signin-help]"), function (hint) {
171
- hint.textContent = message || "Your browser blocked the secure sign-in window. Use the button below, then this page will resume when sign-in returns.";
172
- });
173
- Array.prototype.forEach.call(document.querySelectorAll("[data-signin-fallback]"), function (fallback) {
174
- fallback.setAttribute("href", url || authUrl);
175
- fallback.classList.remove("hidden");
176
- });
230
+ var data = await postJson("/local-auth/send-otp", {
231
+ email: localAuthEmail,
232
+ acceptedTerms: localAuthTermsAccepted,
233
+ ageConfirmed: localAuthAgeConfirmed
234
+ }, 12000);
235
+ localAuthEmail = data.email || localAuthEmail;
236
+ renderLocalOtp("Code sent. Check your inbox.");
237
+ } catch (error) {
238
+ if (button) { button.disabled = false; button.textContent = "Send code"; }
239
+ setLocalAuthStatus(error && error.message ? error.message : "Could not send the code.", "error");
240
+ }
241
+ }
242
+ function renderLocalOtp(message) {
243
+ setCityMode(false);
244
+ setScanMode(false);
245
+ setExtractMode(false);
246
+ setReadyMode(true);
247
+ setHead("Check your email", "Local login");
248
+ app.className = "localAuthStage";
249
+ app.innerHTML =
250
+ '<section class="localAuthCard localAuthCardOtp">' +
251
+ '<div class="localAuthLead">' +
252
+ '<img src="/hud-assets/echo-face-cutout.png" alt="" />' +
253
+ '<p class="consentEyebrow">Verification code</p>' +
254
+ '<h2 class="siteHeadline">Enter the code sent to <strong>' + esc(localAuthEmail) + '</strong>.</h2>' +
255
+ '<p>This code signs in this local EchoMem bridge only. The next step asks for your encryption passphrase.</p>' +
256
+ '</div>' +
257
+ '<form class="localAuthForm" id="localAuthOtpForm">' +
258
+ '<label class="localAuthField"><span>6-digit code</span><input id="localAuthOtp" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="123456" /></label>' +
259
+ '<div class="localAuthActions"><button class="primary" id="localAuthVerify" type="submit">Verify code</button><button class="textButton" id="localAuthBack" type="button">Use a different email</button></div>' +
260
+ '<p class="localAuthStatus" id="localAuthStatus" role="status" aria-live="polite">' + esc(message || "") + '</p>' +
261
+ '</form>' +
262
+ '</section>';
263
+ var form = document.getElementById("localAuthOtpForm");
264
+ if (form) form.onsubmit = function (event) { event.preventDefault(); void verifyLocalOtp(); };
265
+ var back = document.getElementById("localAuthBack");
266
+ if (back) back.onclick = function () { renderLocalLogin(""); };
267
+ var otp = document.getElementById("localAuthOtp");
268
+ if (otp) otp.focus();
269
+ }
270
+ async function verifyLocalOtp() {
271
+ var otpInput = document.getElementById("localAuthOtp");
272
+ var otp = otpInput && otpInput.value ? String(otpInput.value).replace(/\\D/g, "") : "";
273
+ if (otp.length !== 6) { setLocalAuthStatus("Enter the 6-digit code.", "error"); return; }
274
+ var button = document.getElementById("localAuthVerify");
275
+ if (button) { button.disabled = true; button.textContent = "Verifying..."; }
276
+ setLocalAuthStatus("Verifying code...");
277
+ try {
278
+ var data = await postJson("/local-auth/verify-otp", {
279
+ email: localAuthEmail,
280
+ otp: otp,
281
+ acceptedTerms: localAuthTermsAccepted,
282
+ ageConfirmed: localAuthAgeConfirmed
283
+ }, 18000);
284
+ if (data && data.stage === "passphrase") {
285
+ renderLocalPassphrase(data.mode || "unlock", data.email || localAuthEmail);
286
+ return;
287
+ }
288
+ connected = true;
289
+ await waitForStats();
290
+ } catch (error) {
291
+ if (button) { button.disabled = false; button.textContent = "Verify code"; }
292
+ setLocalAuthStatus(error && error.message ? error.message : "Could not verify the code.", "error");
293
+ }
294
+ }
295
+ function renderLocalPassphrase(mode, email, message) {
296
+ localAuthEmail = email || localAuthEmail;
297
+ var setupMode = mode === "setup";
298
+ setCityMode(false);
299
+ setScanMode(false);
300
+ setExtractMode(false);
301
+ setReadyMode(true);
302
+ setHead(setupMode ? "Create encrypted vault" : "Unlock encrypted vault", "Local passphrase");
303
+ app.className = "localAuthStage";
304
+ app.innerHTML =
305
+ '<section class="localAuthCard localAuthCardPassphrase">' +
306
+ '<div class="localAuthLead">' +
307
+ '<img src="/hud-assets/echo-face-cutout.png" alt="" />' +
308
+ '<p class="consentEyebrow">Encryption passphrase</p>' +
309
+ '<h2 class="siteHeadline">' + (setupMode ? 'Create your local vault passphrase.' : 'Enter your vault passphrase.') + '</h2>' +
310
+ '<p>' + (setupMode ? 'Echo derives your encryption key on this device. The passphrase and key are never sent to EchoMem. If you forget it, encrypted memories cannot be recovered.' : 'Echo verifies this passphrase locally against your account encryption token. The passphrase is never sent to EchoMem.') + '</p>' +
311
+ '</div>' +
312
+ '<form class="localAuthForm" id="localAuthPassphraseForm">' +
313
+ '<label class="localAuthField"><span>Passphrase</span><input id="localAuthPassphrase" type="password" autocomplete="' + (setupMode ? "new-password" : "current-password") + '" placeholder="Encryption passphrase" /></label>' +
314
+ (setupMode ? '<label class="localAuthField"><span>Confirm passphrase</span><input id="localAuthPassphraseConfirm" type="password" autocomplete="new-password" placeholder="Confirm passphrase" /></label>' : '') +
315
+ '<div class="localAuthActions"><button class="primary" id="localAuthUnlock" type="submit">' + (setupMode ? "Create vault" : "Unlock") + '</button><button class="textButton" id="localAuthRestart" type="button">Use a different email</button></div>' +
316
+ '<p class="localAuthStatus' + (message ? " is-error" : "") + '" id="localAuthStatus" role="status" aria-live="polite">' + esc(message || "") + '</p>' +
317
+ '</form>' +
318
+ '</section>';
319
+ var form = document.getElementById("localAuthPassphraseForm");
320
+ if (form) form.onsubmit = function (event) { event.preventDefault(); void submitLocalPassphrase(setupMode); };
321
+ var restart = document.getElementById("localAuthRestart");
322
+ if (restart) restart.onclick = function () { renderLocalLogin(""); };
323
+ var pass = document.getElementById("localAuthPassphrase");
324
+ if (pass) pass.focus();
325
+ }
326
+ async function submitLocalPassphrase(setupMode) {
327
+ var pass = document.getElementById("localAuthPassphrase");
328
+ var confirm = document.getElementById("localAuthPassphraseConfirm");
329
+ var passphrase = pass && typeof pass.value === "string" ? pass.value : "";
330
+ var confirmValue = confirm && typeof confirm.value === "string" ? confirm.value : "";
331
+ if (passphrase.length < 4) { setLocalAuthStatus("Use at least 4 characters.", "error"); return; }
332
+ if (setupMode && passphrase !== confirmValue) { setLocalAuthStatus("Passphrases do not match.", "error"); return; }
333
+ var button = document.getElementById("localAuthUnlock");
334
+ if (button) { button.disabled = true; button.textContent = setupMode ? "Creating..." : "Unlocking..."; }
335
+ setLocalAuthStatus(setupMode ? "Creating encrypted vault..." : "Unlocking vault...");
336
+ try {
337
+ await postJson("/local-auth/passphrase", { passphrase: passphrase }, 30000);
338
+ connected = true;
339
+ await waitForStats();
340
+ } catch (error) {
341
+ if (button) { button.disabled = false; button.textContent = setupMode ? "Create vault" : "Unlock"; }
342
+ var msg = error && error.message ? error.message : "Could not unlock encrypted memory.";
343
+ renderLocalPassphrase(setupMode ? "setup" : "unlock", localAuthEmail, msg);
344
+ }
177
345
  }
178
346
  function onConnectClick() {
179
347
  if (!localHistoryConsentGranted) {
@@ -182,8 +350,7 @@ export const SETUP_PAGE_CLIENT_CORE = String.raw ` var params = new URLSear
182
350
  return;
183
351
  }
184
352
  if (connected) { void waitForStats(); return; }
185
- if (!openAuthWindow(authUrl)) showPopupFallback("", authUrl);
186
- startConnectionPoll();
353
+ renderLocalLogin("");
187
354
  }
188
355
  function bindConnect() {
189
356
  Array.prototype.forEach.call(document.querySelectorAll("[data-connect-echo]"), function (signin) {