Compare commits
2 Commits
9dd5face01
...
160a6864cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
160a6864cc | ||
|
|
81a8d0714b |
@@ -10,16 +10,25 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
import { db } from "../src/db";
|
import { db } from "../src/db";
|
||||||
import { mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
import { mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||||
import { user } from "@turbostarter/db/schema/auth";
|
import { user } from "@turbostarter/db/schema/auth";
|
||||||
|
|
||||||
const USER_ID = "test-user-smoke";
|
const USER_ID = "test-user-smoke";
|
||||||
const MESH_SLUG = "smoke-test";
|
const MESH_SLUG = "smoke-test";
|
||||||
const PEER_A_PUBKEY = "a".repeat(64);
|
|
||||||
const PEER_B_PUBKEY = "b".repeat(64);
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// Generate real ed25519 keypairs so crypto_box (via ed25519→X25519
|
||||||
|
// conversion) works in Step 18+ round-trip tests.
|
||||||
|
await sodium.ready;
|
||||||
|
const kpA = sodium.crypto_sign_keypair();
|
||||||
|
const kpB = sodium.crypto_sign_keypair();
|
||||||
|
const PEER_A_PUBKEY = sodium.to_hex(kpA.publicKey);
|
||||||
|
const PEER_A_SECRET = sodium.to_hex(kpA.privateKey);
|
||||||
|
const PEER_B_PUBKEY = sodium.to_hex(kpB.publicKey);
|
||||||
|
const PEER_B_SECRET = sodium.to_hex(kpB.privateKey);
|
||||||
|
|
||||||
// Ensure the test user exists (re-usable across runs).
|
// Ensure the test user exists (re-usable across runs).
|
||||||
const [existingUser] = await db
|
const [existingUser] = await db
|
||||||
.select({ id: user.id })
|
.select({ id: user.id })
|
||||||
@@ -75,8 +84,16 @@ async function main() {
|
|||||||
|
|
||||||
const seed = {
|
const seed = {
|
||||||
meshId: m.id,
|
meshId: m.id,
|
||||||
peerA: { memberId: peerA.id, pubkey: PEER_A_PUBKEY },
|
peerA: {
|
||||||
peerB: { memberId: peerB.id, pubkey: PEER_B_PUBKEY },
|
memberId: peerA.id,
|
||||||
|
pubkey: PEER_A_PUBKEY,
|
||||||
|
secretKey: PEER_A_SECRET,
|
||||||
|
},
|
||||||
|
peerB: {
|
||||||
|
memberId: peerB.id,
|
||||||
|
pubkey: PEER_B_PUBKEY,
|
||||||
|
secretKey: PEER_B_SECRET,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
console.log(JSON.stringify(seed, null, 2));
|
console.log(JSON.stringify(seed, null, 2));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -423,19 +423,21 @@ export async function drainForMember(
|
|||||||
priorities.map((p) => `'${p}'`).join(","),
|
priorities.map((p) => `'${p}'`).join(","),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Atomic claim: inner SELECT locks candidate rows (skipping any
|
// Atomic claim with SQL-side ordering. The CTE claims rows via
|
||||||
// already locked by a concurrent drain), outer UPDATE marks them
|
// UPDATE...RETURNING; the outer SELECT re-orders by created_at
|
||||||
// delivered, the FROM join fetches the sender's pubkey, RETURNING
|
// (with id as tiebreaker so equal-timestamp rows stay deterministic).
|
||||||
// gives us everything we need to push in one round-trip.
|
// Sorting in SQL avoids JS Date's millisecond-precision collapse of
|
||||||
|
// Postgres microsecond timestamps.
|
||||||
const result = await db.execute<{
|
const result = await db.execute<{
|
||||||
id: string;
|
id: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
nonce: string;
|
nonce: string;
|
||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
created_at: Date;
|
created_at: string | Date;
|
||||||
sender_member_id: string;
|
sender_member_id: string;
|
||||||
sender_pubkey: string;
|
sender_pubkey: string;
|
||||||
}>(sql`
|
}>(sql`
|
||||||
|
WITH claimed AS (
|
||||||
UPDATE mesh.message_queue AS mq
|
UPDATE mesh.message_queue AS mq
|
||||||
SET delivered_at = NOW()
|
SET delivered_at = NOW()
|
||||||
FROM mesh.member AS m
|
FROM mesh.member AS m
|
||||||
@@ -445,12 +447,15 @@ export async function drainForMember(
|
|||||||
AND delivered_at IS NULL
|
AND delivered_at IS NULL
|
||||||
AND priority::text IN (${priorityList})
|
AND priority::text IN (${priorityList})
|
||||||
AND (target_spec = ${memberPubkey} OR target_spec = '*')
|
AND (target_spec = ${memberPubkey} OR target_spec = '*')
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
)
|
)
|
||||||
AND m.id = mq.sender_member_id
|
AND m.id = mq.sender_member_id
|
||||||
RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext,
|
RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext,
|
||||||
mq.created_at, mq.sender_member_id, m.peer_pubkey AS sender_pubkey
|
mq.created_at, mq.sender_member_id,
|
||||||
|
m.peer_pubkey AS sender_pubkey
|
||||||
|
)
|
||||||
|
SELECT * FROM claimed ORDER BY created_at ASC, id ASC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const rows = (result.rows ?? result) as Array<{
|
const rows = (result.rows ?? result) as Array<{
|
||||||
@@ -463,23 +468,13 @@ export async function drainForMember(
|
|||||||
sender_pubkey: string;
|
sender_pubkey: string;
|
||||||
}>;
|
}>;
|
||||||
if (!rows || rows.length === 0) return [];
|
if (!rows || rows.length === 0) return [];
|
||||||
// Normalize created_at to Date (pg driver sometimes returns ISO
|
return rows.map((r) => ({
|
||||||
// strings for raw sql results).
|
|
||||||
const normalized = rows.map((r) => ({
|
|
||||||
...r,
|
|
||||||
created_at:
|
|
||||||
r.created_at instanceof Date ? r.created_at : new Date(r.created_at),
|
|
||||||
}));
|
|
||||||
// RETURNING order may not match the inner SELECT's ORDER BY — re-sort.
|
|
||||||
normalized.sort(
|
|
||||||
(a, b) => a.created_at.getTime() - b.created_at.getTime(),
|
|
||||||
);
|
|
||||||
return normalized.map((r) => ({
|
|
||||||
id: r.id,
|
id: r.id,
|
||||||
priority: r.priority as Priority,
|
priority: r.priority as Priority,
|
||||||
nonce: r.nonce,
|
nonce: r.nonce,
|
||||||
ciphertext: r.ciphertext,
|
ciphertext: r.ciphertext,
|
||||||
createdAt: r.created_at,
|
createdAt:
|
||||||
|
r.created_at instanceof Date ? r.created_at : new Date(r.created_at),
|
||||||
senderMemberId: r.sender_member_id,
|
senderMemberId: r.sender_member_id,
|
||||||
senderPubkey: r.sender_pubkey,
|
senderPubkey: r.sender_pubkey,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ async function waitHealthyOrAny(port: number, maxMs = 5000): Promise<void> {
|
|||||||
const r = await fetch(`http://localhost:${port}/health`, {
|
const r = await fetch(`http://localhost:${port}/health`, {
|
||||||
signal: AbortSignal.timeout(500),
|
signal: AbortSignal.timeout(500),
|
||||||
});
|
});
|
||||||
// Any response (even 503) means the HTTP server is up.
|
|
||||||
if (r.status === 200 || r.status === 503) return;
|
if (r.status === 200 || r.status === 503) return;
|
||||||
} catch {
|
} catch {
|
||||||
/* not yet */
|
/* not yet */
|
||||||
@@ -36,6 +35,23 @@ async function waitHealthyOrAny(port: number, maxMs = 5000): Promise<void> {
|
|||||||
throw new Error(`broker on :${port} did not come up`);
|
throw new Error(`broker on :${port} did not come up`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wait until /health returns 200 (HTTP + DB ping both completed). */
|
||||||
|
async function waitFullyHealthy(port: number, maxMs = 5000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < maxMs) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`http://localhost:${port}/health`, {
|
||||||
|
signal: AbortSignal.timeout(500),
|
||||||
|
});
|
||||||
|
if (r.status === 200) return;
|
||||||
|
} catch {
|
||||||
|
/* not yet */
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
throw new Error(`broker on :${port} did not become fully healthy`);
|
||||||
|
}
|
||||||
|
|
||||||
function spawnBroker(env: Record<string, string>): BrokerProc {
|
function spawnBroker(env: Record<string, string>): BrokerProc {
|
||||||
const port = 18000 + Math.floor(Math.random() * 1000);
|
const port = 18000 + Math.floor(Math.random() * 1000);
|
||||||
const brokerEntry = join(
|
const brokerEntry = join(
|
||||||
@@ -73,7 +89,7 @@ describe("/health endpoint", () => {
|
|||||||
process.env.DATABASE_URL ??
|
process.env.DATABASE_URL ??
|
||||||
"postgresql://turbostarter:turbostarter@127.0.0.1:5440/claudemesh_test",
|
"postgresql://turbostarter:turbostarter@127.0.0.1:5440/claudemesh_test",
|
||||||
});
|
});
|
||||||
await waitHealthyOrAny(broker.port);
|
await waitFullyHealthy(broker.port);
|
||||||
});
|
});
|
||||||
afterAll(() => broker?.kill());
|
afterAll(() => broker?.kill());
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export default mergeConfig(
|
|||||||
test: {
|
test: {
|
||||||
testTimeout: 10_000,
|
testTimeout: 10_000,
|
||||||
hookTimeout: 10_000,
|
hookTimeout: 10_000,
|
||||||
// Keep sequential initially — can flip to parallel once
|
// Test files share a Postgres schema and use cleanupAllTestMeshes
|
||||||
// per-test isolation is proven.
|
// in afterAll, so run them serially to avoid cross-file races.
|
||||||
|
fileParallelism: false,
|
||||||
sequence: {
|
sequence: {
|
||||||
concurrent: false,
|
concurrent: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ execSync(`rm -rf "${process.env.CLAUDEMESH_CONFIG_DIR}"`, {
|
|||||||
|
|
||||||
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
|
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
|
||||||
meshId: string;
|
meshId: string;
|
||||||
peerB: { memberId: string; pubkey: string };
|
peerB: { memberId: string; pubkey: string; secretKey: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -65,18 +65,20 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 4. Connect also as peer-B (the target) so we can observe receipt.
|
// 4. Connect also as peer-B (the target) so we can observe receipt.
|
||||||
|
// Uses the real keypair from the seed (needed for crypto_box decrypt).
|
||||||
const targetMesh: JoinedMesh = {
|
const targetMesh: JoinedMesh = {
|
||||||
...joinedMesh,
|
...joinedMesh,
|
||||||
memberId: seed.peerB.memberId,
|
memberId: seed.peerB.memberId,
|
||||||
slug: "rt-join-b",
|
slug: "rt-join-b",
|
||||||
pubkey: seed.peerB.pubkey,
|
pubkey: seed.peerB.pubkey,
|
||||||
|
secretKey: seed.peerB.secretKey,
|
||||||
};
|
};
|
||||||
const joiner = new BrokerClient(joinedMesh);
|
const joiner = new BrokerClient(joinedMesh);
|
||||||
const target = new BrokerClient(targetMesh);
|
const target = new BrokerClient(targetMesh);
|
||||||
|
|
||||||
let received = "";
|
let received = "";
|
||||||
target.onPush((m) => {
|
target.onPush((m) => {
|
||||||
received = Buffer.from(m.ciphertext, "base64").toString("utf-8");
|
received = m.plaintext ?? "";
|
||||||
console.log(`[rt] target got: "${received}"`);
|
console.log(`[rt] target got: "${received}"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import type { JoinedMesh } from "../src/state/config";
|
|||||||
|
|
||||||
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
|
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
|
||||||
meshId: string;
|
meshId: string;
|
||||||
peerA: { memberId: string; pubkey: string };
|
peerA: { memberId: string; pubkey: string; secretKey: string };
|
||||||
peerB: { memberId: string; pubkey: string };
|
peerB: { memberId: string; pubkey: string; secretKey: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const brokerUrl = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
|
const brokerUrl = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
|
||||||
@@ -25,11 +25,17 @@ const meshA: JoinedMesh = {
|
|||||||
slug: "rt-a",
|
slug: "rt-a",
|
||||||
name: "roundtrip-a",
|
name: "roundtrip-a",
|
||||||
pubkey: seed.peerA.pubkey,
|
pubkey: seed.peerA.pubkey,
|
||||||
secretKey: "stub",
|
secretKey: seed.peerA.secretKey,
|
||||||
brokerUrl,
|
brokerUrl,
|
||||||
joinedAt: new Date().toISOString(),
|
joinedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const meshB: JoinedMesh = { ...meshA, memberId: seed.peerB.memberId, slug: "rt-b", pubkey: seed.peerB.pubkey };
|
const meshB: JoinedMesh = {
|
||||||
|
...meshA,
|
||||||
|
memberId: seed.peerB.memberId,
|
||||||
|
slug: "rt-b",
|
||||||
|
pubkey: seed.peerB.pubkey,
|
||||||
|
secretKey: seed.peerB.secretKey,
|
||||||
|
};
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const a = new BrokerClient(meshA, { debug: true });
|
const a = new BrokerClient(meshA, { debug: true });
|
||||||
@@ -38,9 +44,9 @@ async function main(): Promise<void> {
|
|||||||
let received: string | null = null;
|
let received: string | null = null;
|
||||||
let receivedSender: string | null = null;
|
let receivedSender: string | null = null;
|
||||||
b.onPush((msg) => {
|
b.onPush((msg) => {
|
||||||
received = Buffer.from(msg.ciphertext, "base64").toString("utf-8");
|
received = msg.plaintext;
|
||||||
receivedSender = msg.senderPubkey;
|
receivedSender = msg.senderPubkey;
|
||||||
console.log(`[b] push: "${received}" from ${receivedSender}`);
|
console.log(`[b] push (kind=${msg.kind}): "${received}" from ${receivedSender?.slice(0, 16)}…`);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[rt] connecting A + B…");
|
console.log("[rt] connecting A + B…");
|
||||||
|
|||||||
96
apps/cli/src/crypto/envelope.ts
Normal file
96
apps/cli/src/crypto/envelope.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Direct-message encryption via libsodium crypto_box.
|
||||||
|
*
|
||||||
|
* Keys: our peers hold ed25519 signing keypairs (from Step 17).
|
||||||
|
* crypto_box uses X25519 (curve25519) keys, so we convert on the fly
|
||||||
|
* via crypto_sign_ed25519_{pk,sk}_to_curve25519. One signing keypair
|
||||||
|
* serves both purposes cleanly.
|
||||||
|
*
|
||||||
|
* Wire format: {nonce, ciphertext} both base64. Nonce is 24 bytes
|
||||||
|
* (crypto_box_NONCEBYTES), fresh-random per message.
|
||||||
|
*
|
||||||
|
* Broadcasts ("*") and channels ("#foo") are NOT encrypted here —
|
||||||
|
* they need a shared key (mesh_root_key) and land in a later step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureSodium } from "./keypair";
|
||||||
|
|
||||||
|
export interface Envelope {
|
||||||
|
nonce: string; // base64
|
||||||
|
ciphertext: string; // base64
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX_PUBKEY = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
|
/** Does this targetSpec look like a direct-message pubkey? */
|
||||||
|
export function isDirectTarget(targetSpec: string): boolean {
|
||||||
|
return HEX_PUBKEY.test(targetSpec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a plaintext message addressed to a single recipient.
|
||||||
|
* Recipient's ed25519 pubkey (64 hex chars) is converted to X25519
|
||||||
|
* on the fly. Sender's full ed25519 secret key (128 hex chars) is
|
||||||
|
* also converted.
|
||||||
|
*/
|
||||||
|
export async function encryptDirect(
|
||||||
|
message: string,
|
||||||
|
recipientPubkeyHex: string,
|
||||||
|
senderSecretKeyHex: string,
|
||||||
|
): Promise<Envelope> {
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
const recipientPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||||
|
sodium.from_hex(recipientPubkeyHex),
|
||||||
|
);
|
||||||
|
const senderSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
|
||||||
|
sodium.from_hex(senderSecretKeyHex),
|
||||||
|
);
|
||||||
|
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
||||||
|
const ciphertext = sodium.crypto_box_easy(
|
||||||
|
sodium.from_string(message),
|
||||||
|
nonce,
|
||||||
|
recipientPub,
|
||||||
|
senderSec,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||||
|
ciphertext: sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an inbound envelope from a known sender. Returns null if
|
||||||
|
* decryption fails (wrong keys, tampered ciphertext, malformed input).
|
||||||
|
*/
|
||||||
|
export async function decryptDirect(
|
||||||
|
envelope: Envelope,
|
||||||
|
senderPubkeyHex: string,
|
||||||
|
recipientSecretKeyHex: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
try {
|
||||||
|
const senderPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||||
|
sodium.from_hex(senderPubkeyHex),
|
||||||
|
);
|
||||||
|
const recipientSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
|
||||||
|
sodium.from_hex(recipientSecretKeyHex),
|
||||||
|
);
|
||||||
|
const nonce = sodium.from_base64(
|
||||||
|
envelope.nonce,
|
||||||
|
sodium.base64_variants.ORIGINAL,
|
||||||
|
);
|
||||||
|
const ciphertext = sodium.from_base64(
|
||||||
|
envelope.ciphertext,
|
||||||
|
sodium.base64_variants.ORIGINAL,
|
||||||
|
);
|
||||||
|
const plain = sodium.crypto_box_open_easy(
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
senderPub,
|
||||||
|
recipientSec,
|
||||||
|
);
|
||||||
|
return sodium.to_string(plain);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,13 +74,7 @@ function resolveClient(to: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatPush(p: InboundPush, meshSlug: string): string {
|
function formatPush(p: InboundPush, meshSlug: string): string {
|
||||||
const body = (() => {
|
const body = p.plaintext ?? "(decryption failed)";
|
||||||
try {
|
|
||||||
return Buffer.from(p.ciphertext, "base64").toString("utf-8");
|
|
||||||
} catch {
|
|
||||||
return "(invalid base64 ciphertext)";
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
|
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import type { JoinedMesh } from "../state/config";
|
import type { JoinedMesh } from "../state/config";
|
||||||
|
import {
|
||||||
|
decryptDirect,
|
||||||
|
encryptDirect,
|
||||||
|
isDirectTarget,
|
||||||
|
} from "../crypto/envelope";
|
||||||
|
|
||||||
export type Priority = "now" | "next" | "low";
|
export type Priority = "now" | "next" | "low";
|
||||||
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
||||||
@@ -28,6 +33,12 @@ export interface InboundPush {
|
|||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
receivedAt: string;
|
receivedAt: string;
|
||||||
|
/** Decrypted plaintext (if encryption succeeded). null = broadcast
|
||||||
|
* or channel (no per-recipient crypto yet), or decryption failed. */
|
||||||
|
plaintext: string | null;
|
||||||
|
/** Hint for UI: "direct" (crypto_box), "channel"/"broadcast"
|
||||||
|
* (plaintext for now). */
|
||||||
|
kind: "direct" | "broadcast" | "channel" | "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
type PushHandler = (msg: InboundPush) => void;
|
type PushHandler = (msg: InboundPush) => void;
|
||||||
@@ -157,8 +168,22 @@ export class BrokerClient {
|
|||||||
priority: Priority = "next",
|
priority: Priority = "next",
|
||||||
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||||
const id = randomId();
|
const id = randomId();
|
||||||
const nonce = randomNonce();
|
// Direct messages get crypto_box encryption; broadcasts + channels
|
||||||
const ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
// still pass through as base64 plaintext until channel crypto lands.
|
||||||
|
let nonce: string;
|
||||||
|
let ciphertext: string;
|
||||||
|
if (isDirectTarget(targetSpec)) {
|
||||||
|
const env = await encryptDirect(
|
||||||
|
message,
|
||||||
|
targetSpec,
|
||||||
|
this.mesh.secretKey,
|
||||||
|
);
|
||||||
|
nonce = env.nonce;
|
||||||
|
ciphertext = env.ciphertext;
|
||||||
|
} else {
|
||||||
|
nonce = randomNonce();
|
||||||
|
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (this.pendingSends.size >= MAX_QUEUED) {
|
if (this.pendingSends.size >= MAX_QUEUED) {
|
||||||
@@ -254,18 +279,46 @@ export class BrokerClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === "push") {
|
if (msg.type === "push") {
|
||||||
|
const nonce = String(msg.nonce ?? "");
|
||||||
|
const ciphertext = String(msg.ciphertext ?? "");
|
||||||
|
const senderPubkey = String(msg.senderPubkey ?? "");
|
||||||
|
// Decrypt asynchronously, then enqueue. Ordering within the
|
||||||
|
// buffer is preserved by awaiting before push.
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
const kind: InboundPush["kind"] = senderPubkey
|
||||||
|
? "direct"
|
||||||
|
: "unknown";
|
||||||
|
let plaintext: string | null = null;
|
||||||
|
if (senderPubkey && nonce && ciphertext) {
|
||||||
|
plaintext = await decryptDirect(
|
||||||
|
{ nonce, ciphertext },
|
||||||
|
senderPubkey,
|
||||||
|
this.mesh.secretKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// If decryption failed, fall back to base64 UTF-8 unwrap —
|
||||||
|
// this covers the legacy plaintext path for broadcasts/channels
|
||||||
|
// until channel crypto lands.
|
||||||
|
if (plaintext === null && ciphertext) {
|
||||||
|
try {
|
||||||
|
plaintext = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||||
|
} catch {
|
||||||
|
plaintext = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
const push: InboundPush = {
|
const push: InboundPush = {
|
||||||
messageId: String(msg.messageId ?? ""),
|
messageId: String(msg.messageId ?? ""),
|
||||||
meshId: String(msg.meshId ?? ""),
|
meshId: String(msg.meshId ?? ""),
|
||||||
senderPubkey: String(msg.senderPubkey ?? ""),
|
senderPubkey,
|
||||||
priority: (msg.priority as Priority) ?? "next",
|
priority: (msg.priority as Priority) ?? "next",
|
||||||
nonce: String(msg.nonce ?? ""),
|
nonce,
|
||||||
ciphertext: String(msg.ciphertext ?? ""),
|
ciphertext,
|
||||||
createdAt: String(msg.createdAt ?? ""),
|
createdAt: String(msg.createdAt ?? ""),
|
||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
|
plaintext,
|
||||||
|
kind,
|
||||||
};
|
};
|
||||||
this.pushBuffer.push(push);
|
this.pushBuffer.push(push);
|
||||||
// Cap buffer at 500 entries to avoid unbounded growth.
|
|
||||||
if (this.pushBuffer.length > 500) this.pushBuffer.shift();
|
if (this.pushBuffer.length > 500) this.pushBuffer.shift();
|
||||||
for (const h of this.pushHandlers) {
|
for (const h of this.pushHandlers) {
|
||||||
try {
|
try {
|
||||||
@@ -274,6 +327,7 @@ export class BrokerClient {
|
|||||||
/* handler errors are not the transport's problem */
|
/* handler errors are not the transport's problem */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === "error") {
|
if (msg.type === "error") {
|
||||||
|
|||||||
@@ -45,7 +45,14 @@ export const MobileNavigation = ({ links }: NavigationProps) => {
|
|||||||
<>
|
<>
|
||||||
<Hamburger open={open} onOpenChange={setOpen} className="lg:hidden" />
|
<Hamburger open={open} onOpenChange={setOpen} className="lg:hidden" />
|
||||||
|
|
||||||
<div className="pointer-events-none fixed top-14 left-0 z-10 flex h-[calc(100vh-3.5rem)] w-full flex-col gap-7 overflow-auto lg:hidden">
|
{/*
|
||||||
|
NOTE: do NOT put `overflow-auto`/`overflow-y-auto` on THIS container.
|
||||||
|
It's `fixed` + full-viewport + `pointer-events-none`, but a scroll
|
||||||
|
container on top of the page still steals wheel events on hover in
|
||||||
|
some browsers (Chrome/Safari inconsistently), breaking page scroll.
|
||||||
|
Move any needed scroll behavior to the inner panel below.
|
||||||
|
*/}
|
||||||
|
<div className="pointer-events-none fixed top-14 left-0 z-10 flex h-[calc(100vh-3.5rem)] w-full flex-col gap-7 lg:hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 bg-black/80 opacity-0 transition-opacity duration-500 ease-out",
|
"absolute inset-0 bg-black/80 opacity-0 transition-opacity duration-500 ease-out",
|
||||||
@@ -57,7 +64,7 @@ export const MobileNavigation = ({ links }: NavigationProps) => {
|
|||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background flex w-full -translate-y-full flex-col gap-2 rounded-b-md px-[1.7rem] pb-6 transition-transform duration-500 ease-out sm:px-8",
|
"bg-background flex max-h-full w-full -translate-y-full flex-col gap-2 overflow-y-auto rounded-b-md px-[1.7rem] pb-6 transition-transform duration-500 ease-out sm:px-8",
|
||||||
{
|
{
|
||||||
"pointer-events-auto translate-y-0": open,
|
"pointer-events-auto translate-y-0": open,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user