2 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
1aaa483d60 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>
2026-04-06 13:56:01 +01:00
Alejandro Gutiérrez
99d9d19079 docs: update spec with files, multi-target, views, infra vision
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:48:32 +01:00
17 changed files with 4385 additions and 28 deletions

205
SPEC.md
View File

@@ -273,7 +273,184 @@ CREATE INDEX memory_search_idx ON mesh.memory USING gin(search_vector);
--- ---
## 5. AI Context (CLAUDE.md) ## 5. Files
Built-in file sharing. AIs use tools, humans browse the dashboard. Same files, same storage, two interfaces.
### Two types of files
| | Message attachment | Shared file |
|---|---|---|
| Tool | `send_message(file: / files:)` | `share_file(path, tags?)` |
| Lifetime | Ephemeral — 24h or until read | Persistent — until deleted |
| Audience | Message recipients only | Entire mesh (current + future) |
| Findable | Under "Recent" for 24h | `list_files` / search by tags |
| Use case | "look at this screenshot" | "everyone needs this API spec" |
### AI view (MCP tools)
```
# Attach file to a message (ephemeral)
send_message(to: "@reviewers", message: "PR screenshot", file: "/tmp/screenshot.png")
# Attach multiple files
send_message(to: "@team", message: "PR ready", files: ["/tmp/api.ts", "/tmp/test.ts"])
# Share a persistent file with the mesh
share_file(path: "/tmp/api-contract.yaml", tags: ["api", "auth"], name: "Auth v2 Contract")
# Find files
list_files(query?: "auth", from?: "Alice")
# Download
get_file(id: "f_abc", save_to: "/tmp/")
# Check who accessed a file
file_status(id: "f_abc") → [{peer: "Alice", read: true, readAt: "..."}, ...]
# Delete a shared file
delete_file(id: "f_abc")
```
### Human view (Dashboard)
```
claudemesh / dev-team /
├── shared/ ← persistent files, grouped by tags
│ ├── auth/
│ │ ├── api-spec.yaml
│ │ └── wireframes.pdf
│ └── onboarding/
│ └── setup-guide.md
└── recent/ ← message attachments, by date
├── 2026-04-06/
│ └── screenshot-abc.png
└── 2026-04-07/
```
Tags become folders in the dashboard. Humans browse, AIs search.
### Storage
MinIO in the broker's docker-compose. Internal network, invisible to clients.
One bucket per mesh: `mesh-{meshId}`. Flat key structure:
```
mesh-{meshId}/shared/{fileId}/{original-name} ← persistent
mesh-{meshId}/ephemeral/{date}/{fileId}/{name} ← auto-cleaned 24h
```
MinIO lifecycle policy deletes `ephemeral/` after 24h.
### Access model
- Persistent files (`share_file`): accessible to all mesh members
- Ephemeral files (`send_message file:`): accessible to message recipients only
- `get_file` checks access before generating a presigned download URL
- `file_status` tracks who downloaded the file
### Upload flow
1. CLI reads local file, HTTP POSTs to `broker /upload` (multipart)
2. Broker stores in MinIO, creates `mesh.file` row
3. Broker returns file_id
4. For message attachments: file_id attached to the message push
5. Recipients see `📎 filename (size) — use get_file("id")` in the push
### DB schema
```sql
mesh.file (
id text PK,
mesh_id text FK,
name text NOT NULL,
size_bytes bigint NOT NULL,
mime_type text,
minio_key text NOT NULL,
tags text[] DEFAULT '{}',
persistent boolean DEFAULT true,
uploaded_by_name text,
uploaded_by_member text FK,
target_spec text, -- null = entire mesh, else message audience
uploaded_at timestamp DEFAULT NOW(),
expires_at timestamp, -- null for persistent, +24h for ephemeral
deleted_at timestamp
);
mesh.file_access (
id text PK,
file_id text FK,
peer_session_pubkey text,
peer_name text,
accessed_at timestamp DEFAULT NOW()
);
```
### Docker Compose (broker infra)
```yaml
services:
broker:
# ... existing broker config
environment:
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: claudemesh
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
depends_on:
- minio
minio:
image: minio/minio
command: server /data
volumes:
- minio-data:/data
environment:
MINIO_ROOT_USER: claudemesh
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
# Internal only — not exposed to the internet
volumes:
minio-data:
```
---
## 6. Multi-target messages
The `to` field accepts a string or array:
```
# Single target
send_message(to: "Alice", message: "hey")
# Multiple targets
send_message(to: ["Alice", "@backend", "Bob"], message: "sprint starts")
```
Broker resolves each target, deduplicates recipients, delivers once per peer.
---
## 7. Targeted views (MCP instruction pattern)
Not a broker feature — a convention taught via MCP instructions. When sending related information to different audiences, Claude sends tailored messages instead of one generic broadcast:
```
# Instead of:
send_message(to: "*", message: "Auth v2 ready. Check endpoints and UI.")
# Do:
send_message(to: "@frontend", message: "Auth v2: useAuth hook changed, see src/auth/")
send_message(to: "@backend", message: "Auth v2: new /api/auth/v2 endpoints, v1 deprecated 2 weeks")
send_message(to: "@pm", message: "Auth v2 done. 3 points, no blockers.")
```
Zero broker changes. Claude reads the instruction, decides when to split.
---
## 8. AI Context (MCP Instructions)
Each `claudemesh install` copies a `CLAUDEMESH.md` file to `~/.claudemesh/CLAUDEMESH.md`. Claude Code discovers it and injects it as context. Each `claudemesh install` copies a `CLAUDEMESH.md` file to `~/.claudemesh/CLAUDEMESH.md`. Claude Code discovers it and injects it as context.
@@ -341,9 +518,9 @@ Under 2000 tokens. Tool reference only — no behavioral scripts. Claude adapts
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `send_message(to, message, priority?)` | Send to peer name, @group, or * | | `send_message(to, message, priority?, file?, files?)` | Send to name, @group, or * with optional file attachments |
| `check_messages()` | Drain buffered messages | | `check_messages()` | Drain buffered messages |
| `message_status(id)` | Check if a sent message was delivered | | `message_status(id)` | Delivery status with per-recipient detail |
### Presence ### Presence
@@ -376,6 +553,16 @@ Under 2000 tokens. Tool reference only — no behavioral scripts. Claude adapts
| `recall(query)` | Search by relevance | | `recall(query)` | Search by relevance |
| `forget(id)` | Soft-delete | | `forget(id)` | Soft-delete |
### Files
| Tool | Description |
|------|-------------|
| `share_file(path, tags?, name?)` | Share a persistent file with the mesh |
| `get_file(id, save_to)` | Download a shared file |
| `list_files(query?, from?)` | Find files shared with you |
| `file_status(id)` | Who accessed this file |
| `delete_file(id)` | Remove a shared file |
--- ---
## 8. Encryption ## 8. Encryption
@@ -455,10 +642,14 @@ claudemesh mcp Start MCP server (invoked by Claude Code, not users)
| Production hardening | v0.1.15 | Done | Stale sweep, decrypt fallback, sender exclusion | | Production hardening | v0.1.15 | Done | Stale sweep, decrypt fallback, sender exclusion |
| Delivery fix | v0.1.16 | Done | Same-member session message delivery | | Delivery fix | v0.1.16 | Done | Same-member session message delivery |
| **Groups** | **v0.2.0** | **Done** | @group routing, roles, wizard, join/leave | | **Groups** | **v0.2.0** | **Done** | @group routing, roles, wizard, join/leave |
| State | v0.3.0 | Planned | Shared key-value store with push | | **State** | **v0.3.0** | **Done** | Shared key-value store with push notifications |
| Memory | v0.4.0 | Planned | Persistent knowledge with full-text search | | **Memory** | **v0.3.0** | **Done** | Persistent knowledge with full-text search |
| AI Context | v0.2.1 | Planned | CLAUDEMESH.md shipped with CLI | | **Message status** | **v0.3.0** | **Done** | Per-recipient delivery detail |
| Dashboard | v0.5.0 | Planned | Live peers, state, memory in web UI | | **MCP instructions** | **v0.3.0** | **Done** | Dynamic identity, full tool guide, coordination patterns |
| **Multicast fix** | **v0.3.0** | **Done** | Broadcast/group push directly, not queue race |
| Files | v0.4.0 | Planned | MinIO-backed file sharing + message attachments |
| Multi-target | v0.4.0 | Planned | Array `to` field with deduplication |
| Dashboard | v0.5.0 | Planned | Live peers, state, memory, files in web UI |
--- ---

