feat(db): mesh data model — meshes, members, invites, audit log

- pgSchema "mesh" with 4 tables isolating the peer mesh domain
- Enums: visibility, transport, tier, role
- audit_log is metadata-only (E2E encryption enforced at broker/client)
- Cascade on mesh delete, soft-delete via archivedAt/revokedAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 21:19:32 +01:00
commit d3163a5bff
1384 changed files with 314925 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
import * as z from "zod";
import { MemberRole, UserRole } from "../types";
const emailSchema = z.object({
email: z.email().max(254),
});
const password = z.string().min(8);
const passwordSchema = z.object({
password,
});
const otpSchema = z.object({
code: z.string().min(6).max(6),
});
const backupCodeSchema = z.object({
code: z.string().min(11).max(11),
});
const trustDeviceSchema = z.object({
trustDevice: z.boolean().optional(),
});
const updateUserSchema = z.object({
name: z.string().min(2).max(32).optional(),
role: z.enum(UserRole).optional(),
image: z.url().optional(),
});
const changePasswordSchema = z.object({
...passwordSchema.shape,
newPassword: password,
});
const registerSchema = z.object({
...emailSchema.shape,
...passwordSchema.shape,
});
const passwordLoginSchema = z.object({
...emailSchema.shape,
...passwordSchema.shape,
rememberMe: z.boolean().optional().default(true),
});
const magicLinkLoginSchema = emailSchema;
const forgotPasswordSchema = emailSchema;
const updatePasswordSchema = passwordSchema;
const otpVerificationSchema = z.object({
...otpSchema.shape,
...trustDeviceSchema.shape,
});
const backupCodeVerificationSchema = z.object({
...backupCodeSchema.shape,
...trustDeviceSchema.shape,
});
const createOrganizationSchema = z.object({
name: z.string().min(2).max(32),
});
const updateOrganizationSchema = z.object({
name: z.string().min(2).max(32).optional(),
slug: z.string().optional(),
logo: z.string().optional(),
});
const inviteMemberSchema = z.object({
email: z.email().max(254),
role: z.enum(MemberRole),
});
const updateMemberSchema = z.object({
role: z.enum(MemberRole).optional(),
});
const banUserSchema = z.object({
reason: z.string().min(1).max(1000).optional(),
expiresIn: z
.date()
.optional()
.refine((date) => !date || date > new Date()),
});
type EmailPayload = z.infer<typeof emailSchema>;
type PasswordLoginPayload = z.infer<typeof passwordLoginSchema>;
type MagicLinkLoginPayload = z.infer<typeof magicLinkLoginSchema>;
type RegisterPayload = z.infer<typeof registerSchema>;
type ForgotPasswordPayload = z.infer<typeof forgotPasswordSchema>;
type UpdatePasswordPayload = z.infer<typeof updatePasswordSchema>;
type PasswordPayload = z.infer<typeof passwordSchema>;
type UpdateUserPayload = z.infer<typeof updateUserSchema>;
type ChangePasswordPayload = z.infer<typeof changePasswordSchema>;
type OtpPayload = z.infer<typeof otpSchema>;
type OtpVerificationPayload = z.infer<typeof otpVerificationSchema>;
type BackupCodePayload = z.infer<typeof backupCodeSchema>;
type BackupCodeVerificationPayload = z.infer<
typeof backupCodeVerificationSchema
>;
type CreateOrganizationPayload = z.infer<typeof createOrganizationSchema>;
type UpdateOrganizationPayload = z.infer<typeof updateOrganizationSchema>;
type InviteMemberPayload = z.infer<typeof inviteMemberSchema>;
type UpdateMemberPayload = z.infer<typeof updateMemberSchema>;
type BanUserPayload = z.infer<typeof banUserSchema>;
export {
passwordSchema,
registerSchema,
passwordLoginSchema,
magicLinkLoginSchema,
forgotPasswordSchema,
updatePasswordSchema,
updateUserSchema,
emailSchema,
changePasswordSchema,
otpSchema,
otpVerificationSchema,
backupCodeSchema,
backupCodeVerificationSchema,
createOrganizationSchema,
updateOrganizationSchema,
inviteMemberSchema,
updateMemberSchema,
banUserSchema,
};
export type {
PasswordLoginPayload,
MagicLinkLoginPayload,
RegisterPayload,
ForgotPasswordPayload,
UpdatePasswordPayload,
PasswordPayload,
UpdateUserPayload,
EmailPayload,
ChangePasswordPayload,
OtpPayload,
OtpVerificationPayload,
BackupCodePayload,
BackupCodeVerificationPayload,
CreateOrganizationPayload,
UpdateOrganizationPayload,
InviteMemberPayload,
UpdateMemberPayload,
BanUserPayload,
};

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { MemberRole, UserRole } from "../../types";
import {
generateName,
getAllRolesAtOrAbove,
getAllRolesAtOrBelow,
getUrl,
hasAdminPermission,
} from "../utils";
import type { User } from "../../types";
const BASE_URL = "http://localhost:3000";
describe("getUrl", () => {
it("should use x-url header if present", () => {
const request = new Request(BASE_URL, {
headers: { "x-url": "https://custom-url.com/path" },
});
const url = getUrl({ request });
expect(url.toString()).toBe("https://custom-url.com/path");
});
it("should merge params from passed url into x-url", () => {
const request = new Request(BASE_URL, {
headers: { "x-url": "https://custom.com?foo=bar" },
});
// mergeSearchParams with overwrite: false (default behavior for x-url branch)
const url = getUrl({
request,
url: "https://ignored.com?baz=qux&foo=new",
});
// Should preserve foo=bar from x-url, add baz=qux
expect(url.searchParams.get("foo")).toBe("bar");
expect(url.searchParams.get("baz")).toBe("qux");
expect(url.origin).toBe("https://custom.com");
});
it("should use expo-origin header if present (mobile)", () => {
const request = new Request(BASE_URL, {
headers: { "expo-origin": "exp://192.168.1.1:8081" },
});
const url = getUrl({ request });
expect(url.toString()).toBe("exp://192.168.1.1:8081");
});
it("should replace params from passed url into expo-origin", () => {
const request = new Request(BASE_URL, {
headers: { "expo-origin": "exp://host" },
});
const url = getUrl({
request,
url: "https://ignored.com?foo=bar",
});
// Mobile branch uses mergeSearchParams with replace: true
expect(url.searchParams.get("foo")).toBe("bar");
expect(url.protocol).toBe("exp:");
expect(url.host).toBe("host");
});
it("should fallback to request url for web", () => {
const request = new Request("https://web.com/current");
const url = getUrl({ request });
expect(url.toString()).toBe("https://web.com/current");
});
it("should use provided url for web if no headers", () => {
const request = new Request("https://web.com");
const url = getUrl({ request, url: "https://target.com/page" });
expect(url.toString()).toBe("https://target.com/page");
});
it("should append type param if provided", () => {
const request = new Request("https://web.com");
const url = getUrl({ request, type: "invite" });
expect(url.searchParams.get("type")).toBe("invite");
});
});
describe("generateName", () => {
it("should generate name from email", () => {
expect(generateName("john.doe@example.com")).toBe("john.doe");
});
it.each([[undefined, null, "john.doe@example.com"]])(
"should fallback to Anonymous if email is empty",
(email) => {
expect(generateName(email)).toBe("Anonymous");
},
);
});
describe("getAllRolesAtOrBelow", () => {
it.each([
[MemberRole.OWNER, [MemberRole.MEMBER, MemberRole.ADMIN, MemberRole.OWNER]],
[MemberRole.MEMBER, [MemberRole.MEMBER]],
])("should return correct roles for %s", (inputRole, expectedRoles) => {
const roles = getAllRolesAtOrBelow(inputRole);
expect(roles).toEqual(expectedRoles);
});
});
describe("getAllRolesAtOrAbove", () => {
it.each([
[MemberRole.OWNER, [MemberRole.OWNER]],
[
MemberRole.MEMBER,
[MemberRole.MEMBER, MemberRole.ADMIN, MemberRole.OWNER],
],
])("should return correct roles for %s", (inputRole, expectedRoles) => {
const roles = getAllRolesAtOrAbove(inputRole);
expect(roles).toEqual(expectedRoles);
});
});
describe("hasAdminPermission", () => {
it("should return true if user has ADMIN role", () => {
const user = { role: UserRole.ADMIN } as User;
expect(hasAdminPermission(user)).toBe(true);
});
it("should return true if user has multiple roles including ADMIN", () => {
const user = { role: `${UserRole.USER},${UserRole.ADMIN}` } as User;
expect(hasAdminPermission(user)).toBe(true);
});
it("should return false if user does not have ADMIN role", () => {
const user = { role: UserRole.USER } as User;
expect(hasAdminPermission(user)).toBe(false);
});
it("should return false if user is missing role", () => {
const user = {} as User;
expect(hasAdminPermission(user)).toBe(false);
});
});

