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:
150
packages/auth/src/lib/schema.ts
Normal file
150
packages/auth/src/lib/schema.ts
Normal 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,
|
||||
};
|
||||
137
packages/auth/src/lib/test/utils.test.ts
Normal file
137
packages/auth/src/lib/test/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
76
packages/auth/src/lib/utils.ts
Normal file
76
packages/auth/src/lib/utils.ts
Normal 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;
|
||||
Reference in New Issue
Block a user