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,14 @@
import { hc } from "hono/client";
import { getBaseUrl } from "./utils";
import type { AppRouter } from "@turbostarter/api";
export const { api } = hc<AppRouter>(getBaseUrl(), {
headers: {
"x-client-platform": "web-client",
},
init: {
credentials: "include",
},
});

View File

@@ -0,0 +1,13 @@
import { hc } from "hono/client";
import { headers } from "next/headers";
import { getBaseUrl } from "./utils";
import type { AppRouter } from "@turbostarter/api";
export const { api } = hc<AppRouter>(getBaseUrl(), {
headers: async () => ({
...Object.fromEntries((await headers()).entries()),
"x-client-platform": "web-server",
}),
});

View File

@@ -0,0 +1,9 @@
import env from "env.config";
export const getBaseUrl = () => {
if (typeof window !== "undefined") return window.location.origin;
if (env.NEXT_PUBLIC_URL) return env.NEXT_PUBLIC_URL;
if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`;
// eslint-disable-next-line no-restricted-properties, turbo/no-undeclared-env-vars
return `http://localhost:${process.env.PORT ?? 3000}`;
};

View File

@@ -0,0 +1,10 @@
import { createClient } from "@turbostarter/auth/client/web";
export const authClient = createClient({
fetchOptions: {
headers: {
"x-client-platform": "web-client",
},
throw: true,
},
});

View File

@@ -0,0 +1,63 @@
import { headers } from "next/headers";
import { cache } from "react";
import { auth } from "@turbostarter/auth/server";
import { logger } from "@turbostarter/shared/logger";
const getHeaders = async () => {
const newHeaders = new Headers(await headers());
newHeaders.set("x-client-platform", "web-server");
return newHeaders;
};
export const getSession = cache(async () => {
const data = await auth.api.getSession({
headers: await getHeaders(),
});
return {
session: data?.session ?? null,
user: data?.user ?? null,
};
});
export const getOrganization = cache(
async ({ id, slug }: { slug?: string; id?: string }) => {
try {
return await auth.api.getFullOrganization({
query: {
organizationId: id,
organizationSlug: slug,
},
headers: await getHeaders(),
});
} catch (error) {
logger.error(error);
return null;
}
},
);
export const getInvitation = cache(async ({ id }: { id: string }) => {
try {
return await auth.api.getInvitation({
query: {
id,
},
headers: await getHeaders(),
});
} catch {
return null;
}
});
export const getUser = cache(async ({ id }: { id: string }) => {
try {
return await auth.api.getUser({
query: { id },
headers: await getHeaders(),
});
} catch {
return null;
}
});

View File

@@ -0,0 +1,35 @@
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
/**
* Main application navigation items for the sidebar.
* Each app has a title (i18n key), href, and icon.
*/
export const APPS = [
{
title: "chat",
href: pathsConfig.apps.chat.index,
icon: Icons.MessagesSquare,
},
{
title: "image",
href: pathsConfig.apps.image.index,
icon: Icons.Image,
},
{
title: "tts",
href: pathsConfig.apps.tts,
icon: Icons.AudioLines,
},
{
title: "pdf",
href: pathsConfig.apps.pdf.index,
icon: Icons.FileText,
},
{
title: "agent",
href: pathsConfig.apps.agent,
icon: Icons.Sparkles,
},
] as const;

View File

@@ -0,0 +1,116 @@
import { isKey } from "@turbostarter/i18n";
import { getTranslation } from "@turbostarter/i18n/server";
import { appConfig } from "~/config/app";
import type { TranslationKey } from "@turbostarter/i18n";
import type { Metadata, Viewport } from "next";
type OpenGraphType =
| "article"
| "book"
| "music.song"
| "music.album"
| "music.playlist"
| "music.radio_station"
| "profile"
| "website"
| "video.tv_show"
| "video.other"
| "video.movie"
| "video.episode";
interface SeoProps {
readonly title?: TranslationKey | (string & Record<never, never>);
readonly description?: TranslationKey | (string & Record<never, never>);
readonly image?: string;
readonly url?: string;
readonly canonical?: string;
readonly type?: OpenGraphType;
readonly images?: {
url: string;
width: number;
height: number;
alt?: string;
}[];
}
const SITE_NAME_SEPARATOR = " | ";
export const SITE_NAME_TEMPLATE = `%s${SITE_NAME_SEPARATOR}${appConfig.name}`;
const DEFAULT_IMAGE = {
url: `${appConfig.url}/opengraph-image.png`,
width: 1200,
height: 630,
alt: appConfig.name,
};
export const getMetadata =
(
{
title,
description = "common:product.description",
url,
canonical,
images = [DEFAULT_IMAGE],
type = "website",
} = {} as SeoProps,
) =>
async ({
params,
}: {
params?: Promise<{ locale: string }>;
}): Promise<Metadata> => {
const { t, i18n } = await getTranslation({
locale: (await params)?.locale,
});
const common = {
...(title && {
title: isKey(title, i18n) ? (t(title) as string) : title,
}),
description: isKey(description, i18n)
? (t(description) as string)
: description,
};
return {
...common,
openGraph: {
...common,
url: url ?? canonical ?? appConfig.url,
siteName: appConfig.name,
type,
images,
},
...{
...(canonical && {
alternates: {
canonical,
},
}),
},
twitter: {
card: "summary_large_image" as const,
images,
},
};
};
export const DEFAULT_METADATA: Metadata = {
...(await getMetadata()({
params: Promise.resolve({ locale: appConfig.locale }),
})),
title: {
template: SITE_NAME_TEMPLATE,
default: appConfig.name,
},
metadataBase: appConfig.url ? new URL(appConfig.url) : null,
};
export const DEFAULT_VIEWPORT: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#030712" },
],
};

