@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 +4 -0
- package/dist/encryption.js +14 -2
- package/dist/index.js +52 -117
- package/dist/migrate.js +38 -1
- package/dist/setup-page/client-core.js +194 -27
- package/dist/setup-page/client-extraction.js +73 -35
- package/dist/setup-page/client-lifecycle.js +1 -5
- package/dist/setup-page/styles-extraction.js +125 -0
- package/dist/setup-page.js +1 -0
- package/dist/setup-preview.js +127 -38
- package/dist/setup.js +275 -31
- package/dist/v1-contract.js +1 -11
- package/package.json +6 -2
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 {
|
package/dist/encryption.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { deriveKey, exportKeyToBase64, importKeyFromBase64, decrypt, verifyKey,
|
|
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 ??
|
|
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
|
|
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)} —
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
//
|
|
807
|
-
|
|
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) ===
|
|
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
|
|
38
|
-
var
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
186
|
-
startConnectionPoll();
|
|
353
|
+
renderLocalLogin("");
|
|
187
354
|
}
|
|
188
355
|
function bindConnect() {
|
|
189
356
|
Array.prototype.forEach.call(document.querySelectorAll("[data-connect-echo]"), function (signin) {
|