From 8312cc342d623aa83c44fd8deb3bfdff32d6f1a0 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Wed, 4 Jun 2025 15:03:06 +0530 Subject: [PATCH] Feat: ingest api --- .../app/components/layout/LoginPageLayout.tsx | 33 +- apps/webapp/app/components/ui/Fieldset.tsx | 13 + apps/webapp/app/models/user.server.ts | 2 +- .../app/routes/auth.google.callback.tsx | 26 ++ apps/webapp/app/routes/auth.google.tsx | 39 ++ apps/webapp/app/routes/login.tsx | 76 +++- .../workspaces.$workspaceSlug.ingest.tsx | 2 +- apps/webapp/app/services/auth.server.ts | 10 +- apps/webapp/app/services/googleAuth.server.ts | 22 +- .../services/personalAccessToken.server.ts | 373 ++++++++++++++++++ apps/webapp/app/services/redirectTo.server.ts | 51 +++ apps/webapp/app/services/session.server.ts | 8 +- .../app/services/sessionStorage.server.ts | 19 +- apps/webapp/app/utils/requestUrl.server.ts | 10 + apps/webapp/package.json | 8 +- apps/webapp/{server.js => server.mjs} | 0 apps/webapp/vite.config.ts | 4 + helix/config.hx.json | 12 - helix/queries.hx | 26 -- helix/schema.hx | 38 -- .../migration.sql | 22 ++ pnpm-lock.yaml | 147 ++++--- 22 files changed, 741 insertions(+), 200 deletions(-) create mode 100644 apps/webapp/app/components/ui/Fieldset.tsx create mode 100644 apps/webapp/app/routes/auth.google.callback.tsx create mode 100644 apps/webapp/app/routes/auth.google.tsx create mode 100644 apps/webapp/app/services/personalAccessToken.server.ts create mode 100644 apps/webapp/app/services/redirectTo.server.ts create mode 100644 apps/webapp/app/utils/requestUrl.server.ts rename apps/webapp/{server.js => server.mjs} (100%) delete mode 100644 helix/config.hx.json delete mode 100644 helix/queries.hx delete mode 100644 helix/schema.hx create mode 100644 packages/database/prisma/migrations/20250603140024_add_workspace/migration.sql diff --git a/apps/webapp/app/components/layout/LoginPageLayout.tsx b/apps/webapp/app/components/layout/LoginPageLayout.tsx index 24cca4a..eb946f1 100644 --- a/apps/webapp/app/components/layout/LoginPageLayout.tsx +++ b/apps/webapp/app/components/layout/LoginPageLayout.tsx @@ -1,45 +1,14 @@ -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: - "Recall 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, Recall 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(null); - useEffect(() => { - const randomIndex = Math.floor(Math.random() * quotes.length); - setRandomQuote(quotes[randomIndex]); - }, []); const [, setTheme] = useTheme(); return (
- +
diff --git a/apps/webapp/app/components/ui/Fieldset.tsx b/apps/webapp/app/components/ui/Fieldset.tsx new file mode 100644 index 0000000..14100d3 --- /dev/null +++ b/apps/webapp/app/components/ui/Fieldset.tsx @@ -0,0 +1,13 @@ +import { cn } from "~/lib/utils"; + +export function Fieldset({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
{children}
+ ); +} diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index 5ab5f9a..d7f90c4 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -1,5 +1,5 @@ import type { Prisma, User } from "@recall/database"; -import type { GoogleProfile } from "remix-auth-google"; +import type { GoogleProfile } from "@coji/remix-auth-google"; import { prisma } from "~/db.server"; export type { User } from "@recall/database"; diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx new file mode 100644 index 0000000..3681a04 --- /dev/null +++ b/apps/webapp/app/routes/auth.google.callback.tsx @@ -0,0 +1,26 @@ +import { redirect, type LoaderFunction } from "@remix-run/node"; +import { authenticator } from "~/services/auth.server"; +import { redirectCookie } from "./auth.google"; +import { logger } from "~/services/logger.service"; +import { saveSession } from "~/services/sessionStorage.server"; + +export let loader: LoaderFunction = async ({ request }) => { + const cookie = request.headers.get("Cookie"); + const redirectValue = await redirectCookie.parse(cookie); + const redirectTo = redirectValue ?? "/"; + + logger.debug("auth.google.callback loader", { + redirectTo, + }); + + const authuser = await authenticator.authenticate("google", request); + const headers = await saveSession(request, authuser); + + logger.debug("auth.google.callback authuser", { + authuser, + }); + + return redirect(redirectTo, { + headers, + }); +}; diff --git a/apps/webapp/app/routes/auth.google.tsx b/apps/webapp/app/routes/auth.google.tsx new file mode 100644 index 0000000..41a995d --- /dev/null +++ b/apps/webapp/app/routes/auth.google.tsx @@ -0,0 +1,39 @@ +import { + redirect, + createCookie, + type ActionFunction, + type LoaderFunction, +} from "@remix-run/node"; +import { authenticator } from "~/services/auth.server"; + +export let loader: LoaderFunction = async ({ request }) => { + const url = new URL(request.url); + const redirectTo = url.searchParams.get("redirectTo"); + + try { + // call authenticate as usual, in successRedirect use returnTo or a fallback + const rf = await authenticator.authenticate("google", request); + console.log(rf); + + return rf; + } catch (error) { + // here we catch anything authenticator.authenticate throw, this will + // include redirects + // if the error is a Response and is a redirect + if (error instanceof Response) { + // we need to append a Set-Cookie header with a cookie storing the + // returnTo value + error.headers.append( + "Set-Cookie", + await redirectCookie.serialize(redirectTo), + ); + } + + throw error; + } +}; + +export const redirectCookie = createCookie("redirect-to", { + maxAge: 60 * 60, // 1 hour + httpOnly: true, +}); diff --git a/apps/webapp/app/routes/login.tsx b/apps/webapp/app/routes/login.tsx index 511bd17..f4ed066 100644 --- a/apps/webapp/app/routes/login.tsx +++ b/apps/webapp/app/routes/login.tsx @@ -1,31 +1,71 @@ import { type LoaderFunctionArgs } from "@remix-run/node"; -import { useNavigation } from "@remix-run/react"; -import { typedjson } from "remix-typedjson"; +import { Form } from "@remix-run/react"; +import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { LoginPageLayout } from "~/components/layout/LoginPageLayout"; -import { authenticator } from "~/services/auth.server"; -import { - commitSession, - getUserSession, -} from "~/services/sessionStorage.server"; +import { Fieldset } from "~/components/ui/Fieldset"; +import { Header1 } from "~/components/ui/Headers"; +import { Paragraph } from "~/components/ui/Paragraph"; +import { isGoogleAuthSupported } from "~/services/auth.server"; +import { setRedirectTo } from "~/services/redirectTo.server"; +import { getUserId } from "~/services/session.server"; +import { commitSession } from "~/services/sessionStorage.server"; +import { requestUrl } from "~/utils/requestUrl.server"; + +import { RiGoogleLine } from "@remixicon/react"; export async function loader({ request }: LoaderFunctionArgs) { - await authenticator.isAuthenticated(request, { - successRedirect: "/", - }); + const userId = await getUserId(request); + if (userId) return redirect("/"); - const session = await getUserSession(request); + const url = requestUrl(request); + const redirectTo = url.searchParams.get("redirectTo"); - return typedjson({ - headers: { "Set-Cookie": await commitSession(session) }, - }); + if (redirectTo) { + const session = await setRedirectTo(request, redirectTo); + + return typedjson( + { redirectTo, showGoogleAuth: isGoogleAuthSupported }, + { + headers: { + "Set-Cookie": await commitSession(session), + }, + }, + ); + } else { + return typedjson({ + redirectTo: null, + showGoogleAuth: isGoogleAuthSupported, + }); + } } export default function LoginPage() { - const navigate = useNavigation(); + const data = useTypedLoaderData(); return ( - -

Lohin

-
+
+
+ + Welcome + + + Create an account or login + +
+
+ {data.showGoogleAuth && ( + + )} +
+
+
+
); } diff --git a/apps/webapp/app/routes/workspaces.$workspaceSlug.ingest.tsx b/apps/webapp/app/routes/workspaces.$workspaceSlug.ingest.tsx index 279eeb5..73a0f7b 100644 --- a/apps/webapp/app/routes/workspaces.$workspaceSlug.ingest.tsx +++ b/apps/webapp/app/routes/workspaces.$workspaceSlug.ingest.tsx @@ -9,7 +9,7 @@ const ParamsSchema = z.object({ export const IngestBodyRequest = z.object({ name: z.string(), episodeBody: z.string(), - referenceTime: z.date(), + referenceTime: z.string(), type: z.enum(["CONVERSATION", "TEXT"]), // Assuming these are the EpisodeType values source: z.string(), userId: z.string(), diff --git a/apps/webapp/app/services/auth.server.ts b/apps/webapp/app/services/auth.server.ts index 035da44..1823ef8 100644 --- a/apps/webapp/app/services/auth.server.ts +++ b/apps/webapp/app/services/auth.server.ts @@ -2,19 +2,23 @@ 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(sessionStorage); +const authenticator = new Authenticator(); 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); + addGoogleStrategy( + authenticator, + env.AUTH_GOOGLE_CLIENT_ID, + env.AUTH_GOOGLE_CLIENT_SECRET, + ); } export { authenticator, isGoogleAuthSupported }; diff --git a/apps/webapp/app/services/googleAuth.server.ts b/apps/webapp/app/services/googleAuth.server.ts index bf38643..3cc5b4c 100644 --- a/apps/webapp/app/services/googleAuth.server.ts +++ b/apps/webapp/app/services/googleAuth.server.ts @@ -1,5 +1,5 @@ import type { Authenticator } from "remix-auth"; -import { GoogleStrategy } from "remix-auth-google"; +import { GoogleStrategy } from "@coji/remix-auth-google"; import { env } from "~/env.server"; import { findOrCreateUser } from "~/models/user.server"; import type { AuthUser } from "./authUser"; @@ -8,34 +8,36 @@ import { logger } from "./logger.service"; export function addGoogleStrategy( authenticator: Authenticator, - clientID: string, - clientSecret: string + clientId: string, + clientSecret: string, ) { const googleStrategy = new GoogleStrategy( { - clientID, + clientId, clientSecret, - callbackURL: `${env.LOGIN_ORIGIN}/auth/google/callback`, + redirectURI: `${env.LOGIN_ORIGIN}/auth/google/callback`, }, - async ({ extraParams, profile }) => { + async ({ tokens }) => { + const profile = await GoogleStrategy.userProfile(tokens); const emails = profile.emails; if (!emails) { throw new Error("Google login requires an email address"); } + console.log(tokens); + try { logger.debug("Google login", { emails, profile, - extraParams, }); const { user, isNewUser } = await findOrCreateUser({ email: emails[0].value, authenticationMethod: "GOOGLE", authenticationProfile: profile, - authenticationExtraParams: extraParams, + authenticationExtraParams: {}, }); await postAuthentication({ user, isNewUser, loginMethod: "GOOGLE" }); @@ -47,8 +49,8 @@ export function addGoogleStrategy( console.error(error); throw error; } - } + }, ); - authenticator.use(googleStrategy); + authenticator.use(googleStrategy as any, "google"); } diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts new file mode 100644 index 0000000..fb054cd --- /dev/null +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -0,0 +1,373 @@ +import { type PersonalAccessToken } from "@recall/database"; +import { customAlphabet, nanoid } from "nanoid"; +import nodeCrypto from "node:crypto"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { logger } from "./logger.service"; + +const tokenValueLength = 40; +//lowercase only, removed 0 and l to avoid confusion +const tokenGenerator = customAlphabet( + "123456789abcdefghijkmnopqrstuvwxyz", + tokenValueLength, +); + +type CreatePersonalAccessTokenOptions = { + name: string; + userId: string; +}; + +/** Returns obfuscated access tokens that aren't revoked */ +export async function getValidPersonalAccessTokens(userId: string) { + const personalAccessTokens = await prisma.personalAccessToken.findMany({ + select: { + id: true, + name: true, + obfuscatedToken: true, + createdAt: true, + lastAccessedAt: true, + }, + where: { + userId, + revokedAt: null, + }, + }); + + return personalAccessTokens.map((pat) => ({ + id: pat.id, + name: pat.name, + obfuscatedToken: pat.obfuscatedToken, + createdAt: pat.createdAt, + lastAccessedAt: pat.lastAccessedAt, + })); +} + +export type ObfuscatedPersonalAccessToken = Awaited< + ReturnType +>[number]; + +/** Gets a PersonalAccessToken from an Auth Code, this only works within 10 mins of the auth code being created */ +export async function getPersonalAccessTokenFromAuthorizationCode( + authorizationCode: string, +) { + //only allow authorization codes that were created less than 10 mins ago + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); + const code = await prisma.authorizationCode.findUnique({ + select: { + personalAccessToken: true, + }, + where: { + code: authorizationCode, + createdAt: { + gte: tenMinutesAgo, + }, + }, + }); + if (!code) { + throw new Error("Invalid authorization code, or code expired"); + } + + //there's no PersonalAccessToken associated with this code + if (!code.personalAccessToken) { + return { + token: null, + }; + } + + const decryptedToken = decryptPersonalAccessToken(code.personalAccessToken); + return { + token: { + token: decryptedToken, + obfuscatedToken: code.personalAccessToken.obfuscatedToken, + }, + }; +} + +export async function revokePersonalAccessToken(tokenId: string) { + await prisma.personalAccessToken.update({ + where: { + id: tokenId, + }, + data: { + revokedAt: new Date(), + }, + }); +} + +export type PersonalAccessTokenAuthenticationResult = { + userId: string; +}; + +const EncryptedSecretValueSchema = z.object({ + nonce: z.string(), + ciphertext: z.string(), + tag: z.string(), +}); + +const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/); + +export async function authenticateApiRequestWithPersonalAccessToken( + request: Request, +): Promise { + const token = getPersonalAccessTokenFromRequest(request); + if (!token) { + return; + } + + return authenticatePersonalAccessToken(token); +} + +function getPersonalAccessTokenFromRequest(request: Request) { + const rawAuthorization = request.headers.get("Authorization"); + + const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization); + if (!authorization.success) { + return; + } + + const personalAccessToken = authorization.data.replace(/^Bearer /, ""); + return personalAccessToken; +} + +export async function authenticatePersonalAccessToken( + token: string, +): Promise { + if (!token.startsWith(tokenPrefix)) { + logger.warn(`PAT doesn't start with ${tokenPrefix}`); + return; + } + + const hashedToken = hashToken(token); + + const personalAccessToken = await prisma.personalAccessToken.findFirst({ + where: { + hashedToken, + revokedAt: null, + }, + }); + + if (!personalAccessToken) { + // The token may have been revoked or is entirely invalid + return; + } + + await prisma.personalAccessToken.update({ + where: { + id: personalAccessToken.id, + }, + data: { + lastAccessedAt: new Date(), + }, + }); + + const decryptedToken = decryptPersonalAccessToken(personalAccessToken); + + if (decryptedToken !== token) { + logger.error( + `PersonalAccessToken with id: ${personalAccessToken.id} was found in the database with hash ${hashedToken}, but the decrypted token did not match the provided token.`, + ); + return; + } + + return { + userId: personalAccessToken.userId, + }; +} + +export function isPersonalAccessToken(token: string) { + return token.startsWith(tokenPrefix); +} + +export function createAuthorizationCode() { + return prisma.authorizationCode.create({ + data: { + code: nanoid(64), + }, + }); +} + +/** Creates a PersonalAccessToken from an Auth Code, and return the token. We only ever return the unencrypted token once. */ +export async function createPersonalAccessTokenFromAuthorizationCode( + authorizationCode: string, + userId: string, +) { + //only allow authorization codes that were created less than 10 mins ago + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); + const code = await prisma.authorizationCode.findUnique({ + where: { + code: authorizationCode, + personalAccessTokenId: null, + createdAt: { + gte: tenMinutesAgo, + }, + }, + }); + + if (!code) { + throw new Error( + "Invalid authorization code, code already used, or code expired", + ); + } + + const existingCliPersonalAccessToken = + await prisma.personalAccessToken.findFirst({ + where: { + userId, + name: "cli", + }, + }); + + //we only allow you to have one CLI PAT at a time, so return this + if (existingCliPersonalAccessToken) { + //associate this authorization code with the existing personal access token + await prisma.authorizationCode.update({ + where: { + code: authorizationCode, + }, + data: { + personalAccessTokenId: existingCliPersonalAccessToken.id, + }, + }); + + if (existingCliPersonalAccessToken.revokedAt) { + // re-activate revoked CLI PAT so we can use it again + await prisma.personalAccessToken.update({ + where: { + id: existingCliPersonalAccessToken.id, + }, + data: { + revokedAt: null, + }, + }); + } + + //we don't return the decrypted token + return { + id: existingCliPersonalAccessToken.id, + name: existingCliPersonalAccessToken.name, + userId: existingCliPersonalAccessToken.userId, + obfuscateToken: existingCliPersonalAccessToken.obfuscatedToken, + }; + } + + const token = await createPersonalAccessToken({ + name: "cli", + userId, + }); + + await prisma.authorizationCode.update({ + where: { + code: authorizationCode, + }, + data: { + personalAccessTokenId: token.id, + }, + }); + + return token; +} + +/** Created a new PersonalAccessToken, and return the token. We only ever return the unencrypted token once. */ +export async function createPersonalAccessToken({ + name, + userId, +}: CreatePersonalAccessTokenOptions) { + const token = createToken(); + const encryptedToken = encryptToken(token); + + const personalAccessToken = await prisma.personalAccessToken.create({ + data: { + name, + userId, + encryptedToken, + obfuscatedToken: obfuscateToken(token), + hashedToken: hashToken(token), + }, + }); + + return { + id: personalAccessToken.id, + name, + userId, + token, + obfuscatedToken: personalAccessToken.obfuscatedToken, + }; +} + +export type CreatedPersonalAccessToken = Awaited< + ReturnType +>; + +const tokenPrefix = "rc_pat_"; + +/** Creates a PersonalAccessToken that starts with tr_pat_ */ +function createToken() { + return `${tokenPrefix}${tokenGenerator()}`; +} + +/** Obfuscates all but the first and last 4 characters of the token, so it looks like tr_pat_bhbd•••••••••••••••••••fd4a */ +function obfuscateToken(token: string) { + const withoutPrefix = token.replace(tokenPrefix, ""); + const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`; + return `${tokenPrefix}${obfuscated}`; +} + +function encryptToken(value: string) { + const nonce = nodeCrypto.randomBytes(12); + const cipher = nodeCrypto.createCipheriv( + "aes-256-gcm", + env.ENCRYPTION_KEY, + nonce, + ); + + let encrypted = cipher.update(value, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const tag = cipher.getAuthTag().toString("hex"); + + return { + nonce: nonce.toString("hex"), + ciphertext: encrypted, + tag, + }; +} + +function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) { + const encryptedData = EncryptedSecretValueSchema.safeParse( + personalAccessToken.encryptedToken, + ); + if (!encryptedData.success) { + throw new Error( + `Unable to parse encrypted PersonalAccessToken with id: ${personalAccessToken.id}: ${encryptedData.error.message}`, + ); + } + + const decryptedToken = decryptToken( + encryptedData.data.nonce, + encryptedData.data.ciphertext, + encryptedData.data.tag, + ); + return decryptedToken; +} + +function decryptToken(nonce: string, ciphertext: string, tag: string): string { + const decipher = nodeCrypto.createDecipheriv( + "aes-256-gcm", + env.ENCRYPTION_KEY, + Buffer.from(nonce, "hex"), + ); + + decipher.setAuthTag(Buffer.from(tag, "hex")); + + let decrypted = decipher.update(ciphertext, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; +} + +function hashToken(token: string): string { + const hash = nodeCrypto.createHash("sha256"); + hash.update(token); + return hash.digest("hex"); +} diff --git a/apps/webapp/app/services/redirectTo.server.ts b/apps/webapp/app/services/redirectTo.server.ts new file mode 100644 index 0000000..53828e0 --- /dev/null +++ b/apps/webapp/app/services/redirectTo.server.ts @@ -0,0 +1,51 @@ +import { createCookieSessionStorage } from "@remix-run/node"; +import { z } from "zod"; +import { env } from "~/env.server"; + +const ONE_DAY = 60 * 60 * 24; + +export const { commitSession, getSession } = createCookieSessionStorage({ + cookie: { + name: "__redirectTo", + path: "/", + httpOnly: true, + sameSite: "lax", + secrets: [env.SESSION_SECRET], + secure: env.NODE_ENV === "production", + maxAge: ONE_DAY, + }, +}); + +export function getRedirectSession(request: Request) { + return getSession(request.headers.get("Cookie")); +} + +export async function setRedirectTo(request: Request, redirectTo: string) { + const session = await getRedirectSession(request); + + if (session) { + session.set("redirectTo", redirectTo); + } + + return session; +} + +export async function clearRedirectTo(request: Request) { + const session = await getRedirectSession(request); + + if (session) { + session.unset("redirectTo"); + } + + return session; +} + +export async function getRedirectTo( + request: Request, +): Promise { + const session = await getRedirectSession(request); + + if (session) { + return z.string().optional().parse(session.get("redirectTo")); + } +} diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts index b09e827..b841be4 100644 --- a/apps/webapp/app/services/session.server.ts +++ b/apps/webapp/app/services/session.server.ts @@ -1,6 +1,6 @@ import { redirect } from "@remix-run/node"; import { getUserById } from "~/models/user.server"; -import { authenticator } from "./auth.server"; +import { sessionStorage } from "./sessionStorage.server"; import { getImpersonationId } from "./impersonation.server"; export async function getUserId(request: Request): Promise { @@ -8,8 +8,10 @@ export async function getUserId(request: Request): Promise { if (impersonatedUserId) return impersonatedUserId; - let authUser = await authenticator.isAuthenticated(request); - return authUser?.userId; + let session = await sessionStorage.getSession(request.headers.get("cookie")); + let user = session.get("user"); + + return user?.userId; } export async function getUser(request: Request) { diff --git a/apps/webapp/app/services/sessionStorage.server.ts b/apps/webapp/app/services/sessionStorage.server.ts index 1ba119b..4d26749 100644 --- a/apps/webapp/app/services/sessionStorage.server.ts +++ b/apps/webapp/app/services/sessionStorage.server.ts @@ -1,8 +1,13 @@ import { createCookieSessionStorage } from "@remix-run/node"; import { createThemeSessionResolver } from "remix-themes"; import { env } from "~/env.server"; +import { type AuthUser } from "./authUser"; -export const sessionStorage = createCookieSessionStorage({ +let SESSION_KEY = "user"; + +export const sessionStorage = createCookieSessionStorage<{ + [SESSION_KEY]: AuthUser; +}>({ cookie: { name: "__session", // use any name you want here sameSite: "lax", // this helps with CSRF @@ -26,6 +31,18 @@ export const themeStorage = createCookieSessionStorage({ }, }); +export const getSessionFromStore = async (request: Request) => { + return await sessionStorage.getSession(request.headers.get("Cookie")); +}; + +export const saveSession = async (request: Request, user: AuthUser) => { + const session = await getSessionFromStore(request); + session.set(SESSION_KEY, user); + return new Headers({ + "Set-Cookie": await sessionStorage.commitSession(session), + }); +}; + export const themeSessionResolver = createThemeSessionResolver(sessionStorage); export function getUserSession(request: Request) { diff --git a/apps/webapp/app/utils/requestUrl.server.ts b/apps/webapp/app/utils/requestUrl.server.ts new file mode 100644 index 0000000..fddbe82 --- /dev/null +++ b/apps/webapp/app/utils/requestUrl.server.ts @@ -0,0 +1,10 @@ +// Updates the protocol of the request url to match the request.headers x-forwarded-proto +export function requestUrl(request: Request): URL { + const url = new URL(request.url); + + if (request.headers.get("x-forwarded-proto") === "https") { + url.protocol = "https:"; + } + + return url; +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index d56f2c0..f1776a2 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "remix vite:build", - "dev": "node ./server.js", + "dev": "node ./server.mjs", "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "start": "remix-serve ./build/server/index.js", "typecheck": "tsc" @@ -41,8 +41,10 @@ "posthog-js": "^1.116.6", "react": "^18.2.0", "react-dom": "^18.2.0", - "remix-auth": "^3.6.0", - "remix-auth-google": "^2.0.0", + "@remixicon/react": "^4.2.0", + "remix-auth": "^4.2.0", + "@coji/remix-auth-google": "^4.2.0", + "remix-auth-oauth2": "^3.4.1", "remix-themes": "^1.3.1", "remix-typedjson": "0.3.1", "remix-utils": "^7.7.0", diff --git a/apps/webapp/server.js b/apps/webapp/server.mjs similarity index 100% rename from apps/webapp/server.js rename to apps/webapp/server.mjs diff --git a/apps/webapp/vite.config.ts b/apps/webapp/vite.config.ts index 3b74ab7..77527a2 100644 --- a/apps/webapp/vite.config.ts +++ b/apps/webapp/vite.config.ts @@ -23,4 +23,8 @@ export default defineConfig({ }), tsconfigPaths(), ], + server: { + allowedHosts: true, + port: 3033, + }, }); diff --git a/helix/config.hx.json b/helix/config.hx.json deleted file mode 100644 index 3ae481f..0000000 --- a/helix/config.hx.json +++ /dev/null @@ -1,12 +0,0 @@ - -{ - "vector_config": { - "m": 16, - "ef_construction": 128, - "ef_search": 768 - }, - "graph_config": { - "secondary_indices": [] - }, - "db_max_size_gb": 10 -} diff --git a/helix/queries.hx b/helix/queries.hx deleted file mode 100644 index 586a25e..0000000 --- a/helix/queries.hx +++ /dev/null @@ -1,26 +0,0 @@ -// Start writing your queries here. -// -// You can use the schema to help you write your queries. -// -// Queries take the form: -// QUERY {query name}({input name}: {input type}) => -// {variable} <- {traversal} -// RETURN {variable} -// -// Example: -// QUERY GetUserFriends(user_id: String) => -// friends <- N(user_id)::Out -// RETURN friends -// -// -// For more information on how to write queries, -// see the documentation at https://docs.helix-db.com -// or checkout our GitHub at https://github.com/HelixDB/helix-db - -QUERY hnswinsert(vector: [F64]) => - AddV(vector) - RETURN "Success" - -QUERY hnswsearch(query: [F64], k: I32) => - res <- SearchV(query, k) - RETURN res diff --git a/helix/schema.hx b/helix/schema.hx deleted file mode 100644 index 1d4613c..0000000 --- a/helix/schema.hx +++ /dev/null @@ -1,38 +0,0 @@ -// Start building your schema here. -// -// The schema is used to to ensure a level of type safety in your queries. -// -// The schema is made up of Node types, denoted by N::, -// and Edge types, denoted by E:: -// -// Under the Node types you can define fields that -// will be stored in the database. -// -// Under the Edge types you can define what type of node -// the edge will connect to and from, and also the -// properties that you want to store on the edge. -// -// Example: -// -// N::User { -// Name: String, -// Label: String, -// Age: Integer, -// IsAdmin: Boolean, -// } -// -// E::Knows { -// From: User, -// To: User, -// Properties: { -// Since: Integer, -// } -// } -// -// For more information on how to write queries, -// see the documentation at https://docs.helix-db.com -// or checkout our GitHub at https://github.com/HelixDB/helix-db - -V::Embedding { - vec: [F64] -} diff --git a/packages/database/prisma/migrations/20250603140024_add_workspace/migration.sql b/packages/database/prisma/migrations/20250603140024_add_workspace/migration.sql new file mode 100644 index 0000000..9a80546 --- /dev/null +++ b/packages/database/prisma/migrations/20250603140024_add_workspace/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "Workspace" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deleted" TIMESTAMP(3), + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "icon" TEXT, + "userId" TEXT, + + CONSTRAINT "Workspace_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Workspace_slug_key" ON "Workspace"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Workspace_userId_key" ON "Workspace"("userId"); + +-- AddForeignKey +ALTER TABLE "Workspace" ADD CONSTRAINT "Workspace_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba7a86e..0898371 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@ai-sdk/openai': specifier: ^1.3.21 version: 1.3.22(zod@3.23.8) + '@coji/remix-auth-google': + specifier: ^4.2.0 + version: 4.2.0 '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 @@ -66,6 +69,9 @@ importers: '@remix-run/v1-meta': specifier: ^0.1.3 version: 0.1.3(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)) + '@remixicon/react': + specifier: ^4.2.0 + version: 4.6.0(react@18.3.1) '@tailwindcss/container-queries': specifier: ^0.1.1 version: 0.1.1(tailwindcss@4.1.7) @@ -121,11 +127,11 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) remix-auth: - specifier: ^3.6.0 - version: 3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)) - remix-auth-google: - specifier: ^2.0.0 - version: 2.0.0(@remix-run/server-runtime@2.16.7(typescript@5.8.3))(remix-auth@3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))) + specifier: ^4.2.0 + version: 4.2.0 + remix-auth-oauth2: + specifier: ^3.4.1 + version: 3.4.1(remix-auth@4.2.0) remix-themes: specifier: ^1.3.1 version: 1.6.1(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)) @@ -570,6 +576,13 @@ packages: '@changesets/write@0.2.3': resolution: {integrity: sha512-Dbamr7AIMvslKnNYsLFafaVORx4H0pvCA2MHqgtNCySMe1blImEyAEOzDmcgKAkgz4+uwoLz7demIrX+JBr/Xw==} + '@coji/remix-auth-google@4.2.0': + resolution: {integrity: sha512-H9i3fvVz0GE18GUZHpz7p7FQjuiuloTIBAPjW7cfv7lUEk+mI6WRTVLEHJBLLuTlAF1+0EbzvPRYKutxZiFdfw==} + + '@edgefirst-dev/data@0.0.4': + resolution: {integrity: sha512-VLhlvEPDJ0Sd0pE6sAYTQkIqZCXVonaWlgRJIQQHzfjTXCadF77qqHj5NxaPSc4wCul0DJO/0MnejVqJAXUiRg==} + engines: {node: '>=20.0.0'} + '@emnapi/core@1.4.3': resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} @@ -1086,6 +1099,9 @@ packages: '@mdx-js/mdx@2.3.0': resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==} + '@mjackson/headers@0.10.0': + resolution: {integrity: sha512-U1Eu1gF979k7ZoIBsJyD+T5l9MjtPONsZfoXfktsQHPJD0s7SokBGx+tLKDLsOY+gzVYAWS0yRFDNY8cgbQzWQ==} + '@napi-rs/wasm-runtime@0.2.10': resolution: {integrity: sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==} @@ -1128,6 +1144,24 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + + '@oslojs/crypto@1.0.1': + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + + '@oslojs/encoding@0.4.1': + resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oslojs/jwt@0.2.0': + resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1301,6 +1335,11 @@ packages: '@remix-run/web-stream@1.1.0': resolution: {integrity: sha512-KRJtwrjRV5Bb+pM7zxcTJkhIqWWSy+MYsIxHK+0m5atcznsf15YwUBWHWulZerV2+vvHH1Lp1DD7pw6qKW8SgA==} + '@remixicon/react@4.6.0': + resolution: {integrity: sha512-bY56maEgT5IYUSRotqy9h03IAKJC85vlKtWFg2FKzfs8JPrkdBAYSa9dxoUSKFwGzup8Ux6vjShs9Aec3jvr2w==} + peerDependencies: + react: '>=18.2.0' + '@rollup/rollup-android-arm-eabi@4.41.1': resolution: {integrity: sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==} cpu: [arm] @@ -1936,6 +1975,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arctic@3.7.0: + resolution: {integrity: sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -4752,23 +4794,15 @@ packages: remark-rehype@10.1.0: resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==} - remix-auth-google@2.0.0: - resolution: {integrity: sha512-qP38N1ZJADz+HlH2lrEn/rSL6m5Dwcnz8xUFDhbHecDMl9FU/UpkUx3w8jj5XhNSnkSue/GWT+p7OEkatgbXNA==} + remix-auth-oauth2@3.4.1: + resolution: {integrity: sha512-ZhGon1czdIsOw1/O9EcTCzapZB6FpT3u9vtXSVeEMwGNs+iWljRsibnUC1RtwJbzzCdLBSwIXTNTbiRmLZ4cZw==} + engines: {node: ^20.0.0 || >=20.0.0} peerDependencies: - '@remix-run/server-runtime': ^2.0.1 - remix-auth: ^3.2.1 + remix-auth: ^4.0.0 - remix-auth-oauth2@1.11.2: - resolution: {integrity: sha512-5ORP+LMi5CVCA/Wb8Z+FCAJ73Uiy4uyjEzhlVwNBfdAkPOnfxzoi+q/pY/CrueYv3OniCXRM35ZYqkVi3G1UPw==} - peerDependencies: - '@remix-run/server-runtime': ^1.0.0 || ^2.0.0 - remix-auth: ^3.6.0 - - remix-auth@3.7.0: - resolution: {integrity: sha512-2QVjp2nJVaYxuFBecMQwzixCO7CLSssttLBU5eVlNcNlVeNMmY1g7OkmZ1Ogw9sBcoMXZ18J7xXSK0AISVFcfQ==} - peerDependencies: - '@remix-run/react': ^1.0.0 || ^2.0.0 - '@remix-run/server-runtime': ^1.0.0 || ^2.0.0 + remix-auth@4.2.0: + resolution: {integrity: sha512-3LSfWEvSgG2CgbG/p4ge5hbV8tTXWNnnYIGbTr9oSSiHz9dD7wh6S0MEyo3pwh7MlKezB2WIlevGeyqUZykk7g==} + engines: {node: '>=20.0.0'} remix-themes@1.6.1: resolution: {integrity: sha512-wqJyNKJ2hiOweycQzsAk7CZm+2mKNAbW2QZcX0riw52XepAxf9R2v8NYyeUz+uWmb3Fulyi71s4aipNRTxCysw==} @@ -5476,14 +5510,6 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - hasBin: true - - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true - uvu@0.5.6: resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} engines: {node: '>=8'} @@ -6205,6 +6231,10 @@ snapshots: human-id: 1.0.2 prettier: 2.8.8 + '@coji/remix-auth-google@4.2.0': {} + + '@edgefirst-dev/data@0.0.4': {} + '@emnapi/core@1.4.3': dependencies: '@emnapi/wasi-threads': 1.0.2 @@ -6549,6 +6579,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@mjackson/headers@0.10.0': {} + '@napi-rs/wasm-runtime@0.2.10': dependencies: '@emnapi/core': 1.4.3 @@ -6609,6 +6641,25 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/binary@1.0.0': {} + + '@oslojs/crypto@1.0.1': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/encoding@0.4.1': {} + + '@oslojs/encoding@1.1.0': {} + + '@oslojs/jwt@0.2.0': + dependencies: + '@oslojs/encoding': 0.4.1 + '@pkgjs/parseargs@0.11.0': optional: true @@ -6884,6 +6935,10 @@ snapshots: dependencies: web-streams-polyfill: 3.3.3 + '@remixicon/react@4.6.0(react@18.3.1)': + dependencies: + react: 18.3.1 + '@rollup/rollup-android-arm-eabi@4.41.1': optional: true @@ -7534,6 +7589,12 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + arctic@3.7.0: + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@oslojs/jwt': 0.2.0 + arg@5.0.2: {} argparse@1.0.10: @@ -10750,28 +10811,14 @@ snapshots: mdast-util-to-hast: 12.3.0 unified: 10.1.2 - remix-auth-google@2.0.0(@remix-run/server-runtime@2.16.7(typescript@5.8.3))(remix-auth@3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))): + remix-auth-oauth2@3.4.1(remix-auth@4.2.0): dependencies: - '@remix-run/server-runtime': 2.16.7(typescript@5.8.3) - remix-auth: 3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)) - remix-auth-oauth2: 1.11.2(@remix-run/server-runtime@2.16.7(typescript@5.8.3))(remix-auth@3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))) - transitivePeerDependencies: - - supports-color + '@edgefirst-dev/data': 0.0.4 + '@mjackson/headers': 0.10.0 + arctic: 3.7.0 + remix-auth: 4.2.0 - remix-auth-oauth2@1.11.2(@remix-run/server-runtime@2.16.7(typescript@5.8.3))(remix-auth@3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3))): - dependencies: - '@remix-run/server-runtime': 2.16.7(typescript@5.8.3) - debug: 4.4.1 - remix-auth: 3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)) - uuid: 9.0.1 - transitivePeerDependencies: - - supports-color - - remix-auth@3.7.0(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)): - dependencies: - '@remix-run/react': 2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3) - '@remix-run/server-runtime': 2.16.7(typescript@5.8.3) - uuid: 8.3.2 + remix-auth@4.2.0: {} remix-themes@1.6.1(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/server-runtime@2.16.7(typescript@5.8.3)): dependencies: @@ -11565,10 +11612,6 @@ snapshots: utils-merge@1.0.1: {} - uuid@8.3.2: {} - - uuid@9.0.1: {} - uvu@0.5.6: dependencies: dequal: 2.0.3