4 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
8a50e4fe56 feat(web): create-mesh form + invite-link generator with QR code
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
- create-mesh-form: RHF + zod + shadcn Form. Fields name, slug (auto-
  derived from name, editable), visibility, transport. Slug validation
  matches server (lowercase letters, digits, hyphens). Slug collision
  errors surface on the slug field.
- invite-generator: RHF + zod. Fields role, maxUses, expiresInDays.
  After generation: renders the ic://join/... invite link as a 256px
  QR code (PNG data URL, Claude-palette colors) + copy-to-clipboard
  button + "claudemesh join <link>" snippet for teammates.

Add: qrcode 1.5.4 + @types/qrcode 1.5.5 (QR generation runs client-side).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:56:49 +01:00
Alejandro Gutiérrez
c5138beb25 feat(web): user dashboard — my meshes, detail view, invites list
Four new routes under /dashboard/(user)/*:

- /dashboard/meshes — card grid of user's meshes with myRole badge,
  memberCount, tier, archived state. Empty state with "Create first mesh"
  CTA.
- /dashboard/meshes/[id] — mesh detail (members list + active invites)
  with "Generate invite link" CTA in header.
- /dashboard/meshes/new — placeholder route for create form (form lands
  in next commit).
- /dashboard/meshes/[id]/invite — placeholder route for invite generator
  (generator lands in next commit).
- /dashboard/invites — table of invites the user has issued across all
  meshes, with derived status (active/revoked/expired/exhausted).

Sidebar nav (user group) extended with Meshes + Invites entries. paths
config extended with dashboard.user.meshes.{index,new,mesh,invite} and
dashboard.user.invites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:56:40 +01:00
Alejandro Gutiérrez
a486ffd056 feat(api): mesh user router — create, list, invite, archive, leave
New /my/* Hono router scoped by session.user.id. User can only see meshes
they own OR have a non-revoked meshMember row for. All 7 endpoints guard
authz at the query level (ownerUserId = userId OR EXISTS membership).

- GET /my/meshes — paginated list with myRole, isOwner, memberCount
- POST /my/meshes — create mesh (slug collision check, returns id + slug)
- GET /my/meshes/:id — detail (mesh + members + invites)
- POST /my/meshes/:id/invites — generate ic://join/<base64url(JSON)> link.
  Matches apps/cli/src/invite/parse.ts format exactly. mesh_root_key is a
  deterministic sha256(mesh.id:slug) placeholder until Step 18 ed25519
  signing lands.
- POST /my/meshes/:id/archive — owner-only
- POST /my/meshes/:id/leave — member self-removal (sets revokedAt)
- GET /my/invites — list invites this user has issued

Schemas live in packages/api/src/schema/mesh-user.ts. All enums mirror
the DB enums from packages/db/src/schema/mesh.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:56:29 +01:00
Alejandro Gutiérrez
9d3dbcecaf feat(broker): verify ed25519 hello signature against member pubkey
WS handshake is now authenticated end-to-end. The broker proves that
every connected peer actually holds the secret key for the pubkey
they claim as identity — not just that they know the pubkey.

wire format change:
  {type:"hello", meshId, memberId, pubkey, sessionId, pid, cwd,
   timestamp, signature}
  where signature = ed25519_sign(canonical, secretKey)
  and canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`

broker verifies on every hello:
1. timestamp within ±60s of broker clock → else close(1008, timestamp_skew)
2. pubkey is 64 hex chars, signature is 128 hex chars → else malformed
3. crypto_sign_verify_detached(signature, canonical, pubkey) → else bad_signature
4. (existing) mesh.member row exists for (meshId, pubkey) → else unauthorized

All rejection paths close the WS with code 1008 + structured error
message + metrics counter increment (connections_rejected_total by
reason).

new modules:
- apps/broker/src/crypto.ts: canonicalHello, verifyHelloSignature,
  HELLO_SKEW_MS constant
- apps/cli/src/crypto/hello-sig.ts: matching signHello helper

clients updated:
- apps/cli/src/ws/client.ts: signs hello before send
- apps/broker/scripts/{peer-a,peer-b}.ts (smoke-test): sign hellos
  with seed-provided secret keys

new regression tests — tests/hello-signature.test.ts (7):
- valid signature accepted
- bad signature (signed with wrong key) rejected
- timestamp too old rejected (>60s)
- timestamp too far in future rejected (>60s)
- tampered canonical field (different meshId at verify time) rejected
- malformed hex pubkey rejected
- malformed signature length rejected

verified live:
- apps/broker/scripts/smoke-test.sh: full hello+ack+send+push flow
- apps/cli/scripts/roundtrip.ts: signed hello + encrypted message
- 55/55 tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:53:40 +01:00
24 changed files with 1850 additions and 32 deletions

View File

@@ -8,12 +8,13 @@
*/
import { readFileSync } from "node:fs";
import sodium from "libsodium-wrappers";
import WebSocket from "ws";
const seed = JSON.parse(readFileSync("/tmp/smoke-seed.json", "utf-8")) as {
meshId: string;
peerA: { memberId: string; pubkey: string };
peerB: { memberId: string; pubkey: string };
peerA: { memberId: string; pubkey: string; secretKey: string };
peerB: { memberId: string; pubkey: string; secretKey: string };
};
const BROKER = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
@@ -21,8 +22,17 @@ const ws = new WebSocket(BROKER);
let helloAcked = false;
ws.on("open", () => {
console.log("[peer-a] connected, sending hello");
ws.on("open", async () => {
await sodium.ready;
const timestamp = Date.now();
const canonical = `${seed.meshId}|${seed.peerA.memberId}|${seed.peerA.pubkey}|${timestamp}`;
const signature = sodium.to_hex(
sodium.crypto_sign_detached(
sodium.from_string(canonical),
sodium.from_hex(seed.peerA.secretKey),
),
);
console.log("[peer-a] connected, sending signed hello");
ws.send(
JSON.stringify({
type: "hello",
@@ -32,8 +42,8 @@ ws.on("open", () => {
sessionId: "peer-a-session",
pid: process.pid,
cwd: "/tmp/peer-a",
signature: "stub",
nonce: "stub",
timestamp,
signature,
}),
);
});

View File

@@ -8,12 +8,13 @@
*/
import { readFileSync } from "node:fs";
import sodium from "libsodium-wrappers";
import WebSocket from "ws";
const seed = JSON.parse(readFileSync("/tmp/smoke-seed.json", "utf-8")) as {
meshId: string;
peerA: { memberId: string; pubkey: string };
peerB: { memberId: string; pubkey: string };
peerA: { memberId: string; pubkey: string; secretKey: string };
peerB: { memberId: string; pubkey: string; secretKey: string };
};
const BROKER = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
@@ -21,8 +22,17 @@ const ws = new WebSocket(BROKER);
let received = false;
ws.on("open", () => {
console.log("[peer-b] connected, sending hello");
ws.on("open", async () => {
await sodium.ready;
const timestamp = Date.now();
const canonical = `${seed.meshId}|${seed.peerB.memberId}|${seed.peerB.pubkey}|${timestamp}`;
const signature = sodium.to_hex(
sodium.crypto_sign_detached(
sodium.from_string(canonical),
sodium.from_hex(seed.peerB.secretKey),
),
);
console.log("[peer-b] connected, sending signed hello");
ws.send(
JSON.stringify({
type: "hello",
@@ -32,8 +42,8 @@ ws.on("open", () => {
sessionId: "peer-b-session",
pid: process.pid,
cwd: "/tmp/peer-b",
signature: "stub",
nonce: "stub",
timestamp,
signature,
}),
);
});

79
apps/broker/src/crypto.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* Broker-side ed25519 verification helpers.
*
* Used to authenticate the WS hello handshake: clients sign a canonical
* byte string with their mesh.member.peerPubkey's secret key, broker
* verifies with the claimed pubkey, then cross-checks the pubkey is a
* current member of the claimed mesh.
*/
import sodium from "libsodium-wrappers";
let ready = false;
async function ensureSodium(): Promise<typeof sodium> {
if (!ready) {
await sodium.ready;
ready = true;
}
return sodium;
}
/** Canonical hello bytes: clients sign this, broker verifies this. */
export function canonicalHello(
meshId: string,
memberId: string,
pubkey: string,
timestamp: number,
): string {
return `${meshId}|${memberId}|${pubkey}|${timestamp}`;
}
export const HELLO_SKEW_MS = 60_000;
/**
* Verify a hello's ed25519 signature + timestamp skew.
* Returns { ok: true } on success, or { ok: false, reason } describing
* which check failed (for structured error response).
*/
export async function verifyHelloSignature(args: {
meshId: string;
memberId: string;
pubkey: string;
timestamp: number;
signature: string;
now?: number;
}): Promise<
| { ok: true }
| { ok: false; reason: "timestamp_skew" | "bad_signature" | "malformed" }
> {
const now = args.now ?? Date.now();
if (
!Number.isFinite(args.timestamp) ||
Math.abs(now - args.timestamp) > HELLO_SKEW_MS
) {
return { ok: false, reason: "timestamp_skew" };
}
if (
!/^[0-9a-f]{64}$/i.test(args.pubkey) ||
!/^[0-9a-f]{128}$/i.test(args.signature)
) {
return { ok: false, reason: "malformed" };
}
const s = await ensureSodium();
try {
const canonical = canonicalHello(
args.meshId,
args.memberId,
args.pubkey,
args.timestamp,
);
const ok = s.crypto_sign_verify_detached(
s.from_hex(args.signature),
s.from_string(canonical),
s.from_hex(args.pubkey),
);
return ok ? { ok: true } : { ok: false, reason: "bad_signature" };
} catch {
return { ok: false, reason: "malformed" };
}
}

View File

@@ -42,6 +42,7 @@ import { metrics, metricsToText } from "./metrics";
import { TokenBucket } from "./rate-limit";
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
import { buildInfo } from "./build-info";
import { verifyHelloSignature } from "./crypto";
const PORT = env.BROKER_PORT;
const WS_PATH = "/ws";
@@ -364,6 +365,26 @@ async function handleHello(
ws.close(1008, "capacity");
return null;
}
// Signature + skew check. Proves the client holds the secret key
// for the pubkey they're claiming as identity.
const sig = await verifyHelloSignature({
meshId: hello.meshId,
memberId: hello.memberId,
pubkey: hello.pubkey,
timestamp: hello.timestamp,
signature: hello.signature,
});
if (!sig.ok) {
metrics.connectionsRejected.inc({ reason: sig.reason });
log.warn("hello sig rejected", {
reason: sig.reason,
mesh_id: hello.meshId,
pubkey: hello.pubkey?.slice(0, 12),
});
sendError(ws, sig.reason, `hello rejected: ${sig.reason}`);
ws.close(1008, sig.reason);
return null;
}
const member = await findMemberByPubkey(hello.meshId, hello.pubkey);
if (!member) {
metrics.connectionsRejected.inc({ reason: "unauthorized" });

View File

@@ -55,8 +55,11 @@ export interface WSHelloMessage {
sessionId: string;
pid: number;
cwd: string;
signature: string; // ed25519 over (meshId||memberId||sessionId||nonce)
nonce: string;
/** ms epoch; broker rejects if outside ±60s of its own clock. */
timestamp: number;
/** ed25519 signature (hex) over the canonical hello bytes:
* `${meshId}|${memberId}|${pubkey}|${timestamp}` */
signature: string;
}
/** Client → broker: send an E2E-encrypted envelope to a target. */

View File

@@ -0,0 +1,159 @@
/**
* Hello signature verification — unit tests on the verifyHelloSignature
* function directly. Covers valid signature, bad signature, timestamp
* skew, and cross-member attacks (signing with wrong key).
*
* Integration WS-level testing happens implicitly via the smoke-test
* scripts (apps/broker/scripts/smoke-test.sh, apps/cli/scripts/
* roundtrip.ts), which exercise the full hello handshake.
*/
import { beforeAll, describe, expect, test } from "vitest";
import sodium from "libsodium-wrappers";
import {
canonicalHello,
verifyHelloSignature,
HELLO_SKEW_MS,
} from "../src/crypto";
interface Keypair {
publicKey: string;
secretKey: string;
}
async function makeKeypair(): Promise<Keypair> {
await sodium.ready;
const kp = sodium.crypto_sign_keypair();
return {
publicKey: sodium.to_hex(kp.publicKey),
secretKey: sodium.to_hex(kp.privateKey),
};
}
function sign(canonical: string, secretKeyHex: string): string {
return sodium.to_hex(
sodium.crypto_sign_detached(
sodium.from_string(canonical),
sodium.from_hex(secretKeyHex),
),
);
}
describe("verifyHelloSignature", () => {
let kp: Keypair;
beforeAll(async () => {
kp = await makeKeypair();
});
test("valid signature accepted", async () => {
const meshId = "mesh-x";
const memberId = "member-y";
const timestamp = Date.now();
const canonical = canonicalHello(meshId, memberId, kp.publicKey, timestamp);
const signature = sign(canonical, kp.secretKey);
const result = await verifyHelloSignature({
meshId,
memberId,
pubkey: kp.publicKey,
timestamp,
signature,
});
expect(result.ok).toBe(true);
});
test("bad signature rejected", async () => {
const meshId = "mesh-x";
const memberId = "member-y";
const timestamp = Date.now();
// Sign with a DIFFERENT key than the one we claim
const otherKp = await makeKeypair();
const canonical = canonicalHello(meshId, memberId, kp.publicKey, timestamp);
const signature = sign(canonical, otherKp.secretKey);
const result = await verifyHelloSignature({
meshId,
memberId,
pubkey: kp.publicKey, // claim kp's identity
timestamp,
signature, // but signed with otherKp — mismatch
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("bad_signature");
});
test("timestamp too old rejected", async () => {
const timestamp = Date.now() - HELLO_SKEW_MS - 1000;
const canonical = canonicalHello("m", "mem", kp.publicKey, timestamp);
const signature = sign(canonical, kp.secretKey);
const result = await verifyHelloSignature({
meshId: "m",
memberId: "mem",
pubkey: kp.publicKey,
timestamp,
signature,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("timestamp_skew");
});
test("timestamp too far in future rejected", async () => {
const timestamp = Date.now() + HELLO_SKEW_MS + 1000;
const canonical = canonicalHello("m", "mem", kp.publicKey, timestamp);
const signature = sign(canonical, kp.secretKey);
const result = await verifyHelloSignature({
meshId: "m",
memberId: "mem",
pubkey: kp.publicKey,
timestamp,
signature,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("timestamp_skew");
});
test("tampered canonical field fails verification", async () => {
const timestamp = Date.now();
// Sign over one meshId, claim a different one at verify time
const canonical = canonicalHello(
"original-mesh",
"mem",
kp.publicKey,
timestamp,
);
const signature = sign(canonical, kp.secretKey);
const result = await verifyHelloSignature({
meshId: "different-mesh",
memberId: "mem",
pubkey: kp.publicKey,
timestamp,
signature,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("bad_signature");
});
test("malformed hex pubkey rejected", async () => {
const timestamp = Date.now();
const result = await verifyHelloSignature({
meshId: "m",
memberId: "mem",
pubkey: "not-hex",
timestamp,
signature: "a".repeat(128),
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("malformed");
});
test("malformed signature length rejected", async () => {
const timestamp = Date.now();
const result = await verifyHelloSignature({
meshId: "m",
memberId: "mem",
pubkey: kp.publicKey,
timestamp,
signature: "abc123", // wrong length
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("malformed");
});
});

View File

@@ -0,0 +1,28 @@
/**
* Client-side signing of the WS hello handshake.
*
* Canonical bytes: `${meshId}|${memberId}|${pubkey}|${timestamp}` —
* MUST match the broker's `canonicalHello()` exactly. Any mismatch
* (delimiter, field order, whitespace) produces a bad_signature reject.
*
* Uses the full ed25519 secret key (64 bytes) that libsodium returns
* from crypto_sign_keypair — seed || pubkey layout.
*/
import { ensureSodium } from "./keypair";
export async function signHello(
meshId: string,
memberId: string,
pubkey: string,
secretKeyHex: string,
): Promise<{ timestamp: number; signature: string }> {
const s = await ensureSodium();
const timestamp = Date.now();
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
const sig = s.crypto_sign_detached(
s.from_string(canonical),
s.from_hex(secretKeyHex),
);
return { timestamp, signature: s.to_hex(sig) };
}

View File

@@ -20,6 +20,7 @@ import {
encryptDirect,
isDirectTarget,
} from "../crypto/envelope";
import { signHello } from "../crypto/hello-sig";
export type Priority = "now" | "next" | "low";
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
@@ -97,21 +98,36 @@ export class BrokerClient {
this.ws = ws;
return new Promise<void>((resolve, reject) => {
const onOpen = (): void => {
this.debug("ws open → sending hello");
ws.send(
JSON.stringify({
type: "hello",
meshId: this.mesh.meshId,
memberId: this.mesh.memberId,
pubkey: this.mesh.pubkey,
sessionId: `${process.pid}-${Date.now()}`,
pid: process.pid,
cwd: process.cwd(),
signature: "stub", // libsodium sign_detached lands in Step 18
nonce: randomNonce(),
}),
);
const onOpen = async (): Promise<void> => {
this.debug("ws open → signing + sending hello");
try {
const { timestamp, signature } = await signHello(
this.mesh.meshId,
this.mesh.memberId,
this.mesh.pubkey,
this.mesh.secretKey,
);
ws.send(
JSON.stringify({
type: "hello",
meshId: this.mesh.meshId,
memberId: this.mesh.memberId,
pubkey: this.mesh.pubkey,
sessionId: `${process.pid}-${Date.now()}`,
pid: process.pid,
cwd: process.cwd(),
timestamp,
signature,
}),
);
} catch (e) {
reject(
new Error(
`hello sign failed: ${e instanceof Error ? e.message : e}`,
),
);
return;
}
// Arm the hello_ack timeout.
this.helloTimer = setTimeout(() => {
this.debug("hello_ack timeout");

View File

@@ -45,6 +45,7 @@
"next-themes": "0.4.6",
"nuqs": "2.7.2",
"pdfjs-dist": "5.4.530",
"qrcode": "1.5.4",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"react-dropzone": "14.3.8",
@@ -67,6 +68,7 @@
"@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*",
"@types/node": "catalog:node22",
"@types/qrcode": "1.5.6",
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
"autoprefixer": "10.4.21",

View File

@@ -0,0 +1,111 @@
import Link from "next/link";
import { getMyInvitesResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Badge } from "@turbostarter/ui-web/badge";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
export const generateMetadata = getMetadata({
title: "Invites",
description: "Invites you've issued.",
});
export default async function InvitesPage() {
const { sent } = await handle(api.my.invites.$get, {
schema: getMyInvitesResponseSchema,
})();
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>Invites</DashboardHeaderTitle>
<DashboardHeaderDescription>
Invite links you&apos;ve issued across all your meshes.
</DashboardHeaderDescription>
</div>
</DashboardHeader>
{sent.length === 0 ? (
<div className="rounded-lg border border-dashed p-10 text-center">
<p className="text-muted-foreground">
You haven&apos;t issued any invites yet. Open a mesh and generate
one.
</p>
</div>
) : (
<div className="rounded-lg border">
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left text-xs uppercase">
<tr>
<th className="px-4 py-3 font-medium">Mesh</th>
<th className="px-4 py-3 font-medium">Role</th>
<th className="px-4 py-3 font-medium">Uses</th>
<th className="px-4 py-3 font-medium">Expires</th>
<th className="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{sent.map((inv) => (
<tr key={inv.id}>
<td className="px-4 py-3">
{inv.meshId ? (
<Link
href={pathsConfig.dashboard.user.meshes.mesh(inv.meshId)}
className="group flex flex-col gap-0.5"
>
<span className="group-hover:text-primary font-medium underline underline-offset-4">
{inv.meshName ?? "—"}
</span>
<span className="text-muted-foreground font-mono text-xs">
{inv.meshSlug ?? "—"}
</span>
</Link>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3">
<Badge variant="outline">{inv.role}</Badge>
</td>
<td className="px-4 py-3 font-mono text-xs">
{inv.usedCount} / {inv.maxUses}
</td>
<td className="text-muted-foreground px-4 py-3 text-xs">
{new Date(inv.expiresAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
{inv.revokedAt ? (
<Badge className="bg-destructive/15 text-destructive text-xs">
revoked
</Badge>
) : new Date(inv.expiresAt) < new Date() ? (
<Badge variant="outline" className="text-xs">
expired
</Badge>
) : inv.usedCount >= inv.maxUses ? (
<Badge variant="outline" className="text-xs">
exhausted
</Badge>
) : (
<Badge className="bg-success/15 text-success text-xs">
active
</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}

View File

@@ -21,9 +21,14 @@ const menu = [
icon: Icons.Home,
},
{
title: "aiTools",
href: pathsConfig.apps.chat.index,
icon: Icons.Sparkles,
title: "meshes",
href: pathsConfig.dashboard.user.meshes.index,
icon: Icons.Share,
},
{
title: "invites",
href: pathsConfig.dashboard.user.invites,
icon: Icons.Link,
},
],
},

View File

@@ -0,0 +1,34 @@
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { InviteGenerator } from "~/modules/mesh/invite-generator";
export const generateMetadata = getMetadata({
title: "Invite to mesh",
description: "Generate an invite link for this mesh.",
});
export default async function InvitePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>Invite teammate</DashboardHeaderTitle>
<DashboardHeaderDescription>
Generate a one-time or reusable invite link.
</DashboardHeaderDescription>
</div>
</DashboardHeader>
<InviteGenerator meshId={id} />
</>
);
}

View File

@@ -0,0 +1,158 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMyMeshResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Badge } from "@turbostarter/ui-web/badge";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
export const generateMetadata = getMetadata({
title: "Mesh",
description: "Mesh detail.",
});
export default async function MeshPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const data = await handle(api.my.meshes[":id"].$get, {
schema: getMyMeshResponseSchema,
})({ param: { id } }).catch(() => null);
if (!data || !data.mesh) notFound();
const { mesh, members, invites } = data;
const activeInvites = invites.filter(
(i) => !i.revokedAt && new Date(i.expiresAt) > new Date(),
);
return (
<>
<DashboardHeader>
<div className="flex w-full items-start justify-between gap-4">
<div>
<DashboardHeaderTitle>
<span className="flex items-center gap-3">
{mesh.name}
<Badge variant="outline" className="font-mono text-xs">
{mesh.slug}
</Badge>
</span>
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{mesh.isOwner ? "You own this mesh" : `You're a ${mesh.myRole}`}{" "}
· tier {mesh.tier} · {mesh.visibility} · {mesh.transport}
</DashboardHeaderDescription>
</div>
<Link
href={pathsConfig.dashboard.user.meshes.invite(mesh.id)}
className={buttonVariants({ variant: "default" })}
>
Generate invite link
</Link>
</div>
</DashboardHeader>
<div className="grid gap-8">
<section className="rounded-lg border">
<header className="flex items-center justify-between border-b px-4 py-3">
<h2 className="font-medium">
Members{" "}
<span className="text-muted-foreground">({members.length})</span>
</h2>
</header>
{members.length === 0 ? (
<p className="text-muted-foreground px-4 py-8 text-center text-sm">
No members yet.
</p>
) : (
<div className="divide-y">
{members.map((m) => (
<div
key={m.id}
className="flex items-center justify-between px-4 py-3"
>
<div className="flex items-center gap-3">
<span className="font-medium">
{m.displayName}
{m.isMe && (
<Badge
variant="outline"
className="ml-2 text-[10px]"
>
you
</Badge>
)}
</span>
<Badge variant="secondary" className="text-xs">
{m.role}
</Badge>
{m.revokedAt && (
<Badge className="bg-destructive/15 text-destructive text-xs">
revoked
</Badge>
)}
</div>
<span className="text-muted-foreground text-xs">
joined {new Date(m.joinedAt).toLocaleDateString()}
</span>
</div>
))}
</div>
)}
</section>
<section className="rounded-lg border">
<header className="flex items-center justify-between border-b px-4 py-3">
<h2 className="font-medium">
Active invites{" "}
<span className="text-muted-foreground">
({activeInvites.length})
</span>
</h2>
</header>
{activeInvites.length === 0 ? (
<p className="text-muted-foreground px-4 py-8 text-center text-sm">
No active invites. Generate one to add teammates.
</p>
) : (
<div className="divide-y">
{activeInvites.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between px-4 py-3 text-sm"
>
<div className="flex items-center gap-3">
<code className="bg-muted rounded px-2 py-0.5 text-xs">
{inv.token.slice(0, 12)}
</code>
<Badge variant="outline" className="text-xs">
{inv.role}
</Badge>
<span className="text-muted-foreground">
{inv.usedCount} / {inv.maxUses} used
</span>
</div>
<span className="text-muted-foreground text-xs">
expires {new Date(inv.expiresAt).toLocaleDateString()}
</span>
</div>
))}
</div>
)}
</section>
</div>
</>
);
}

View File

@@ -0,0 +1,30 @@
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { CreateMeshForm } from "~/modules/mesh/create-mesh-form";
export const generateMetadata = getMetadata({
title: "New mesh",
description: "Create a mesh.",
});
export default function NewMeshPage() {
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>New mesh</DashboardHeaderTitle>
<DashboardHeaderDescription>
One mesh per team, project, or rollout. You can archive it later.
</DashboardHeaderDescription>
</div>
</DashboardHeader>
<div className="max-w-xl">
<CreateMeshForm />
</div>
</>
);
}

View File

@@ -0,0 +1,100 @@
import Link from "next/link";
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Badge } from "@turbostarter/ui-web/badge";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
export const generateMetadata = getMetadata({
title: "Meshes",
description: "Meshes you own or belong to.",
});
export default async function MeshesPage() {
const { data } = await handle(api.my.meshes.$get, {
schema: getMyMeshesResponseSchema,
})({
query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
});
return (
<>
<DashboardHeader>
<div className="flex w-full items-start justify-between gap-4">
<div>
<DashboardHeaderTitle>Meshes</DashboardHeaderTitle>
<DashboardHeaderDescription>
Meshes you own or have joined. Click any to open.
</DashboardHeaderDescription>
</div>
<Link
href={pathsConfig.dashboard.user.meshes.new}
className={buttonVariants({ variant: "default" })}
>
New mesh
</Link>
</div>
</DashboardHeader>
{data.length === 0 ? (
<div className="rounded-lg border border-dashed p-10 text-center">
<p className="text-muted-foreground mb-4">
You haven&apos;t joined any meshes yet.
</p>
<Link
href={pathsConfig.dashboard.user.meshes.new}
className={buttonVariants({ variant: "default" })}
>
Create your first mesh
</Link>
</div>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{data.map((m) => (
<Link
key={m.id}
href={pathsConfig.dashboard.user.meshes.mesh(m.id)}
className="group rounded-lg border p-5 transition-colors hover:border-primary hover:bg-muted/30"
>
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="group-hover:text-primary truncate font-medium">
{m.name}
</h3>
<p className="text-muted-foreground truncate font-mono text-xs">
{m.slug}
</p>
</div>
<Badge variant="outline" className="flex-shrink-0 text-xs">
{m.isOwner ? "owner" : m.myRole}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs">
<Badge variant="secondary" className="text-xs">
{m.tier}
</Badge>
<span className="text-muted-foreground">
{m.memberCount} {m.memberCount === 1 ? "member" : "members"}
</span>
{m.archivedAt && (
<Badge variant="outline" className="text-xs">
archived
</Badge>
)}
</div>
</Link>
))}
</div>
)}
</>
);
}

View File

@@ -90,6 +90,13 @@ const pathsConfig = {
index: DASHBOARD_PREFIX,
ai: `${DASHBOARD_PREFIX}/ai`,
vocabulary: `${DASHBOARD_PREFIX}/vocabulary`,
meshes: {
index: `${DASHBOARD_PREFIX}/meshes`,
new: `${DASHBOARD_PREFIX}/meshes/new`,
mesh: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}`,
invite: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/invite`,
},
invites: `${DASHBOARD_PREFIX}/invites`,
settings: {
index: `${DASHBOARD_PREFIX}/settings`,
security: `${DASHBOARD_PREFIX}/settings/security`,

View File

@@ -0,0 +1,177 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
createMyMeshInputSchema,
type CreateMyMeshInput,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Input } from "@turbostarter/ui-web/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/client";
const slugify = (s: string) =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
export const CreateMeshForm = () => {
const router = useRouter();
const form = useForm<CreateMyMeshInput>({
resolver: zodResolver(createMyMeshInputSchema),
defaultValues: {
name: "",
slug: "",
visibility: "private",
transport: "managed",
},
});
const nameValue = form.watch("name");
const slugDirty = form.formState.dirtyFields.slug;
useEffect(() => {
if (!slugDirty && nameValue) {
form.setValue("slug", slugify(nameValue));
}
}, [nameValue, slugDirty, form]);
const onSubmit = async (values: CreateMyMeshInput) => {
try {
const res = (await handle(api.my.meshes.$post)({
json: values,
})) as { id: string; slug: string } | { error: string };
if ("error" in res) {
form.setError("slug", { message: res.error });
return;
}
router.push(pathsConfig.dashboard.user.meshes.mesh(res.id));
} catch (e) {
form.setError("root", {
message: e instanceof Error ? e.message : "Failed to create mesh.",
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Platform team" {...field} />
</FormControl>
<FormDescription>
Display name what teammates see.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="platform-team" {...field} />
</FormControl>
<FormDescription>
URL-safe identifier: lowercase letters, digits, hyphens.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>Visibility</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="private">
Private invite-only
</SelectItem>
<SelectItem value="public">
Public anyone with the link
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="transport"
render={({ field }) => (
<FormItem>
<FormLabel>Transport</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="managed">Managed (claudemesh.com)</SelectItem>
<SelectItem value="tailscale">Tailscale</SelectItem>
<SelectItem value="self_hosted">Self-hosted broker</SelectItem>
</SelectContent>
</Select>
<FormDescription>
How peers reach the broker.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className="text-destructive text-sm">
{form.formState.errors.root.message}
</p>
)}
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Creating…" : "Create mesh"}
</Button>
</form>
</Form>
);
};

View File

@@ -0,0 +1,227 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import QRCode from "qrcode";
import {
createMyInviteInputSchema,
type CreateMyInviteInput,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Input } from "@turbostarter/ui-web/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { api } from "~/lib/api/client";
interface GeneratedInvite {
id: string;
token: string;
inviteLink: string;
expiresAt: Date;
qrDataUrl: string;
}
export const InviteGenerator = ({ meshId }: { meshId: string }) => {
const [result, setResult] = useState<GeneratedInvite | null>(null);
const [copied, setCopied] = useState(false);
const form = useForm<CreateMyInviteInput>({
resolver: zodResolver(createMyInviteInputSchema),
defaultValues: { role: "member", maxUses: 1, expiresInDays: 7 },
});
const onSubmit = async (values: CreateMyInviteInput) => {
try {
const res = (await handle(api.my.meshes[":id"].invites.$post)({
param: { id: meshId },
json: values,
})) as
| {
id: string;
token: string;
inviteLink: string;
expiresAt: string;
}
| { error: string };
if ("error" in res) {
form.setError("root", { message: res.error });
return;
}
const qrDataUrl = await QRCode.toDataURL(res.inviteLink, {
width: 256,
margin: 1,
color: { dark: "#141413", light: "#ffffff" },
});
setResult({
id: res.id,
token: res.token,
inviteLink: res.inviteLink,
expiresAt: new Date(res.expiresAt),
qrDataUrl,
});
} catch (e) {
form.setError("root", {
message: e instanceof Error ? e.message : "Failed to generate invite.",
});
}
};
const onCopy = async () => {
if (!result) return;
await navigator.clipboard.writeText(result.inviteLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (result) {
return (
<div className="space-y-6">
<div className="rounded-lg border p-6">
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
<div className="flex items-start justify-center">
<img
src={result.qrDataUrl}
alt="Invite QR code"
className="h-[220px] w-[220px] rounded border"
/>
</div>
<div className="space-y-4">
<div>
<div className="text-muted-foreground mb-1 text-xs uppercase tracking-wider">
Invite link
</div>
<code className="bg-muted block break-all rounded p-3 font-mono text-xs">
{result.inviteLink}
</code>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs">
<Badge variant="outline">
expires {result.expiresAt.toLocaleDateString()}
</Badge>
</div>
<div className="flex gap-2">
<Button onClick={onCopy} size="sm">
{copied ? "Copied ✓" : "Copy link"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setResult(null);
form.reset();
}}
>
Generate another
</Button>
</div>
</div>
</div>
</div>
<div className="text-muted-foreground rounded border border-dashed p-4 text-xs">
<p className="mb-2 font-medium">How your teammate joins:</p>
<code className="bg-muted block rounded p-2 font-mono text-xs">
claudemesh join {result.inviteLink}
</code>
<p className="mt-2">
Or scan the QR code from the claudemesh mobile app (coming soon).
</p>
</div>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-md space-y-5">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxUses"
render={({ field }) => (
<FormItem>
<FormLabel>Max uses</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={1000}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiresInDays"
render={({ field }) => (
<FormItem>
<FormLabel>Expires in (days)</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={365}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className="text-destructive text-sm">
{form.formState.errors.root.message}
</p>
)}
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Generating…" : "Generate invite"}
</Button>
</form>
</Form>
);
};

View File

@@ -13,6 +13,7 @@ import { adminRouter } from "./modules/admin/router";
// import { aiRouter } from "./modules/ai/router"; // disabled: @turbostarter/ai package removed in claudemesh
import { authRouter } from "./modules/auth/router";
import { billingRouter } from "./modules/billing/router";
import { myRouter } from "./modules/mesh/router";
import { organizationRouter } from "./modules/organization/router";
import { storageRouter } from "./modules/storage/router";
import { onError } from "./utils/on-error";
@@ -48,6 +49,7 @@ const appRouter = new Hono()
// .route("/ai", aiRouter) // disabled: @turbostarter/ai package removed in claudemesh
.route("/auth", authRouter)
.route("/billing", billingRouter)
.route("/my", myRouter)
.route("/organizations", organizationRouter)
.route("/storage", storageRouter)
.onError(onError);

View File

@@ -0,0 +1,180 @@
import { randomBytes, createHash } from "node:crypto";
import { and, eq, isNull } from "@turbostarter/db";
import { invite, mesh, meshMember } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import type {
CreateMyInviteInput,
CreateMyMeshInput,
} from "../../schema";
const BROKER_URL = process.env.NEXT_PUBLIC_BROKER_URL ?? "ws://localhost:7900";
export const createMyMesh = async ({
userId,
input,
}: {
userId: string;
input: CreateMyMeshInput;
}) => {
// Slug collision check
const [existing] = await db
.select({ id: mesh.id })
.from(mesh)
.where(eq(mesh.slug, input.slug))
.limit(1);
if (existing) {
throw new Error("A mesh with that slug already exists.");
}
const [created] = await db
.insert(mesh)
.values({
name: input.name,
slug: input.slug,
visibility: input.visibility,
transport: input.transport,
ownerUserId: userId,
})
.returning({ id: mesh.id, slug: mesh.slug });
return created!;
};
export const archiveMyMesh = async ({
userId,
meshId,
}: {
userId: string;
meshId: string;
}) => {
const [updated] = await db
.update(mesh)
.set({ archivedAt: new Date() })
.where(and(eq(mesh.id, meshId), eq(mesh.ownerUserId, userId)))
.returning({ id: mesh.id });
if (!updated) {
throw new Error("Mesh not found or you are not the owner.");
}
return updated;
};
export const leaveMyMesh = async ({
userId,
meshId,
}: {
userId: string;
meshId: string;
}) => {
const [updated] = await db
.update(meshMember)
.set({ revokedAt: new Date() })
.where(
and(
eq(meshMember.meshId, meshId),
eq(meshMember.userId, userId),
isNull(meshMember.revokedAt),
),
)
.returning({ id: meshMember.id });
if (!updated) {
throw new Error("You are not a member of this mesh.");
}
return updated;
};
/** Encode an ic://join/<base64url(JSON)> invite link. Format mirrors
* apps/cli/src/invite/parse.ts exactly. */
const encodeInviteLink = (payload: unknown): string => {
const json = JSON.stringify(payload);
const encoded = Buffer.from(json, "utf-8").toString("base64url");
return `ic://join/${encoded}`;
};
/** Placeholder deterministic root key until mesh_root_key column lands
* (Step 18 crypto). Signature verification is Step 18, so an actual
* ed25519 pubkey is not yet required — only presence is checked. */
const derivePlaceholderRootKey = (meshId: string, meshSlug: string): string =>
createHash("sha256").update(`${meshId}:${meshSlug}`).digest("hex");
export const createMyInvite = async ({
userId,
meshId,
input,
}: {
userId: string;
meshId: string;
input: CreateMyInviteInput;
}) => {
// Authz: owner or admin member can invite
const [meshRow] = await db
.select({
id: mesh.id,
slug: mesh.slug,
ownerUserId: mesh.ownerUserId,
})
.from(mesh)
.where(eq(mesh.id, meshId))
.limit(1);
if (!meshRow) {
throw new Error("Mesh not found.");
}
const isOwner = meshRow.ownerUserId === userId;
if (!isOwner) {
const [membership] = await db
.select({ role: meshMember.role })
.from(meshMember)
.where(
and(
eq(meshMember.meshId, meshId),
eq(meshMember.userId, userId),
isNull(meshMember.revokedAt),
),
)
.limit(1);
if (!membership || membership.role !== "admin") {
throw new Error("Only owners and admins can issue invites.");
}
}
const token = randomBytes(24).toString("base64url");
const expiresAt = new Date(
Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000,
);
const [created] = await db
.insert(invite)
.values({
meshId,
token,
maxUses: input.maxUses,
role: input.role,
expiresAt,
createdBy: userId,
})
.returning({ id: invite.id, token: invite.token, expiresAt: invite.expiresAt });
const payload = {
v: 1 as const,
mesh_id: meshRow.id,
mesh_slug: meshRow.slug,
broker_url: BROKER_URL,
expires_at: Math.floor(expiresAt.getTime() / 1000),
mesh_root_key: derivePlaceholderRootKey(meshRow.id, meshRow.slug),
role: input.role,
// signature: added in Step 18 (ed25519 sign by mesh_root_key)
};
return {
id: created!.id,
token: created!.token,
expiresAt: created!.expiresAt,
inviteLink: encodeInviteLink(payload),
};
};

View File

@@ -0,0 +1,185 @@
import {
and,
asc,
count,
desc,
eq,
getOrderByFromSort,
ilike,
isNull,
or,
sql,
} from "@turbostarter/db";
import { invite, mesh, meshMember } from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
import type { GetMyMeshesInput } from "../../schema";
export const getMyMeshes = async ({
userId,
...input
}: GetMyMeshesInput & { userId: string }) => {
const offset = (input.page - 1) * input.perPage;
// User sees: meshes they own OR meshes where they have a meshMember row
const baseWhere = or(
eq(mesh.ownerUserId, userId),
sql`EXISTS (SELECT 1 FROM mesh.member mm WHERE mm.mesh_id = ${mesh.id} AND mm.user_id = ${userId} AND mm.revoked_at IS NULL)`,
);
const where = and(
baseWhere,
input.q
? or(ilike(mesh.name, `%${input.q}%`), ilike(mesh.slug, `%${input.q}%`))
: undefined,
);
const orderBy = input.sort
? getOrderByFromSort({ sort: input.sort, defaultSchema: mesh })
: [desc(mesh.createdAt)];
return db.transaction(async (tx) => {
const data = await tx
.select({
id: mesh.id,
name: mesh.name,
slug: mesh.slug,
visibility: mesh.visibility,
transport: mesh.transport,
tier: mesh.tier,
createdAt: mesh.createdAt,
archivedAt: mesh.archivedAt,
isOwner: sql<boolean>`${mesh.ownerUserId} = ${userId}`,
myRole: sql<"admin" | "member">`CASE WHEN ${mesh.ownerUserId} = ${userId} THEN 'admin'::text ELSE COALESCE((SELECT role::text FROM mesh.member mm2 WHERE mm2.mesh_id = ${mesh.id} AND mm2.user_id = ${userId} AND mm2.revoked_at IS NULL LIMIT 1), 'member') END`,
memberCount: sql<number>`(SELECT COUNT(*)::int FROM mesh.member mm3 WHERE mm3.mesh_id = ${mesh.id} AND mm3.revoked_at IS NULL)`,
})
.from(mesh)
.where(where)
.limit(input.perPage)
.offset(offset)
.orderBy(...orderBy);
const total = await tx
.select({ count: count() })
.from(mesh)
.where(where)
.execute()
.then((res) => res[0]?.count ?? 0);
return { data, total };
});
};
export const getMyMeshById = async ({
userId,
meshId,
}: {
userId: string;
meshId: string;
}) => {
const [m] = await db
.select({
id: mesh.id,
name: mesh.name,
slug: mesh.slug,
visibility: mesh.visibility,
transport: mesh.transport,
tier: mesh.tier,
maxPeers: mesh.maxPeers,
createdAt: mesh.createdAt,
archivedAt: mesh.archivedAt,
ownerUserId: mesh.ownerUserId,
})
.from(mesh)
.where(eq(mesh.id, meshId))
.limit(1);
if (!m) return null;
// Authz: user must own OR be a non-revoked member
const isOwner = m.ownerUserId === userId;
if (!isOwner) {
const [membership] = await db
.select({ id: meshMember.id, role: meshMember.role })
.from(meshMember)
.where(
and(
eq(meshMember.meshId, meshId),
eq(meshMember.userId, userId),
isNull(meshMember.revokedAt),
),
)
.limit(1);
if (!membership) return null;
}
const members = await db
.select({
id: meshMember.id,
displayName: meshMember.displayName,
role: meshMember.role,
joinedAt: meshMember.joinedAt,
lastSeenAt: meshMember.lastSeenAt,
revokedAt: meshMember.revokedAt,
userId: meshMember.userId,
})
.from(meshMember)
.where(eq(meshMember.meshId, meshId))
.orderBy(asc(meshMember.joinedAt));
const invites = await db
.select({
id: invite.id,
token: invite.token,
maxUses: invite.maxUses,
usedCount: invite.usedCount,
role: invite.role,
expiresAt: invite.expiresAt,
createdAt: invite.createdAt,
revokedAt: invite.revokedAt,
})
.from(invite)
.where(eq(invite.meshId, meshId))
.orderBy(desc(invite.createdAt))
.limit(50);
// Derive myRole for the mesh top-level field
const myRole: "admin" | "member" = isOwner
? "admin"
: (members.find((mem) => mem.userId === userId)?.role ?? "member");
return {
mesh: { ...m, isOwner, myRole },
members: members.map((mem) => ({
id: mem.id,
displayName: mem.displayName,
role: mem.role,
joinedAt: mem.joinedAt,
lastSeenAt: mem.lastSeenAt,
revokedAt: mem.revokedAt,
isMe: mem.userId === userId,
})),
invites,
};
};
export const getMyInvitesSent = async ({ userId }: { userId: string }) =>
db
.select({
id: invite.id,
meshId: invite.meshId,
meshName: mesh.name,
meshSlug: mesh.slug,
token: invite.token,
role: invite.role,
maxUses: invite.maxUses,
usedCount: invite.usedCount,
expiresAt: invite.expiresAt,
createdAt: invite.createdAt,
revokedAt: invite.revokedAt,
})
.from(invite)
.leftJoin(mesh, eq(invite.meshId, mesh.id))
.where(eq(invite.createdBy, userId))
.orderBy(desc(invite.createdAt))
.limit(100);

View File

@@ -0,0 +1,114 @@
import { Hono } from "hono";
import type { User } from "@turbostarter/auth";
import { enforceAuth, validate } from "../../middleware";
import {
createMyInviteInputSchema,
createMyMeshInputSchema,
getMyMeshesInputSchema,
} from "../../schema";
import {
archiveMyMesh,
createMyInvite,
createMyMesh,
leaveMyMesh,
} from "./mutations";
import {
getMyInvitesSent,
getMyMeshById,
getMyMeshes,
} from "./queries";
type Env = { Variables: { user: User } };
export const myRouter = new Hono<Env>()
.use(enforceAuth)
.get("/meshes", validate("query", getMyMeshesInputSchema), async (c) => {
const user = c.var.user;
return c.json(
await getMyMeshes({ userId: user.id, ...c.req.valid("query") }),
);
})
.post("/meshes", validate("json", createMyMeshInputSchema), async (c) => {
const user = c.var.user;
try {
const result = await createMyMesh({
userId: user.id,
input: c.req.valid("json"),
});
return c.json(result);
} catch (e) {
return c.json(
{ error: e instanceof Error ? e.message : "Failed to create mesh." },
400,
);
}
})
.get("/meshes/:id", async (c) => {
const user = c.var.user;
return c.json(
(await getMyMeshById({
userId: user.id,
meshId: c.req.param("id"),
})) ?? { mesh: null, members: [], invites: [] },
);
})
.post(
"/meshes/:id/invites",
validate("json", createMyInviteInputSchema),
async (c) => {
const user = c.var.user;
try {
const result = await createMyInvite({
userId: user.id,
meshId: c.req.param("id"),
input: c.req.valid("json"),
});
return c.json(result);
} catch (e) {
return c.json(
{
error:
e instanceof Error ? e.message : "Failed to create invite.",
},
400,
);
}
},
)
.post("/meshes/:id/archive", async (c) => {
const user = c.var.user;
try {
const result = await archiveMyMesh({
userId: user.id,
meshId: c.req.param("id"),
});
return c.json(result);
} catch (e) {
return c.json(
{ error: e instanceof Error ? e.message : "Failed to archive." },
400,
);
}
})
.post("/meshes/:id/leave", async (c) => {
const user = c.var.user;
try {
const result = await leaveMyMesh({
userId: user.id,
meshId: c.req.param("id"),
});
return c.json(result);
} catch (e) {
return c.json(
{ error: e instanceof Error ? e.message : "Failed to leave." },
400,
);
}
})
.get("/invites", async (c) => {
const user = c.var.user;
return c.json({ sent: await getMyInvitesSent({ userId: user.id }) });
});

View File

@@ -1,3 +1,4 @@
export * from "./admin";
export * from "./mesh-admin";
export * from "./mesh-user";
export * from "./organization";

View File

@@ -0,0 +1,159 @@
import * as z from "zod";
import {
offsetPaginationSchema,
sortSchema,
} from "@turbostarter/shared/schema";
export const meshVisibilityEnum = z.enum(["private", "public"]);
export const meshTransportEnum = z.enum([
"managed",
"tailscale",
"self_hosted",
]);
export const meshRoleEnum = z.enum(["admin", "member"]);
// ---------------------------------------------------------------------
// List my meshes
// ---------------------------------------------------------------------
export const getMyMeshesInputSchema = offsetPaginationSchema.extend({
sort: z
.string()
.transform((val) =>
z.array(sortSchema).parse(JSON.parse(decodeURIComponent(val))),
)
.optional(),
q: z.string().optional(),
});
export type GetMyMeshesInput = z.infer<typeof getMyMeshesInputSchema>;
export const getMyMeshesResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
name: z.string(),
slug: z.string(),
visibility: meshVisibilityEnum,
transport: meshTransportEnum,
tier: z.enum(["free", "pro", "team", "enterprise"]),
createdAt: z.coerce.date(),
archivedAt: z.coerce.date().nullable(),
myRole: meshRoleEnum,
isOwner: z.boolean(),
memberCount: z.number(),
}),
),
total: z.number(),
});
export type GetMyMeshesResponse = z.infer<typeof getMyMeshesResponseSchema>;
// ---------------------------------------------------------------------
// Create mesh
// ---------------------------------------------------------------------
export const createMyMeshInputSchema = z.object({
name: z.string().min(2).max(80),
slug: z
.string()
.min(2)
.max(40)
.regex(/^[a-z0-9-]+$/, "slug must be lowercase letters, digits, hyphens"),
visibility: meshVisibilityEnum.default("private"),
transport: meshTransportEnum.default("managed"),
});
export type CreateMyMeshInput = z.infer<typeof createMyMeshInputSchema>;
export const createMyMeshResponseSchema = z.object({
id: z.string(),
slug: z.string(),
});
export type CreateMyMeshResponse = z.infer<typeof createMyMeshResponseSchema>;
// ---------------------------------------------------------------------
// Single mesh (user view)
// ---------------------------------------------------------------------
export const getMyMeshResponseSchema = z.object({
mesh: z
.object({
id: z.string(),
name: z.string(),
slug: z.string(),
visibility: meshVisibilityEnum,
transport: meshTransportEnum,
tier: z.enum(["free", "pro", "team", "enterprise"]),
maxPeers: z.number().nullable(),
createdAt: z.coerce.date(),
archivedAt: z.coerce.date().nullable(),
isOwner: z.boolean(),
myRole: meshRoleEnum,
})
.nullable(),
members: z.array(
z.object({
id: z.string(),
displayName: z.string(),
role: meshRoleEnum,
joinedAt: z.coerce.date(),
lastSeenAt: z.coerce.date().nullable(),
revokedAt: z.coerce.date().nullable(),
isMe: z.boolean(),
}),
),
invites: z.array(
z.object({
id: z.string(),
token: z.string(),
maxUses: z.number(),
usedCount: z.number(),
role: meshRoleEnum,
expiresAt: z.coerce.date(),
createdAt: z.coerce.date(),
revokedAt: z.coerce.date().nullable(),
}),
),
});
export type GetMyMeshResponse = z.infer<typeof getMyMeshResponseSchema>;
// ---------------------------------------------------------------------
// Generate invite
// ---------------------------------------------------------------------
export const createMyInviteInputSchema = z.object({
role: meshRoleEnum.default("member"),
maxUses: z.number().int().min(1).max(1000).default(1),
expiresInDays: z.number().int().min(1).max(365).default(7),
});
export type CreateMyInviteInput = z.infer<typeof createMyInviteInputSchema>;
export const createMyInviteResponseSchema = z.object({
id: z.string(),
token: z.string(),
inviteLink: z.string(),
expiresAt: z.coerce.date(),
});
export type CreateMyInviteResponse = z.infer<typeof createMyInviteResponseSchema>;
// ---------------------------------------------------------------------
// List my invites (pending + sent)
// ---------------------------------------------------------------------
export const getMyInvitesResponseSchema = z.object({
sent: z.array(
z.object({
id: z.string(),
meshId: z.string(),
meshName: z.string().nullable(),
meshSlug: z.string().nullable(),
token: z.string(),
role: meshRoleEnum,
maxUses: z.number(),
usedCount: z.number(),
expiresAt: z.coerce.date(),
createdAt: z.coerce.date(),
revokedAt: z.coerce.date().nullable(),
}),
),
});
export type GetMyInvitesResponse = z.infer<typeof getMyInvitesResponseSchema>;