feat: v0.4.0 — File sharing + multi-target messages
Some checks failed
Some checks failed
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:
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
28
apps/broker/src/minio.ts
Normal 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, "-")}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user