Compare commits
4 Commits
bde83cc757
...
8a50e4fe56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a50e4fe56 | ||
|
|
c5138beb25 | ||
|
|
a486ffd056 | ||
|
|
9d3dbcecaf |
@@ -8,12 +8,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
|
||||||
const seed = JSON.parse(readFileSync("/tmp/smoke-seed.json", "utf-8")) as {
|
const seed = JSON.parse(readFileSync("/tmp/smoke-seed.json", "utf-8")) as {
|
||||||
meshId: string;
|
meshId: string;
|
||||||
peerA: { memberId: string; pubkey: string };
|
peerA: { memberId: string; pubkey: string; secretKey: string };
|
||||||
peerB: { memberId: string; pubkey: string };
|
peerB: { memberId: string; pubkey: string; secretKey: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const BROKER = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
|
const BROKER = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
|
||||||
@@ -21,8 +22,17 @@ const ws = new WebSocket(BROKER);
|
|||||||
|
|
||||||
let helloAcked = false;
|
let helloAcked = false;
|
||||||
|
|
||||||
ws.on("open", () => {
|
ws.on("open", async () => {
|
||||||
console.log("[peer-a] connected, sending hello");
|
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(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "hello",
|
type: "hello",
|
||||||
@@ -32,8 +42,8 @@ ws.on("open", () => {
|
|||||||
sessionId: "peer-a-session",
|
sessionId: "peer-a-session",
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
cwd: "/tmp/peer-a",
|
cwd: "/tmp/peer-a",
|
||||||
signature: "stub",
|
timestamp,
|
||||||
nonce: "stub",
|
signature,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,12 +8,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
|
||||||
const seed = JSON.parse(readFileSync("/tmp/smoke-seed.json", "utf-8")) as {
|
const seed = JSON.parse(readFileSync("/tmp/smoke-seed.json", "utf-8")) as {
|
||||||
meshId: string;
|
meshId: string;
|
||||||
peerA: { memberId: string; pubkey: string };
|
peerA: { memberId: string; pubkey: string; secretKey: string };
|
||||||
peerB: { memberId: string; pubkey: string };
|
peerB: { memberId: string; pubkey: string; secretKey: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const BROKER = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
|
const BROKER = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
|
||||||
@@ -21,8 +22,17 @@ const ws = new WebSocket(BROKER);
|
|||||||
|
|
||||||
let received = false;
|
let received = false;
|
||||||
|
|
||||||
ws.on("open", () => {
|
ws.on("open", async () => {
|
||||||
console.log("[peer-b] connected, sending hello");
|
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(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "hello",
|
type: "hello",
|
||||||
@@ -32,8 +42,8 @@ ws.on("open", () => {
|
|||||||
sessionId: "peer-b-session",
|
sessionId: "peer-b-session",
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
cwd: "/tmp/peer-b",
|
cwd: "/tmp/peer-b",
|
||||||
signature: "stub",
|
timestamp,
|
||||||
nonce: "stub",
|
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 { TokenBucket } from "./rate-limit";
|
||||||
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
|
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
|
||||||
import { buildInfo } from "./build-info";
|
import { buildInfo } from "./build-info";
|
||||||
|
import { verifyHelloSignature } from "./crypto";
|
||||||
|
|
||||||
const PORT = env.BROKER_PORT;
|
const PORT = env.BROKER_PORT;
|
||||||
const WS_PATH = "/ws";
|
const WS_PATH = "/ws";
|
||||||
@@ -364,6 +365,26 @@ async function handleHello(
|
|||||||
ws.close(1008, "capacity");
|
ws.close(1008, "capacity");
|
||||||
return null;
|
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);
|
const member = await findMemberByPubkey(hello.meshId, hello.pubkey);
|
||||||
if (!member) {
|
if (!member) {
|
||||||
metrics.connectionsRejected.inc({ reason: "unauthorized" });
|
metrics.connectionsRejected.inc({ reason: "unauthorized" });
|
||||||
|
|||||||
@@ -55,8 +55,11 @@ export interface WSHelloMessage {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
pid: number;
|
pid: number;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
signature: string; // ed25519 over (meshId||memberId||sessionId||nonce)
|
/** ms epoch; broker rejects if outside ±60s of its own clock. */
|
||||||
nonce: string;
|
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. */
|
/** 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,
|
encryptDirect,
|
||||||
isDirectTarget,
|
isDirectTarget,
|
||||||
} from "../crypto/envelope";
|
} from "../crypto/envelope";
|
||||||
|
import { signHello } from "../crypto/hello-sig";
|
||||||
|
|
||||||
export type Priority = "now" | "next" | "low";
|
export type Priority = "now" | "next" | "low";
|
||||||
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
||||||
@@ -97,21 +98,36 @@ export class BrokerClient {
|
|||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const onOpen = (): void => {
|
const onOpen = async (): Promise<void> => {
|
||||||
this.debug("ws open → sending hello");
|
this.debug("ws open → signing + sending hello");
|
||||||
ws.send(
|
try {
|
||||||
JSON.stringify({
|
const { timestamp, signature } = await signHello(
|
||||||
type: "hello",
|
this.mesh.meshId,
|
||||||
meshId: this.mesh.meshId,
|
this.mesh.memberId,
|
||||||
memberId: this.mesh.memberId,
|
this.mesh.pubkey,
|
||||||
pubkey: this.mesh.pubkey,
|
this.mesh.secretKey,
|
||||||
sessionId: `${process.pid}-${Date.now()}`,
|
);
|
||||||
pid: process.pid,
|
ws.send(
|
||||||
cwd: process.cwd(),
|
JSON.stringify({
|
||||||
signature: "stub", // libsodium sign_detached lands in Step 18
|
type: "hello",
|
||||||
nonce: randomNonce(),
|
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.
|
// Arm the hello_ack timeout.
|
||||||
this.helloTimer = setTimeout(() => {
|
this.helloTimer = setTimeout(() => {
|
||||||
this.debug("hello_ack timeout");
|
this.debug("hello_ack timeout");
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nuqs": "2.7.2",
|
"nuqs": "2.7.2",
|
||||||
"pdfjs-dist": "5.4.530",
|
"pdfjs-dist": "5.4.530",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
"react-dom": "catalog:react19",
|
"react-dom": "catalog:react19",
|
||||||
"react-dropzone": "14.3.8",
|
"react-dropzone": "14.3.8",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"@turbostarter/prettier-config": "workspace:*",
|
"@turbostarter/prettier-config": "workspace:*",
|
||||||
"@turbostarter/tsconfig": "workspace:*",
|
"@turbostarter/tsconfig": "workspace:*",
|
||||||
"@types/node": "catalog:node22",
|
"@types/node": "catalog:node22",
|
||||||
|
"@types/qrcode": "1.5.6",
|
||||||
"@types/react": "catalog:react19",
|
"@types/react": "catalog:react19",
|
||||||
"@types/react-dom": "catalog:react19",
|
"@types/react-dom": "catalog:react19",
|
||||||
"autoprefixer": "10.4.21",
|
"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,
|
icon: Icons.Home,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "aiTools",
|
title: "meshes",
|
||||||
href: pathsConfig.apps.chat.index,
|
href: pathsConfig.dashboard.user.meshes.index,
|
||||||
icon: Icons.Sparkles,
|
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,
|
index: DASHBOARD_PREFIX,
|
||||||
ai: `${DASHBOARD_PREFIX}/ai`,
|
ai: `${DASHBOARD_PREFIX}/ai`,
|
||||||
vocabulary: `${DASHBOARD_PREFIX}/vocabulary`,
|
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: {
|
settings: {
|
||||||
index: `${DASHBOARD_PREFIX}/settings`,
|
index: `${DASHBOARD_PREFIX}/settings`,
|
||||||
security: `${DASHBOARD_PREFIX}/settings/security`,
|
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 { aiRouter } from "./modules/ai/router"; // disabled: @turbostarter/ai package removed in claudemesh
|
||||||
import { authRouter } from "./modules/auth/router";
|
import { authRouter } from "./modules/auth/router";
|
||||||
import { billingRouter } from "./modules/billing/router";
|
import { billingRouter } from "./modules/billing/router";
|
||||||
|
import { myRouter } from "./modules/mesh/router";
|
||||||
import { organizationRouter } from "./modules/organization/router";
|
import { organizationRouter } from "./modules/organization/router";
|
||||||
import { storageRouter } from "./modules/storage/router";
|
import { storageRouter } from "./modules/storage/router";
|
||||||
import { onError } from "./utils/on-error";
|
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("/ai", aiRouter) // disabled: @turbostarter/ai package removed in claudemesh
|
||||||
.route("/auth", authRouter)
|
.route("/auth", authRouter)
|
||||||
.route("/billing", billingRouter)
|
.route("/billing", billingRouter)
|
||||||
|
.route("/my", myRouter)
|
||||||
.route("/organizations", organizationRouter)
|
.route("/organizations", organizationRouter)
|
||||||
.route("/storage", storageRouter)
|
.route("/storage", storageRouter)
|
||||||
.onError(onError);
|
.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 "./admin";
|
||||||
export * from "./mesh-admin";
|
export * from "./mesh-admin";
|
||||||
|
export * from "./mesh-user";
|
||||||
export * from "./organization";
|
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