View File

@@ -19,6 +19,7 @@
"@turbostarter/shared": "workspace:*", "@turbostarter/shared": "workspace:*",
"drizzle-orm": "0.44.7", "drizzle-orm": "0.44.7",
"libsodium-wrappers": "0.7.15", "libsodium-wrappers": "0.7.15",
"minio": "8.0.7",
"ws": "8.20.0", "ws": "8.20.0",
"zod": "catalog:" "zod": "catalog:"
}, },

View File

@@ -32,6 +32,8 @@ import { db } from "./db";
import { import {
invite as inviteTable, invite as inviteTable,
mesh, mesh,
meshFile,
meshFileAccess,
meshMember as memberTable, meshMember as memberTable,
meshMemory, meshMemory,
meshState, 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 --- // --- Message queueing + delivery ---
export interface QueueParams { 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_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536), MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30), 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 NODE_ENV: z
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),

View File

@@ -21,20 +21,25 @@ import { db } from "./db";
import { messageQueue } from "@turbostarter/db/schema/mesh"; import { messageQueue } from "@turbostarter/db/schema/mesh";
import { import {
connectPresence, connectPresence,
deleteFile,
disconnectPresence, disconnectPresence,
drainForMember, drainForMember,
findMemberByPubkey, findMemberByPubkey,
forgetMemory, forgetMemory,
getFile,
getFileStatus,
getState, getState,
handleHookSetStatus, handleHookSetStatus,
heartbeat, heartbeat,
joinGroup, joinGroup,
joinMesh, joinMesh,
leaveGroup, leaveGroup,
listFiles,
listPeersInMesh, listPeersInMesh,
listState, listState,
queueMessage, queueMessage,
recallMemory, recallMemory,
recordFileAccess,
refreshQueueDepth, refreshQueueDepth,
refreshStatusFromJsonl, refreshStatusFromJsonl,
rememberMemory, rememberMemory,
@@ -42,8 +47,10 @@ import {
setState, setState,
startSweepers, startSweepers,
stopSweepers, stopSweepers,
uploadFile,
writeStatus, writeStatus,
} from "./broker"; } from "./broker";
import { ensureBucket, meshBucketName, minioClient } from "./minio";
import type { import type {
HookSetStatusRequest, HookSetStatusRequest,
WSClientMessage, WSClientMessage,
@@ -140,7 +147,7 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
const started = Date.now(); const started = Date.now();
res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS"); 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") { if (req.method === "OPTIONS") {
res.writeHead(204); res.writeHead(204);
res.end(); res.end();
@@ -177,6 +184,11 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
return; return;
} }
if (req.method === "POST" && req.url === "/upload") {
handleUploadPost(req, res, started);
return;
}
res.writeHead(404); res.writeHead(404);
res.end("not found"); res.end("not found");
log.debug("http", { route, status: 404, latency_ms: Date.now() - started }); 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( function handleUpgrade(
wss: WebSocketServer, wss: WebSocketServer,
req: IncomingMessage, req: IncomingMessage,
@@ -775,6 +900,106 @@ function handleConnection(ws: WebSocket): void {
}); });
break; 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": { case "message_status": {
const ms = msg as Extract<WSClientMessage, { type: "message_status" }>; const ms = msg as Extract<WSClientMessage, { type: "message_status" }>;
// Look up the message in the queue. // 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. */ /** Broker → client: structured error. */
export interface WSErrorMessage { export interface WSErrorMessage {
type: "error"; type: "error";
@@ -272,7 +331,11 @@ export type WSClientMessage =
| WSRememberMessage | WSRememberMessage
| WSRecallMessage | WSRecallMessage
| WSForgetMessage | WSForgetMessage
| WSMessageStatusMessage; | WSMessageStatusMessage
| WSGetFileMessage
| WSListFilesMessage
| WSFileStatusMessage
| WSDeleteFileMessage;
export type WSServerMessage = export type WSServerMessage =
| WSHelloAckMessage | WSHelloAckMessage
@@ -285,4 +348,7 @@ export type WSServerMessage =
| WSMemoryStoredMessage | WSMemoryStoredMessage
| WSMemoryResultsMessage | WSMemoryResultsMessage
| WSMessageStatusResultMessage | WSMessageStatusResultMessage
| WSFileUrlMessage
| WSFileListMessage
| WSFileStatusResultMessage
| WSErrorMessage; | WSErrorMessage;

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "0.3.0", "version": "0.4.0",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [ "keywords": [
"claude-code", "claude-code",

View File

@@ -161,9 +161,24 @@ When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATEL
| remember(content, tags?) | Store persistent knowledge with optional tags. | | remember(content, tags?) | Store persistent knowledge with optional tags. |
| recall(query) | Full-text search over mesh memory. | | recall(query) | Full-text search over mesh memory. |
| forget(id) | Soft-delete a memory entry. | | forget(id) | Soft-delete a memory entry. |
| share_file(path, name?, tags?) | Share a persistent file with the mesh. |
| get_file(id, save_to) | Download a shared file to a local path. |
| list_files(query?, from?) | Find files shared in the mesh. |
| file_status(id) | Check who has accessed a file. |
| delete_file(id) | Remove a shared file from the mesh. |
If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`). If multiple meshes are joined, prefix \`to\` with \`<mesh-slug>:\` to disambiguate (e.g. \`dev-team:Alice\`).
Multi-target: send_message accepts an array of targets for the 'to' field.
send_message(to: ["Alice", "@backend"], message: "sprint starts")
Targets are deduplicated — each peer receives the message once.
Targeted views: when different audiences need different details about the same event,
send tailored messages instead of one generic broadcast:
send_message(to: "@frontend", message: "Auth v2: useAuth hook changed, see src/auth/")
send_message(to: "@backend", message: "Auth v2: new /api/auth/v2 endpoints, v1 deprecated")
send_message(to: "@pm", message: "Auth v2 done. 3 points, no blockers.")
## Groups ## Groups
Groups are routing labels. Send to @groupname to multicast to all members. Roles are metadata that peers interpret: a "lead" gathers input before synthesizing a response, a "member" contributes when asked, an "observer" watches silently. Join and leave groups dynamically with join_group/leave_group. Check list_peers to see who belongs to which groups and their roles. Groups are routing labels. Send to @groupname to multicast to all members. Roles are metadata that peers interpret: a "lead" gathers input before synthesizing a response, a "member" contributes when asked, an "observer" watches silently. Join and leave groups dynamically with join_group/leave_group. Check list_peers to see who belongs to which groups and their roles.
@@ -173,6 +188,10 @@ Shared key-value store scoped to the mesh. Use get_state/set_state for live coor
## Memory ## Memory
Persistent knowledge that survives across sessions. Use remember(content, tags?) to store lessons, decisions, and incidents. Use recall(query) to search before asking peers. New peers should recall at session start to load institutional knowledge. Persistent knowledge that survives across sessions. Use remember(content, tags?) to store lessons, decisions, and incidents. Use recall(query) to search before asking peers. New peers should recall at session start to load institutional knowledge.
## Files
share_file for persistent references, send_message(file:) for ephemeral attachments.
Tags on shared files make them searchable. Use list_files to find what peers shared.
## Priority ## Priority
- "now": interrupt immediately, even if recipient is in DND (use for urgent: broken deploy, blocking issue) - "now": interrupt immediately, even if recipient is in DND (use for urgent: broken deploy, blocking issue)
- "next" (default): deliver when recipient goes idle (normal coordination) - "next" (default): deliver when recipient goes idle (normal coordination)
@@ -201,22 +220,32 @@ Call list_peers at session start to understand who is online, their roles, and w
const { to, message, priority } = (args ?? {}) as SendMessageArgs; const { to, message, priority } = (args ?? {}) as SendMessageArgs;
if (!to || !message) if (!to || !message)
return text("send_message: `to` and `message` required", true); return text("send_message: `to` and `message` required", true);
const { client, targetSpec, error } = await resolveClient(to);
if (!client) // Handle multi-target: to can be string or string[]
return text(`send_message: ${error ?? "no client resolved"}`, true); const targets = Array.isArray(to) ? to : [to];
const result = await client.send( const results: string[] = [];
targetSpec, const seen = new Set<string>(); // dedup by resolved pubkey
message,
(priority ?? "next") as Priority, for (const target of targets) {
); const { client, targetSpec, error } = await resolveClient(target);
if (!result.ok) if (!client) {
return text( results.push(`${target}: ${error ?? "no client resolved"}`);
`send_message failed (${client.meshSlug}): ${result.error}`, continue;
true, }
if (seen.has(targetSpec)) continue; // dedup
seen.add(targetSpec);
const result = await client.send(
targetSpec,
message,
(priority ?? "next") as Priority,
); );
return text( if (!result.ok) {
`Sent to ${targetSpec} via ${client.meshSlug} [${priority ?? "next"}] → ${result.messageId}`, results.push(`${target}: ${result.error}`);
); } else {
results.push(`${target}${result.messageId}`);
}
}
return text(results.join("\n"));
} }
case "list_peers": { case "list_peers": {
@@ -363,6 +392,69 @@ Call list_peers at session start to understand who is online, their roles, and w
return text(`Forgotten: ${id}`); return text(`Forgotten: ${id}`);
} }
// --- Files ---
case "share_file": {
const { path: filePath, name: fileName, tags } = (args ?? {}) as { path?: string; name?: string; tags?: string[] };
if (!filePath) return text("share_file: `path` required", true);
const { existsSync } = await import("node:fs");
if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true);
const client = allClients()[0];
if (!client) return text("share_file: not connected", true);
const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, {
name: fileName, tags, persistent: true,
});
if (!fileId) return text("share_file: upload failed", true);
return text(`Shared: ${fileName ?? filePath} (${fileId})`);
}
case "get_file": {
const { id, save_to } = (args ?? {}) as { id?: string; save_to?: string };
if (!id || !save_to) return text("get_file: `id` and `save_to` required", true);
const client = allClients()[0];
if (!client) return text("get_file: not connected", true);
const result = await client.getFile(id);
if (!result) return text(`get_file: file ${id} not found`, true);
const res = await fetch(result.url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) return text(`get_file: download failed (${res.status})`, true);
const { writeFileSync, mkdirSync } = await import("node:fs");
const { dirname } = await import("node:path");
mkdirSync(dirname(save_to), { recursive: true });
writeFileSync(save_to, Buffer.from(await res.arrayBuffer()));
return text(`Downloaded: ${result.name}${save_to}`);
}
case "list_files": {
const { query, from } = (args ?? {}) as { query?: string; from?: string };
const client = allClients()[0];
if (!client) return text("list_files: not connected", true);
const files = await client.listFiles(query, from);
if (files.length === 0) return text("No files found.");
const lines = files.map(f =>
`- **${f.name}** (${f.id.slice(0, 8)}…, ${f.size} bytes) by ${f.uploadedBy}${f.tags.length ? ` [${f.tags.join(", ")}]` : ""}`
);
return text(lines.join("\n"));
}
case "file_status": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("file_status: `id` required", true);
const client = allClients()[0];
if (!client) return text("file_status: not connected", true);
const accesses = await client.fileStatus(id);
if (accesses.length === 0) return text("No one has accessed this file yet.");
const lines = accesses.map(a => `- ${a.peerName} at ${a.accessedAt}`);
return text(`Accessed by:\n${lines.join("\n")}`);
}
case "delete_file": {
const { id } = (args ?? {}) as { id?: string };
if (!id) return text("delete_file: `id` required", true);
const client = allClients()[0];
if (!client) return text("delete_file: not connected", true);
await client.deleteFile(id);
return text(`Deleted: ${id}`);
}
default: default:
return text(`Unknown tool: ${name}`, true); return text(`Unknown tool: ${name}`, true);
} }

View File

@@ -17,8 +17,11 @@ export const TOOLS: Tool[] = [
type: "object", type: "object",
properties: { properties: {
to: { to: {
type: "string", oneOf: [
description: "Peer name, pubkey, @group, or #channel", { type: "string", description: "Peer name, pubkey, @group" },
{ type: "array", items: { type: "string" }, description: "Multiple targets" },
],
description: "Single target or array of targets",
}, },
message: { type: "string", description: "Message text" }, message: { type: "string", description: "Message text" },
priority: { priority: {
@@ -195,4 +198,75 @@ export const TOOLS: Tool[] = [
required: ["id"], required: ["id"],
}, },
}, },
// --- File tools ---
{
name: "share_file",
description:
"Share a persistent file with the mesh. All current and future peers can access it.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Local file path to share" },
name: {
type: "string",
description: "Display name (defaults to filename)",
},
tags: {
type: "array",
items: { type: "string" },
description: "Tags for categorization",
},
},
required: ["path"],
},
},
{
name: "get_file",
description: "Download a shared file to a local path.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
save_to: {
type: "string",
description: "Local path to save the file",
},
},
required: ["id", "save_to"],
},
},
{
name: "list_files",
description: "List files shared in the mesh.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search by name or tags" },
from: { type: "string", description: "Filter by uploader name" },
},
},
},
{
name: "file_status",
description: "Check who has accessed a shared file.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
},
required: ["id"],
},
},
{
name: "delete_file",
description: "Remove a shared file from the mesh.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "File ID" },
},
required: ["id"],
},
},
]; ];

View File

@@ -6,7 +6,7 @@ export type Priority = "now" | "next" | "low";
export type PeerStatus = "idle" | "working" | "dnd"; export type PeerStatus = "idle" | "working" | "dnd";
export interface SendMessageArgs { export interface SendMessageArgs {
to: string; // peer name, pubkey, or #channel to: string | string[]; // peer name, pubkey, @group, or array of targets
message: string; message: string;
priority?: Priority; priority?: Priority;
} }

View File

@@ -412,6 +412,9 @@ export class BrokerClient {
/** Check delivery status of a sent message. */ /** Check delivery status of a sent message. */
private messageStatusResolvers: Array<(result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void> = []; private messageStatusResolvers: Array<(result: { messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null) => void> = [];
private fileUrlResolvers: Array<(result: { url: string; name: string } | null) => void> = [];
private fileListResolvers: Array<(files: Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) => void> = [];
private fileStatusResolvers: Array<(accesses: Array<{ peerName: string; accessedAt: string }>) => void> = [];
async messageStatus(messageId: string): Promise<{ messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null> { async messageStatus(messageId: string): Promise<{ messageId: string; targetSpec: string; delivered: boolean; deliveredAt: string | null; recipients: Array<{ name: string; pubkey: string; status: string }> } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null; if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
@@ -425,6 +428,95 @@ export class BrokerClient {
}); });
} }
// --- Files ---
/** Get a download URL for a shared file. */
async getFile(fileId: string): Promise<{ url: string; name: string } | null> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null;
return new Promise((resolve) => {
this.fileUrlResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "get_file", fileId }));
setTimeout(() => {
const idx = this.fileUrlResolvers.indexOf(resolve);
if (idx !== -1) {
this.fileUrlResolvers.splice(idx, 1);
resolve(null);
}
}, 5_000);
});
}
/** List files shared in the mesh. */
async listFiles(query?: string, from?: string): Promise<Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.fileListResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "list_files", query, from }));
setTimeout(() => {
const idx = this.fileListResolvers.indexOf(resolve);
if (idx !== -1) {
this.fileListResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
});
}
/** Check who has accessed a shared file. */
async fileStatus(fileId: string): Promise<Array<{ peerName: string; accessedAt: string }>> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return [];
return new Promise((resolve) => {
this.fileStatusResolvers.push(resolve);
this.ws!.send(JSON.stringify({ type: "file_status", fileId }));
setTimeout(() => {
const idx = this.fileStatusResolvers.indexOf(resolve);
if (idx !== -1) {
this.fileStatusResolvers.splice(idx, 1);
resolve([]);
}
}, 5_000);
});
}
/** Delete a shared file from the mesh. */
async deleteFile(fileId: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "delete_file", fileId }));
}
/** Upload a file to the broker via HTTP POST. Returns file ID or null. */
async uploadFile(filePath: string, meshId: string, memberId: string, opts: {
name?: string; tags?: string[]; persistent?: boolean; targetSpec?: string;
}): Promise<string | null> {
const { readFileSync } = await import("node:fs");
const { basename } = await import("node:path");
const data = readFileSync(filePath);
const fileName = opts.name ?? basename(filePath);
// Convert WS broker URL to HTTP
const brokerHttp = this.mesh.brokerUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
.replace("/ws", "");
const res = await fetch(`${brokerHttp}/upload`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"X-Mesh-Id": meshId,
"X-Member-Id": memberId,
"X-File-Name": fileName,
"X-Tags": JSON.stringify(opts.tags ?? []),
"X-Persistent": String(opts.persistent ?? true),
"X-Target-Spec": opts.targetSpec ?? "",
},
body: data,
signal: AbortSignal.timeout(30_000),
});
const body = await res.json() as { ok?: boolean; fileId?: string };
return body.fileId ?? null;
}
/** Subscribe to state change notifications. Returns an unsubscribe function. */ /** Subscribe to state change notifications. Returns an unsubscribe function. */
onStateChange(handler: (change: { key: string; value: unknown; updatedBy: string }) => void): () => void { onStateChange(handler: (change: { key: string; value: unknown; updatedBy: string }) => void): () => void {
this.stateChangeHandlers.add(handler); this.stateChangeHandlers.add(handler);
@@ -583,6 +675,29 @@ export class BrokerClient {
if (resolver) resolver(msg as any); if (resolver) resolver(msg as any);
return; return;
} }
if (msg.type === "file_url") {
const resolver = this.fileUrlResolvers.shift();
if (resolver) {
if (msg.url) {
resolver({ url: String(msg.url), name: String(msg.name ?? "") });
} else {
resolver(null);
}
}
return;
}
if (msg.type === "file_list") {
const files = (msg.files as Array<{ id: string; name: string; size: number; tags: string[]; uploadedBy: string; uploadedAt: string; persistent: boolean }>) ?? [];
const resolver = this.fileListResolvers.shift();
if (resolver) resolver(files);
return;
}
if (msg.type === "file_status_result") {
const accesses = (msg.accesses as Array<{ peerName: string; accessedAt: string }>) ?? [];
const resolver = this.fileStatusResolvers.shift();
if (resolver) resolver(accesses);
return;
}
if (msg.type === "error") { if (msg.type === "error") {
this.debug(`broker error: ${msg.code} ${msg.message}`); this.debug(`broker error: ${msg.code} ${msg.message}`);
const id = msg.id ? String(msg.id) : null; const id = msg.id ? String(msg.id) : null;

View File

@@ -28,6 +28,26 @@ services:
networks: networks:
- claudemesh-internal - claudemesh-internal
minio:
image: minio/minio
command: server /data --console-address ":9001"
restart: always
volumes:
- minio-data:/data
environment:
MINIO_ROOT_USER: claudemesh
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-changeme}
expose:
- "9000"
networks:
- claudemesh-internal
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 15s
timeout: 5s
start_period: 10s
retries: 3
broker: broker:
image: ${BROKER_IMAGE:-claudemesh-broker:latest} image: ${BROKER_IMAGE:-claudemesh-broker:latest}
restart: always restart: always
@@ -40,11 +60,18 @@ services:
MAX_CONNECTIONS_PER_MESH: ${MAX_CONNECTIONS_PER_MESH:-100} MAX_CONNECTIONS_PER_MESH: ${MAX_CONNECTIONS_PER_MESH:-100}
MAX_MESSAGE_BYTES: ${MAX_MESSAGE_BYTES:-65536} MAX_MESSAGE_BYTES: ${MAX_MESSAGE_BYTES:-65536}
HOOK_RATE_LIMIT_PER_MIN: ${HOOK_RATE_LIMIT_PER_MIN:-30} HOOK_RATE_LIMIT_PER_MIN: ${HOOK_RATE_LIMIT_PER_MIN:-30}
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: claudemesh
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-changeme}
MINIO_USE_SSL: "false"
expose: expose:
- "7900" - "7900"
networks: networks:
- coolify - coolify
- claudemesh-internal - claudemesh-internal
depends_on:
minio:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"] test: ["CMD", "bun", "-e", "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
interval: 15s interval: 15s
@@ -85,6 +112,9 @@ services:
start_period: 20s start_period: 20s
retries: 3 retries: 3
volumes:
minio-data:
networks: networks:
# Coolify's shared Traefik network — must already exist on the host # Coolify's shared Traefik network — must already exist on the host
coolify: coolify:

View File

@@ -0,0 +1,28 @@
CREATE TABLE "mesh"."file" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL,
"name" text NOT NULL,
"size_bytes" integer NOT NULL,
"mime_type" text,
"minio_key" text NOT NULL,
"tags" text[] DEFAULT '{}',
"persistent" boolean DEFAULT true NOT NULL,
"uploaded_by_name" text,
"uploaded_by_member" text,
"target_spec" text,
"uploaded_at" timestamp DEFAULT now() NOT NULL,
"expires_at" timestamp,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "mesh"."file_access" (
"id" text PRIMARY KEY NOT NULL,
"file_id" text NOT NULL,
"peer_session_pubkey" text,
"peer_name" text,
"accessed_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "mesh"."file" ADD CONSTRAINT "file_mesh_id_mesh_id_fk" FOREIGN KEY ("mesh_id") REFERENCES "mesh"."mesh"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "mesh"."file" ADD CONSTRAINT "file_uploaded_by_member_member_id_fk" FOREIGN KEY ("uploaded_by_member") REFERENCES "mesh"."member"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mesh"."file_access" ADD CONSTRAINT "file_access_file_id_file_id_fk" FOREIGN KEY ("file_id") REFERENCES "mesh"."file"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1775477883426, "when": 1775477883426,
"tag": "0008_add-state-and-memory", "tag": "0008_add-state-and-memory",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1775480008546,
"tag": "0009_add-file-tables",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,5 +1,6 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import {
boolean,
integer, integer,
jsonb, jsonb,
pgSchema, pgSchema,
@@ -289,6 +290,43 @@ export const meshMemory = meshSchema.table("memory", {
forgottenAt: timestamp(), forgottenAt: timestamp(),
}); });
/**
* File metadata for shared files in a mesh. Actual bytes live in MinIO;
* this table tracks ownership, access control, and soft-deletion.
*/
export const meshFile = meshSchema.table("file", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
name: text().notNull(),
sizeBytes: integer().notNull(),
mimeType: text(),
minioKey: text().notNull(),
tags: text().array().default([]),
persistent: boolean().notNull().default(true),
uploadedByName: text(),
uploadedByMember: text().references(() => meshMember.id),
targetSpec: text(), // null = entire mesh
uploadedAt: timestamp().defaultNow().notNull(),
expiresAt: timestamp(),
deletedAt: timestamp(),
});
/**
* Access log for file downloads. Tracks which peer accessed which file
* and when, for auditability and read-receipt semantics.
*/
export const meshFileAccess = meshSchema.table("file_access", {
id: text().primaryKey().notNull().$defaultFn(generateId),
fileId: text()
.references(() => meshFile.id, { onDelete: "cascade" })
.notNull(),
peerSessionPubkey: text(),
peerName: text(),
accessedAt: timestamp().defaultNow().notNull(),
});
export const meshRelations = relations(mesh, ({ one, many }) => ({ export const meshRelations = relations(mesh, ({ one, many }) => ({
owner: one(user, { owner: one(user, {
fields: [mesh.ownerUserId], fields: [mesh.ownerUserId],
@@ -367,6 +405,25 @@ export const meshMemoryRelations = relations(meshMemory, ({ one }) => ({
}), }),
})); }));
export const meshFileRelations = relations(meshFile, ({ one, many }) => ({
mesh: one(mesh, {
fields: [meshFile.meshId],
references: [mesh.id],
}),
uploader: one(meshMember, {
fields: [meshFile.uploadedByMember],
references: [meshMember.id],
}),
accesses: many(meshFileAccess),
}));
export const meshFileAccessRelations = relations(meshFileAccess, ({ one }) => ({
file: one(meshFile, {
fields: [meshFileAccess.fileId],
references: [meshFile.id],
}),
}));
export const selectMeshSchema = createSelectSchema(mesh); export const selectMeshSchema = createSelectSchema(mesh);
export const insertMeshSchema = createInsertSchema(mesh); export const insertMeshSchema = createInsertSchema(mesh);
export const selectMemberSchema = createSelectSchema(meshMember); export const selectMemberSchema = createSelectSchema(meshMember);
@@ -404,3 +461,11 @@ export type SelectMeshState = typeof meshState.$inferSelect;
export type InsertMeshState = typeof meshState.$inferInsert; export type InsertMeshState = typeof meshState.$inferInsert;
export type SelectMeshMemory = typeof meshMemory.$inferSelect; export type SelectMeshMemory = typeof meshMemory.$inferSelect;
export type InsertMeshMemory = typeof meshMemory.$inferInsert; export type InsertMeshMemory = typeof meshMemory.$inferInsert;
export const selectMeshFileSchema = createSelectSchema(meshFile);
export const insertMeshFileSchema = createInsertSchema(meshFile);
export const selectMeshFileAccessSchema = createSelectSchema(meshFileAccess);
export const insertMeshFileAccessSchema = createInsertSchema(meshFileAccess);
export type SelectMeshFile = typeof meshFile.$inferSelect;
export type InsertMeshFile = typeof meshFile.$inferInsert;
export type SelectMeshFileAccess = typeof meshFileAccess.$inferSelect;
export type InsertMeshFileAccess = typeof meshFileAccess.$inferInsert;