diff --git a/apps/broker/scripts/seed-test-mesh.ts b/apps/broker/scripts/seed-test-mesh.ts index 8609467..bef198b 100644 --- a/apps/broker/scripts/seed-test-mesh.ts +++ b/apps/broker/scripts/seed-test-mesh.ts @@ -12,18 +12,23 @@ import { eq } from "drizzle-orm"; import sodium from "libsodium-wrappers"; import { db } from "../src/db"; -import { mesh, meshMember } from "@turbostarter/db/schema/mesh"; +import { invite, mesh, meshMember } from "@turbostarter/db/schema/mesh"; import { user } from "@turbostarter/db/schema/auth"; +import { canonicalInvite } from "../src/crypto"; const USER_ID = "test-user-smoke"; const MESH_SLUG = "smoke-test"; +const BROKER_URL = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws"; async function main() { // Generate real ed25519 keypairs so crypto_box (via ed25519→X25519 // conversion) works in Step 18+ round-trip tests. await sodium.ready; + const kpOwner = sodium.crypto_sign_keypair(); const kpA = sodium.crypto_sign_keypair(); const kpB = sodium.crypto_sign_keypair(); + const OWNER_PUBKEY = sodium.to_hex(kpOwner.publicKey); + const OWNER_SECRET = sodium.to_hex(kpOwner.privateKey); const PEER_A_PUBKEY = sodium.to_hex(kpA.publicKey); const PEER_A_SECRET = sodium.to_hex(kpA.privateKey); const PEER_B_PUBKEY = sodium.to_hex(kpB.publicKey); @@ -53,6 +58,7 @@ async function main() { name: "Smoke Test", slug: MESH_SLUG, ownerUserId: USER_ID, + ownerPubkey: OWNER_PUBKEY, visibility: "private", transport: "managed", tier: "free", @@ -60,6 +66,40 @@ async function main() { .returning({ id: mesh.id }); if (!m) throw new Error("mesh insert failed"); + // Build + sign an invite, store it so /join can verify. + const expiresAtSec = Math.floor(Date.now() / 1000) + 3600; + const invitePayload = { + v: 1 as const, + mesh_id: m.id, + mesh_slug: MESH_SLUG, + broker_url: BROKER_URL, + expires_at: expiresAtSec, + mesh_root_key: "c21va2UtdGVzdC1tZXNoLXJvb3Qta2V5LWRldg", + role: "member" as const, + owner_pubkey: OWNER_PUBKEY, + }; + const canonical = canonicalInvite(invitePayload); + const signature = sodium.to_hex( + sodium.crypto_sign_detached( + sodium.from_string(canonical), + kpOwner.privateKey, + ), + ); + const fullPayload = { ...invitePayload, signature }; + const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString( + "base64url", + ); + await db.insert(invite).values({ + meshId: m.id, + token, + tokenBytes: canonical, + maxUses: 5, + usedCount: 0, + role: "member", + expiresAt: new Date(expiresAtSec * 1000), + createdBy: USER_ID, + }); + const [peerA] = await db .insert(meshMember) .values({ @@ -84,6 +124,10 @@ async function main() { const seed = { meshId: m.id, + ownerPubkey: OWNER_PUBKEY, + ownerSecretKey: OWNER_SECRET, + inviteToken: token, + inviteLink: `ic://join/${token}`, peerA: { memberId: peerA.id, pubkey: PEER_A_PUBKEY, diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index 8a27193..cf50251 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -30,12 +30,17 @@ import { } from "drizzle-orm"; import { db } from "./db"; import { + invite as inviteTable, mesh, meshMember as memberTable, messageQueue, pendingStatus, presence, } from "@turbostarter/db/schema/mesh"; +import { + canonicalInvite, + verifyEd25519, +} from "./crypto"; import { env } from "./env"; import { metrics } from "./metrics"; import { inferStatusFromJsonl } from "./paths"; @@ -510,37 +515,108 @@ export async function stopSweepers(): Promise { .where(isNull(presence.disconnectedAt)); } +export type JoinError = + | "mesh_not_found" + | "mesh_missing_owner_key" + | "invite_not_found" + | "invite_expired" + | "invite_exhausted" + | "invite_revoked" + | "invite_bad_signature" + | "invite_mesh_mismatch" + | "invite_owner_mismatch" + | "member_insert_failed"; + +export interface InvitePayload { + v: number; + mesh_id: string; + mesh_slug: string; + broker_url: string; + expires_at: number; + mesh_root_key: string; + role: "admin" | "member"; + owner_pubkey: string; + signature: string; +} + /** - * Enroll a new member in an existing mesh. Called by the CLI join - * flow after invite-link parsing + keypair generation client-side. + * Enroll a new member in an existing mesh. * - * v0.1.0: trusts the request. Signature verification + invite-token - * one-time-use tracking land in Step 18. + * Requires a signed invite payload. Verifies: + * - invite row exists (looked up by token = base64 link payload) + * - not expired, not revoked, used_count < max_uses + * - payload's signature matches payload's owner_pubkey + * - payload's owner_pubkey matches mesh.owner_pubkey (prevents a + * malicious admin from substituting their own owner key) + * - payload's mesh_id matches the row's mesh_id (belt + braces) + * + * Then atomically increments used_count (CAS guarded by max_uses) and + * inserts the member. Idempotent: same pubkey enrolling twice returns + * the existing memberId WITHOUT burning an invite use. */ export async function joinMesh(args: { - meshId: string; + inviteToken: string; + invitePayload: InvitePayload; peerPubkey: string; displayName: string; - role: "admin" | "member"; }): Promise< | { ok: true; memberId: string; alreadyMember?: boolean } - | { ok: false; error: string } + | { ok: false; error: JoinError } > { - // Validate the mesh exists. - const [m] = await db - .select({ id: mesh.id }) - .from(mesh) - .where(and(eq(mesh.id, args.meshId), isNull(mesh.archivedAt))); - if (!m) return { ok: false, error: "mesh not found or archived" }; + const { inviteToken, invitePayload, peerPubkey, displayName } = args; - // Idempotency: same pubkey already a member → return existing id. + // 1. Verify invite signature. + const canonical = canonicalInvite({ + v: invitePayload.v, + mesh_id: invitePayload.mesh_id, + mesh_slug: invitePayload.mesh_slug, + broker_url: invitePayload.broker_url, + expires_at: invitePayload.expires_at, + mesh_root_key: invitePayload.mesh_root_key, + role: invitePayload.role, + owner_pubkey: invitePayload.owner_pubkey, + }); + const sigValid = await verifyEd25519( + canonical, + invitePayload.signature, + invitePayload.owner_pubkey, + ); + if (!sigValid) return { ok: false, error: "invite_bad_signature" }; + + // 2. Load the mesh. Require owner_pubkey is set and matches payload. + const [m] = await db + .select({ id: mesh.id, ownerPubkey: mesh.ownerPubkey }) + .from(mesh) + .where(and(eq(mesh.id, invitePayload.mesh_id), isNull(mesh.archivedAt))); + if (!m) return { ok: false, error: "mesh_not_found" }; + if (!m.ownerPubkey) return { ok: false, error: "mesh_missing_owner_key" }; + if (m.ownerPubkey !== invitePayload.owner_pubkey) { + return { ok: false, error: "invite_owner_mismatch" }; + } + + // 3. Load the invite row. Must belong to this mesh. + const [inv] = await db + .select() + .from(inviteTable) + .where(eq(inviteTable.token, inviteToken)); + if (!inv) return { ok: false, error: "invite_not_found" }; + if (inv.meshId !== invitePayload.mesh_id) { + return { ok: false, error: "invite_mesh_mismatch" }; + } + if (inv.revokedAt) return { ok: false, error: "invite_revoked" }; + if (inv.expiresAt.getTime() < Date.now()) { + return { ok: false, error: "invite_expired" }; + } + + // 4. Idempotency: if this pubkey is already a member, short-circuit + // without consuming an invite use. const [existing] = await db .select({ id: memberTable.id }) .from(memberTable) .where( and( - eq(memberTable.meshId, args.meshId), - eq(memberTable.peerPubkey, args.peerPubkey), + eq(memberTable.meshId, invitePayload.mesh_id), + eq(memberTable.peerPubkey, peerPubkey), isNull(memberTable.revokedAt), ), ); @@ -548,16 +624,30 @@ export async function joinMesh(args: { return { ok: true, memberId: existing.id, alreadyMember: true }; } + // 5. Atomic claim: increment used_count iff under max_uses. + const [claimed] = await db + .update(inviteTable) + .set({ usedCount: sql`${inviteTable.usedCount} + 1` }) + .where( + and( + eq(inviteTable.id, inv.id), + lt(inviteTable.usedCount, inv.maxUses), + ), + ) + .returning({ id: inviteTable.id, usedCount: inviteTable.usedCount }); + if (!claimed) return { ok: false, error: "invite_exhausted" }; + + // 6. Insert the member with the role from the payload. const [row] = await db .insert(memberTable) .values({ - meshId: args.meshId, - peerPubkey: args.peerPubkey, - displayName: args.displayName, - role: args.role, + meshId: invitePayload.mesh_id, + peerPubkey, + displayName, + role: invitePayload.role, }) .returning({ id: memberTable.id }); - if (!row) return { ok: false, error: "member insert failed" }; + if (!row) return { ok: false, error: "member_insert_failed" }; return { ok: true, memberId: row.id }; } diff --git a/apps/broker/src/crypto.ts b/apps/broker/src/crypto.ts index 81932bb..52d2300 100644 --- a/apps/broker/src/crypto.ts +++ b/apps/broker/src/crypto.ts @@ -28,6 +28,47 @@ export function canonicalHello( return `${meshId}|${memberId}|${pubkey}|${timestamp}`; } +/** Canonical invite bytes — everything in the payload except the signature. */ +export function canonicalInvite(fields: { + v: number; + mesh_id: string; + mesh_slug: string; + broker_url: string; + expires_at: number; + mesh_root_key: string; + role: "admin" | "member"; + owner_pubkey: string; +}): string { + return `${fields.v}|${fields.mesh_id}|${fields.mesh_slug}|${fields.broker_url}|${fields.expires_at}|${fields.mesh_root_key}|${fields.role}|${fields.owner_pubkey}`; +} + +/** + * Verify an ed25519 signature over arbitrary canonical bytes. + * Used by invite verification + (future) any other signed payload. + */ +export async function verifyEd25519( + canonicalText: string, + signatureHex: string, + pubkeyHex: string, +): Promise { + if ( + !/^[0-9a-f]{64}$/i.test(pubkeyHex) || + !/^[0-9a-f]{128}$/i.test(signatureHex) + ) { + return false; + } + const s = await ensureSodium(); + try { + return s.crypto_sign_verify_detached( + s.from_hex(signatureHex), + s.from_string(canonicalText), + s.from_hex(pubkeyHex), + ); + } catch { + return false; + } +} + export const HELLO_SKEW_MS = 60_000; /** diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 660dc7e..f1729d6 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -251,21 +251,21 @@ function handleJoinPost( if (aborted) return; try { const payload = JSON.parse(Buffer.concat(chunks).toString()) as { - mesh_id?: string; + invite_token?: string; + invite_payload?: unknown; peer_pubkey?: string; display_name?: string; - role?: "admin" | "member"; }; - // Minimal shape validation. if ( - !payload.mesh_id || + !payload.invite_token || + !payload.invite_payload || !payload.peer_pubkey || - !payload.display_name || - !payload.role + !payload.display_name ) { writeJson(res, 400, { ok: false, - error: "mesh_id, peer_pubkey, display_name, role required", + error: + "invite_token, invite_payload, peer_pubkey, display_name required", }); return; } @@ -277,18 +277,21 @@ function handleJoinPost( return; } const result = await joinMesh({ - meshId: payload.mesh_id, + inviteToken: payload.invite_token, + invitePayload: payload.invite_payload as Parameters< + typeof joinMesh + >[0]["invitePayload"], peerPubkey: payload.peer_pubkey, displayName: payload.display_name, - role: payload.role, }); writeJson(res, result.ok ? 200 : 400, result); log.info("join", { route: "POST /join", - mesh_id: payload.mesh_id, pubkey: payload.peer_pubkey.slice(0, 12), ok: result.ok, - already_member: "alreadyMember" in result ? result.alreadyMember : false, + error: !result.ok ? result.error : undefined, + already_member: + "alreadyMember" in result ? result.alreadyMember : false, latency_ms: Date.now() - started, }); } catch (e) { diff --git a/apps/broker/tests/helpers.ts b/apps/broker/tests/helpers.ts index c5f9546..5e6dc07 100644 --- a/apps/broker/tests/helpers.ts +++ b/apps/broker/tests/helpers.ts @@ -8,10 +8,12 @@ */ import { eq, inArray } from "drizzle-orm"; +import sodium from "libsodium-wrappers"; import { db } from "../src/db"; -import { mesh, meshMember } from "@turbostarter/db/schema/mesh"; +import { invite, mesh, meshMember } from "@turbostarter/db/schema/mesh"; import { user } from "@turbostarter/db/schema/auth"; import { randomBytes } from "node:crypto"; +import { canonicalInvite } from "../src/crypto"; const TEST_USER_ID = "test-user-integration"; @@ -37,11 +39,29 @@ export async function ensureTestUser(): Promise { export interface TestMesh { meshId: string; + ownerPubkey: string; + ownerSecretKey: string; peerA: { memberId: string; pubkey: string }; peerB: { memberId: string; pubkey: string }; cleanup: () => Promise; } +export interface TestInvite { + token: string; + payload: { + v: 1; + mesh_id: string; + mesh_slug: string; + broker_url: string; + expires_at: number; + mesh_root_key: string; + role: "admin" | "member"; + owner_pubkey: string; + signature: string; + }; + inviteId: string; +} + /** * Create a test mesh + 2 members. Returns IDs + pubkeys and a * cleanup function that cascade-deletes the mesh (and all presence, @@ -51,12 +71,18 @@ export async function setupTestMesh(label: string): Promise { const userId = await ensureTestUser(); const slug = `t-${label}-${randomBytes(4).toString("hex")}`; + await sodium.ready; + const kpOwner = sodium.crypto_sign_keypair(); + const ownerPubkey = sodium.to_hex(kpOwner.publicKey); + const ownerSecretKey = sodium.to_hex(kpOwner.privateKey); + const [m] = await db .insert(mesh) .values({ name: `Test ${label}`, slug, ownerUserId: userId, + ownerPubkey, visibility: "private", transport: "managed", tier: "free", @@ -91,6 +117,8 @@ export async function setupTestMesh(label: string): Promise { return { meshId: m.id, + ownerPubkey, + ownerSecretKey, peerA: { memberId: mA.id, pubkey: pubkeyA }, peerB: { memberId: mB.id, pubkey: pubkeyB }, cleanup: async () => { @@ -100,6 +128,74 @@ export async function setupTestMesh(label: string): Promise { }; } +/** + * Create a signed invite row for an existing test mesh. Returns the + * token + full payload + DB invite id. Defaults: 1-hour expiry, max + * uses = 1, role = "member". + */ +export async function createTestInvite( + m: TestMesh, + opts: { + maxUses?: number; + expiresInSec?: number; + role?: "admin" | "member"; + slug?: string; + brokerUrl?: string; + } = {}, +): Promise { + await sodium.ready; + const now = Math.floor(Date.now() / 1000); + const expiresAt = now + (opts.expiresInSec ?? 3600); + const payload = { + v: 1 as const, + mesh_id: m.meshId, + mesh_slug: opts.slug ?? "test-slug", + broker_url: opts.brokerUrl ?? "ws://localhost:7900/ws", + expires_at: expiresAt, + mesh_root_key: "dGVzdC1tZXNoLXJvb3Qta2V5", + role: opts.role ?? ("member" as const), + owner_pubkey: m.ownerPubkey, + }; + const canonical = canonicalInvite(payload); + const signature = sodium.to_hex( + sodium.crypto_sign_detached( + sodium.from_string(canonical), + sodium.from_hex(m.ownerSecretKey), + ), + ); + const full = { ...payload, signature }; + const token = Buffer.from(JSON.stringify(full), "utf-8").toString( + "base64url", + ); + const [row] = await db + .insert(invite) + .values({ + meshId: m.meshId, + token, + tokenBytes: canonical, + maxUses: opts.maxUses ?? 1, + usedCount: 0, + role: opts.role ?? "member", + expiresAt: new Date(expiresAt * 1000), + createdBy: "test-user-integration", + }) + .returning({ id: invite.id }); + if (!row) throw new Error("invite insert failed"); + return { token, payload: full, inviteId: row.id }; +} + +export async function generateRawKeypair(): Promise<{ + publicKey: string; + secretKey: string; +}> { + await sodium.ready; + const kp = sodium.crypto_sign_keypair(); + return { + publicKey: sodium.to_hex(kp.publicKey), + secretKey: sodium.to_hex(kp.privateKey), + }; +} + /** * Delete all meshes with slugs starting with "t-" (test prefix). * Used as a safety net in afterAll if individual cleanup() didn't run. diff --git a/apps/broker/tests/invite-signature.test.ts b/apps/broker/tests/invite-signature.test.ts new file mode 100644 index 0000000..f489dae --- /dev/null +++ b/apps/broker/tests/invite-signature.test.ts @@ -0,0 +1,271 @@ +/** + * Invite signature + one-time-use tracking. + * + * Covers the full joinMesh() security envelope: + * - signed invites accepted + * - tampered payloads rejected + * - mismatched owner_pubkey rejected + * - expired / revoked / exhausted invites rejected + * - idempotency: same pubkey rejoins without burning a use + * - atomic single-use: concurrent joins produce exactly one winner + */ + +import { afterAll, afterEach, describe, expect, test } from "vitest"; +import { eq } from "drizzle-orm"; +import { db } from "../src/db"; +import { invite, mesh } from "@turbostarter/db/schema/mesh"; +import { joinMesh } from "../src/broker"; +import { + cleanupAllTestMeshes, + createTestInvite, + generateRawKeypair, + setupTestMesh, + type TestInvite, + type TestMesh, +} from "./helpers"; + +afterAll(async () => { + await cleanupAllTestMeshes(); +}); + +describe("joinMesh — signed invites", () => { + let m: TestMesh; + afterEach(async () => m && (await m.cleanup())); + + test("valid signed invite → join succeeds", async () => { + m = await setupTestMesh("inv-valid"); + const inv = await createTestInvite(m); + const kp = await generateRawKeypair(); + const result = await joinMesh({ + inviteToken: inv.token, + invitePayload: inv.payload, + peerPubkey: kp.publicKey, + displayName: "alice", + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.memberId).toMatch(/^[A-Za-z0-9]+$/); + }); + + test("tampered payload → invite_bad_signature", async () => { + m = await setupTestMesh("inv-tampered"); + const inv = await createTestInvite(m); + const kp = await generateRawKeypair(); + const tampered = { ...inv.payload, mesh_slug: "HACKED" }; + const result = await joinMesh({ + inviteToken: inv.token, + invitePayload: tampered, + peerPubkey: kp.publicKey, + displayName: "mallory", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invite_bad_signature"); + }); + + test("owner key mismatch → invite_owner_mismatch", async () => { + m = await setupTestMesh("inv-owner-mismatch"); + // Signer has a valid keypair but is NOT the mesh owner. + const fake = await generateRawKeypair(); + // Build a properly-signed payload with the fake owner key. + const { canonicalInvite } = await import("../src/crypto"); + const sodium = await import("libsodium-wrappers").then((m) => m.default); + await sodium.ready; + const now = Math.floor(Date.now() / 1000); + const payload = { + v: 1 as const, + mesh_id: m.meshId, + mesh_slug: "x", + broker_url: "ws://localhost/ws", + expires_at: now + 3600, + mesh_root_key: "a", + role: "member" as const, + owner_pubkey: fake.publicKey, // wrong owner + }; + const sig = sodium.to_hex( + sodium.crypto_sign_detached( + sodium.from_string(canonicalInvite(payload)), + sodium.from_hex(fake.secretKey), + ), + ); + const token = Buffer.from( + JSON.stringify({ ...payload, signature: sig }), + "utf-8", + ).toString("base64url"); + // Have to insert a matching invite row so broker can look it up. + await db.insert(invite).values({ + meshId: m.meshId, + token, + maxUses: 1, + usedCount: 0, + role: "member", + expiresAt: new Date((now + 3600) * 1000), + createdBy: "test-user-integration", + }); + + const joiner = await generateRawKeypair(); + const result = await joinMesh({ + inviteToken: token, + invitePayload: { ...payload, signature: sig }, + peerPubkey: joiner.publicKey, + displayName: "joiner", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invite_owner_mismatch"); + }); + + test("expired invite → invite_expired", async () => { + m = await setupTestMesh("inv-expired"); + // Create invite with expiry in the past (we use a far-future expiry + // for signing, then back-date the DB row to simulate staleness + // without the client-side expiry check tripping). + const inv = await createTestInvite(m, { expiresInSec: 3600 }); + await db + .update(invite) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(eq(invite.id, inv.inviteId)); + const kp = await generateRawKeypair(); + const result = await joinMesh({ + inviteToken: inv.token, + invitePayload: inv.payload, + peerPubkey: kp.publicKey, + displayName: "late", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invite_expired"); + }); + + test("revoked invite → invite_revoked", async () => { + m = await setupTestMesh("inv-revoked"); + const inv = await createTestInvite(m); + await db + .update(invite) + .set({ revokedAt: new Date() }) + .where(eq(invite.id, inv.inviteId)); + const kp = await generateRawKeypair(); + const result = await joinMesh({ + inviteToken: inv.token, + invitePayload: inv.payload, + peerPubkey: kp.publicKey, + displayName: "blocked", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invite_revoked"); + }); + + test("exhausted invite → invite_exhausted", async () => { + m = await setupTestMesh("inv-exhausted"); + const inv = await createTestInvite(m, { maxUses: 2 }); + // First two joins succeed. + const k1 = await generateRawKeypair(); + const k2 = await generateRawKeypair(); + const r1 = await joinMesh({ + inviteToken: inv.token, + invitePayload: inv.payload, + peerPubkey: k1.publicKey, + displayName: "first", + }); + const r2 = await joinMesh({ + inviteToken: inv.token, + invitePayload: inv.payload, + peerPubkey: k2.publicKey, + displayName: "second", + }); + expect(r1.ok).toBe(true); + expect(r2.ok).toBe(true); + // Third should be rejected. + const k3 = await generateRawKeypair(); + const r3 = await joinMesh({ + inviteToken: inv.token, + invitePayload: inv.payload, + peerPubkey: k3.publicKey, + displayName: "third", + }); + expect(r3.ok).toBe(false); + if (!r3.ok) expect(r3.error).toBe("invite_exhausted"); + }); + + test("idempotent re-join doesn't burn a use", async () => { + m = await setupTestMesh("inv-idempotent"); + const inv = await createTestInvite(m, { maxUses: 1 }); + const kp = await generateRawKeypair(); + const r1 = await joinMesh({ + inviteToken: inv.token, + invitePayload: inv.payload, + peerPubkey: kp.publicKey, + displayName: "alice", + }); + const r2 = await joinMesh({ + inviteToken: inv.token, + invitePayload: inv.payload, + peerPubkey: kp.publicKey, + displayName: "alice", + }); + expect(r1.ok).toBe(true); + expect(r2.ok).toBe(true); + if (r1.ok && r2.ok) { + expect(r2.memberId).toBe(r1.memberId); + expect(r2.alreadyMember).toBe(true); + } + // usedCount should still be 1, not 2. + const [row] = await db + .select({ usedCount: invite.usedCount }) + .from(invite) + .where(eq(invite.id, inv.inviteId)); + expect(row?.usedCount).toBe(1); + }); + + test("atomic single-use: concurrent joins, exactly one wins", async () => { + m = await setupTestMesh("inv-atomic"); + const inv = await createTestInvite(m, { maxUses: 1 }); + // Fire 5 distinct joiners concurrently at a 1-use invite. + const joiners = await Promise.all( + Array.from({ length: 5 }).map(() => generateRawKeypair()), + ); + const results = await Promise.all( + joiners.map((kp, i) => + joinMesh({ + inviteToken: inv.token, + invitePayload: inv.payload, + peerPubkey: kp.publicKey, + displayName: `racer-${i}`, + }), + ), + ); + const oks = results.filter((r) => r.ok); + const exhausted = results.filter( + (r) => !r.ok && r.error === "invite_exhausted", + ); + expect(oks.length).toBe(1); + expect(exhausted.length).toBe(4); + }); + + test("wrong mesh_id in payload vs DB row → invite_mesh_mismatch", async () => { + m = await setupTestMesh("inv-mesh-mismatch"); + const inv = await createTestInvite(m); + // Point the DB row at a different mesh (create another one with + // the SAME owner_pubkey so we get past the owner check). + const other = await setupTestMesh("inv-mesh-other"); + try { + // Align other's owner_pubkey to m's so only mesh_id differs. + await db + .update(mesh) + .set({ ownerPubkey: m.ownerPubkey }) + .where(eq(mesh.id, other.meshId)); + // Re-point invite row's meshId to other. + await db + .update(invite) + .set({ meshId: other.meshId }) + .where(eq(invite.id, inv.inviteId)); + const kp = await generateRawKeypair(); + const result = await joinMesh({ + inviteToken: inv.token, + invitePayload: inv.payload, // still claims m.meshId + peerPubkey: kp.publicKey, + displayName: "cross", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe("invite_mesh_mismatch"); + } finally { + await other.cleanup(); + } + }); +}); diff --git a/apps/cli/scripts/join-roundtrip.ts b/apps/cli/scripts/join-roundtrip.ts index fad4854..d08e9a2 100644 --- a/apps/cli/scripts/join-roundtrip.ts +++ b/apps/cli/scripts/join-roundtrip.ts @@ -57,8 +57,8 @@ async function main(): Promise { console.log(`[rt] loading config from: ${getConfigPath()}`); const config = loadConfig(); console.log(`[rt] loaded ${config.meshes.length} mesh(es)`); - const joined = config.meshes.find((m) => m.slug === "rt-join"); - if (!joined) throw new Error("rt-join mesh not found in config"); + const joined = config.meshes.find((m) => m.slug === "smoke-test"); + if (!joined) throw new Error("smoke-test mesh not found in config"); const joinedMesh: JoinedMesh = joined; console.log( `[rt] joined member_id=${joinedMesh.memberId} pubkey=${joinedMesh.pubkey.slice(0, 16)}…`, diff --git a/apps/cli/scripts/make-invite.ts b/apps/cli/scripts/make-invite.ts index dcc2de1..8455bc7 100644 --- a/apps/cli/scripts/make-invite.ts +++ b/apps/cli/scripts/make-invite.ts @@ -1,24 +1,23 @@ #!/usr/bin/env bun /** - * Build a test invite link from a seeded mesh (reads /tmp/cli-seed.json). - * Writes the link to stdout. + * Emit the signed invite link produced by the broker's seed-test-mesh. + * + * The seed script (apps/broker/scripts/seed-test-mesh.ts) creates a + * mesh with an owner keypair and a signed invite row, then writes + * both into /tmp/cli-seed.json. We just echo its inviteLink here so + * downstream test scripts can pipe it. */ import { readFileSync } from "node:fs"; -import { encodeInviteLink } from "../src/invite/parse"; const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as { - meshId: string; + inviteLink: string; }; -const link = encodeInviteLink({ - v: 1, - mesh_id: seed.meshId, - mesh_slug: "rt-join", - broker_url: process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws", - expires_at: Math.floor(Date.now() / 1000) + 3600, - mesh_root_key: "Y2xhdWRlbWVzaC10ZXN0LW1lc2gta2V5LWRldm9ubHk", - role: "member", -}); - -console.log(link); +if (!seed.inviteLink) { + console.error( + "seed missing inviteLink — re-run apps/broker/scripts/seed-test-mesh.ts", + ); + process.exit(1); +} +console.log(seed.inviteLink); diff --git a/apps/cli/src/commands/join.ts b/apps/cli/src/commands/join.ts index b80552d..7630e8f 100644 --- a/apps/cli/src/commands/join.ts +++ b/apps/cli/src/commands/join.ts @@ -25,17 +25,17 @@ export async function runJoin(args: string[]): Promise { process.exit(1); } - // 1. Parse. + // 1. Parse + verify signature client-side. let invite; try { - invite = parseInviteLink(link); + invite = await parseInviteLink(link); } catch (e) { console.error( `claudemesh: ${e instanceof Error ? e.message : String(e)}`, ); process.exit(1); } - const { payload } = invite; + const { payload, token } = invite; console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`); // 2. Generate keypair. @@ -47,10 +47,10 @@ export async function runJoin(args: string[]): Promise { try { enroll = await enrollWithBroker({ brokerWsUrl: payload.broker_url, - meshId: payload.mesh_id, + inviteToken: token, + invitePayload: payload, peerPubkey: keypair.publicKey, displayName, - role: payload.role, }); } catch (e) { console.error( diff --git a/apps/cli/src/invite/enroll.ts b/apps/cli/src/invite/enroll.ts index 5890839..d283fbe 100644 --- a/apps/cli/src/invite/enroll.ts +++ b/apps/cli/src/invite/enroll.ts @@ -19,22 +19,24 @@ function wsToHttp(wsUrl: string): string { return `${httpScheme}//${u.host}`; } +import type { InvitePayload } from "./parse"; + export async function enrollWithBroker(args: { brokerWsUrl: string; - meshId: string; + inviteToken: string; + invitePayload: InvitePayload; peerPubkey: string; displayName: string; - role: "admin" | "member"; }): Promise { const base = wsToHttp(args.brokerWsUrl); const res = await fetch(`${base}/join`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - mesh_id: args.meshId, + invite_token: args.inviteToken, + invite_payload: args.invitePayload, peer_pubkey: args.peerPubkey, display_name: args.displayName, - role: args.role, }), signal: AbortSignal.timeout(10_000), }); diff --git a/apps/cli/src/invite/parse.ts b/apps/cli/src/invite/parse.ts index c1c3657..cf91eb3 100644 --- a/apps/cli/src/invite/parse.ts +++ b/apps/cli/src/invite/parse.ts @@ -6,6 +6,7 @@ */ import { z } from "zod"; +import { ensureSodium } from "../crypto/keypair"; const invitePayloadSchema = z.object({ v: z.literal(1), @@ -15,7 +16,8 @@ const invitePayloadSchema = z.object({ expires_at: z.number().int().positive(), mesh_root_key: z.string().min(1), role: z.enum(["admin", "member"]), - signature: z.string().optional(), // ed25519 b64, validated in Step 18 + owner_pubkey: z.string().regex(/^[0-9a-f]{64}$/i), + signature: z.string().regex(/^[0-9a-f]{128}$/i), }); export type InvitePayload = z.infer; @@ -23,9 +25,24 @@ export type InvitePayload = z.infer; export interface ParsedInvite { payload: InvitePayload; raw: string; // the original ic://join/... string + token: string; // base64url(JSON) — DB lookup key (everything after ic://join/) } -export function parseInviteLink(link: string): ParsedInvite { +/** Canonical invite bytes — must match broker's canonicalInvite(). */ +export function canonicalInvite(p: { + v: number; + mesh_id: string; + mesh_slug: string; + broker_url: string; + expires_at: number; + mesh_root_key: string; + role: "admin" | "member"; + owner_pubkey: string; +}): string { + return `${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`; +} + +export async function parseInviteLink(link: string): Promise { if (!link.startsWith("ic://join/")) { throw new Error( `invalid invite link: expected prefix "ic://join/", got "${link.slice(0, 20)}…"`, @@ -67,7 +84,36 @@ export function parseInviteLink(link: string): ParsedInvite { ); } - return { payload: parsed.data, raw: link }; + // Verify the ed25519 signature against the embedded owner_pubkey. + // Client-side verification gives immediate feedback on tampered + // links; broker re-verifies authoritatively on /join. + const s = await ensureSodium(); + const canonical = canonicalInvite({ + v: parsed.data.v, + mesh_id: parsed.data.mesh_id, + mesh_slug: parsed.data.mesh_slug, + broker_url: parsed.data.broker_url, + expires_at: parsed.data.expires_at, + mesh_root_key: parsed.data.mesh_root_key, + role: parsed.data.role, + owner_pubkey: parsed.data.owner_pubkey, + }); + const sigOk = (() => { + try { + return s.crypto_sign_verify_detached( + s.from_hex(parsed.data.signature), + s.from_string(canonical), + s.from_hex(parsed.data.owner_pubkey), + ); + } catch { + return false; + } + })(); + if (!sigOk) { + throw new Error("invite signature invalid (link tampered?)"); + } + + return { payload: parsed.data, raw: link, token: encoded }; } /** @@ -79,3 +125,52 @@ export function encodeInviteLink(payload: InvitePayload): string { const encoded = Buffer.from(json, "utf-8").toString("base64url"); return `ic://join/${encoded}`; } + +/** + * Sign and assemble an invite payload → ic://join/... link. + * The canonical bytes (everything except signature) are signed with + * the mesh owner's ed25519 secret key. + */ +export async function buildSignedInvite(args: { + v: 1; + mesh_id: string; + mesh_slug: string; + broker_url: string; + expires_at: number; + mesh_root_key: string; + role: "admin" | "member"; + owner_pubkey: string; + owner_secret_key: string; +}): Promise<{ link: string; token: string; payload: InvitePayload }> { + const s = await ensureSodium(); + const canonical = canonicalInvite({ + v: args.v, + mesh_id: args.mesh_id, + mesh_slug: args.mesh_slug, + broker_url: args.broker_url, + expires_at: args.expires_at, + mesh_root_key: args.mesh_root_key, + role: args.role, + owner_pubkey: args.owner_pubkey, + }); + const signature = s.to_hex( + s.crypto_sign_detached( + s.from_string(canonical), + s.from_hex(args.owner_secret_key), + ), + ); + const payload: InvitePayload = { + v: args.v, + mesh_id: args.mesh_id, + mesh_slug: args.mesh_slug, + broker_url: args.broker_url, + expires_at: args.expires_at, + mesh_root_key: args.mesh_root_key, + role: args.role, + owner_pubkey: args.owner_pubkey, + signature, + }; + const json = JSON.stringify(payload); + const token = Buffer.from(json, "utf-8").toString("base64url"); + return { link: `ic://join/${token}`, token, payload }; +} diff --git a/packages/db/migrations/0001_demonic_karnak.sql b/packages/db/migrations/0001_demonic_karnak.sql new file mode 100644 index 0000000..0b9856f --- /dev/null +++ b/packages/db/migrations/0001_demonic_karnak.sql @@ -0,0 +1,2 @@ +ALTER TABLE "mesh"."invite" ADD COLUMN "token_bytes" text;--> statement-breakpoint +ALTER TABLE "mesh"."mesh" ADD COLUMN "owner_pubkey" text; \ No newline at end of file diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..6edccb8 --- /dev/null +++ b/packages/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,2821 @@ +{ + "id": "dcec14c1-d1b1-4371-a9ad-d004839ed856", + "prevId": "a1b0192f-d300-48eb-8f1d-e76c65b030dc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "twoFactor_secret_idx": { + "name": "twoFactor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "twoFactor_userId_idx": { + "name": "twoFactor_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_transaction": { + "name": "credit_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credit_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "balance_after": { + "name": "balance_after", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "credit_transaction_customer_id_customer_id_fk": { + "name": "credit_transaction_customer_id_customer_id_fk", + "tableFrom": "credit_transaction", + "tableTo": "customer", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer": { + "name": "customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "customer_user_id_user_id_fk": { + "name": "customer_user_id_user_id_fk", + "tableFrom": "customer", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "customer_userId_unique": { + "name": "customer_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "customer_customerId_unique": { + "name": "customer_customerId_unique", + "nullsNotDistinct": false, + "columns": [ + "customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "chat.chat": { + "name": "chat", + "schema": "chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "chat.message": { + "name": "message", + "schema": "chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "chat", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "message_chat_id_chat_id_fk": { + "name": "message_chat_id_chat_id_fk", + "tableFrom": "message", + "tableTo": "chat", + "schemaTo": "chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "chat.part": { + "name": "part", + "schema": "chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "part_message_id_message_id_fk": { + "name": "part_message_id_message_id_fk", + "tableFrom": "part", + "tableTo": "message", + "schemaTo": "chat", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "pdf.chat": { + "name": "chat", + "schema": "pdf", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "pdf.citation_unit": { + "name": "citation_unit", + "schema": "pdf", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retrieval_chunk_id": { + "name": "retrieval_chunk_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "paragraph_index": { + "name": "paragraph_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "char_start": { + "name": "char_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "char_end": { + "name": "char_end", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bbox_x": { + "name": "bbox_x", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bbox_y": { + "name": "bbox_y", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bbox_width": { + "name": "bbox_width", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bbox_height": { + "name": "bbox_height", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_type": { + "name": "unit_type", + "type": "unit_type", + "typeSchema": "pdf", + "primaryKey": false, + "notNull": false, + "default": "'prose'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_cu_document": { + "name": "idx_cu_document", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cu_retrieval": { + "name": "idx_cu_retrieval", + "columns": [ + { + "expression": "retrieval_chunk_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cu_page": { + "name": "idx_cu_page", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "page_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cu_unique": { + "name": "idx_cu_unique", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "page_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "paragraph_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "citation_unit_document_id_document_id_fk": { + "name": "citation_unit_document_id_document_id_fk", + "tableFrom": "citation_unit", + "tableTo": "document", + "schemaTo": "pdf", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "citation_unit_retrieval_chunk_id_retrieval_chunk_id_fk": { + "name": "citation_unit_retrieval_chunk_id_retrieval_chunk_id_fk", + "tableFrom": "citation_unit", + "tableTo": "retrieval_chunk", + "schemaTo": "pdf", + "columnsFrom": [ + "retrieval_chunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "pdf.document": { + "name": "document", + "schema": "pdf", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processing_status": { + "name": "processing_status", + "type": "processing_status", + "typeSchema": "pdf", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_chat_id_chat_id_fk": { + "name": "document_chat_id_chat_id_fk", + "tableFrom": "document", + "tableTo": "chat", + "schemaTo": "pdf", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "pdf.embedding": { + "name": "embedding", + "schema": "pdf", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "char_start": { + "name": "char_start", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "char_end": { + "name": "char_end", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "pdf_embeddingIndex": { + "name": "pdf_embeddingIndex", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "schemaTo": "pdf", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "pdf.message": { + "name": "message", + "schema": "pdf", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "pdf", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "message_chat_id_chat_id_fk": { + "name": "message_chat_id_chat_id_fk", + "tableFrom": "message", + "tableTo": "chat", + "schemaTo": "pdf", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "pdf.retrieval_chunk": { + "name": "retrieval_chunk", + "schema": "pdf", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "page_start": { + "name": "page_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "page_end": { + "name": "page_end", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "section_hierarchy": { + "name": "section_hierarchy", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "chunk_type": { + "name": "chunk_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'prose'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_rc_document": { + "name": "idx_rc_document", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rc_embedding": { + "name": "idx_rc_embedding", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "retrieval_chunk_document_id_document_id_fk": { + "name": "retrieval_chunk_document_id_document_id_fk", + "tableFrom": "retrieval_chunk", + "tableTo": "document", + "schemaTo": "pdf", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "image.generation": { + "name": "generation", + "schema": "image", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aspect_ratio": { + "name": "aspect_ratio", + "type": "aspect_ratio", + "typeSchema": "image", + "primaryKey": false, + "notNull": true, + "default": "'square'" + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "generation_user_id_user_id_fk": { + "name": "generation_user_id_user_id_fk", + "tableFrom": "generation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "image.image": { + "name": "image", + "schema": "image", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "image_generation_id_generation_id_fk": { + "name": "image_generation_id_generation_id_fk", + "tableFrom": "image", + "tableTo": "generation", + "schemaTo": "image", + "columnsFrom": [ + "generation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "mesh.audit_log": { + "name": "audit_log", + "schema": "mesh", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mesh_id": { + "name": "mesh_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_peer_id": { + "name": "actor_peer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_peer_id": { + "name": "target_peer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_mesh_id_mesh_id_fk": { + "name": "audit_log_mesh_id_mesh_id_fk", + "tableFrom": "audit_log", + "tableTo": "mesh", + "schemaTo": "mesh", + "columnsFrom": [ + "mesh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "mesh.invite": { + "name": "invite", + "schema": "mesh", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mesh_id": { + "name": "mesh_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_bytes": { + "name": "token_bytes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "mesh", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invite_mesh_id_mesh_id_fk": { + "name": "invite_mesh_id_mesh_id_fk", + "tableFrom": "invite", + "tableTo": "mesh", + "schemaTo": "mesh", + "columnsFrom": [ + "mesh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "invite_created_by_user_id_fk": { + "name": "invite_created_by_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "mesh.mesh": { + "name": "mesh", + "schema": "mesh", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "visibility", + "typeSchema": "mesh", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "transport": { + "name": "transport", + "type": "transport", + "typeSchema": "mesh", + "primaryKey": false, + "notNull": true, + "default": "'managed'" + }, + "max_peers": { + "name": "max_peers", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "mesh", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "owner_pubkey": { + "name": "owner_pubkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mesh_owner_user_id_user_id_fk": { + "name": "mesh_owner_user_id_user_id_fk", + "tableFrom": "mesh", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mesh_slug_unique": { + "name": "mesh_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "mesh.member": { + "name": "member", + "schema": "mesh", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mesh_id": { + "name": "mesh_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "peer_pubkey": { + "name": "peer_pubkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "mesh", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "member_mesh_id_mesh_id_fk": { + "name": "member_mesh_id_mesh_id_fk", + "tableFrom": "member", + "tableTo": "mesh", + "schemaTo": "mesh", + "columnsFrom": [ + "mesh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "mesh.message_queue": { + "name": "message_queue", + "schema": "mesh", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mesh_id": { + "name": "mesh_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_spec": { + "name": "target_spec", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "message_priority", + "typeSchema": "mesh", + "primaryKey": false, + "notNull": true, + "default": "'next'" + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_queue_mesh_id_mesh_id_fk": { + "name": "message_queue_mesh_id_mesh_id_fk", + "tableFrom": "message_queue", + "tableTo": "mesh", + "schemaTo": "mesh", + "columnsFrom": [ + "mesh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "message_queue_sender_member_id_member_id_fk": { + "name": "message_queue_sender_member_id_member_id_fk", + "tableFrom": "message_queue", + "tableTo": "member", + "schemaTo": "mesh", + "columnsFrom": [ + "sender_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "mesh.pending_status": { + "name": "pending_status", + "schema": "mesh", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_source": { + "name": "status_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "mesh.presence": { + "name": "presence", + "schema": "mesh", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "presence_status", + "typeSchema": "mesh", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "status_source": { + "name": "status_source", + "type": "presence_status_source", + "typeSchema": "mesh", + "primaryKey": false, + "notNull": true, + "default": "'jsonl'" + }, + "status_updated_at": { + "name": "status_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ping_at": { + "name": "last_ping_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "presence_member_id_member_id_fk": { + "name": "presence_member_id_member_id_fk", + "tableFrom": "presence", + "tableTo": "member", + "schemaTo": "mesh", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.credit_transaction_type": { + "name": "credit_transaction_type", + "schema": "public", + "values": [ + "signup", + "purchase", + "usage", + "admin_grant", + "admin_deduct", + "refund", + "promo", + "referral", + "expiry" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "canceled", + "incomplete", + "incomplete_expired", + "past_due", + "paused", + "trialing", + "unpaid" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "premium", + "enterprise" + ] + }, + "chat.role": { + "name": "role", + "schema": "chat", + "values": [ + "system", + "assistant", + "user" + ] + }, + "pdf.role": { + "name": "role", + "schema": "pdf", + "values": [ + "user", + "assistant", + "system" + ] + }, + "pdf.processing_status": { + "name": "processing_status", + "schema": "pdf", + "values": [ + "pending", + "processing", + "ready", + "failed" + ] + }, + "pdf.unit_type": { + "name": "unit_type", + "schema": "pdf", + "values": [ + "prose", + "heading", + "list", + "table", + "code" + ] + }, + "image.aspect_ratio": { + "name": "aspect_ratio", + "schema": "image", + "values": [ + "square", + "standard", + "landscape", + "portrait" + ] + }, + "mesh.role": { + "name": "role", + "schema": "mesh", + "values": [ + "admin", + "member" + ] + }, + "mesh.tier": { + "name": "tier", + "schema": "mesh", + "values": [ + "free", + "pro", + "team", + "enterprise" + ] + }, + "mesh.transport": { + "name": "transport", + "schema": "mesh", + "values": [ + "managed", + "tailscale", + "self_hosted" + ] + }, + "mesh.visibility": { + "name": "visibility", + "schema": "mesh", + "values": [ + "private", + "public" + ] + }, + "mesh.message_priority": { + "name": "message_priority", + "schema": "mesh", + "values": [ + "now", + "next", + "low" + ] + }, + "mesh.presence_status": { + "name": "presence_status", + "schema": "mesh", + "values": [ + "idle", + "working", + "dnd" + ] + }, + "mesh.presence_status_source": { + "name": "presence_status_source", + "schema": "mesh", + "values": [ + "hook", + "manual", + "jsonl" + ] + } + }, + "schemas": { + "chat": "chat", + "pdf": "pdf", + "image": "image", + "mesh": "mesh" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index a61b7fa..aea4be9 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775336269295, "tag": "0000_living_namora", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1775339743477, + "tag": "0001_demonic_karnak", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index ae0e12f..74dd60f 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -82,6 +82,13 @@ export const mesh = meshSchema.table("mesh", { transport: meshTransportEnum().notNull().default("managed"), maxPeers: integer(), tier: meshTierEnum().notNull().default("free"), + /** + * ed25519 public key (hex) of the mesh owner / admin signer. + * Invites are signed by the corresponding secret key and verified + * by the broker on /join against this column. Nullable for existing + * rows; required for new meshes. + */ + ownerPubkey: text(), createdAt: timestamp().defaultNow().notNull(), archivedAt: timestamp(), }); @@ -116,6 +123,10 @@ export const meshMember = meshSchema.table("member", { /** * Invite tokens used to join a mesh via shareable URL. + * + * `token` — opaque DB lookup key (the ic:// link's payload) + * `tokenBytes` — canonical signed bytes that the broker re-verifies + * against mesh.ownerPubkey on every /join call */ export const invite = meshSchema.table("invite", { id: text().primaryKey().notNull().$defaultFn(generateId), @@ -123,6 +134,7 @@ export const invite = meshSchema.table("invite", { .references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" }) .notNull(), token: text().notNull().unique(), + tokenBytes: text(), maxUses: integer().notNull().default(1), usedCount: integer().notNull().default(0), role: meshRoleEnum().notNull().default("member"),