Compare commits
4 Commits
bde83cc757
...
8a50e4fe56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a50e4fe56 | ||
|
|
c5138beb25 | ||
|
|
a486ffd056 | ||
|
|
9d3dbcecaf |
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
79
apps/broker/src/crypto.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
159
apps/broker/tests/hello-signature.test.ts
Normal file
159
apps/broker/tests/hello-signature.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
28
apps/cli/src/crypto/hello-sig.ts
Normal file
28
apps/cli/src/crypto/hello-sig.ts
Normal 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) };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
111
apps/web/src/app/[locale]/dashboard/(user)/invites/page.tsx
Normal file
111
apps/web/src/app/[locale]/dashboard/(user)/invites/page.tsx
Normal 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'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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
158
apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx
Normal file
158
apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
apps/web/src/app/[locale]/dashboard/(user)/meshes/page.tsx
Normal file
100
apps/web/src/app/[locale]/dashboard/(user)/meshes/page.tsx
Normal 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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
177
apps/web/src/modules/mesh/create-mesh-form.tsx
Normal file
177
apps/web/src/modules/mesh/create-mesh-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
227
apps/web/src/modules/mesh/invite-generator.tsx
Normal file
227
apps/web/src/modules/mesh/invite-generator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
180
packages/api/src/modules/mesh/mutations.ts
Normal file
180
packages/api/src/modules/mesh/mutations.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
185
packages/api/src/modules/mesh/queries.ts
Normal file
185
packages/api/src/modules/mesh/queries.ts
Normal 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);
|
||||
114
packages/api/src/modules/mesh/router.ts
Normal file
114
packages/api/src/modules/mesh/router.ts
Normal 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 }) });
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./admin";
|
||||
export * from "./mesh-admin";
|
||||
export * from "./mesh-user";
|
||||
export * from "./organization";
|
||||
|
||||
159
packages/api/src/schema/mesh-user.ts
Normal file
159
packages/api/src/schema/mesh-user.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user