@scales-baby/nest-bridge 1.0.1 → 1.0.2

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/data.js CHANGED
@@ -25,15 +25,18 @@ const PATHS = {
25
25
  class DataLayer {
26
26
  client;
27
27
  vault;
28
- constructor(client, vault) {
28
+ ensureUnlocked;
29
+ constructor(client, vault, ensureUnlocked = async () => { }) {
29
30
  this.client = client;
30
31
  this.vault = vault;
32
+ this.ensureUnlocked = ensureUnlocked;
31
33
  }
32
34
  path(model) {
33
35
  return PATHS[model];
34
36
  }
35
37
  // --- READS ---------------------------------------------------------------
36
38
  async list(model, query = {}) {
39
+ await this.ensureUnlocked();
37
40
  const qs = Object.entries(query)
38
41
  .filter(([, v]) => v !== undefined && v !== "")
39
42
  .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
@@ -43,6 +46,7 @@ class DataLayer {
43
46
  return Promise.all(rows.map((r) => this.decrypt(model, r)));
44
47
  }
45
48
  async get(model, id) {
49
+ await this.ensureUnlocked();
46
50
  const row = await this.client.get(`${this.path(model)}/${id}`);
47
51
  if (!row)
48
52
  return null;
@@ -65,6 +69,7 @@ class DataLayer {
65
69
  // account is encrypted) and POST ciphertext. Returns the created record,
66
70
  // decrypted back for the caller.
67
71
  async create(model, fields) {
72
+ await this.ensureUnlocked();
68
73
  const payload = await (0, crypto_1.buildWritePayload)(model, {
69
74
  encrypted: this.vault.encrypted,
70
75
  dek: this.vault.dek,
@@ -80,6 +85,7 @@ class DataLayer {
80
85
  // record, merge the caller's changes on top, then build the write from the
81
86
  // merged full record (sensitive) + the caller's changes (cleartext only).
82
87
  async update(model, id, changes) {
88
+ await this.ensureUnlocked();
83
89
  let payload;
84
90
  if (this.vault.encrypted && this.vault.dek) {
85
91
  const current = await this.get(model, id); // decrypted
@@ -101,12 +107,14 @@ class DataLayer {
101
107
  }
102
108
  // Convenience: mark a task done (metadata-only write, never touches content).
103
109
  async completeTask(id) {
110
+ await this.ensureUnlocked();
104
111
  const updated = await this.client.patch(`${this.path("task")}/${id}`, { status: "done" });
105
112
  if (!updated)
106
113
  return null;
107
114
  return this.decrypt("task", updated);
108
115
  }
109
116
  async digest() {
117
+ await this.ensureUnlocked();
110
118
  return this.client.get("/api/digest");
111
119
  }
112
120
  }
package/dist/index.js CHANGED
@@ -46,35 +46,57 @@ async function main() {
46
46
  process.exit(1);
47
47
  }
48
48
  const client = new nestClient_1.NestClient({ apiUrl, apiKey });
49
- // 1) Fetch the password-wrapped DEK blob + the account's encryption state.
50
- log(`Connecting to ${apiUrl} …`);
51
- let wrapInfo;
52
- try {
53
- wrapInfo = await client.getBridgeWrap();
54
- }
55
- catch (e) {
56
- log("Failed to reach Nest or authenticate the API key:", e instanceof Error ? e.message : String(e));
57
- process.exit(1);
58
- }
59
- const encrypted = wrapInfo.encState === "encrypted";
60
- let vault = { encrypted, dek: null, dekVersion: 1 };
61
- if (!encrypted) {
62
- log("Account is NOT end-to-end encrypted — running in pass-through mode (no key needed).");
63
- }
64
- else if (!wrapInfo.hasPasswordWrap || !wrapInfo.wrap) {
65
- log("Account is encrypted but has NO password unlock method. The bridge unlocks by password; add a password method in Nest → Settings → Encryption, then retry. Continuing LOCKED (content will be blank).");
66
- }
67
- else {
49
+ // The vault is mutated IN PLACE once the background unlock resolves; the
50
+ // DataLayer holds this same reference, so a tool call that awaits the unlock
51
+ // gate sees the populated DEK. canWrite/encrypted are likewise filled in by
52
+ // the background unlock and read lazily by the write gate.
53
+ const vault = { encrypted: false, dek: null, dekVersion: 1 };
54
+ let canWrite = false;
55
+ let encrypted = false;
56
+ let unlocked = false;
57
+ // ---- BACKGROUND UNLOCK ----------------------------------------------------
58
+ // We do NOT block the MCP handshake on this. It runs right after we connect
59
+ // the stdio transport (kicked off below). Tool calls await `unlockGate`; a
60
+ // rejection carries a clean, human-readable reason that surfaces to Claude.
61
+ let unlockPromise = null;
62
+ async function doUnlock() {
63
+ // 1) Fetch the password-wrapped DEK blob + the account's encryption state.
64
+ log(`Connecting to ${apiUrl} …`);
65
+ let wrapInfo;
66
+ try {
67
+ wrapInfo = await client.getBridgeWrap();
68
+ }
69
+ catch (e) {
70
+ const msg = e instanceof Error ? e.message : String(e);
71
+ log("Failed to reach Nest or authenticate the API key:", msg);
72
+ throw new Error(`Could not unlock: failed to reach Nest or the API key is invalid (${msg}).`);
73
+ }
74
+ encrypted = wrapInfo.encState === "encrypted";
75
+ vault.encrypted = encrypted;
76
+ // Determine write capability from the key's scopes (the server still
77
+ // enforces authoritatively per-route).
78
+ const scopes = wrapInfo.scopes ?? [];
79
+ canWrite = scopes.some((s) => WRITE_SCOPES.has(s));
80
+ if (process.env.NEST_READ_ONLY === "1")
81
+ canWrite = false;
82
+ log(`Key scopes: ${scopes.join(", ") || "(unknown)"} → ${canWrite ? "read+write" : "read-only"}.`);
83
+ if (!encrypted) {
84
+ log("Account is NOT end-to-end encrypted — running in pass-through mode (no key needed).");
85
+ unlocked = true;
86
+ return;
87
+ }
88
+ if (!wrapInfo.hasPasswordWrap || !wrapInfo.wrap) {
89
+ log("Account is encrypted but has NO password unlock method. The bridge unlocks by password; add a password method in Nest → Settings → Encryption, then retry. Continuing LOCKED (content will be blank).");
90
+ throw new Error("Could not unlock: this encrypted account has no password unlock method. Add a password method in Nest → Settings → Encryption.");
91
+ }
68
92
  // 2) Get the password. Priority: env/connector config (NEST_PASSWORD) for
69
93
  // non-interactive launches (Claude Desktop / OpenAI inject user_config as
70
94
  // env), else a hidden TTY prompt for manual terminal runs. When launched
71
- // WITHOUT a real interactive terminal AND without NEST_PASSWORD (e.g. a
72
- // Claude Desktop connector with the field left blank), DO NOT try to read
73
- // the MCP stdio pipe — continue locked with a clear message instead.
95
+ // WITHOUT a real interactive terminal AND without NEST_PASSWORD, do NOT try
96
+ // to read the MCP stdio pipe fail the unlock with a clear message.
74
97
  let password = process.env.NEST_PASSWORD || "";
75
98
  if (!password) {
76
- const interactive = hasInteractiveTty();
77
- if (interactive) {
99
+ if (hasInteractiveTty()) {
78
100
  password = await (0, prompt_1.promptHidden)("Nest encryption password: ");
79
101
  }
80
102
  else {
@@ -82,40 +104,53 @@ async function main() {
82
104
  }
83
105
  }
84
106
  if (!password) {
85
- log("No password provided continuing LOCKED (content will be blank).");
107
+ throw new Error("Could not unlock: no password provided. Set NEST_PASSWORD in your connector config, or run the bridge in a terminal to be prompted.");
86
108
  }
87
- else {
88
- // 3) Derive the password KEK (Argon2id, matches the browser) unwrap DEK.
89
- log("Deriving key (Argon2id) and unwrapping the DEK locally …");
90
- try {
91
- const { dek, dekVersion } = await (0, crypto_1.unlockDekWithPassword)(password, wrapInfo.wrap);
92
- vault = { encrypted: true, dek, dekVersion };
93
- log("Unlocked. The DEK is held in memory only; the server never sees it.");
94
- }
95
- catch (e) {
96
- log("Unlock FAILED (wrong password or wrap mismatch):", e instanceof Error ? e.message : String(e), "— continuing LOCKED.");
97
- }
109
+ // 3) Derive the password KEK (Argon2id, matches the browser) → unwrap DEK.
110
+ log("Deriving key (Argon2id) and unwrapping the DEK locally …");
111
+ try {
112
+ const { dek, dekVersion } = await (0, crypto_1.unlockDekWithPassword)(password, wrapInfo.wrap);
113
+ vault.dek = dek;
114
+ vault.dekVersion = dekVersion;
115
+ unlocked = true;
116
+ log("Unlocked. The DEK is held in memory only; the server never sees it.");
98
117
  }
99
- // Best-effort scrub of the password reference.
100
- password = "";
118
+ catch (e) {
119
+ const msg = e instanceof Error ? e.message : String(e);
120
+ log("Unlock FAILED (wrong password or wrap mismatch):", msg);
121
+ throw new Error("Could not unlock: wrong password (the wrapped key failed to decrypt).");
122
+ }
123
+ finally {
124
+ // Best-effort scrub of the password reference.
125
+ password = "";
126
+ }
127
+ }
128
+ // Idempotent gate: start the unlock once, await the same promise everywhere.
129
+ // A rejection is cached so every subsequent tool call sees the same clean
130
+ // error (we don't retry a wrong password on each call).
131
+ function ensureUnlocked() {
132
+ if (!unlockPromise)
133
+ unlockPromise = doUnlock();
134
+ return unlockPromise;
101
135
  }
102
- // Determine write capability from the key's scopes (the server still enforces
103
- // authoritatively per-route). The bridge-wrap endpoint reports the key's
104
- // scopes; a read-only key gets a clean "read-only" message instead of a 403.
105
- const scopes = wrapInfo.scopes ?? [];
106
- let canWrite = scopes.some((s) => WRITE_SCOPES.has(s));
107
- if (process.env.NEST_READ_ONLY === "1")
108
- canWrite = false;
109
- log(`Key scopes: ${scopes.join(", ") || "(unknown)"} → ${canWrite ? "read+write" : "read-only"}.`);
110
- const data = new data_1.DataLayer(client, vault);
136
+ const data = new data_1.DataLayer(client, vault, ensureUnlocked);
111
137
  const server = await (0, mcp_1.buildServer)({
112
138
  data,
113
- canWrite,
114
- encrypted,
115
- unlocked: vault.dek !== null,
139
+ ensureUnlocked,
140
+ getStatus: () => ({ canWrite, encrypted, unlocked }),
116
141
  });
142
+ // ---- CONNECT FIRST (instant handshake) ------------------------------------
143
+ // Connect the stdio transport BEFORE unlocking so the client's MCP
144
+ // initialize/connect probe completes immediately (no Argon2id/fetch on the
145
+ // handshake path). tools/list works right away.
117
146
  await (0, mcp_1.runStdio)(server);
118
147
  log("MCP server ready (stdio). Connect Claude to this process.");
148
+ // ---- THEN unlock in the background ----------------------------------------
149
+ // Kick it off now so the DEK is usually ready by the first tool call; if a
150
+ // tool is called sooner, DataLayer.ensureUnlocked() awaits this same promise.
151
+ // Swallow the rejection here (it's surfaced per tool call); never crash the
152
+ // process — tools/list must keep working even if unlock fails.
153
+ ensureUnlocked().catch(() => { });
119
154
  }
120
155
  main().catch((e) => {
121
156
  console.error("[nest-bridge] fatal:", e);
package/dist/mcp.js CHANGED
@@ -86,13 +86,25 @@ async function buildServer(ctx) {
86
86
  const { McpServer } = await loadSdk();
87
87
  const server = new McpServer({
88
88
  name: "nest-bridge",
89
- version: "1.0.0",
89
+ version: "1.0.2",
90
90
  });
91
- const encNote = ctx.encrypted
92
- ? ctx.unlocked
93
- ? " This account is end-to-end encrypted; the bridge decrypts/encrypts locally so you see and write real content."
94
- : " WARNING: this encrypted account is LOCKED (no key) content fields will be blank."
95
- : "";
91
+ // Descriptions are registered ONCE at startup, before the background unlock
92
+ // finishes — so this note can't depend on the (not-yet-known) unlock state.
93
+ // Tool calls themselves await the unlock; if it's an encrypted account the
94
+ // bridge decrypts locally once unlocked. Keep the note generic + honest.
95
+ const encNote = " If your Nest account is end-to-end encrypted, the bridge unlocks with your" +
96
+ " password in the background and decrypts/encrypts locally so you see and" +
97
+ " write real content; the server only ever sees ciphertext.";
98
+ // Shared write-gate: tool calls go through DataLayer (which awaits the unlock)
99
+ // for the heavy lifting, but the canWrite scope is only known post-unlock, so
100
+ // write tools ensure the unlock settled, then check the scope for a clean
101
+ // "read-only" message before attempting the write.
102
+ async function ensureWritable() {
103
+ await ctx.ensureUnlocked();
104
+ if (!ctx.getStatus().canWrite) {
105
+ throw new Error("This API key is read-only (no write scope).");
106
+ }
107
+ }
96
108
  // ---- generic registrars -------------------------------------------------
97
109
  const listFilter = {
98
110
  status: zod_1.z.string().optional().describe("Filter by status"),
@@ -154,10 +166,8 @@ async function buildServer(ctx) {
154
166
  }
155
167
  function registerCreate(name, model, shape, desc) {
156
168
  server.registerTool(name, { description: desc + encNote, inputSchema: shape }, async (args) => {
157
- if (!ctx.canWrite) {
158
- return errResult(new Error("This API key is read-only (no write scope)."));
159
- }
160
169
  try {
170
+ await ensureWritable();
161
171
  const clean = Object.fromEntries(Object.entries(args).filter(([, v]) => v !== undefined));
162
172
  const created = await ctx.data.create(model, clean);
163
173
  return jsonResult(created);
@@ -172,10 +182,8 @@ async function buildServer(ctx) {
172
182
  description: desc + encNote,
173
183
  inputSchema: { id: zod_1.z.string().describe("The record's _id"), ...shape },
174
184
  }, async (args) => {
175
- if (!ctx.canWrite) {
176
- return errResult(new Error("This API key is read-only (no write scope)."));
177
- }
178
185
  try {
186
+ await ensureWritable();
179
187
  const { id, ...rest } = args;
180
188
  const changes = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
181
189
  const updated = await ctx.data.update(model, id, changes);
@@ -264,10 +272,8 @@ async function buildServer(ctx) {
264
272
  description: "Mark a task done (metadata-only).",
265
273
  inputSchema: { id: zod_1.z.string().describe("The task _id") },
266
274
  }, async (args) => {
267
- if (!ctx.canWrite) {
268
- return errResult(new Error("This API key is read-only (no write scope)."));
269
- }
270
275
  try {
276
+ await ensureWritable();
271
277
  return jsonResult(await ctx.data.completeTask(args.id));
272
278
  }
273
279
  catch (e) {
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "scales-nest",
4
4
  "display_name": "Nest by SCALES",
5
- "version": "1.0.0",
5
+ "version": "1.0.2",
6
6
  "description": "Read and write your end-to-end-encrypted Nest through your own AI. Your encryption key is derived locally and never leaves your machine; Nest's servers only ever see ciphertext.",
7
7
  "long_description": "Nest is your encrypted second brain (people, companies, tasks, events). This connector runs a small MCP server on your own machine. It fetches your password-wrapped key from Nest, unwraps it locally, then decrypts on read and encrypts on write right here. The Nest server only ever stores ciphertext and never receives your password, your key, or your plaintext. Mint a scoped API key in Nest (Settings then Connect your AI), paste it plus your encryption password below, and your AI can read and (with a full-control key) write your Nest data.",
8
8
  "author": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scales-baby/nest-bridge",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Local MCP bridge for Nest. Read and write your end-to-end-encrypted Nest data through your own AI; the encryption key is derived locally from your password and never leaves your machine.",
5
5
  "license": "MIT",
6
6
  "author": "SCALES (https://nest.scales.baby)",