@scales-baby/nest-bridge 1.0.0

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.
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ // Nest encryption — client-side WebCrypto primitives.
3
+ //
4
+ // THE ENCRYPTION ENGINE. Everything here runs via WebCrypto (crypto.subtle).
5
+ // These are the shared building blocks the per-method KEK derivers and the
6
+ // unlock flow use:
7
+ //
8
+ // - base64url encode/decode (matches the server's WrappedDek string fields)
9
+ // - random DEK generation (32 bytes / 256-bit)
10
+ // - AES-256-GCM encrypt/decrypt (used for BOTH wrapping the DEK and for
11
+ // encrypting CRM content fields)
12
+ // - HKDF-SHA256 to stretch a raw IKM into a clean 256-bit KEK
13
+ //
14
+ // These run identically in the browser (the Nest web app) and in Node 20+
15
+ // (this bridge): crypto.subtle, btoa/atob, TextEncoder/TextDecoder are all
16
+ // global in both. That is how the bridge derives the SAME KEK/DEK and produces
17
+ // the SAME ciphertext as the web client.
18
+ //
19
+ // Hard rule: the server must NEVER receive the DEK, a KEK, or plaintext
20
+ // content. These functions only ever hand wrapped/ciphertext blobs to the
21
+ // network layer.
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.bytesToB64u = bytesToB64u;
24
+ exports.b64uToBytes = b64uToBytes;
25
+ exports.utf8 = utf8;
26
+ exports.fromUtf8 = fromUtf8;
27
+ exports.generateDek = generateDek;
28
+ exports.aesGcmEncrypt = aesGcmEncrypt;
29
+ exports.aesGcmDecrypt = aesGcmDecrypt;
30
+ exports.hkdfKek = hkdfKek;
31
+ // ---------------------------------------------------------------------------
32
+ // base64url (no padding) — the on-wire format for every encrypted/binding field
33
+ // ---------------------------------------------------------------------------
34
+ function bytesToB64u(bytes) {
35
+ let bin = "";
36
+ for (let i = 0; i < bytes.length; i++)
37
+ bin += String.fromCharCode(bytes[i]);
38
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
39
+ }
40
+ function b64uToBytes(s) {
41
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
42
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad;
43
+ const bin = atob(b64);
44
+ const out = new Uint8Array(bin.length);
45
+ for (let i = 0; i < bin.length; i++)
46
+ out[i] = bin.charCodeAt(i);
47
+ return out;
48
+ }
49
+ function utf8(s) {
50
+ return new TextEncoder().encode(s);
51
+ }
52
+ function fromUtf8(bytes) {
53
+ return new TextDecoder().decode(bytes);
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // DEK generation — one random 256-bit key per user, generated client-side
57
+ // ---------------------------------------------------------------------------
58
+ // A fresh 32-byte (256-bit) Data Encryption Key. NEVER sent to the server in
59
+ // this form — only ever wrapped under a KEK first (see envelope.ts).
60
+ function generateDek() {
61
+ return crypto.getRandomValues(new Uint8Array(32));
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // AES-256-GCM — content encryption AND DEK wrapping
65
+ // ---------------------------------------------------------------------------
66
+ async function importAesKey(keyBytes, usage) {
67
+ if (keyBytes.length !== 32) {
68
+ throw new Error("aes_key_must_be_256_bit");
69
+ }
70
+ return crypto.subtle.importKey("raw", bufferSource(keyBytes), "AES-GCM", false, usage);
71
+ }
72
+ // Encrypt `plaintext` under a 256-bit key. Returns { iv, ct, tag } as base64url.
73
+ // `aad` (additional authenticated data) is optional and bound into the tag.
74
+ async function aesGcmEncrypt(keyBytes, plaintext, aad) {
75
+ const key = await importAesKey(keyBytes, ["encrypt"]);
76
+ const iv = crypto.getRandomValues(new Uint8Array(12));
77
+ const params = { name: "AES-GCM", iv: bufferSource(iv) };
78
+ if (aad)
79
+ params.additionalData = bufferSource(aad);
80
+ const buf = new Uint8Array(await crypto.subtle.encrypt(params, key, bufferSource(plaintext)));
81
+ // WebCrypto appends the 16-byte tag to the ciphertext.
82
+ return {
83
+ iv: bytesToB64u(iv),
84
+ ct: bytesToB64u(buf.slice(0, buf.length - 16)),
85
+ tag: bytesToB64u(buf.slice(buf.length - 16)),
86
+ };
87
+ }
88
+ // Decrypt an { iv, ct, tag } blob under a 256-bit key. Throws if the tag fails
89
+ // (wrong key / tampered ciphertext) — which is exactly the integrity guarantee.
90
+ async function aesGcmDecrypt(keyBytes, blob, aad) {
91
+ const key = await importAesKey(keyBytes, ["decrypt"]);
92
+ const ct = b64uToBytes(blob.ct);
93
+ const tag = b64uToBytes(blob.tag);
94
+ const joined = new Uint8Array(ct.length + tag.length);
95
+ joined.set(ct, 0);
96
+ joined.set(tag, ct.length);
97
+ const params = {
98
+ name: "AES-GCM",
99
+ iv: bufferSource(b64uToBytes(blob.iv)),
100
+ };
101
+ if (aad)
102
+ params.additionalData = bufferSource(aad);
103
+ const buf = await crypto.subtle.decrypt(params, key, bufferSource(joined));
104
+ return new Uint8Array(buf);
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // HKDF-SHA256 — stretch raw IKM into a 256-bit KEK
108
+ // ---------------------------------------------------------------------------
109
+ // Fixed application salt + info so the same IKM always derives the same KEK.
110
+ // Domain-bound so input keying material captured for Nest can't be reused to
111
+ // derive a key in another app.
112
+ const HKDF_SALT = utf8("nest.scales.baby/kek/v1");
113
+ // Derive a 256-bit KEK from raw input keying material. `info` further separates
114
+ // keys derived from the same IKM for different purposes if ever needed.
115
+ async function hkdfKek(ikm, info = "nest-dek-wrap-v1") {
116
+ const baseKey = await crypto.subtle.importKey("raw", bufferSource(ikm), "HKDF", false, ["deriveBits"]);
117
+ const bits = await crypto.subtle.deriveBits({
118
+ name: "HKDF",
119
+ hash: "SHA-256",
120
+ salt: bufferSource(HKDF_SALT),
121
+ info: bufferSource(utf8(info)),
122
+ }, baseKey, 256);
123
+ return new Uint8Array(bits);
124
+ }
125
+ // WebCrypto's BufferSource typing (TS 5.7+) distinguishes
126
+ // Uint8Array<ArrayBuffer> from Uint8Array<ArrayBufferLike> (the latter could be
127
+ // SharedArrayBuffer-backed). Copy into a fresh, exactly-sized ArrayBuffer and
128
+ // return an ArrayBuffer (not a view), which satisfies BufferSource cleanly and
129
+ // is also a runtime no-op-safe copy.
130
+ function bufferSource(bytes) {
131
+ const ab = new ArrayBuffer(bytes.length);
132
+ new Uint8Array(ab).set(bytes);
133
+ return ab;
134
+ }
package/dist/crypto.js ADDED
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ // Bridge crypto — REUSES the EXACT same client crypto modules the Nest web app
3
+ // uses, so reads/writes round-trip byte-for-byte with the browser.
4
+ //
5
+ // We import the shared crypto from ./crypto/*. Those modules are pure WebCrypto
6
+ // (crypto.subtle) + hash-wasm Argon2id + btoa/atob/TextEncoder/TextDecoder —
7
+ // ALL global in Node 20+. Nothing here is browser-only (no IndexedDB, no DOM),
8
+ // so it runs unchanged in this Node process. This is how we GUARANTEE the
9
+ // bridge derives the same KEK/DEK and produces the same `enc` ciphertext as the
10
+ // web client.
11
+ //
12
+ // SECURITY INVARIANT: the DEK and password live ONLY in this process's memory.
13
+ // The Nest server only ever receives the wrapped-DEK fetch (it sends US the
14
+ // wrap), ciphertext on writes, and never the password/DEK/plaintext.
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.hydrateRecord = exports.buildWritePayload = exports.CONTENT_SCHEMA = void 0;
17
+ exports.unlockDekWithPassword = unlockDekWithPassword;
18
+ exports.encryptedFieldsFor = encryptedFieldsFor;
19
+ const password_1 = require("./crypto/kek/password");
20
+ const envelope_1 = require("./crypto/envelope");
21
+ const crmData_1 = require("./crypto/crmData");
22
+ Object.defineProperty(exports, "buildWritePayload", { enumerable: true, get: function () { return crmData_1.buildWritePayload; } });
23
+ Object.defineProperty(exports, "hydrateRecord", { enumerable: true, get: function () { return crmData_1.hydrateRecord; } });
24
+ const contentSchema_1 = require("./crypto/contentSchema");
25
+ Object.defineProperty(exports, "CONTENT_SCHEMA", { enumerable: true, get: function () { return contentSchema_1.CONTENT_SCHEMA; } });
26
+ // Derive the password KEK (Argon2id, matching the browser exactly) from the
27
+ // stored salt/params, then unwrap the DEK locally. Returns the raw DEK bytes —
28
+ // they never leave this process.
29
+ async function unlockDekWithPassword(password, wrap) {
30
+ if (!wrap.kdfSalt)
31
+ throw new Error("password_salt_missing");
32
+ const kek = await (0, password_1.derivePasswordKek)(password, wrap.kdfSalt, wrap.kdf);
33
+ const dek = await (0, envelope_1.unwrapDek)(kek, wrap);
34
+ return { dek, dekVersion: wrap.dekVersion ?? 1 };
35
+ }
36
+ // The encrypted-vs-clear field split, for callers that want to know which keys
37
+ // a model encrypts (e.g. to validate a write payload).
38
+ function encryptedFieldsFor(model) {
39
+ return contentSchema_1.CONTENT_SCHEMA[model].encrypted;
40
+ }
package/dist/data.js ADDED
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ // Bridge — the CRM data layer: encrypt-on-write / decrypt-on-read.
3
+ //
4
+ // READ: fetch ciphertext from the Nest REST API → hydrateRecord() with the DEK
5
+ // locally → return plaintext to the MCP caller (Claude).
6
+ // WRITE: take the caller's plaintext → buildWritePayload() encrypts the
7
+ // sensitive fields into an `enc` blob + blanks plaintext locally → POST
8
+ // the ciphertext to the Nest API (the server stamps ownerId, stores the
9
+ // blob, never sees plaintext or the DEK).
10
+ //
11
+ // When the account is UNENCRYPTED (no DEK), everything is a pass-through — the
12
+ // server stores/returns plaintext exactly as the web app does.
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.DataLayer = void 0;
15
+ const crypto_1 = require("./crypto");
16
+ // Map a CrmModel to its REST collection path.
17
+ const PATHS = {
18
+ person: "/api/people",
19
+ company: "/api/companies",
20
+ task: "/api/tasks",
21
+ event: "/api/events",
22
+ merchant: "/api/merchants",
23
+ route: "/api/routes",
24
+ };
25
+ class DataLayer {
26
+ client;
27
+ vault;
28
+ constructor(client, vault) {
29
+ this.client = client;
30
+ this.vault = vault;
31
+ }
32
+ path(model) {
33
+ return PATHS[model];
34
+ }
35
+ // --- READS ---------------------------------------------------------------
36
+ async list(model, query = {}) {
37
+ const qs = Object.entries(query)
38
+ .filter(([, v]) => v !== undefined && v !== "")
39
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
40
+ .join("&");
41
+ const path = qs ? `${this.path(model)}?${qs}` : this.path(model);
42
+ const rows = (await this.client.get(path)) ?? [];
43
+ return Promise.all(rows.map((r) => this.decrypt(model, r)));
44
+ }
45
+ async get(model, id) {
46
+ const row = await this.client.get(`${this.path(model)}/${id}`);
47
+ if (!row)
48
+ return null;
49
+ return this.decrypt(model, row);
50
+ }
51
+ async decrypt(model, row) {
52
+ // hydrateRecord is a no-op for plaintext docs (no `enc`) and for unencrypted
53
+ // accounts (dek=null leaves blanks). With the DEK it merges decrypted
54
+ // content back over the cleartext metadata.
55
+ try {
56
+ return (await (0, crypto_1.hydrateRecord)(model, this.vault.dek, row));
57
+ }
58
+ catch {
59
+ // A single corrupt blob shouldn't blank the whole call — return as-is.
60
+ return row;
61
+ }
62
+ }
63
+ // --- WRITES --------------------------------------------------------------
64
+ // Create: `fields` is the caller's plaintext. We encrypt locally (if the
65
+ // account is encrypted) and POST ciphertext. Returns the created record,
66
+ // decrypted back for the caller.
67
+ async create(model, fields) {
68
+ const payload = await (0, crypto_1.buildWritePayload)(model, {
69
+ encrypted: this.vault.encrypted,
70
+ dek: this.vault.dek,
71
+ dekVersion: this.vault.dekVersion,
72
+ }, fields, fields);
73
+ const created = await this.client.post(this.path(model), payload);
74
+ if (!created)
75
+ return null;
76
+ return this.decrypt(model, created);
77
+ }
78
+ // Update: PATCH. For an encrypted account, `enc` is ONE per-doc blob, so we
79
+ // must re-encrypt the COMPLETE sensitive set. We fetch + decrypt the current
80
+ // record, merge the caller's changes on top, then build the write from the
81
+ // merged full record (sensitive) + the caller's changes (cleartext only).
82
+ async update(model, id, changes) {
83
+ let payload;
84
+ if (this.vault.encrypted && this.vault.dek) {
85
+ const current = await this.get(model, id); // decrypted
86
+ const merged = { ...(current ?? {}), ...changes };
87
+ payload = await (0, crypto_1.buildWritePayload)(model, {
88
+ encrypted: true,
89
+ dek: this.vault.dek,
90
+ dekVersion: this.vault.dekVersion,
91
+ }, merged, changes);
92
+ }
93
+ else {
94
+ // Unencrypted: pass the caller's changes through.
95
+ payload = await (0, crypto_1.buildWritePayload)(model, { encrypted: false, dek: null }, changes, changes);
96
+ }
97
+ const updated = await this.client.patch(`${this.path(model)}/${id}`, payload);
98
+ if (!updated)
99
+ return null;
100
+ return this.decrypt(model, updated);
101
+ }
102
+ // Convenience: mark a task done (metadata-only write, never touches content).
103
+ async completeTask(id) {
104
+ const updated = await this.client.patch(`${this.path("task")}/${id}`, { status: "done" });
105
+ if (!updated)
106
+ return null;
107
+ return this.decrypt("task", updated);
108
+ }
109
+ async digest() {
110
+ return this.client.get("/api/digest");
111
+ }
112
+ }
113
+ exports.DataLayer = DataLayer;
package/dist/index.js ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // Nest local MCP bridge — entry point.
4
+ //
5
+ // Runs on the USER's machine. Holds the user's DEK in memory after deriving it
6
+ // from their PASSWORD; the Nest SERVER never receives the DEK, the password, or
7
+ // plaintext. Reads decrypt locally, writes encrypt locally.
8
+ //
9
+ // Config (env or .env in cwd):
10
+ // NEST_API_URL default https://nest.scales.baby
11
+ // NEST_API_KEY the user's scoped key (nest_<prefix>_<secret>) [required]
12
+ // NEST_PASSWORD OPTIONAL — if unset, prompt securely at startup (preferred)
13
+ //
14
+ // All logging goes to stderr so it never corrupts the stdio MCP protocol.
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ const node_fs_1 = require("node:fs");
17
+ const nestClient_1 = require("./nestClient");
18
+ const crypto_1 = require("./crypto");
19
+ const data_1 = require("./data");
20
+ const mcp_1 = require("./mcp");
21
+ const prompt_1 = require("./prompt");
22
+ const log = (...a) => console.error("[nest-bridge]", ...a);
23
+ const WRITE_SCOPES = new Set(["crm:write", "tasks:write"]);
24
+ // True only when launched from a real interactive terminal where a hidden
25
+ // password prompt is safe. Claude Desktop / OpenAI launch us non-interactively
26
+ // (stdio wired to the MCP client), so this is false there and we never touch
27
+ // the protocol pipe trying to read a password.
28
+ function hasInteractiveTty() {
29
+ if (process.env.NEST_NON_INTERACTIVE === "1")
30
+ return false;
31
+ try {
32
+ // openSync throws if there is no controlling terminal.
33
+ const fd = (0, node_fs_1.openSync)("/dev/tty", "r");
34
+ (0, node_fs_1.closeSync)(fd);
35
+ return true;
36
+ }
37
+ catch {
38
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
39
+ }
40
+ }
41
+ async function main() {
42
+ const apiUrl = process.env.NEST_API_URL || "https://nest.scales.baby";
43
+ const apiKey = process.env.NEST_API_KEY || "";
44
+ if (!apiKey) {
45
+ log("Missing NEST_API_KEY. Mint a scoped key in Nest → Settings → Connect your AI, then set NEST_API_KEY.");
46
+ process.exit(1);
47
+ }
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 {
68
+ // 2) Get the password. Priority: env/connector config (NEST_PASSWORD) for
69
+ // non-interactive launches (Claude Desktop / OpenAI inject user_config as
70
+ // 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.
74
+ let password = process.env.NEST_PASSWORD || "";
75
+ if (!password) {
76
+ const interactive = hasInteractiveTty();
77
+ if (interactive) {
78
+ password = await (0, prompt_1.promptHidden)("Nest encryption password: ");
79
+ }
80
+ else {
81
+ log("No NEST_PASSWORD set and no interactive terminal — set the password in your connector config (Claude Desktop / OpenAI) or run the bridge in a terminal to be prompted. Continuing LOCKED (content will be blank).");
82
+ }
83
+ }
84
+ if (!password) {
85
+ log("No password provided — continuing LOCKED (content will be blank).");
86
+ }
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
+ }
98
+ }
99
+ // Best-effort scrub of the password reference.
100
+ password = "";
101
+ }
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);
111
+ const server = await (0, mcp_1.buildServer)({
112
+ data,
113
+ canWrite,
114
+ encrypted,
115
+ unlocked: vault.dek !== null,
116
+ });
117
+ await (0, mcp_1.runStdio)(server);
118
+ log("MCP server ready (stdio). Connect Claude to this process.");
119
+ }
120
+ main().catch((e) => {
121
+ console.error("[nest-bridge] fatal:", e);
122
+ process.exit(1);
123
+ });