View File

@@ -0,0 +1,76 @@
import { getOrigin, mergeSearchParams } from "@turbostarter/shared/utils";
import { MemberRole, UserRole } from "../types";
import type { User } from "../types";
export const getUrl = ({
request,
url,
type,
}: {
request?: Request;
url?: string;
type?: string;
}) => {
const passedUrl = request?.headers.get("x-url");
const expoOrigin = request?.headers.get("expo-origin");
let resultUrl: URL;
if (passedUrl) {
// Base on x-url; merge in params from provided `url` without overwriting existing keys
resultUrl = new URL(passedUrl);
if (url) {
const urlObj = new URL(
url,
getOrigin(resultUrl.toString()) ?? expoOrigin ?? undefined,
);
mergeSearchParams(resultUrl, urlObj, { overwrite: false });
}
} else if (expoOrigin) {
// For Expo/mobile, base on expo-origin; if `url` has a query, adopt it entirely
resultUrl = new URL(expoOrigin);
if (url) {
const targetUrl = new URL(url);
if (targetUrl.search) {
mergeSearchParams(resultUrl, targetUrl, { replace: true });
}
}
} else {
// For web, use the provided URL or fall back to the request URL
resultUrl = new URL(url ?? request?.url ?? "");
}
if (type) {
resultUrl.searchParams.set("type", type);
}
return resultUrl;
};
const hierarchy: MemberRole[] = [
MemberRole.MEMBER,
MemberRole.ADMIN,
MemberRole.OWNER,
];
export const generateName = (email?: string) => {
return email?.split("@")[0] ?? "Anonymous";
};
export const getAllRolesAtOrBelow = (role: MemberRole): MemberRole[] => {
const idx = hierarchy.indexOf(role);
if (idx === -1) return [];
return hierarchy.slice(0, idx + 1);
};
export const getAllRolesAtOrAbove = (role: MemberRole): MemberRole[] => {
const idx = hierarchy.indexOf(role);
if (idx === -1) return [];
return hierarchy.slice(idx, hierarchy.length);
};
export const hasAdminPermission = (user: User) =>
user.role?.split(",").includes(UserRole.ADMIN) ?? false;