Fix: echo v2

This commit is contained in:
Harshith Mullapudi 2025-05-27 23:12:05 +05:30
parent 72edab887e
commit 060668e8c0
54 changed files with 4714 additions and 996 deletions

4
apps/webapp/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"tailwindConfig": "./tailwind.config.ts",
"plugins": [ "prettier-plugin-tailwindcss" ]
}

View File

@ -1,40 +1,43 @@
# Welcome to Remix!
# Memory Plane
- 📖 [Remix docs](https://remix.run/docs)
Simple memory management system for AI agents with per-space ingestion and search capabilities.
## Development
## Core APIs
Run the dev server:
### 1. Ingest API
```shellscript
npm run dev
```
- Endpoint per space for data ingestion
- Queue-based processing per user
- Creates and links graph nodes automatically
- Optional review queue for controlled ingestion
## Deployment
### 2. Search API
First, build your app for production:
- Simple text-based search
- Input: query string
- Output: relevant text matches
- Scoped to specific memory space
```sh
npm run build
```
## Features (v1)
Then run the app in production mode:
[ ] Auto-mode default with optional queue review
[ ] Multiple Spaces support (unique URL per space)
[ ] Basic rules engine for ingestion filters
[ ] Clear, user-friendly guidelines
[ ] Simple text search
```sh
npm start
```
## Usage Guidelines
Now you'll need to pick a host to deploy it to.
Store:
### DIY
- Conversation history
- User preferences
- Task context
- Reference materials
If you're familiar with deploying Node applications, the built-in Remix app server is production-ready.
Don't Store:
Make sure to deploy the output of `npm run build`
- `build/server`
- `build/client`
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever css framework you prefer. See the [Vite docs on css](https://vitejs.dev/guide/features.html#css) for more information.
- Sensitive data (PII)
- Credentials
- System logs
- Temporary data

View File

@ -0,0 +1,61 @@
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
import { friendlyErrorDisplay } from "~/utils/httpErrors";
import { type ReactNode } from "react";
import { Button } from "./ui";
import { Header1 } from "./ui/Headers";
import { Paragraph } from "./ui/Paragraph";
type ErrorDisplayOptions = {
button?: {
title: string;
to: string;
};
};
export function RouteErrorDisplay(options?: ErrorDisplayOptions) {
const error = useRouteError();
return (
<>
{isRouteErrorResponse(error) ? (
<ErrorDisplay
title={friendlyErrorDisplay(error.status, error.statusText).title}
message={
error.data.message ??
friendlyErrorDisplay(error.status, error.statusText).message
}
{...options}
/>
) : error instanceof Error ? (
<ErrorDisplay title={error.name} message={error.message} {...options} />
) : (
<ErrorDisplay
title="Oops"
message={JSON.stringify(error)}
{...options}
/>
)}
</>
);
}
type DisplayOptionsProps = {
title: string;
message?: ReactNode;
} & ErrorDisplayOptions;
export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
return (
<div className="bg-background relative flex min-h-screen flex-col items-center justify-center">
<div className="z-10 mt-[30vh] flex flex-col items-center gap-8">
<Header1>{title}</Header1>
{message && <Paragraph>{message}</Paragraph>}
<Button variant="link">
{button ? button.title : "Go to homepage"}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,75 @@
import { cn } from "~/lib/utils";
/** This container is used to surround the entire app, it correctly places the nav bar */
export function AppContainer({ children }: { children: React.ReactNode }) {
return <div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>;
}
export function MainBody({ children }: { children: React.ReactNode }) {
return <div className={cn("grid grid-rows-1 overflow-hidden")}>{children}</div>;
}
/** This container should be placed around the content on a page */
export function PageContainer({ children }: { children: React.ReactNode }) {
return <div className="grid grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
}
export function PageBody({
children,
scrollable = true,
className,
}: {
children: React.ReactNode;
scrollable?: boolean;
className?: string;
}) {
return (
<div
className={cn(
scrollable
? "scrollbar-thumb-charcoal-600 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent"
: "overflow-hidden",
className
)}
>
{children}
</div>
);
}
export function MainCenteredContainer({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="scrollbar-thumb-charcoal-600 h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent">
<div className={cn("mx-auto mt-6 max-w-xs overflow-y-auto p-1 md:mt-[22vh]", className)}>
{children}
</div>
</div>
);
}
export function MainHorizontallyCenteredContainer({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="scrollbar-thumb-charcoal-600 w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent">
<div
className={cn(
"scrollbar-thumb-charcoal-600 mx-auto mt-6 max-w-lg overflow-y-auto p-1 scrollbar-thin scrollbar-track-transparent md:mt-14",
className
)}
>
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { Paragraph } from "../ui/Paragraph";
import { Header3 } from "../ui/Headers";
import { Button } from "../ui";
import Logo from "../logo/logo";
import { Theme, useTheme } from "remix-themes";
interface QuoteType {
quote: string;
}
const quotes: QuoteType[] = [
{
quote:
"Echo remembers that I prefer emails in dark mode and hate promotional content. It automatically filters and formats my communications just the way I like.",
},
{
quote:
"When I mention liking Nike's latest running shoes, Echo remembers this preference and helps surface relevant product launches and deals across my browsing.",
},
{
quote:
"Echo knows I'm a vegetarian and helps filter restaurant recommendations and recipes accordingly, without me having to specify it every time.",
},
{
quote:
"By remembering that I prefer technical documentation with code examples, Echo helps prioritize learning resources that match my learning style.",
},
];
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
useEffect(() => {
const randomIndex = Math.floor(Math.random() * quotes.length);
setRandomQuote(quotes[randomIndex]);
}, []);
const [, setTheme] = useTheme();
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<div className="pt-8">
<Logo width={20} height={20} />
<Button onClick={() => setTheme(Theme.DARK)}>theme</Button>
</div>
<div className="flex h-full flex-grow items-center justify-center">
{children}
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export * from "./logo";

View File

@ -0,0 +1,67 @@
import React from "react";
import { Theme, useTheme } from "remix-themes";
export interface LogoProps {
width: number;
height: number;
}
export default function StaticLogo({ width, height }: LogoProps) {
const [theme] = useTheme();
if (theme === Theme.LIGHT) {
return (
<svg
width="153"
height="153"
viewBox="0 0 153 153"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_47_2)">
<path
d="M43 40H110V62.2H72.7778V77H110V114H43V91.8H80.2222V77H43V40Z"
fill="black"
/>
</g>
<defs>
<clipPath id="clip0_47_2">
<path
d="M0 12C0 5.37258 5.37258 0 12 0H141C147.627 0 153 5.37258 153 12V141C153 147.627 147.627 153 141 153H12C5.37258 153 0 147.627 0 141V12Z"
fill="white"
/>
</clipPath>
</defs>
</svg>
);
}
return (
<svg
width="153"
height="153"
viewBox="0 0 153 153"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_55_2)">
<path
d="M0 12C0 5.37258 5.37258 0 12 0H141C147.627 0 153 5.37258 153 12V141C153 147.627 147.627 153 141 153H12C5.37258 153 0 147.627 0 141V12Z"
fill="#1E1E1E"
/>
<path
d="M43 40H110V62.2H72.7778V77H110V114H43V91.8H80.2222V77H43V40Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_55_2">
<path
d="M0 12C0 5.37258 5.37258 0 12 0H141C147.627 0 153 5.37258 153 12V141C153 147.627 147.627 153 141 153H12C5.37258 153 0 147.627 0 141V12Z"
fill="white"
/>
</clipPath>
</defs>
</svg>
);
}

View File

@ -0,0 +1,90 @@
import { cn } from "~/lib/utils";
const headerVariants = {
header1: {
text: "font-sans text-2xl leading-5 md:leading-6 lg:leading-7 font-medium",
spacing: "mb-2",
},
header2: {
text: "font-sans text-base leading-6 font-medium",
spacing: "mb-2",
},
header3: {
text: "font-sans text-sm leading-5 font-medium",
spacing: "mb-2",
},
};
const textColorVariants = {
bright: "text-text-bright",
dimmed: "text-text-dimmed",
};
export type HeaderVariant = keyof typeof headerVariants;
type HeaderProps = {
className?: string;
children: React.ReactNode;
spacing?: boolean;
textColor?: "bright" | "dimmed";
} & React.HTMLAttributes<HTMLHeadingElement>;
export function Header1({
className,
children,
spacing = false,
textColor = "bright",
}: HeaderProps) {
return (
<h1
className={cn(
headerVariants.header1.text,
spacing === true && headerVariants.header1.spacing,
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
className
)}
>
{children}
</h1>
);
}
export function Header2({
className,
children,
spacing = false,
textColor = "bright",
}: HeaderProps) {
return (
<h2
className={cn(
headerVariants.header2.text,
spacing === true && headerVariants.header2.spacing,
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
className
)}
>
{children}
</h2>
);
}
export function Header3({
className,
children,
spacing = false,
textColor = "bright",
}: HeaderProps) {
return (
<h3
className={cn(
headerVariants.header3.text,
spacing === true && headerVariants.header3.spacing,
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
className
)}
>
{children}
</h3>
);
}

View File

@ -0,0 +1,95 @@
import { cn } from "~/lib/utils";
const paragraphVariants = {
base: {
text: "font-sans text-base font-normal text-text-dimmed",
spacing: "mb-3",
},
"base/bright": {
text: "font-sans text-base font-normal text-text-bright",
spacing: "mb-3",
},
small: {
text: "font-sans text-sm font-normal text-text-dimmed",
spacing: "mb-2",
},
"small/bright": {
text: "font-sans text-sm font-normal text-text-bright",
spacing: "mb-2",
},
"extra-small": {
text: "font-sans text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/bright": {
text: "font-sans text-xs font-normal text-text-bright",
spacing: "mb-1.5",
},
"extra-small/mono": {
text: "font-mono text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/bright/mono": {
text: "font-mono text-xs text-text-bright",
spacing: "mb-1.5",
},
"extra-small/caps": {
text: "font-sans text-xs uppercase tracking-wider font-normal text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/bright/caps": {
text: "font-sans text-xs uppercase tracking-wider font-normal text-text-bright",
spacing: "mb-1.5",
},
"extra-extra-small": {
text: "font-sans text-xxs font-normal text-text-dimmed",
spacing: "mb-1",
},
"extra-extra-small/bright": {
text: "font-sans text-xxs font-normal text-text-bright",
spacing: "mb-1",
},
"extra-extra-small/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed",
spacing: "mb-1",
},
"extra-extra-small/bright/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-bright",
spacing: "mb-1",
},
"extra-extra-small/dimmed/caps": {
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed",
spacing: "mb-1",
},
};
export type ParagraphVariant = keyof typeof paragraphVariants;
type ParagraphProps = {
variant?: ParagraphVariant;
className?: string;
spacing?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLParagraphElement>;
export function Paragraph({
variant = "base",
className,
spacing = false,
children,
...props
}: ParagraphProps) {
return (
<p
className={cn(
paragraphVariants[variant].text,
spacing === true && paragraphVariants[variant].spacing,
className
)}
{...props}
>
{children}
</p>
);
}

View File

@ -0,0 +1,88 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import React from "react";
import { cn } from "../../lib/utils";
import { Loader } from "lucide-react";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded transition-colors focus-visible:outline-none focus-visible:shadow-none disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-slate-300",
{
variants: {
variant: {
default: "bg-primary text-white shadow hover:bg-primary/90 dark:hover:bg-primary/90",
destructive: "text-red-500 bg-grayAlpha-100 border-none",
outline: "border border-border shadow-sm hover:bg-gray-100 shadow-none",
secondary: "bg-grayAlpha-100 border-none",
ghost: "dark:focus-visible:ring-0 hover:bg-grayAlpha-100",
link: "dark:focus-visible:ring-0",
},
size: {
default: "h-7 rounded px-2 py-1",
sm: "h-6 rounded-sm px-2 py-2",
xs: "h-5 rounded-sm px-1 py-1",
lg: "h-8 px-4 py-2",
xl: "h-9 rounded px-8",
"2xl": "h-12 rounded px-8",
icon: "h-9 w-9",
},
full: {
false: "w-auto",
true: "w-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
full: false,
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
asChild?: boolean;
isActive?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
full,
asChild = false,
children,
isLoading,
isActive,
disabled,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
buttonVariants({ variant, size, full, className }),
isActive && "bg-accent text-accent-foreground"
)}
ref={ref}
type="button"
{...props}
disabled={isLoading ?? disabled}
>
{isLoading ? <Loader className="mr-2 animate-spin" /> : <></>}
{children}
</Comp>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1 @@
export * from "./button";

View File

@ -0,0 +1,232 @@
import {
Prisma,
PrismaClient,
type PrismaClientOrTransaction,
type PrismaReplicaClient,
type PrismaTransactionClient,
type PrismaTransactionOptions,
$transaction as transac,
} from "@echo/database";
import invariant from "tiny-invariant";
import { z } from "zod";
import { env } from "./env.server";
import { logger } from "./services/logger.service";
import { isValidDatabaseUrl } from "./utils/db";
import { singleton } from "./utils/singleton";
import { type Span } from "@opentelemetry/api";
export type {
PrismaTransactionClient,
PrismaClientOrTransaction,
PrismaTransactionOptions,
PrismaReplicaClient,
};
export async function $transaction<R>(
prisma: PrismaClientOrTransaction,
name: string,
fn: (prisma: PrismaTransactionClient, span?: Span) => Promise<R>,
options?: PrismaTransactionOptions
): Promise<R | undefined>;
export async function $transaction<R>(
prisma: PrismaClientOrTransaction,
fn: (prisma: PrismaTransactionClient) => Promise<R>,
options?: PrismaTransactionOptions
): Promise<R | undefined>;
export async function $transaction<R>(
prisma: PrismaClientOrTransaction,
fnOrName: ((prisma: PrismaTransactionClient) => Promise<R>) | string,
fnOrOptions?: ((prisma: PrismaTransactionClient) => Promise<R>) | PrismaTransactionOptions,
options?: PrismaTransactionOptions
): Promise<R | undefined> {
if (typeof fnOrName === "string") {
const fn = fnOrOptions as (prisma: PrismaTransactionClient) => Promise<R>;
return await transac(
prisma,
(client) => fn(client),
(error) => {
logger.error("prisma.$transaction error", {
code: error.code,
meta: error.meta,
stack: error.stack,
message: error.message,
name: error.name,
});
},
options
);
} else {
return transac(
prisma,
fnOrName,
(error) => {
logger.error("prisma.$transaction error", {
code: error.code,
meta: error.meta,
stack: error.stack,
message: error.message,
name: error.name,
});
},
typeof fnOrOptions === "function" ? undefined : fnOrOptions
);
}
}
export { Prisma };
export const prisma = singleton("prisma", getClient);
export const $replica: PrismaReplicaClient = singleton(
"replica",
() => getReplicaClient() ?? prisma
);
function getClient() {
const { DATABASE_URL } = process.env;
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
const databaseUrl = extendQueryParams(DATABASE_URL, {
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
});
console.log(`🔌 setting up prisma client to ${redactUrlSecrets(databaseUrl)}`);
const client = new PrismaClient({
datasources: {
db: {
url: databaseUrl.href,
},
},
// @ts-expect-error
log: [
{
emit: "stdout",
level: "error",
},
{
emit: "stdout",
level: "info",
},
{
emit: "stdout",
level: "warn",
},
].concat(
process.env.VERBOSE_PRISMA_LOGS === "1"
? [
{ emit: "event", level: "query" },
{ emit: "stdout", level: "query" },
]
: []
),
});
// connect eagerly
client.$connect();
console.log(`🔌 prisma client connected`);
return client;
}
function getReplicaClient() {
if (!env.DATABASE_READ_REPLICA_URL) {
console.log(`🔌 No database replica, using the regular client`);
return;
}
const replicaUrl = extendQueryParams(env.DATABASE_READ_REPLICA_URL, {
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
});
console.log(`🔌 setting up read replica connection to ${redactUrlSecrets(replicaUrl)}`);
const replicaClient = new PrismaClient({
datasources: {
db: {
url: replicaUrl.href,
},
},
// @ts-expect-error
log: [
{
emit: "stdout",
level: "error",
},
{
emit: "stdout",
level: "info",
},
{
emit: "stdout",
level: "warn",
},
].concat(
process.env.VERBOSE_PRISMA_LOGS === "1"
? [
{ emit: "event", level: "query" },
{ emit: "stdout", level: "query" },
]
: []
),
});
// connect eagerly
replicaClient.$connect();
console.log(`🔌 read replica connected`);
return replicaClient;
}
function extendQueryParams(hrefOrUrl: string | URL, queryParams: Record<string, string>) {
const url = new URL(hrefOrUrl);
const query = url.searchParams;
for (const [key, val] of Object.entries(queryParams)) {
query.set(key, val);
}
url.search = query.toString();
return url;
}
function redactUrlSecrets(hrefOrUrl: string | URL) {
const url = new URL(hrefOrUrl);
url.password = "";
return url.href;
}
export type { PrismaClient } from "@echo/database";
export const PrismaErrorSchema = z.object({
code: z.string(),
});
function getDatabaseSchema() {
if (!isValidDatabaseUrl(env.DATABASE_URL)) {
throw new Error("Invalid Database URL");
}
const databaseUrl = new URL(env.DATABASE_URL);
const schemaFromSearchParam = databaseUrl.searchParams.get("schema");
if (!schemaFromSearchParam) {
console.debug("❗ database schema unspecified, will default to `public` schema");
return "public";
}
return schemaFromSearchParam;
}
export const DATABASE_SCHEMA = singleton("DATABASE_SCHEMA", getDatabaseSchema);
export const sqlDatabaseSchema = Prisma.sql([`${DATABASE_SCHEMA}`]);

View File

@ -22,8 +22,13 @@ const EnvironmentSchema = z.object({
SESSION_SECRET: z.string(),
APP_ENV: z.string().default(process.env.NODE_ENV),
LOGIN_ORIGIN: z.string().default("http://localhost:5173"),
APP_ORIGIN: z.string().default("http://localhost:5173"),
POSTHOG_PROJECT_KEY: z.string().default(""),
// google auth
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
});
export type Environment = z.infer<typeof EnvironmentSchema>;

View File

@ -0,0 +1,26 @@
import { useEffect, useRef } from "react";
/** Call a function when the id of the item changes */
export function useChanged<T extends { id: string }>(
getItem: () => T | undefined,
action: (item: T | undefined) => void,
sendInitialUndefined = true
) {
const previousItemId = useRef<string | undefined>();
const item = getItem();
//when the value changes, call the action
useEffect(() => {
if (previousItemId.current !== item?.id) {
action(item);
}
previousItemId.current = item?.id;
}, [item]);
//if sendInitialUndefined is true, call the action when the component first renders
useEffect(() => {
if (item !== undefined || sendInitialUndefined === false) return;
action(item);
}, []);
}

View File

@ -0,0 +1,49 @@
import { useLocation } from "@remix-run/react";
import posthog from "posthog-js";
import { useEffect, useRef } from "react";
import { useOptionalUser, useUserChanged } from "./useUser";
export const usePostHog = (apiKey?: string, logging = false, debug = false): void => {
const postHogInitialized = useRef(false);
const location = useLocation();
const user = useOptionalUser();
//start PostHog once
useEffect(() => {
if (apiKey === undefined || apiKey === "") return;
if (postHogInitialized.current === true) return;
if (logging) console.log("Initializing PostHog");
posthog.init(apiKey, {
api_host: "https://eu.posthog.com",
opt_in_site_apps: true,
debug,
loaded: function (posthog) {
if (logging) console.log("PostHog loaded");
if (user !== undefined) {
if (logging) console.log("Loaded: Identifying user", user);
posthog.identify(user.id, { email: user.email });
}
},
});
postHogInitialized.current = true;
}, [apiKey, logging, user]);
useUserChanged((user) => {
if (postHogInitialized.current === false) return;
if (logging) console.log("User changed");
if (user) {
if (logging) console.log("Identifying user", user);
posthog.identify(user.id, { email: user.email });
} else {
if (logging) console.log("Resetting user");
posthog.reset();
}
});
//page view
useEffect(() => {
if (postHogInitialized.current === false) return;
posthog.capture("$pageview");
}, [location, logging]);
};

View File

@ -0,0 +1,44 @@
import { type UIMatch, useMatches } from "@remix-run/react";
import {
type RemixSerializedType,
type UseDataFunctionReturn,
deserializeRemix,
} from "remix-typedjson";
type AppData = any;
function useTypedDataFromMatches<T = AppData>({
id,
matches,
}: {
id: string;
matches: UIMatch[];
}): UseDataFunctionReturn<T> | undefined {
const match = matches.find((m) => m.id === id);
return useTypedMatchData<T>(match);
}
export function useTypedMatchesData<T = AppData>({
id,
matches,
}: {
id: string;
matches?: UIMatch[];
}): UseDataFunctionReturn<T> | undefined {
if (!matches) {
matches = useMatches();
}
return useTypedDataFromMatches<T>({ id, matches });
}
export function useTypedMatchData<T = AppData>(
match: UIMatch | undefined
): UseDataFunctionReturn<T> | undefined {
if (!match) {
return undefined;
}
return deserializeRemix<T>(match.data as RemixSerializedType<T>) as
| UseDataFunctionReturn<T>
| undefined;
}

View File

@ -0,0 +1,43 @@
import { type UIMatch } from "@remix-run/react";
import type { User } from "~/models/user.server";
import { type loader } from "~/root";
import { useChanged } from "./useChanged";
import { useTypedMatchesData } from "./useTypedMatchData";
export function useIsImpersonating(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.workspace.$workspaceSlug",
matches,
});
return data?.isImpersonating === true;
}
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
const routeMatch = useTypedMatchesData<typeof loader>({
id: "root",
matches,
});
return routeMatch?.user ?? undefined;
}
export function useUser(matches?: UIMatch[]): User {
const maybeUser = useOptionalUser(matches);
if (!maybeUser) {
throw new Error(
"No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead."
);
}
return maybeUser;
}
export function useUserChanged(callback: (user: User | undefined) => void) {
useChanged(useOptionalUser, callback);
}
export function useHasAdminAccess(matches?: UIMatch[]): boolean {
const user = useOptionalUser(matches);
const isImpersonating = useIsImpersonating(matches);
return Boolean(user?.admin) || isImpersonating;
}

View File

@ -1,6 +1,11 @@
import { json, type Session , createCookieSessionStorage } from "@remix-run/node";
import {
json,
type Session,
createCookieSessionStorage,
} from "@remix-run/node";
import { redirect } from "remix-typedjson";
import { env } from "~/env.server";
import { createThemeSessionResolver } from "remix-themes";
export type ToastMessage = {
message: string;
@ -29,7 +34,7 @@ export const { commitSession, getSession } = createCookieSessionStorage({
export function setSuccessMessage(
session: Session,
message: string,
options?: ToastMessageOptions
options?: ToastMessageOptions,
) {
session.flash("toastMessage", {
message,
@ -40,7 +45,11 @@ export function setSuccessMessage(
} as ToastMessage);
}
export function setErrorMessage(session: Session, message: string, options?: ToastMessageOptions) {
export function setErrorMessage(
session: Session,
message: string,
options?: ToastMessageOptions,
) {
session.flash("toastMessage", {
message,
type: "error",
@ -53,7 +62,7 @@ export function setErrorMessage(session: Session, message: string, options?: Toa
export async function setRequestErrorMessage(
request: Request,
message: string,
options?: ToastMessageOptions
options?: ToastMessageOptions,
) {
const session = await getSession(request.headers.get("cookie"));
@ -65,7 +74,7 @@ export async function setRequestErrorMessage(
export async function setRequestSuccessMessage(
request: Request,
message: string,
options?: ToastMessageOptions
options?: ToastMessageOptions,
) {
const session = await getSession(request.headers.get("cookie"));
@ -86,7 +95,7 @@ export async function jsonWithSuccessMessage(
data: any,
request: Request,
message: string,
options?: ToastMessageOptions
options?: ToastMessageOptions,
) {
const session = await getSession(request.headers.get("cookie"));
@ -105,7 +114,7 @@ export async function jsonWithErrorMessage(
data: any,
request: Request,
message: string,
options?: ToastMessageOptions
options?: ToastMessageOptions,
) {
const session = await getSession(request.headers.get("cookie"));
@ -124,7 +133,7 @@ export async function redirectWithSuccessMessage(
path: string,
request: Request,
message: string,
options?: ToastMessageOptions
options?: ToastMessageOptions,
) {
const session = await getSession(request.headers.get("cookie"));
@ -143,7 +152,7 @@ export async function redirectWithErrorMessage(
path: string,
request: Request,
message: string,
options?: ToastMessageOptions
options?: ToastMessageOptions,
) {
const session = await getSession(request.headers.get("cookie"));
@ -161,7 +170,7 @@ export async function redirectWithErrorMessage(
export async function redirectBackWithErrorMessage(
request: Request,
message: string,
options?: ToastMessageOptions
options?: ToastMessageOptions,
) {
const url = new URL(request.url);
return redirectWithErrorMessage(url.pathname, request, message, options);
@ -170,7 +179,7 @@ export async function redirectBackWithErrorMessage(
export async function redirectBackWithSuccessMessage(
request: Request,
message: string,
options?: ToastMessageOptions
options?: ToastMessageOptions,
) {
const url = new URL(request.url);
return redirectWithSuccessMessage(url.pathname, request, message, options);

View File

@ -0,0 +1,155 @@
import type { Prisma, User } from "@echo/database";
import type { GoogleProfile } from "remix-auth-google";
import { prisma } from "~/db.server";
export type { User } from "@echo/database";
type FindOrCreateGoogle = {
authenticationMethod: "GOOGLE";
email: User["email"];
authenticationProfile: GoogleProfile;
authenticationExtraParams: Record<string, unknown>;
};
type FindOrCreateUser = FindOrCreateGoogle;
type LoggedInUser = {
user: User;
isNewUser: boolean;
};
export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedInUser> {
return findOrCreateGoogleUser(input);
}
export async function findOrCreateGoogleUser({
email,
authenticationProfile,
authenticationExtraParams,
}: FindOrCreateGoogle): Promise<LoggedInUser> {
const name = authenticationProfile._json.name;
let avatarUrl: string | undefined = undefined;
if (authenticationProfile.photos[0]) {
avatarUrl = authenticationProfile.photos[0].value;
}
const displayName = authenticationProfile.displayName;
const authProfile = authenticationProfile
? (authenticationProfile as unknown as Prisma.JsonObject)
: undefined;
const authExtraParams = authenticationExtraParams
? (authenticationExtraParams as unknown as Prisma.JsonObject)
: undefined;
const authIdentifier = `github:${authenticationProfile.id}`;
const existingUser = await prisma.user.findUnique({
where: {
authIdentifier,
},
});
const existingEmailUser = await prisma.user.findUnique({
where: {
email,
},
});
if (existingEmailUser && !existingUser) {
const user = await prisma.user.update({
where: {
email,
},
data: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
avatarUrl,
authIdentifier,
},
});
return {
user,
isNewUser: false,
};
}
if (existingEmailUser && existingUser) {
const user = await prisma.user.update({
where: {
id: existingUser.id,
},
data: {},
});
return {
user,
isNewUser: false,
};
}
const user = await prisma.user.upsert({
where: {
authIdentifier,
},
update: {},
create: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
name,
avatarUrl,
displayName,
authIdentifier,
email,
authenticationMethod: "GOOGLE",
},
});
return {
user,
isNewUser: !existingUser,
};
}
export async function getUserById(id: User["id"]) {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return null;
}
return {
...user,
};
}
export async function getUserByEmail(email: User["email"]) {
return prisma.user.findUnique({ where: { email } });
}
export function updateUser({
id,
name,
email,
marketingEmails,
referralSource,
}: Pick<User, "id" | "name" | "email"> & {
marketingEmails?: boolean;
referralSource?: string;
}) {
return prisma.user.update({
where: { id },
data: { name, email, marketingEmails, referralSource, confirmedBasicDetails: true },
});
}
export async function grantUserCloudAccess({ id, inviteCode }: { id: string; inviteCode: string }) {
return prisma.user.update({
where: { id },
data: {
InvitationCode: {
connect: {
code: inviteCode,
},
},
},
});
}

View File

@ -1,47 +1,73 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import type { LinksFunction, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import type {
LinksFunction,
LoaderFunctionArgs,
MetaFunction,
} from "@remix-run/node";
import {
type UseDataFunctionReturn,
typedjson,
useTypedLoaderData,
} from "remix-typedjson";
import styles from "./tailwind.css?url";
import tailwindStylesheetUrl from "~/tailwind.css";
import { appEnvTitleTag } from "./utils";
import { commitSession, getSession, type ToastMessage } from "./models/message.server";
import {
commitSession,
getSession,
type ToastMessage,
} from "./models/message.server";
import { env } from "./env.server";
import { getUser } from "./services/session.server";
import { usePostHog } from "./hooks/usePostHog";
import {
AppContainer,
MainCenteredContainer,
} from "./components/layout/AppLayout";
import { RouteErrorDisplay } from "./components/ErrorDisplay";
import { themeSessionResolver } from "./services/sessionStorage.server";
import {
PreventFlashOnWrongTheme,
ThemeProvider,
useTheme,
} from "remix-themes";
import clsx from "clsx";
export const links: LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
{ rel: "stylesheet", href: tailwindStylesheetUrl },
];
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const session = await getSession(request.headers.get("cookie"));
const toastMessage = session.get("toastMessage") as ToastMessage;
const { getTheme } = await themeSessionResolver(request);
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
return typedjson(
{
user: await getUser(request),
toastMessage,
theme: getTheme(),
posthogProjectKey,
appEnv: env.APP_ENV,
appOrigin: env.APP_ORIGIN,
},
{ headers: { "Set-Cookie": await commitSession(session) } }
{ headers: { "Set-Cookie": await commitSession(session) } },
);
};
export const meta: MetaFunction = ({ data }) => {
const typedData = data as UseDataFunctionReturn<typeof loader>;
return [
{ title: `Echo${appEnvTitleTag(typedData.appEnv)}` },
{ title: `Echo${typedData && appEnvTitleTag(typedData.appEnv)}` },
{
name: "viewport",
content: "width=1024, initial-scale=1",
@ -49,31 +75,70 @@ export const meta: MetaFunction = ({ data }) => {
{
name: "robots",
content:
typeof window === "undefined" || window.location.hostname !== "echo.mysigma.ai"
typeof window === "undefined" ||
window.location.hostname !== "echo.mysigma.ai"
? "noindex, nofollow"
: "index, follow",
},
];
};
export function Layout({ children }: { children: React.ReactNode }) {
export function ErrorBoundary() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
<>
<html lang="en" className="h-full">
<head>
<meta charSet="utf-8" />
<Meta />
<Links />
</head>
<body className="bg-background h-full overflow-hidden">
<AppContainer>
<MainCenteredContainer>
<RouteErrorDisplay />
</MainCenteredContainer>
</AppContainer>
<Scripts />
</body>
</html>
</>
);
}
export default function App() {
return <Outlet />;
function App() {
const { posthogProjectKey } = useTypedLoaderData<typeof loader>();
usePostHog(posthogProjectKey);
const [theme] = useTheme();
return (
<>
<html lang="en" className={clsx(theme, "h-full")}>
<head>
<Meta />
<Links />
<PreventFlashOnWrongTheme ssrTheme={Boolean(theme)} />
</head>
<body className="bg-background h-full overflow-hidden">
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
</>
);
}
// Wrap your app with ThemeProvider.
// `specifiedTheme` is the stored theme in the session storage.
// `themeAction` is the action name that's used to change the theme in the session storage.
export default function AppWithProviders() {
const data = useLoaderData<typeof loader>();
return (
<ThemeProvider specifiedTheme={data.theme} themeAction="/action/set-theme">
<App />
</ThemeProvider>
);
}

View File

@ -0,0 +1,4 @@
import { createThemeAction } from "remix-themes";
import { themeSessionResolver } from "~/services/sessionStorage.server";
export const action = createThemeAction(themeSessionResolver);

View File

@ -0,0 +1,31 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { useNavigation } from "@remix-run/react";
import { typedjson } from "remix-typedjson";
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
import { authenticator } from "~/services/auth.server";
import {
commitSession,
getUserSession,
} from "~/services/sessionStorage.server";
export async function loader({ request }: LoaderFunctionArgs) {
await authenticator.isAuthenticated(request, {
successRedirect: "/",
});
const session = await getUserSession(request);
return typedjson({
headers: { "Set-Cookie": await commitSession(session) },
});
}
export default function LoginPage() {
const navigate = useNavigation();
return (
<LoginPageLayout>
<h2>Lohin</h2>
</LoginPageLayout>
);
}

View File

@ -0,0 +1,64 @@
import { env } from "~/env.server";
import { authenticateAuthorizationHeader } from "./apiAuth.server";
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
import { Duration } from "./rateLimiter.server";
export const apiRateLimiter = authorizationRateLimitMiddleware({
redis: {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
keyPrefix: "api",
defaultLimiter: {
type: "tokenBucket",
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
maxTokens: env.API_RATE_LIMIT_MAX,
},
limiterCache: {
fresh: 60_000 * 10, // Data is fresh for 10 minutes
stale: 60_000 * 20, // Date is stale after 20 minutes
},
limiterConfigOverride: async (authorizationValue) => {
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
allowPublicKey: true,
allowJWT: true,
});
if (!authenticatedEnv || !authenticatedEnv.ok) {
return;
}
if (authenticatedEnv.type === "PUBLIC_JWT") {
return {
type: "fixedWindow",
window: env.API_RATE_LIMIT_JWT_WINDOW,
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
};
} else {
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
}
},
pathMatchers: [/^\/api/],
// Allow /api/v1/tasks/:id/callback/:secret
pathWhiteList: [
"/api/internal/stripe_webhooks",
"/api/v1/authorization-code",
"/api/v1/token",
"/api/v1/usage/ingest",
"/api/v1/timezones",
"/api/v1/usage/ingest",
"/api/v1/auth/jwt/claims",
],
log: {
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
requests: env.API_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1",
limiter: env.API_RATE_LIMIT_LIMITER_LOGS_ENABLED === "1",
},
});
export type RateLimitMiddleware = ReturnType<typeof authorizationRateLimitMiddleware>;

View File

@ -0,0 +1,20 @@
import { Authenticator } from "remix-auth";
import type { AuthUser } from "./authUser";
import { addGoogleStrategy } from "./googleAuth.server";
import { sessionStorage } from "./sessionStorage.server";
import { env } from "~/env.server";
// Create an instance of the authenticator, pass a generic with what
// strategies will return and will store in the session
const authenticator = new Authenticator<AuthUser>(sessionStorage);
const isGoogleAuthSupported =
typeof env.AUTH_GOOGLE_CLIENT_ID === "string" &&
typeof env.AUTH_GOOGLE_CLIENT_SECRET === "string";
if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
addGoogleStrategy(authenticator, env.AUTH_GOOGLE_CLIENT_ID, env.AUTH_GOOGLE_CLIENT_SECRET);
}
export { authenticator, isGoogleAuthSupported };

View File

@ -0,0 +1,3 @@
export type AuthUser = {
userId: string;
};

View File

@ -0,0 +1,54 @@
import type { Authenticator } from "remix-auth";
import { GoogleStrategy } from "remix-auth-google";
import { env } from "~/env.server";
import { findOrCreateUser } from "~/models/user.server";
import type { AuthUser } from "./authUser";
import { postAuthentication } from "./postAuth.server";
import { logger } from "./logger.service";
export function addGoogleStrategy(
authenticator: Authenticator<AuthUser>,
clientID: string,
clientSecret: string
) {
const googleStrategy = new GoogleStrategy(
{
clientID,
clientSecret,
callbackURL: `${env.LOGIN_ORIGIN}/auth/google/callback`,
},
async ({ extraParams, profile }) => {
const emails = profile.emails;
if (!emails) {
throw new Error("Google login requires an email address");
}
try {
logger.debug("Google login", {
emails,
profile,
extraParams,
});
const { user, isNewUser } = await findOrCreateUser({
email: emails[0].value,
authenticationMethod: "GOOGLE",
authenticationProfile: profile,
authenticationExtraParams: extraParams,
});
await postAuthentication({ user, isNewUser, loginMethod: "GOOGLE" });
return {
userId: user.id,
};
} catch (error) {
console.error(error);
throw error;
}
}
);
authenticator.use(googleStrategy);
}

View File

@ -0,0 +1,20 @@
import { AsyncLocalStorage } from "node:async_hooks";
export type HttpLocalStorage = {
requestId: string;
path: string;
host: string;
method: string;
};
const httpLocalStorage = new AsyncLocalStorage<HttpLocalStorage>();
export type RunWithHttpContextFunction = <T>(context: HttpLocalStorage, fn: () => T) => T;
export function runWithHttpContext<T>(context: HttpLocalStorage, fn: () => T): T {
return httpLocalStorage.run(context, fn);
}
export function getHttpContext(): HttpLocalStorage | undefined {
return httpLocalStorage.getStore();
}

View File

@ -0,0 +1,44 @@
import { createCookieSessionStorage, type Session } from "@remix-run/node";
import { env } from "~/env.server";
export const impersonationSessionStorage = createCookieSessionStorage({
cookie: {
name: "__impersonate", // use any name you want here
sameSite: "lax", // this helps with CSRF
path: "/", // remember to add this so the cookie will work in all routes
httpOnly: true, // for security reasons, make this cookie http only
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production", // enable this in prod only
maxAge: 60 * 60 * 24, // 1 day
},
});
export function getImpersonationSession(request: Request) {
return impersonationSessionStorage.getSession(request.headers.get("Cookie"));
}
export function commitImpersonationSession(session: Session) {
return impersonationSessionStorage.commitSession(session);
}
export async function getImpersonationId(request: Request) {
const session = await getImpersonationSession(request);
return session.get("impersonatedUserId") as string | undefined;
}
export async function setImpersonationId(userId: string, request: Request) {
const session = await getImpersonationSession(request);
session.set("impersonatedUserId", userId);
return session;
}
export async function clearImpersonationId(request: Request) {
const session = await getImpersonationSession(request);
session.unset("impersonatedUserId");
return session;
}

View File

@ -0,0 +1,296 @@
import { sensitiveDataReplacer } from "./sensitiveDataReplacer";
import { AsyncLocalStorage } from "async_hooks";
import { getHttpContext } from "./httpAsyncStorage.server";
import { env } from "node:process";
import { Buffer } from "node:buffer";
import { trace, context } from "@opentelemetry/api";
export type LogLevel = "log" | "error" | "warn" | "info" | "debug";
const logLevels: Array<LogLevel> = ["log", "error", "warn", "info", "debug"];
export class Logger {
#name: string;
readonly #level: number;
#filteredKeys: string[] = [];
#jsonReplacer?: (key: string, value: unknown) => unknown;
#additionalFields: () => Record<string, unknown>;
constructor(
name: string,
level: LogLevel = "info",
filteredKeys: string[] = [],
jsonReplacer?: (key: string, value: unknown) => unknown,
additionalFields?: () => Record<string, unknown>
) {
this.#name = name;
this.#level = logLevels.indexOf((env.APP_LOG_LEVEL ?? level) as LogLevel);
this.#filteredKeys = filteredKeys;
this.#jsonReplacer = createReplacer(jsonReplacer);
this.#additionalFields = additionalFields ?? (() => ({}));
}
child(fields: Record<string, unknown>) {
return new Logger(
this.#name,
logLevels[this.#level],
this.#filteredKeys,
this.#jsonReplacer,
() => ({ ...this.#additionalFields(), ...fields })
);
}
// Return a new Logger instance with the same name and a new log level
// but filter out the keys from the log messages (at any level)
filter(...keys: string[]) {
return new Logger(this.#name, logLevels[this.#level], keys, this.#jsonReplacer);
}
static satisfiesLogLevel(logLevel: LogLevel, setLevel: LogLevel) {
return logLevels.indexOf(logLevel) <= logLevels.indexOf(setLevel);
}
log(message: string, ...args: Array<Record<string, unknown> | undefined>) {
if (this.#level < 0) return;
this.#structuredLog(console.log, message, "log", ...args);
}
error(message: string, ...args: Array<Record<string, unknown> | undefined>) {
if (this.#level < 1) return;
this.#structuredLog(console.error, message, "error", ...args);
}
warn(message: string, ...args: Array<Record<string, unknown> | undefined>) {
if (this.#level < 2) return;
this.#structuredLog(console.warn, message, "warn", ...args);
}
info(message: string, ...args: Array<Record<string, unknown> | undefined>) {
if (this.#level < 3) return;
this.#structuredLog(console.info, message, "info", ...args);
}
debug(message: string, ...args: Array<Record<string, unknown> | undefined>) {
if (this.#level < 4) return;
this.#structuredLog(console.debug, message, "debug", ...args);
}
#structuredLog(
loggerFunction: (message: string, ...args: any[]) => void,
message: string,
level: string,
...args: Array<Record<string, unknown> | undefined>
) {
// Get the current context from trace if it exists
const currentSpan = trace.getSpan(context.active());
const structuredError = extractStructuredErrorFromArgs(...args);
const structuredMessage = extractStructuredMessageFromArgs(...args);
const structuredLog = {
...structureArgs(safeJsonClone(args) as Record<string, unknown>[], this.#filteredKeys),
...this.#additionalFields(),
...(structuredError ? { error: structuredError } : {}),
timestamp: new Date(),
name: this.#name,
message,
...(structuredMessage ? { $message: structuredMessage } : {}),
level,
traceId:
currentSpan && currentSpan.isRecording() ? currentSpan?.spanContext().traceId : undefined,
parentSpanId:
currentSpan && currentSpan.isRecording() ? currentSpan?.spanContext().spanId : undefined,
};
// If the span is not recording, and it's a debug log, mark it so we can filter it out when we forward it
if (currentSpan && !currentSpan.isRecording() && level === "debug") {
structuredLog.skipForwarding = true;
}
loggerFunction(JSON.stringify(structuredLog, this.#jsonReplacer));
}
}
// Detect if args is an error object
// Or if args contains an error object at the "error" key
// In both cases, return the error object as a structured error
function extractStructuredErrorFromArgs(...args: Array<Record<string, unknown> | undefined>) {
const error = args.find((arg) => arg instanceof Error) as Error | undefined;
if (error) {
return {
message: error.message,
stack: error.stack,
name: error.name,
};
}
const structuredError = args.find((arg) => arg?.error);
if (structuredError && structuredError.error instanceof Error) {
return {
message: structuredError.error.message,
stack: structuredError.error.stack,
name: structuredError.error.name,
};
}
return;
}
function extractStructuredMessageFromArgs(...args: Array<Record<string, unknown> | undefined>) {
// Check to see if there is a `message` key in the args, and if so, return it
const structuredMessage = args.find((arg) => arg?.message);
if (structuredMessage) {
return structuredMessage.message;
}
return;
}
function createReplacer(replacer?: (key: string, value: unknown) => unknown) {
return (key: string, value: unknown) => {
if (typeof value === "bigint") {
return value.toString();
}
if (replacer) {
return replacer(key, value);
}
return value;
};
}
// Replacer function for JSON.stringify that converts BigInts to strings
function bigIntReplacer(_key: string, value: unknown) {
if (typeof value === "bigint") {
return value.toString();
}
return value;
}
function safeJsonClone(obj: unknown) {
try {
return JSON.parse(JSON.stringify(obj, bigIntReplacer));
} catch (e) {
return;
}
}
// If args is has a single item that is an object, return that object
function structureArgs(args: Array<Record<string, unknown>>, filteredKeys: string[] = []) {
if (!args) {
return;
}
if (args.length === 0) {
return;
}
if (args.length === 1 && typeof args[0] === "object") {
return filterKeys(JSON.parse(JSON.stringify(args[0], bigIntReplacer)), filteredKeys);
}
return args;
}
// Recursively filter out keys from an object, including nested objects, and arrays
function filterKeys(obj: unknown, keys: string[]): any {
if (typeof obj !== "object" || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => filterKeys(item, keys));
}
const filteredObj: any = {};
for (const [key, value] of Object.entries(obj)) {
if (keys.includes(key)) {
if (value) {
filteredObj[key] = `[filtered ${prettyPrintBytes(value)}]`;
} else {
filteredObj[key] = value;
}
continue;
}
filteredObj[key] = filterKeys(value, keys);
}
return filteredObj;
}
function prettyPrintBytes(value: unknown): string {
if (env.NODE_ENV === "production") {
return "skipped size";
}
const sizeInBytes = getSizeInBytes(value);
if (sizeInBytes < 1024) {
return `${sizeInBytes} bytes`;
}
if (sizeInBytes < 1024 * 1024) {
return `${(sizeInBytes / 1024).toFixed(2)} KB`;
}
if (sizeInBytes < 1024 * 1024 * 1024) {
return `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
}
return `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function getSizeInBytes(value: unknown) {
const jsonString = JSON.stringify(value);
return Buffer.byteLength(jsonString, "utf8");
}
const currentFieldsStore = new AsyncLocalStorage<Record<string, unknown>>();
export const logger = new Logger(
"webapp",
(process.env.APP_LOG_LEVEL ?? "debug") as LogLevel,
["examples", "output", "connectionString", "payload"],
sensitiveDataReplacer,
() => {
const fields = currentFieldsStore.getStore();
const httpContext = getHttpContext();
return { ...fields, http: httpContext };
}
);
export const workerLogger = new Logger(
"worker",
(process.env.APP_LOG_LEVEL ?? "debug") as LogLevel,
["examples", "output", "connectionString"],
sensitiveDataReplacer,
() => {
const fields = currentFieldsStore.getStore();
return fields ? { ...fields } : {};
}
);
export const socketLogger = new Logger(
"socket",
(process.env.APP_LOG_LEVEL ?? "debug") as LogLevel,
[],
sensitiveDataReplacer,
() => {
const fields = currentFieldsStore.getStore();
return fields ? { ...fields } : {};
}
);

View File

@ -0,0 +1,14 @@
import type { User } from "~/models/user.server";
import { singleton } from "~/utils/singleton";
export async function postAuthentication({
user,
loginMethod,
isNewUser,
}: {
user: User;
loginMethod: User["authenticationMethod"];
isNewUser: boolean;
}) {
console.log(user);
}

View File

@ -0,0 +1,33 @@
import { z } from "zod";
export const RedactStringSchema = z.object({
__redactedString: z.literal(true),
strings: z.array(z.string()),
interpolations: z.array(z.string()),
});
export type RedactString = z.infer<typeof RedactStringSchema>;
// Replaces redacted strings with "******".
// For example, this object: {"Authorization":{"__redactedString":true,"strings":["Bearer ",""],"interpolations":["sk-1234"]}}
// Would get stringified like so: {"Authorization": "Bearer ******"}
export function sensitiveDataReplacer(key: string, value: any): any {
if (typeof value === "object" && value !== null && value.__redactedString === true) {
return redactString(value);
}
return value;
}
function redactString(value: RedactString) {
let result = "";
for (let i = 0; i < value.strings.length; i++) {
result += value.strings[i];
if (i < value.interpolations.length) {
result += "********";
}
}
return result;
}

View File

@ -0,0 +1,27 @@
import { redirect } from "@remix-run/node";
import { getUserById } from "~/models/user.server";
import { authenticator } from "./auth.server";
import { getImpersonationId } from "./impersonation.server";
export async function getUserId(request: Request): Promise<string | undefined> {
const impersonatedUserId = await getImpersonationId(request);
if (impersonatedUserId) return impersonatedUserId;
let authUser = await authenticator.isAuthenticated(request);
return authUser?.userId;
}
export async function getUser(request: Request) {
const userId = await getUserId(request);
if (userId === undefined) return null;
const user = await getUserById(userId);
if (user) return user;
throw await logout(request);
}
export async function logout(request: Request) {
return redirect("/logout");
}

View File

@ -0,0 +1,35 @@
import { createCookieSessionStorage } from "@remix-run/node";
import { createThemeSessionResolver } from "remix-themes";
import { env } from "~/env.server";
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session", // use any name you want here
sameSite: "lax", // this helps with CSRF
path: "/", // remember to add this so the cookie will work in all routes
httpOnly: true, // for security reasons, make this cookie http only
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production", // enable this in prod only
maxAge: 60 * 60 * 24 * 365, // 7 days
},
});
export const themeStorage = createCookieSessionStorage({
cookie: {
name: "__theme",
sameSite: "lax",
path: "/",
httpOnly: true,
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365, // 1 year
},
});
export const themeSessionResolver = createThemeSessionResolver(sessionStorage);
export function getUserSession(request: Request) {
return sessionStorage.getSession(request.headers.get("Cookie"));
}
export const { getSession, commitSession, destroySession } = sessionStorage;

View File

@ -1,85 +1,66 @@
@import url("non.geist");
@import url("non.geist/mono");
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
html,
body {
@apply bg-background;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
:root {
--background: oklch(91.28% 0 0);
--background-2: oklch(95.21% 0 0);
--background-3: oklch(100% 0 0);
--foreground: oklch(0% 0 0);
--popover: oklch(93.05% 0 0);
--popover-foreground: oklch(0% 0 0);
--primary: oklch(54% 0.1789 271);
--primary-foreground: oklch(100% 0 0);
--secondary: 210 40% 96.1%;
--secondary-foreground: oklch(0% 0 0);
--muted: oklch(0% 0 0 / 19.22%);
--muted-foreground: oklch(49.26% 0 0);
--accent: oklch(100% 0 0);
--accent-foreground: oklch(0% 0 0);
--destructive: oklch(61.34% 0.162 23.58);
--destructive-foreground: oklch(100% 0 0);
--warning: oklch(70.43% 0.14390424619548714 87.9634104985311);
--warning-foreground: oklch(100% 0 0);
--success: oklch(64.93% 0.107 154.24);
--success-foreground: oklch(100% 0 0);
--border: oklch(0% 0 0 / 19.22%);
--border-dark: oklch(0% 0 0 / 39.22%);
--input: oklch(0% 0 0 / 6.27%);
--ring: 221.2 83.2% 53.3%;
--radius: 8px;
}
@layer base {
:root {
--background: 91.28% 0 0;
--background-2: 95.21% 0 0;
--background-3: 100% 0 0;
--foreground: 0% 0 0;
--popover: 93.05% 0 0;
--popover-foreground: 0% 0 0;
--primary: 54% 0.1789 271;
--primary-foreground: 100% 0 0;
--secondary: 210 40% 96.1%;
--secondary-foreground: 0% 0 0;
--muted: 0% 0 0 / 19.22%;
--muted-foreground: 49.26% 0 0;
--accent: 100% 0 0;
--accent-foreground: 0% 0 0;
--destructive: 61.34% 0.162 23.58;
--destructive-foreground: 100% 0 0;
--warning: 70.43% 0.14390424619548714 87.9634104985311;
--warning-foreground: 100% 0 0;
--success: 64.93% 0.107 154.24;
--success-foreground: 100% 0 0;
--border: 0% 0 0 / 19.22%;
--border-dark: 0% 0 0 / 39.22%;
--input: 0% 0 0 / 6.27%;
--ring: 221.2 83.2% 53.3%;
--radius: 8px;
}
.dark {
--background: 21.34% 0 0;
--background-2: 25.2% 0 0;
--background-3: 28.5% 0 0;
--foreground: 92.8% 0 0;
--popover: 28.5% 0 0;
--popover-foreground: 92.8% 0 0;
--primary: 54% 0.1789 271;
--primary-foreground: 92.8% 0 0;
--secondary: 210 40% 96.1%;
--secondary-foreground: 92.8% 0 0;
--muted: 100% 0 0 / 13.33%;
--muted-foreground: 76.99% 0 0;
--accent: 95.81% 0 0;
--accent-foreground: 0% 0 0;
--warning: 70.43% 0.14390424619548714 87.9634104985311;
--warning-foreground: 100% 0 0;
--destructive: 61.34% 0.162 23.58;
--destructive-foreground: 100% 0 0;
--success: 64.93% 0.107 154.24;
--success-foreground: 100% 0 0;
--border: 100% 0 0 / 13.33%;
--border-dark: 100% 0 0 / 39.33%;
--input: 100% 0 0 / 10.59%;
--ring: 221.2 83.2% 53.3%;
}
.dark,
:root[class~="dark"] {
--background: oklch(21.34% 0 0);
--background-2: oklch(25.2% 0 0);
--background-3: oklch(28.5% 0 0);
--foreground: oklch(92.8% 0 0);
--popover: oklch(28.5% 0 0);
--popover-foreground: oklch(92.8% 0 0);
--primary: oklch(54% 0.1789 271);
--primary-foreground: oklch(92.8% 0 0);
--secondary: 210 40% 96.1%;
--secondary-foreground: oklch(92.8% 0 0);
--muted: oklch(100% 0 0 / 13.33%);
--muted-foreground: oklch(76.99% 0 0);
--accent: oklch(95.81% 0 0);
--accent-foreground: oklch(0% 0 0);
--warning: oklch(70.43% 0.14390424619548714 87.9634104985311);
--warning-foreground: oklch(100% 0 0);
--destructive: oklch(61.34% 0.162 23.58);
--destructive-foreground: oklch(100% 0 0);
--success: oklch(64.93% 0.107 154.24);
--success-foreground: oklch(100% 0 0);
--border: oklch(100% 0 0 / 13.33%);
--border-dark: oklch(100% 0 0 / 39.33%);
--input: oklch(100% 0 0 / 10.59%);
--ring: 221.2 83.2% 53.3%;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}

View File

@ -1,5 +1,4 @@
import type { UIMatch } from "@remix-run/react";
import { useMatches } from "@remix-run/react";
import { useMatches, type UIMatch } from "@remix-run/react";
const DEFAULT_REDIRECT = "/";
@ -12,7 +11,7 @@ const DEFAULT_REDIRECT = "/";
*/
export function safeRedirect(
to: FormDataEntryValue | string | null | undefined,
defaultRedirect: string = DEFAULT_REDIRECT
defaultRedirect: string = DEFAULT_REDIRECT,
) {
if (!to || typeof to !== "string") {
return defaultRedirect;
@ -31,7 +30,10 @@ export function safeRedirect(
* @param {string} id The route id
* @returns {JSON|undefined} The router data or undefined if not found
*/
export function useMatchesData(id: string | string[], debug: boolean = false): UIMatch | undefined {
export function useMatchesData(
id: string | string[],
debug: boolean = false,
): UIMatch | undefined {
const matchingRoutes = useMatches();
if (debug) {
@ -41,10 +43,13 @@ export function useMatchesData(id: string | string[], debug: boolean = false): U
const paths = Array.isArray(id) ? id : [id];
// Get the first matching route
const route = paths.reduce((acc, path) => {
if (acc) return acc;
return matchingRoutes.find((route) => route.id === path);
}, undefined as UIMatch | undefined);
const route = paths.reduce(
(acc, path) => {
if (acc) return acc;
return matchingRoutes.find((route) => route.id === path);
},
undefined as UIMatch | undefined,
);
return route;
}

View File

@ -0,0 +1,24 @@
export function friendlyErrorDisplay(statusCode: number, statusText?: string) {
switch (statusCode) {
case 400:
return {
title: "400: Bad request",
message: statusText ?? "The request was invalid.",
};
case 404:
return {
title: "404: Page not found",
message: statusText ?? "The page you're looking for doesn't exist.",
};
case 500:
return {
title: "500: Server error",
message: statusText ?? "Something went wrong on our end. Please try again later.",
};
default:
return {
title: `${statusCode}: Error`,
message: statusText ?? "An error occurred.",
};
}
}

View File

@ -0,0 +1,6 @@
export function singleton<T>(name: string, getValue: () => T): T {
const thusly = globalThis as any;
thusly.__echo_singletons ??= {};
thusly.__echo_singletons[name] ??= getValue();
return thusly.__echo_singletons[name];
}

View File

@ -5,55 +5,85 @@
"type": "module",
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"dev": "node ./server.js",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@remix-run/node": "^2.16.7",
"@remix-run/react": "^2.16.7",
"@remix-run/serve": "^2.16.7",
"@tailwindcss/container-queries": "^0.1.1",
"@echo/database": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@radix-ui/react-slot": "^1.2.3",
"@remix-run/express": "2.16.7",
"@remix-run/node": "2.1.0",
"@remix-run/react": "2.16.7",
"@remix-run/router": "^1.15.3",
"@remix-run/serve": "2.16.7",
"@remix-run/server-runtime": "2.16.7",
"@remix-run/v1-meta": "^0.1.3",
"@tailwindcss/postcss": "^4.1.7",
"@tailwindcss/container-queries": "^0.1.1",
"compression": "^1.7.4",
"class-variance-authority": "^0.7.1",
"remix-typedjson": "0.3.1",
"clsx": "^2.1.1",
"cross-env": "^7.0.3",
"express": "^4.18.1",
"isbot": "^4.1.0",
"morgan": "^1.10.0",
"nanoid": "3.3.8",
"lucide-react": "^0.511.0",
"non.geist": "^1.0.2",
"posthog-js": "^1.116.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^1.12.0",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.5",
"remix-auth": "^3.6.0",
"remix-auth-google": "^2.0.0",
"remix-typedjson": "0.3.1",
"remix-utils": "^7.7.0",
"remix-themes": "^1.3.1",
"tailwind-merge": "^2.6.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-textshadow": "^2.1.3",
"non.geist": "^1.0.2",
"tiny-invariant": "^1.3.1",
"zod": "3.23.8",
"zod-error": "1.5.0",
"zod-validation-error": "^1.5.0"
},
"devDependencies": {
"@remix-run/dev": "^2.16.7",
"@remix-run/dev": "2.16.7",
"@remix-run/eslint-config": "2.16.7",
"@remix-run/testing": "^2.16.7",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.7",
"@types/compression": "^1.7.2",
"@types/morgan": "^1.9.3",
"@types/express": "^4.17.13",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.19",
"esbuild": "0.25.5",
"eslint": "^8.38.0",
"css-loader": "^6.10.0",
"esbuild": "^0.25.5",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-turbo": "^2.0.4",
"postcss": "^8.4.38",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"tailwind-scrollbar": "^3.0.1",
"tailwindcss": "3.4.1",
"typescript": "^5.1.6",
"postcss-import": "^16.0.1",
"postcss-loader": "^8.1.1",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "4.1.7",
"typescript": "5.8.3",
"vite": "^6.0.0",
"vite-tsconfig-paths": "^4.2.1"
},

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -1,4 +0,0 @@
module.exports = {
...require("../../prettier.config.js"),
plugins: [require("prettier-plugin-tailwindcss")],
};

View File

@ -1,30 +0,0 @@
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
dev: {
port: 8002,
},
tailwind: true,
postcss: true,
cacheDirectory: "./node_modules/.cache/remix",
ignoredRouteFiles: ["**/.*"],
serverModuleFormat: "cjs",
serverDependenciesToBundle: [
/^remix-utils.*/,
/^@internal\//, // Bundle all internal packages
"marked",
"axios",
"p-limit",
"yocto-queue",
"@unkey/cache",
"@unkey/cache/stores",
"emails",
"highlight.run",
"random-words",
"superjson",
"prismjs/components/prism-json",
"prismjs/components/prism-typescript",
"redlock",
"parse-duration",
],
browserNodeBuiltinsPolyfill: { modules: { path: true, os: true, crypto: true } },
};

51
apps/webapp/server.js Normal file
View File

@ -0,0 +1,51 @@
import { createRequestHandler } from "@remix-run/express";
import compression from "compression";
import express from "express";
import morgan from "morgan";
const viteDevServer =
process.env.NODE_ENV === "production"
? undefined
: await import("vite").then((vite) =>
vite.createServer({
server: { middlewareMode: true },
}),
);
const remixHandler = createRequestHandler({
build: viteDevServer
? () => viteDevServer.ssrLoadModule("virtual:remix/server-build")
: await import("./build/server/index.js"),
});
const app = express();
app.use(compression());
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable("x-powered-by");
// handle asset requests
if (viteDevServer) {
app.use(viteDevServer.middlewares);
} else {
// Vite fingerprints its assets so we can cache forever.
app.use(
"/assets",
express.static("build/client/assets", { immutable: true, maxAge: "1y" }),
);
}
// Everything else (like favicon.ico) is cached for an hour. You may want to be
// more aggressive with this caching.
app.use(express.static("build/client", { maxAge: "1h" }));
app.use(morgan("tiny"));
// handle SSR requests
app.all("*", remixHandler);
const port = process.env.REMIX_APP_PORT || 3000;
app.listen(port, () =>
console.log(`Express server listening at http://localhost:${port}`),
);

View File

@ -1,167 +0,0 @@
/** @type {import('tailwindcss').Config} */
const colors = require("tailwindcss/colors");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
sans: ["Geist Variable", "Helvetica Neue", "Helvetica", "Arial", "sans-serif"],
mono: ["Geist Mono Variable", "monaco", "Consolas", "Lucida Console", "monospace"],
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
colors: {
border: {
DEFAULT: 'oklch(var(--border))',
dark: 'oklch(var(--border-dark))',
},
input: 'oklch(var(--input))',
ring: 'oklch(var(--ring))',
background: {
'2': 'oklch(var(--background-2) / <alpha-value>)',
'3': 'oklch(var(--background-3) / <alpha-value>)',
DEFAULT: 'oklch(var(--background) / <alpha-value>)',
},
foreground: 'oklch(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'oklch(var(--primary) / <alpha-value>)',
foreground: 'oklch(var(--primary-foreground) / <alpha-value>)',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'oklch(var(--destructive) / <alpha-value>)',
foreground: 'oklch(var(--destructive-foreground) / <alpha-value>)',
},
warning: {
DEFAULT: 'oklch(var(--warning) / <alpha-value>)',
foreground: 'oklch(var(--warning-foreground) / <alpha-value>)',
},
success: {
DEFAULT: 'oklch(var(--success) / <alpha-value>)',
foreground: 'oklch(var(--success-foreground) / <alpha-value>)',
},
muted: {
DEFAULT: 'oklch(var(--muted))',
foreground: 'oklch(var(--muted-foreground) / <alpha-value>)',
},
accent: {
DEFAULT: 'oklch(var(--accent) / <alpha-value>)',
foreground: 'oklch(var(--accent-foreground) / <alpha-value>)',
},
popover: {
DEFAULT: 'oklch(var(--popover) / <alpha-value>)',
foreground: 'oklch(var(--popover-foreground) / <alpha-value>)',
},
gray: {
'50': 'var(--gray-50)',
'100': 'var(--gray-100)',
'200': 'var(--gray-200)',
'300': 'var(--gray-300)',
'400': 'var(--gray-400)',
'500': 'var(--gray-500)',
'600': 'var(--gray-600)',
'700': 'var(--gray-700)',
'800': 'var(--gray-800)',
'900': 'var(--gray-900)',
'950': 'var(--gray-950)',
},
grayAlpha: {
'50': 'oklch(var(--grayAlpha-50))',
'100': 'oklch(var(--grayAlpha-100))',
'200': 'oklch(var(--grayAlpha-200))',
'300': 'oklch(var(--grayAlpha-300))',
'400': 'oklch(var(--grayAlpha-400))',
'500': 'oklch(var(--grayAlpha-500))',
'600': 'oklch(var(--grayAlpha-600))',
'700': 'oklch(var(--grayAlpha-700))',
'800': 'oklch(var(--grayAlpha-800))',
'900': 'oklch(var(--grayAlpha-900))',
'950': 'oklch(var(--grayAlpha-950))',
},
red: {
'50': '#fdf3f3',
'100': '#fbe9e8',
'200': '#f7d4d4',
'300': '#f0b1b1',
'400': '#e78587',
'500': '#d75056',
'600': '#c43a46',
'700': '#a52b3a',
'800': '#8a2735',
'900': '#772433',
'950': '#420f18',
},
orange: {
'50': '#fdf6ef',
'100': '#fbead9',
'200': '#f7d2b1',
'300': '#f1b480',
'400': '#ea8c4d',
'500': '#e67333',
'600': '#d65520',
'700': '#b2401c',
'800': '#8e341e',
'900': '#732d1b',
'950': '#3e140c',
},
yellow: {
'50': '#fdfbe9',
'100': '#faf7c7',
'200': '#f7ec91',
'300': '#f1db53',
'400': '#ebc724',
'500': '#dcb016',
'600': '#c28c11',
'700': '#976211',
'800': '#7d4f16',
'900': '#6b4118',
'950': '#3e220a',
},
sidebar: {
DEFAULT: 'oklch(var(--background-2) / <alpha-value>)',
foreground: 'oklch(var(--foreground) / <alpha-value>)',
primary: 'oklch(var(--primary) / <alpha-value>)',
'primary-foreground':
'oklch(var(--primary-foreground) / <alpha-value>)',
accent: 'oklch(var(--accent) / <alpha-value>)',
'accent-foreground':
'oklch(var(--accent-foreground) / <alpha-value>)',
border: 'oklch(var(--border))',
ring: 'oklch(var(--ring))',
},
},
}
},
plugins: [ require("@tailwindcss/container-queries"),
require("@tailwindcss/forms"),
require("@tailwindcss/typography"),
require("tailwindcss-animate"),
require("tailwind-scrollbar"),
require("tailwind-scrollbar-hide"),
require("tailwindcss-textshadow"),
function ({ addUtilities, theme }) {
const focusStyles = theme("focusStyles", {});
addUtilities({
".focus-custom": {
"&:focus-visible": focusStyles,
},
});
},],
}

View File

@ -0,0 +1,169 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
sans: [
"Geist Variable",
"Helvetica Neue",
"Helvetica",
"Arial",
"sans-serif",
],
mono: [
"Geist Mono Variable",
"monaco",
"Consolas",
"Lucida Console",
"monospace",
],
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
border: {
DEFAULT: "oklch(var(--border))",
dark: "oklch(var(--border-dark))",
},
input: "oklch(var(--input))",
ring: "oklch(var(--ring))",
background: {
2: "oklch(var(--background-2) / <alpha-value>)",
3: "oklch(var(--background-3) / <alpha-value>)",
DEFAULT: "oklch(var(--background) / <alpha-value>)",
},
foreground: "oklch(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "oklch(var(--primary) / <alpha-value>)",
foreground: "oklch(var(--primary-foreground) / <alpha-value>)",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "oklch(var(--destructive) / <alpha-value>)",
foreground: "oklch(var(--destructive-foreground) / <alpha-value>)",
},
warning: {
DEFAULT: "oklch(var(--warning) / <alpha-value>)",
foreground: "oklch(var(--warning-foreground) / <alpha-value>)",
},
success: {
DEFAULT: "oklch(var(--success) / <alpha-value>)",
foreground: "oklch(var(--success-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "oklch(var(--muted))",
foreground: "oklch(var(--muted-foreground) / <alpha-value>)",
},
accent: {
DEFAULT: "oklch(var(--accent) / <alpha-value>)",
foreground: "oklch(var(--accent-foreground) / <alpha-value>)",
},
popover: {
DEFAULT: "oklch(var(--popover) / <alpha-value>)",
foreground: "oklch(var(--popover-foreground) / <alpha-value>)",
},
gray: {
50: "var(--gray-50)",
100: "var(--gray-100)",
200: "var(--gray-200)",
300: "var(--gray-300)",
400: "var(--gray-400)",
500: "var(--gray-500)",
600: "var(--gray-600)",
700: "var(--gray-700)",
800: "var(--gray-800)",
900: "var(--gray-900)",
950: "var(--gray-950)",
},
grayAlpha: {
50: "oklch(var(--grayAlpha-50))",
100: "oklch(var(--grayAlpha-100))",
200: "oklch(var(--grayAlpha-200))",
300: "oklch(var(--grayAlpha-300))",
400: "oklch(var(--grayAlpha-400))",
500: "oklch(var(--grayAlpha-500))",
600: "oklch(var(--grayAlpha-600))",
700: "oklch(var(--grayAlpha-700))",
800: "oklch(var(--grayAlpha-800))",
900: "oklch(var(--grayAlpha-900))",
950: "oklch(var(--grayAlpha-950))",
},
red: {
50: "#fdf3f3",
100: "#fbe9e8",
200: "#f7d4d4",
300: "#f0b1b1",
400: "#e78587",
500: "#d75056",
600: "#c43a46",
700: "#a52b3a",
800: "#8a2735",
900: "#772433",
950: "#420f18",
},
orange: {
50: "#fdf6ef",
100: "#fbead9",
200: "#f7d2b1",
300: "#f1b480",
400: "#ea8c4d",
500: "#e67333",
600: "#d65520",
700: "#b2401c",
800: "#8e341e",
900: "#732d1b",
950: "#3e140c",
},
yellow: {
50: "#fdfbe9",
100: "#faf7c7",
200: "#f7ec91",
300: "#f1db53",
400: "#ebc724",
500: "#dcb016",
600: "#c28c11",
700: "#976211",
800: "#7d4f16",
900: "#6b4118",
950: "#3e220a",
},
sidebar: {
DEFAULT: "oklch(var(--background-2) / <alpha-value>)",
foreground: "oklch(var(--foreground) / <alpha-value>)",
primary: "oklch(var(--primary) / <alpha-value>)",
"primary-foreground":
"oklch(var(--primary-foreground) / <alpha-value>)",
accent: "oklch(var(--accent) / <alpha-value>)",
"accent-foreground":
"oklch(var(--accent-foreground) / <alpha-value>)",
border: "oklch(var(--border))",
ring: "oklch(var(--ring))",
},
},
},
},
plugins: [
require("@tailwindcss/container-queries"),
require("@tailwindcss/forms"),
require("@tailwindcss/typography"),
require("tailwindcss-animate"),
require("tailwind-scrollbar"),
require("tailwind-scrollbar-hide"),
require("tailwindcss-textshadow"),
],
} satisfies Config;

View File

@ -1,8 +1,15 @@
{
"exclude": [],
"include": ["remix.env.d.ts", "global.d.ts", "**/*.ts", "**/*.tsx"],
"include": [
"remix.env.d.ts",
"global.d.ts",
"**/*.ts",
"**/*.tsx",
"tailwind.config.js",
"tailwind.config.js"
],
"compilerOptions": {
"types": [],
"types": ["@remix-run/node", "vite/client"],
"lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2020"],
"isolatedModules": true,
"esModuleInterop": true,

View File

@ -1,6 +1,7 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import tailwindcss from "@tailwindcss/vite";
declare module "@remix-run/node" {
interface Future {
@ -10,6 +11,7 @@ declare module "@remix-run/node" {
export default defineConfig({
plugins: [
tailwindcss(),
remix({
future: {
v3_fetcherPersist: true,

View File

@ -2,24 +2,26 @@
"name": "echo",
"private": true,
"workspaces":
[ "apps/*" ]
[ "apps/*", "packages/*" ]
,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types",
"db:migrate": "turbo run db:migrate:deploy generate",
"db:seed": "turbo run db:seed",
"db:studio": "turbo run db:studio",
"db:populate": "turbo run db:populate",
"generate": "turbo run generate"
"build": "dotenv -- turbo run build",
"dev": "dotenv -- turbo run dev",
"lint": "dotenv -- turbo run lint",
"format": "dotenv -- prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "dotenv -- turbo run check-types",
"db:migrate": "dotenv -- turbo run db:migrate:deploy generate",
"db:migrate:create": "dotenv -- turbo run db:migrate:create generate",
"db:seed": "dotenv -- turbo run db:seed",
"db:studio": "dotenv -- turbo run db:studio",
"db:populate": "dotenv -- turbo run db:populate",
"generate": "dotenv -- turbo run generate"
},
"devDependencies": {
"dotenv-cli": "^7.4.4",
"prettier": "^3.5.3",
"turbo": "^2.5.3",
"typescript": "5.8.2"
"typescript": "5.5.4"
},
"dependencies": {
"@changesets/cli": "2.26.2",

View File

@ -9,12 +9,13 @@
},
"devDependencies": {
"prisma": "5.4.1",
"rimraf": "6.0.1"
"rimraf": "6.0.1",
"esbuild": "^0.15.10"
},
"scripts": {
"clean": "rimraf dist",
"generate": "prisma generate",
"db:migrate:dev:create": "prisma migrate dev --create-only",
"db:migrate:create": "prisma migrate dev --preview-feature",
"db:migrate:deploy": "prisma migrate deploy",
"db:push": "prisma db push",
"db:studio": "prisma studio",

View File

@ -0,0 +1,84 @@
-- CreateEnum
CREATE TYPE "AuthenticationMethod" AS ENUM ('GOOGLE');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"authenticationMethod" "AuthenticationMethod" NOT NULL,
"authenticationProfile" JSONB,
"authenticationExtraParams" JSONB,
"authIdentifier" TEXT,
"displayName" TEXT,
"name" TEXT,
"avatarUrl" TEXT,
"admin" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"marketingEmails" BOOLEAN NOT NULL DEFAULT true,
"confirmedBasicDetails" BOOLEAN NOT NULL DEFAULT false,
"referralSource" TEXT,
"invitationCodeId" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuthorizationCode" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"personalAccessTokenId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AuthorizationCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PersonalAccessToken" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"encryptedToken" JSONB NOT NULL,
"obfuscatedToken" TEXT NOT NULL,
"hashedToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"revokedAt" TIMESTAMP(3),
"lastAccessedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InvitationCode" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InvitationCode_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_authIdentifier_key" ON "User"("authIdentifier");
-- CreateIndex
CREATE UNIQUE INDEX "AuthorizationCode_code_key" ON "AuthorizationCode"("code");
-- CreateIndex
CREATE UNIQUE INDEX "PersonalAccessToken_hashedToken_key" ON "PersonalAccessToken"("hashedToken");
-- CreateIndex
CREATE UNIQUE INDEX "InvitationCode_code_key" ON "InvitationCode"("code");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_invitationCodeId_fkey" FOREIGN KEY ("invitationCodeId") REFERENCES "InvitationCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuthorizationCode" ADD CONSTRAINT "AuthorizationCode_personalAccessTokenId_fkey" FOREIGN KEY ("personalAccessTokenId") REFERENCES "PersonalAccessToken"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,75 @@
-- CreateEnum
CREATE TYPE "IngestionStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "memoryFilter" TEXT;
-- CreateTable
CREATE TABLE "Space" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"autoMode" BOOLEAN NOT NULL DEFAULT false,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Space_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Entity" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Entity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SpaceEntity" (
"id" TEXT NOT NULL,
"spaceId" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"settings" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SpaceEntity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "IngestionQueue" (
"id" TEXT NOT NULL,
"spaceId" TEXT NOT NULL,
"data" JSONB NOT NULL,
"status" "IngestionStatus" NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"error" TEXT,
"retryCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"processedAt" TIMESTAMP(3),
CONSTRAINT "IngestionQueue_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Entity_name_key" ON "Entity"("name");
-- CreateIndex
CREATE UNIQUE INDEX "SpaceEntity_spaceId_entityId_key" ON "SpaceEntity"("spaceId", "entityId");
-- AddForeignKey
ALTER TABLE "Space" ADD CONSTRAINT "Space_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SpaceEntity" ADD CONSTRAINT "SpaceEntity_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SpaceEntity" ADD CONSTRAINT "SpaceEntity_entityId_fkey" FOREIGN KEY ("entityId") REFERENCES "Entity"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IngestionQueue" ADD CONSTRAINT "IngestionQueue_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -23,27 +23,22 @@ model User {
name String?
avatarUrl String?
admin Boolean @default(false)
memoryFilter String? // Adding memory filter instructions
/// Preferences for the dashboard
dashboardPreferences Json?
admin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// @deprecated
isOnCloudWaitlist Boolean @default(false)
/// @deprecated
featureCloud Boolean @default(false)
/// @deprecated
isOnHostedRepoWaitlist Boolean @default(false)
marketingEmails Boolean @default(true)
confirmedBasicDetails Boolean @default(false)
referralSource String?
personalAccessTokens PersonalAccessToken[]
InvitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id])
invitationCodeId String?
Space Space[]
}
enum AuthenticationMethod {
@ -90,3 +85,95 @@ model PersonalAccessToken {
authorizationCodes AuthorizationCode[]
}
model InvitationCode {
id String @id @default(cuid())
code String @unique
users User[]
createdAt DateTime @default(now())
}
// Space model for user workspaces
model Space {
id String @id @default(cuid())
name String
description String?
autoMode Boolean @default(false)
// Relations
user User @relation(fields: [userId], references: [id])
userId String
// Space's enabled entities
enabledEntities SpaceEntity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
IngestionQueue IngestionQueue[]
}
// Entity types that can be stored in the memory plane
model Entity {
id String @id @default(cuid())
name String @unique // e.g., "User", "Issue", "Task", "Automation"
metadata Json // Store field definitions and their types
// Relations
spaceEntities SpaceEntity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Junction table for Space-Entity relationship (what entities are enabled in each space)
model SpaceEntity {
id String @id @default(cuid())
// Relations
space Space @relation(fields: [spaceId], references: [id])
spaceId String
entity Entity @relation(fields: [entityId], references: [id])
entityId String
// Custom settings for this entity in this space
settings Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([spaceId, entityId])
}
// Queue for processing ingestion tasks
model IngestionQueue {
id String @id @default(cuid())
// Relations
space Space @relation(fields: [spaceId], references: [id])
spaceId String
// Queue metadata
data Json // The actual data to be processed
status IngestionStatus
priority Int @default(0)
// Error handling
error String?
retryCount Int @default(0)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
processedAt DateTime?
}
enum IngestionStatus {
PENDING
PROCESSING
COMPLETED
FAILED
CANCELLED
}

2716
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,13 @@
"build": {
"dependsOn": [ "^build" ],
"inputs": [ "$TURBO_DEFAULT$", ".env*" ],
"outputs": [ ".next/**", "!.next/cache/**" ]
"outputs": [
"dist/**",
"public/build/**",
"build/**",
"app/styles/tailwind.css",
".cache"
]
},
"lint": {
"dependsOn": [ "^lint" ]
@ -14,8 +20,7 @@
"dependsOn": [ "^check-types" ]
},
"dev": {
"cache": false,
"persistent": true
"cache": false
},
"db:generate": {
"cache": false
@ -23,6 +28,10 @@
"db:migrate:deploy": {
"cache": false
},
"db:migrate:create": {
"cache": false,
"interactive": true
},
"db:studio": {
"cache": false
},
@ -31,5 +40,23 @@
"^generate"
]
}
}
},
"globalDependencies": [
".env"
],
"globalEnv": [
"NODE_ENV",
"REMIX_APP_PORT",
"CI",
"DATABASE_URL",
"DIRECT_URL",
"SESSION_SECRET",
"APP_ORIGIN",
"LOGIN_ORIGIN",
"POSTHOG_PROJECT_KEY",
"AUTH_GOOGLE_CLIENT_ID",
"AUTH_GOOGLE_CLIENT_SECRET",
"APP_ENV",
"APP_LOG_LEVEL"
]
}