feat: v0.4.0 — File sharing + multi-target messages
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Release / Publish multi-arch images (push) Has been cancelled

Files: MinIO-backed file sharing built into the broker.
share_file for persistent mesh files, send_message(file:) for
ephemeral attachments. Presigned URLs for download, access
tracking per peer.

Broker infra: MinIO in docker-compose, internal network.
HTTP POST /upload endpoint. WS handlers for get_file,
list_files, file_status, delete_file.

Multi-target: send_message(to:) accepts string or array.
Targets deduplicated before delivery.

Targeted views: MCP instructions teach Claude to send
tailored messages per audience instead of generic broadcasts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-06 13:56:01 +01:00
parent 99d9d19079
commit 1aaa483d60
16 changed files with 4187 additions and 21 deletions

View File

@@ -32,6 +32,8 @@ import { db } from "./db";
import {
invite as inviteTable,
mesh,
meshFile,
meshFileAccess,
meshMember as memberTable,
meshMemory,
meshState,
@@ -695,6 +697,198 @@ export async function forgetMemory(
);
}
// --- File sharing ---
/**
* Insert a file metadata row after upload to MinIO.
*/
export async function uploadFile(args: {
meshId: string;
name: string;
sizeBytes: number;
mimeType?: string;
minioKey: string;
tags?: string[];
persistent?: boolean;
uploadedByName?: string;
uploadedByMember?: string;
targetSpec?: string;
expiresAt?: Date;
}): Promise<string> {
const [row] = await db
.insert(meshFile)
.values({
meshId: args.meshId,
name: args.name,
sizeBytes: args.sizeBytes,
mimeType: args.mimeType ?? null,
minioKey: args.minioKey,
tags: args.tags ?? [],
persistent: args.persistent ?? true,
uploadedByName: args.uploadedByName ?? null,
uploadedByMember: args.uploadedByMember ?? null,
targetSpec: args.targetSpec ?? null,
expiresAt: args.expiresAt ?? null,
})
.returning({ id: meshFile.id });
if (!row) throw new Error("failed to insert file row");
return row.id;
}
/**
* Get a single file by id, check it belongs to the mesh and is not deleted.
*/
export async function getFile(
meshId: string,
fileId: string,
): Promise<{
id: string;
name: string;
sizeBytes: number;
mimeType: string | null;
minioKey: string;
tags: string[];
persistent: boolean;
uploadedByName: string | null;
targetSpec: string | null;
uploadedAt: Date;
} | null> {
const [row] = await db
.select({
id: meshFile.id,
name: meshFile.name,
sizeBytes: meshFile.sizeBytes,
mimeType: meshFile.mimeType,
minioKey: meshFile.minioKey,
tags: meshFile.tags,
persistent: meshFile.persistent,
uploadedByName: meshFile.uploadedByName,
targetSpec: meshFile.targetSpec,
uploadedAt: meshFile.uploadedAt,
})
.from(meshFile)
.where(
and(
eq(meshFile.id, fileId),
eq(meshFile.meshId, meshId),
isNull(meshFile.deletedAt),
),
)
.limit(1);
if (!row) return null;
return {
...row,
tags: (row.tags ?? []) as string[],
};
}
/**
* List files in a mesh. Optionally filter by query (name ILIKE) or uploader.
*/
export async function listFiles(
meshId: string,
query?: string,
from?: string,
): Promise<
Array<{
id: string;
name: string;
sizeBytes: number;
tags: string[];
uploadedBy: string;
uploadedAt: Date;
persistent: boolean;
}>
> {
const conditions = [
eq(meshFile.meshId, meshId),
isNull(meshFile.deletedAt),
];
if (query) {
conditions.push(sql`${meshFile.name} ILIKE ${"%" + query + "%"}`);
}
if (from) {
conditions.push(eq(meshFile.uploadedByName, from));
}
const rows = await db
.select({
id: meshFile.id,
name: meshFile.name,
sizeBytes: meshFile.sizeBytes,
tags: meshFile.tags,
uploadedByName: meshFile.uploadedByName,
uploadedAt: meshFile.uploadedAt,
persistent: meshFile.persistent,
})
.from(meshFile)
.where(and(...conditions))
.orderBy(desc(meshFile.uploadedAt))
.limit(100);
return rows.map((r) => ({
id: r.id,
name: r.name,
sizeBytes: r.sizeBytes,
tags: (r.tags ?? []) as string[],
uploadedBy: r.uploadedByName ?? "unknown",
uploadedAt: r.uploadedAt,
persistent: r.persistent,
}));
}
/**
* Record a file access event (download/presigned URL generation).
*/
export async function recordFileAccess(
fileId: string,
sessionPubkey?: string,
peerName?: string,
): Promise<void> {
await db.insert(meshFileAccess).values({
fileId,
peerSessionPubkey: sessionPubkey ?? null,
peerName: peerName ?? null,
});
}
/**
* Get access log for a file.
*/
export async function getFileStatus(
fileId: string,
): Promise<Array<{ peerName: string; accessedAt: Date }>> {
const rows = await db
.select({
peerName: meshFileAccess.peerName,
accessedAt: meshFileAccess.accessedAt,
})
.from(meshFileAccess)
.where(eq(meshFileAccess.fileId, fileId))
.orderBy(desc(meshFileAccess.accessedAt));
return rows.map((r) => ({
peerName: r.peerName ?? "unknown",
accessedAt: r.accessedAt,
}));
}
/**
* Soft-delete a file by setting deleted_at.
*/
export async function deleteFile(
meshId: string,
fileId: string,
): Promise<void> {
await db
.update(meshFile)
.set({ deletedAt: new Date() })
.where(
and(
eq(meshFile.id, fileId),
eq(meshFile.meshId, meshId),
isNull(meshFile.deletedAt),
),
);
}
// --- Message queueing + delivery ---
export interface QueueParams {

View File

@@ -20,6 +20,10 @@ const envSchema = z.object({
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
MINIO_ENDPOINT: z.string().default("minio:9000"),
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
MINIO_SECRET_KEY: z.string().default("changeme"),
MINIO_USE_SSL: z.coerce.boolean().default(false),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),

View File

@@ -21,20 +21,25 @@ import { db } from "./db";
import { messageQueue } from "@turbostarter/db/schema/mesh";
import {
connectPresence,
deleteFile,
disconnectPresence,
drainForMember,
findMemberByPubkey,
forgetMemory,
getFile,
getFileStatus,
getState,
handleHookSetStatus,
heartbeat,
joinGroup,
joinMesh,
leaveGroup,
listFiles,
listPeersInMesh,
listState,
queueMessage,
recallMemory,
recordFileAccess,
refreshQueueDepth,
refreshStatusFromJsonl,
rememberMemory,
@@ -42,8 +47,10 @@ import {
setState,
startSweepers,
stopSweepers,
uploadFile,
writeStatus,
} from "./broker";
import { ensureBucket, meshBucketName, minioClient } from "./minio";
import type {
HookSetStatusRequest,
WSClientMessage,
@@ -140,7 +147,7 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
const started = Date.now();
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Mesh-Id, X-Member-Id, X-File-Name, X-Tags, X-Persistent, X-Target-Spec");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
@@ -177,6 +184,11 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
return;
}
if (req.method === "POST" && req.url === "/upload") {
handleUploadPost(req, res, started);
return;
}
res.writeHead(404);
res.end("not found");
log.debug("http", { route, status: 404, latency_ms: Date.now() - started });
@@ -327,6 +339,119 @@ function handleJoinPost(
});
}
function handleUploadPost(
req: IncomingMessage,
res: ServerResponse,
started: number,
): void {
const meshId = req.headers["x-mesh-id"] as string | undefined;
const memberId = req.headers["x-member-id"] as string | undefined;
const fileName = req.headers["x-file-name"] as string | undefined;
const tagsRaw = req.headers["x-tags"] as string | undefined;
const persistentRaw = req.headers["x-persistent"] as string | undefined;
const targetSpec = req.headers["x-target-spec"] as string | undefined;
if (!meshId || !memberId || !fileName) {
writeJson(res, 400, {
ok: false,
error: "X-Mesh-Id, X-Member-Id, and X-File-Name headers required",
});
return;
}
const persistent = persistentRaw !== "false";
let tags: string[] = [];
if (tagsRaw) {
try {
tags = JSON.parse(tagsRaw);
} catch {
tags = [];
}
}
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
const chunks: Buffer[] = [];
let total = 0;
let aborted = false;
req.on("data", (chunk: Buffer) => {
if (aborted) return;
total += chunk.length;
if (total > MAX_UPLOAD_SIZE) {
aborted = true;
writeJson(res, 413, { ok: false, error: "file too large (max 50MB)" });
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", async () => {
if (aborted) return;
try {
const body = Buffer.concat(chunks);
if (body.length === 0) {
writeJson(res, 400, { ok: false, error: "empty body" });
return;
}
// Generate a file ID for the MinIO key
const { generateId } = await import("@turbostarter/shared/utils");
const fileId = generateId();
const dateStr = new Date().toISOString().split("T")[0];
const keyPrefix = persistent
? `shared/${fileId}`
: `ephemeral/${dateStr}/${fileId}`;
const minioKey = `${keyPrefix}/${fileName}`;
const bucket = meshBucketName(meshId);
// Ensure bucket exists + upload
await ensureBucket(bucket);
await minioClient.putObject(
bucket,
minioKey,
body,
body.length,
req.headers["content-type"]
? { "Content-Type": req.headers["content-type"] }
: undefined,
);
// Insert DB row
const dbFileId = await uploadFile({
meshId,
name: fileName,
sizeBytes: body.length,
mimeType: (req.headers["content-type"] as string) || undefined,
minioKey,
tags,
persistent,
uploadedByMember: memberId,
targetSpec: targetSpec || undefined,
});
writeJson(res, 200, { ok: true, fileId: dbFileId });
log.info("upload", {
route: "POST /upload",
mesh_id: meshId,
file_id: dbFileId,
name: fileName,
size: body.length,
persistent,
latency_ms: Date.now() - started,
});
} catch (e) {
writeJson(res, 500, {
ok: false,
error: e instanceof Error ? e.message : String(e),
});
log.error("upload handler error", {
error: e instanceof Error ? e.message : String(e),
});
}
});
}
function handleUpgrade(
wss: WebSocketServer,
req: IncomingMessage,
@@ -775,6 +900,106 @@ function handleConnection(ws: WebSocket): void {
});
break;
}
case "get_file": {
const gf = msg as Extract<WSClientMessage, { type: "get_file" }>;
const file = await getFile(conn.meshId, gf.fileId);
if (!file) {
sendError(conn.ws, "not_found", "file not found");
break;
}
// Access control: if targetSpec is set, verify peer matches
if (file.targetSpec) {
const matches =
file.targetSpec === conn.memberPubkey ||
file.targetSpec === conn.sessionPubkey ||
file.targetSpec === "*";
if (!matches) {
sendError(conn.ws, "forbidden", "file not targeted at you");
break;
}
}
// Generate presigned URL (60s expiry)
const bucket = meshBucketName(conn.meshId);
const presignedUrl = await minioClient.presignedGetObject(
bucket,
file.minioKey,
60,
);
// Record access
const memberInfo = conn.memberPubkey
? await findMemberByPubkey(conn.meshId, conn.memberPubkey)
: null;
await recordFileAccess(
gf.fileId,
conn.sessionPubkey ?? undefined,
memberInfo?.displayName,
);
sendToPeer(presenceId, {
type: "file_url",
fileId: gf.fileId,
url: presignedUrl,
name: file.name,
});
log.info("ws get_file", {
presence_id: presenceId,
file_id: gf.fileId,
});
break;
}
case "list_files": {
const lf = msg as Extract<WSClientMessage, { type: "list_files" }>;
const files = await listFiles(conn.meshId, lf.query, lf.from);
sendToPeer(presenceId, {
type: "file_list",
files: files.map((f) => ({
id: f.id,
name: f.name,
size: f.sizeBytes,
tags: f.tags,
uploadedBy: f.uploadedBy,
uploadedAt: f.uploadedAt.toISOString(),
persistent: f.persistent,
})),
});
log.info("ws list_files", {
presence_id: presenceId,
mesh_id: conn.meshId,
count: files.length,
});
break;
}
case "file_status": {
const fs = msg as Extract<WSClientMessage, { type: "file_status" }>;
const accesses = await getFileStatus(fs.fileId);
sendToPeer(presenceId, {
type: "file_status_result",
fileId: fs.fileId,
accesses: accesses.map((a) => ({
peerName: a.peerName,
accessedAt: a.accessedAt.toISOString(),
})),
});
log.info("ws file_status", {
presence_id: presenceId,
file_id: fs.fileId,
});
break;
}
case "delete_file": {
const df = msg as Extract<WSClientMessage, { type: "delete_file" }>;
await deleteFile(conn.meshId, df.fileId);
sendToPeer(presenceId, {
type: "ack" as const,
id: df.fileId,
messageId: df.fileId,
queued: false,
});
log.info("ws delete_file", {
presence_id: presenceId,
file_id: df.fileId,
});
break;
}
case "message_status": {
const ms = msg as Extract<WSClientMessage, { type: "message_status" }>;
// Look up the message in the queue.

28
apps/broker/src/minio.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* MinIO client for file storage.
*
* Each mesh gets its own bucket (mesh-{meshId}). Files are stored under
* a key path that encodes persistence and origin:
* - persistent: shared/{fileId}/{originalName}
* - ephemeral: ephemeral/{YYYY-MM-DD}/{fileId}/{originalName}
*/
import { Client } from "minio";
import { env } from "./env";
export const minioClient = new Client({
endPoint: env.MINIO_ENDPOINT.split(":")[0]!,
port: parseInt(env.MINIO_ENDPOINT.split(":")[1] || "9000"),
useSSL: env.MINIO_USE_SSL,
accessKey: env.MINIO_ACCESS_KEY,
secretKey: env.MINIO_SECRET_KEY,
});
export async function ensureBucket(name: string): Promise<void> {
const exists = await minioClient.bucketExists(name);
if (!exists) await minioClient.makeBucket(name);
}
export function meshBucketName(meshId: string): string {
return `mesh-${meshId.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
}

View File

@@ -250,6 +250,65 @@ export interface WSMessageStatusResultMessage {
}>;
}
// --- File sharing messages ---
/** Client → broker: get a presigned download URL for a file. */
export interface WSGetFileMessage {
type: "get_file";
fileId: string;
}
/** Client → broker: list files in the mesh. */
export interface WSListFilesMessage {
type: "list_files";
query?: string;
from?: string;
}
/** Client → broker: get access log for a file. */
export interface WSFileStatusMessage {
type: "file_status";
fileId: string;
}
/** Client → broker: soft-delete a file. */
export interface WSDeleteFileMessage {
type: "delete_file";
fileId: string;
}
/** Broker → client: presigned URL for downloading a file. */
export interface WSFileUrlMessage {
type: "file_url";
fileId: string;
url: string;
name: string;
}
/** Broker → client: list of files in the mesh. */
export interface WSFileListMessage {
type: "file_list";
files: Array<{
id: string;
name: string;
size: number;
tags: string[];
uploadedBy: string;
uploadedAt: string;
persistent: boolean;
}>;
}
/** Broker → client: access log for a file. */
export interface WSFileStatusResultMessage {
type: "file_status_result";
fileId: string;
accesses: Array<{
peerName: string;
accessedAt: string;
}>;
}
/** Broker → client: structured error. */
export interface WSErrorMessage {
type: "error";
@@ -272,7 +331,11 @@ export type WSClientMessage =
| WSRememberMessage
| WSRecallMessage
| WSForgetMessage
| WSMessageStatusMessage;
| WSMessageStatusMessage
| WSGetFileMessage
| WSListFilesMessage
| WSFileStatusMessage
| WSDeleteFileMessage;
export type WSServerMessage =
| WSHelloAckMessage
@@ -285,4 +348,7 @@ export type WSServerMessage =
| WSMemoryStoredMessage
| WSMemoryResultsMessage
| WSMessageStatusResultMessage
| WSFileUrlMessage
| WSFileListMessage
| WSFileStatusResultMessage
| WSErrorMessage;