View File

@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
import { identify, Provider, reset } from "@turbostarter/analytics-web";
import { authClient } from "~/lib/auth/client";
export const AnalyticsProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const session = authClient.useSession();
useEffect(() => {
if (session.isPending) {
return;
}
if (session.data?.user) {
const { id, email, name } = session.data.user;
identify(id, { email, name });
} else {
reset();
}
}, [session]);
return <Provider>{children}</Provider>;
};

View File

@@ -0,0 +1,25 @@
"use client";
import { useEffect } from "react";
import { identify } from "@turbostarter/monitoring-web";
import { authClient } from "~/lib/auth/client";
export const MonitoringProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const session = authClient.useSession();
useEffect(() => {
if (session.isPending) {
return;
}
identify(session.data?.user ?? null);
}, [session]);
return <>{children}</>;
};

View File

@@ -0,0 +1,34 @@
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { memo } from "react";
import { I18nProvider } from "@turbostarter/i18n";
import { appConfig } from "~/config/app";
import { QueryClientProvider } from "~/lib/query/client";
import { AnalyticsProvider } from "./analytics";
import { MonitoringProvider } from "./monitoring";
import { ThemeProvider } from "./theme";
interface ProvidersProps {
readonly children: React.ReactNode;
readonly locale: string;
}
export const Providers = memo<ProvidersProps>(({ children, locale }) => {
return (
<I18nProvider locale={locale} defaultLocale={appConfig.locale}>
<QueryClientProvider>
<NuqsAdapter>
<AnalyticsProvider>
<MonitoringProvider>
<ThemeProvider>{children}</ThemeProvider>
</MonitoringProvider>
</AnalyticsProvider>
</NuqsAdapter>
</QueryClientProvider>
</I18nProvider>
);
});
Providers.displayName = "Providers";

View File

@@ -0,0 +1,50 @@
"use client";
import { ThemeProvider as NextThemeProvider } from "next-themes";
import { memo, useEffect } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { appConfig } from "~/config/app";
import type { ThemeConfig } from "@turbostarter/ui";
export const useThemeConfig = create<{
config: Omit<ThemeConfig, "mode">;
setConfig: (config: Omit<ThemeConfig, "mode">) => void;
}>()(
persist(
(set) => ({
config: appConfig.theme,
setConfig: (config) => set({ config }),
}),
{
name: "theme-config",
},
),
);
interface ThemeProviderProps {
readonly children: React.ReactNode;
}
export const ThemeProvider = memo<ThemeProviderProps>(({ children }) => {
const config = useThemeConfig((s) => s.config);
useEffect(() => {
document.body.dataset.theme = config.color;
}, [config.color]);
return (
<NextThemeProvider
attribute="class"
defaultTheme={appConfig.theme.mode}
enableSystem
disableTransitionOnChange
>
{children}
</NextThemeProvider>
);
});
ThemeProvider.displayName = "ThemeProvider";

View File

@@ -0,0 +1,30 @@
"use client";
import { QueryClientProvider as TanstackQueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createQueryClient } from "./utils";
import type { QueryClient } from "@tanstack/react-query";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
export const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
} else {
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= createQueryClient());
}
};
export function QueryClientProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<TanstackQueryClientProvider client={queryClient}>
{props.children}
<ReactQueryDevtools />
</TanstackQueryClientProvider>
);
}

View File

@@ -0,0 +1,5 @@
import { cache } from "react";
import { createQueryClient } from "./utils";
export const getQueryClient = cache(createQueryClient);

View File

@@ -0,0 +1,33 @@
import {
QueryClient,
defaultShouldDehydrateQuery,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { logger } from "@turbostarter/shared/logger";
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
mutations: {
onError: (error: Error | { error: Error }) => {
if ("error" in error) {
error = error.error;
}
logger.error(error);
toast.error(error.message);
},
},
},
});