diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 0cd9480..f8137fd 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -2,12 +2,16 @@ import { z } from "zod"; import { isValidDatabaseUrl } from "./utils/db"; const EnvironmentSchema = z.object({ - NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]), + NODE_ENV: z.union([ + z.literal("development"), + z.literal("production"), + z.literal("test"), + ]), DATABASE_URL: z .string() .refine( isValidDatabaseUrl, - "DATABASE_URL is invalid, for details please check the additional output above this message." + "DATABASE_URL is invalid, for details please check the additional output above this message.", ), DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10), DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60), @@ -16,10 +20,11 @@ const EnvironmentSchema = z.object({ .string() .refine( isValidDatabaseUrl, - "DIRECT_URL is invalid, for details please check the additional output above this message." + "DIRECT_URL is invalid, for details please check the additional output above this message.", ), DATABASE_READ_REPLICA_URL: z.string().optional(), SESSION_SECRET: z.string(), + ENCRYPTION_KEY: z.string(), APP_ENV: z.string().default(process.env.NODE_ENV), LOGIN_ORIGIN: z.string().default("http://localhost:5173"), diff --git a/apps/webapp/app/models/personal-token.server.ts b/apps/webapp/app/models/personal-token.server.ts new file mode 100644 index 0000000..090aa81 --- /dev/null +++ b/apps/webapp/app/models/personal-token.server.ts @@ -0,0 +1,95 @@ +import { type PersonalAccessToken } from "@recall/database"; +import { prisma } from "~/db.server"; +import nodeCrypto from "node:crypto"; +import { z } from "zod"; +import { logger } from "~/services/logger.service"; +import { env } from "~/env.server"; + +export type PersonalAccessTokenAuthenticationResult = { + userId: string; +}; + +const EncryptedSecretValueSchema = z.object({ + nonce: z.string(), + ciphertext: z.string(), + tag: z.string(), +}); + +export async function findUserByToken( + token: string, +): Promise { + 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 null; + } + + 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 null; + } + + return { + userId: personalAccessToken.userId, + }; +} + +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/routes/workspaces.$workspaceSlug.ingest.tsx b/apps/webapp/app/routes/workspaces.$workspaceSlug.ingest.tsx index 8b13789..279eeb5 100644 --- a/apps/webapp/app/routes/workspaces.$workspaceSlug.ingest.tsx +++ b/apps/webapp/app/routes/workspaces.$workspaceSlug.ingest.tsx @@ -1 +1,37 @@ +import { json, LoaderFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +const ParamsSchema = z.object({ + workspaceSlug: z.string(), +}); + +export const IngestBodyRequest = z.object({ + name: z.string(), + episodeBody: z.string(), + referenceTime: z.date(), + type: z.enum(["CONVERSATION", "TEXT"]), // Assuming these are the EpisodeType values + source: z.string(), + userId: z.string(), + spaceId: z.string().optional(), + sessionId: z.string().optional(), +}); + +const { action, loader } = createActionApiRoute( + { + params: ParamsSchema, + body: IngestBodyRequest, + allowJWT: true, + authorization: { + action: "ingest", + }, + corsStrategy: "all", + }, + async ({ body, headers, params, authentication }) => { + console.log(body, headers, params, authentication); + + return json({}); + }, +); + +export { action, loader }; diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts new file mode 100644 index 0000000..61701b9 --- /dev/null +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -0,0 +1,109 @@ +import { SignJWT, errors, jwtVerify } from "jose"; + +import { env } from "~/env.server"; +import { findUserByToken } from "~/models/personal-token.server"; + +// See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20 +export type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +export type ApiAuthenticationResult = + | ApiAuthenticationResultSuccess + | ApiAuthenticationResultFailure; + +export type ApiAuthenticationResultSuccess = { + ok: true; + apiKey: string; + type: "PRIVATE"; + userId: string; + scopes?: string[]; + oneTimeUse?: boolean; +}; + +export type ApiAuthenticationResultFailure = { + ok: false; + error: string; +}; + +/** + * This method is the same as `authenticateApiRequest` but it returns a failure result instead of undefined. + * It should be used from now on to ensure that the API key is always validated and provide a failure result. + */ +export async function authenticateApiRequestWithFailure( + request: Request, + options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}, +): Promise { + const apiKey = getApiKeyFromRequest(request); + + if (!apiKey) { + return { + ok: false, + error: "Invalid API Key", + }; + } + + const authentication = await authenticateApiKeyWithFailure(apiKey, options); + + return authentication; +} + +/** + * This method is the same as `authenticateApiKey` but it returns a failure result instead of undefined. + * It should be used from now on to ensure that the API key is always validated and provide a failure result. + */ +export async function authenticateApiKeyWithFailure( + apiKey: string, + options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}, +): Promise { + const result = getApiKeyResult(apiKey); + + if (!result) { + return { + ok: false, + error: "Invalid API Key", + }; + } + + switch (result.type) { + case "PRIVATE": { + const user = await findUserByToken(result.apiKey); + if (!user) { + return { + ok: false, + error: "Invalid API Key", + }; + } + + return { + ok: true, + ...result, + userId: user.userId, + }; + } + } +} + +export function isSecretApiKey(key: string) { + return key.startsWith("tr_"); +} + +export function getApiKeyFromRequest(request: Request) { + return getApiKeyFromHeader(request.headers.get("Authorization")); +} + +export function getApiKeyFromHeader(authorization?: string | null) { + if (typeof authorization !== "string" || !authorization) { + return; + } + + const apiKey = authorization.replace(/^Bearer /, ""); + return apiKey; +} + +export function getApiKeyResult(apiKey: string): { + apiKey: string; + type: "PRIVATE"; +} { + return { apiKey, type: "PRIVATE" }; +} diff --git a/apps/webapp/app/services/authorization.server.ts b/apps/webapp/app/services/authorization.server.ts new file mode 100644 index 0000000..f1b9732 --- /dev/null +++ b/apps/webapp/app/services/authorization.server.ts @@ -0,0 +1,30 @@ +export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed + +const ResourceTypes = ["spaces"] as const; + +export type AuthorizationResources = { + [key in (typeof ResourceTypes)[number]]?: string | string[]; +}; + +export type AuthorizationEntity = { + type: "PRIVATE"; + scopes?: string[]; +}; + +export type AuthorizationResult = + | { authorized: true } + | { authorized: false; reason: string }; + +/** + * Checks if the given entity is authorized to perform a specific action on a resource. + */ +export function checkAuthorization( + entity: AuthorizationEntity, +): AuthorizationResult { + // "PRIVATE" is a secret key and has access to everything + if (entity.type === "PRIVATE") { + return { authorized: true }; + } + + return { authorized: false, reason: "No key" }; +} diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts new file mode 100644 index 0000000..8aed2f8 --- /dev/null +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -0,0 +1,631 @@ +import { type z } from "zod"; +import { + type ApiAuthenticationResultSuccess, + authenticateApiRequestWithFailure, +} from "../apiAuth.server"; +import { + type ActionFunctionArgs, + json, + type LoaderFunctionArgs, +} from "@remix-run/server-runtime"; +import { fromZodError } from "zod-validation-error"; +import { apiCors } from "~/utils/apiCors"; +import { + type AuthorizationAction, + type AuthorizationResources, + checkAuthorization, +} from "../authorization.server"; +import { logger } from "../logger.service"; + +import { safeJsonParse } from "~/utils/json"; + +type AnyZodSchema = + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion; + +type ApiKeyRouteBuilderOptions< + TParamsSchema extends AnyZodSchema | undefined = undefined, + TSearchParamsSchema extends AnyZodSchema | undefined = undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TResource = never, +> = { + params?: TParamsSchema; + searchParams?: TSearchParamsSchema; + headers?: THeadersSchema; + allowJWT?: boolean; + corsStrategy?: "all" | "none"; + findResource: ( + params: TParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + authentication: ApiAuthenticationResultSuccess, + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + ) => Promise; + shouldRetryNotFound?: boolean; + authorization?: { + action: AuthorizationAction; + resource: ( + resource: NonNullable, + params: TParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + headers: THeadersSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + ) => AuthorizationResources; + superScopes?: string[]; + }; +}; + +type ApiKeyHandlerFunction< + TParamsSchema extends AnyZodSchema | undefined, + TSearchParamsSchema extends AnyZodSchema | undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TResource = never, +> = (args: { + params: TParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + headers: THeadersSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + authentication: ApiAuthenticationResultSuccess; + request: Request; + resource: NonNullable; +}) => Promise; + +export function createLoaderApiRoute< + TParamsSchema extends AnyZodSchema | undefined = undefined, + TSearchParamsSchema extends AnyZodSchema | undefined = undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TResource = never, +>( + options: ApiKeyRouteBuilderOptions< + TParamsSchema, + TSearchParamsSchema, + THeadersSchema, + TResource + >, + handler: ApiKeyHandlerFunction< + TParamsSchema, + TSearchParamsSchema, + THeadersSchema, + TResource + >, +) { + return async function loader({ request, params }: LoaderFunctionArgs) { + const { + params: paramsSchema, + searchParams: searchParamsSchema, + headers: headersSchema, + allowJWT = false, + corsStrategy = "none", + authorization, + findResource, + shouldRetryNotFound, + } = options; + + if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") { + return apiCors(request, json({})); + } + + try { + const authenticationResult = await authenticateApiRequestWithFailure( + request, + { allowJWT }, + ); + + if (!authenticationResult) { + return await wrapResponse( + request, + json({ error: "Invalid or Missing API key" }, { status: 401 }), + corsStrategy !== "none", + ); + } + + if (!authenticationResult.ok) { + return await wrapResponse( + request, + json({ error: authenticationResult.error }, { status: 401 }), + corsStrategy !== "none", + ); + } + + let parsedParams: any = undefined; + if (paramsSchema) { + const parsed = paramsSchema.safeParse(params); + if (!parsed.success) { + return await wrapResponse( + request, + json( + { + error: "Params Error", + details: fromZodError(parsed.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedParams = parsed.data; + } + + let parsedSearchParams: any = undefined; + if (searchParamsSchema) { + const searchParams = Object.fromEntries( + new URL(request.url).searchParams, + ); + const parsed = searchParamsSchema.safeParse(searchParams); + if (!parsed.success) { + return await wrapResponse( + request, + json( + { + error: "Query Error", + details: fromZodError(parsed.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedSearchParams = parsed.data; + } + + let parsedHeaders: any = undefined; + if (headersSchema) { + const rawHeaders = Object.fromEntries(request.headers); + const headers = headersSchema.safeParse(rawHeaders); + if (!headers.success) { + return await wrapResponse( + request, + json( + { + error: "Headers Error", + details: fromZodError(headers.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedHeaders = headers.data; + } + + // Find the resource + const resource = await findResource( + parsedParams, + authenticationResult, + parsedSearchParams, + ); + + if (!resource) { + return await wrapResponse( + request, + json( + { error: "Not found" }, + { + status: 404, + headers: { + "x-should-retry": shouldRetryNotFound ? "true" : "false", + }, + }, + ), + corsStrategy !== "none", + ); + } + + if (authorization) { + const { action, resource: authResource, superScopes } = authorization; + const $authResource = authResource( + resource, + parsedParams, + parsedSearchParams, + parsedHeaders, + ); + + logger.debug("Checking authorization", { + action, + resource: $authResource, + superScopes, + scopes: authenticationResult.scopes, + }); + + const authorizationResult = checkAuthorization(authenticationResult); + + if (!authorizationResult.authorized) { + return await wrapResponse( + request, + json( + { + error: `Unauthorized: ${authorizationResult.reason}`, + code: "unauthorized", + param: "access_token", + type: "authorization", + }, + { status: 403 }, + ), + corsStrategy !== "none", + ); + } + } + + const result = await handler({ + params: parsedParams, + searchParams: parsedSearchParams, + headers: parsedHeaders, + authentication: authenticationResult, + request, + resource, + }); + return await wrapResponse(request, result, corsStrategy !== "none"); + } catch (error) { + try { + if (error instanceof Response) { + return await wrapResponse(request, error, corsStrategy !== "none"); + } + + logger.error("Error in loader", { + error: + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + } + : String(error), + url: request.url, + }); + + return await wrapResponse( + request, + json({ error: "Internal Server Error" }, { status: 500 }), + corsStrategy !== "none", + ); + } catch (innerError) { + logger.error("[apiBuilder] Failed to handle error", { + error, + innerError, + }); + + return json({ error: "Internal Server Error" }, { status: 500 }); + } + } + }; +} + +type ApiKeyActionRouteBuilderOptions< + TParamsSchema extends AnyZodSchema | undefined = undefined, + TSearchParamsSchema extends AnyZodSchema | undefined = undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TBodySchema extends AnyZodSchema | undefined = undefined, +> = { + params?: TParamsSchema; + searchParams?: TSearchParamsSchema; + headers?: THeadersSchema; + allowJWT?: boolean; + corsStrategy?: "all" | "none"; + method?: "POST" | "PUT" | "DELETE" | "PATCH"; + authorization?: { + action: AuthorizationAction; + }; + maxContentLength?: number; + body?: TBodySchema; +}; + +type ApiKeyActionHandlerFunction< + TParamsSchema extends AnyZodSchema | undefined, + TSearchParamsSchema extends AnyZodSchema | undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TBodySchema extends AnyZodSchema | undefined = undefined, +> = (args: { + params: TParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + headers: THeadersSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + body: TBodySchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + authentication: ApiAuthenticationResultSuccess; + request: Request; +}) => Promise; + +export function createActionApiRoute< + TParamsSchema extends AnyZodSchema | undefined = undefined, + TSearchParamsSchema extends AnyZodSchema | undefined = undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TBodySchema extends AnyZodSchema | undefined = undefined, +>( + options: ApiKeyActionRouteBuilderOptions< + TParamsSchema, + TSearchParamsSchema, + THeadersSchema, + TBodySchema + >, + handler: ApiKeyActionHandlerFunction< + TParamsSchema, + TSearchParamsSchema, + THeadersSchema, + TBodySchema + >, +) { + const { + params: paramsSchema, + searchParams: searchParamsSchema, + headers: headersSchema, + body: bodySchema, + allowJWT = false, + corsStrategy = "none", + authorization, + maxContentLength, + } = options; + + async function loader({ request, params }: LoaderFunctionArgs) { + if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") { + return apiCors(request, json({})); + } + + return new Response(null, { status: 405 }); + } + + async function action({ request, params }: ActionFunctionArgs) { + if (options.method) { + if (request.method.toUpperCase() !== options.method) { + return await wrapResponse( + request, + json( + { error: "Method not allowed" }, + { status: 405, headers: { Allow: options.method } }, + ), + corsStrategy !== "none", + ); + } + } + + try { + const authenticationResult = await authenticateApiRequestWithFailure( + request, + { allowJWT }, + ); + + if (!authenticationResult) { + return await wrapResponse( + request, + json({ error: "Invalid or Missing API key" }, { status: 401 }), + corsStrategy !== "none", + ); + } + + if (!authenticationResult.ok) { + return await wrapResponse( + request, + json({ error: authenticationResult.error }, { status: 401 }), + corsStrategy !== "none", + ); + } + + if (maxContentLength) { + const contentLength = request.headers.get("content-length"); + + if (!contentLength || parseInt(contentLength) > maxContentLength) { + return json({ error: "Request body too large" }, { status: 413 }); + } + } + + let parsedParams: any = undefined; + if (paramsSchema) { + const parsed = paramsSchema.safeParse(params); + if (!parsed.success) { + return await wrapResponse( + request, + json( + { + error: "Params Error", + details: fromZodError(parsed.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedParams = parsed.data; + } + + let parsedSearchParams: any = undefined; + if (searchParamsSchema) { + const searchParams = Object.fromEntries( + new URL(request.url).searchParams, + ); + const parsed = searchParamsSchema.safeParse(searchParams); + if (!parsed.success) { + return await wrapResponse( + request, + json( + { + error: "Query Error", + details: fromZodError(parsed.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedSearchParams = parsed.data; + } + + let parsedHeaders: any = undefined; + if (headersSchema) { + const rawHeaders = Object.fromEntries(request.headers); + const headers = headersSchema.safeParse(rawHeaders); + if (!headers.success) { + return await wrapResponse( + request, + json( + { + error: "Headers Error", + details: fromZodError(headers.error).details, + }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedHeaders = headers.data; + } + + let parsedBody: any = undefined; + if (bodySchema) { + const rawBody = await request.text(); + if (rawBody.length === 0) { + return await wrapResponse( + request, + json({ error: "Request body is empty" }, { status: 400 }), + corsStrategy !== "none", + ); + } + + const rawParsedJson = safeJsonParse(rawBody); + + if (!rawParsedJson) { + return await wrapResponse( + request, + json({ error: "Invalid JSON" }, { status: 400 }), + corsStrategy !== "none", + ); + } + + const body = bodySchema.safeParse(rawParsedJson); + if (!body.success) { + return await wrapResponse( + request, + json( + { error: fromZodError(body.error).toString() }, + { status: 400 }, + ), + corsStrategy !== "none", + ); + } + parsedBody = body.data; + } + + if (authorization) { + const { action } = authorization; + + logger.debug("Checking authorization", { + action, + scopes: authenticationResult.scopes, + }); + + const authorizationResult = checkAuthorization(authenticationResult); + + if (!authorizationResult.authorized) { + return await wrapResponse( + request, + json( + { + error: `Unauthorized: ${authorizationResult.reason}`, + code: "unauthorized", + param: "access_token", + type: "authorization", + }, + { status: 403 }, + ), + corsStrategy !== "none", + ); + } + } + + const result = await handler({ + params: parsedParams, + searchParams: parsedSearchParams, + headers: parsedHeaders, + body: parsedBody, + authentication: authenticationResult, + request, + }); + return await wrapResponse(request, result, corsStrategy !== "none"); + } catch (error) { + try { + if (error instanceof Response) { + return await wrapResponse(request, error, corsStrategy !== "none"); + } + + logger.error("Error in action", { + error: + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: error.stack, + } + : String(error), + url: request.url, + }); + + return await wrapResponse( + request, + json({ error: "Internal Server Error" }, { status: 500 }), + corsStrategy !== "none", + ); + } catch (innerError) { + logger.error("[apiBuilder] Failed to handle error", { + error, + innerError, + }); + + return json({ error: "Internal Server Error" }, { status: 500 }); + } + } + } + + return { loader, action }; +} + +async function wrapResponse( + request: Request, + response: Response, + useCors: boolean, +): Promise { + return useCors + ? await apiCors(request, response, { + exposedHeaders: ["x-sol-jwt", "x-sol-jwt-claims"], + }) + : response; +} diff --git a/apps/webapp/app/utils/apiCors.ts b/apps/webapp/app/utils/apiCors.ts new file mode 100644 index 0000000..28fea86 --- /dev/null +++ b/apps/webapp/app/utils/apiCors.ts @@ -0,0 +1,35 @@ +import { cors } from "remix-utils/cors"; + +type CorsMethod = "GET" | "HEAD" | "PUT" | "PATCH" | "POST" | "DELETE"; + +type CorsOptions = { + methods?: CorsMethod[]; + /** Defaults to 5 mins */ + maxAge?: number; + origin?: boolean | string; + credentials?: boolean; + exposedHeaders?: string[]; +}; + +export async function apiCors( + request: Request, + response: Response, + options: CorsOptions = { maxAge: 5 * 60 }, +): Promise { + if (hasCorsHeaders(response)) { + return response; + } + + return cors(request, response, options); +} + +export function makeApiCors( + request: Request, + options: CorsOptions = { maxAge: 5 * 60 }, +): (response: Response) => Promise { + return (response: Response) => apiCors(request, response, options); +} + +function hasCorsHeaders(response: Response) { + return response.headers.has("access-control-allow-origin"); +} diff --git a/apps/webapp/app/utils/json.ts b/apps/webapp/app/utils/json.ts new file mode 100644 index 0000000..df2798a --- /dev/null +++ b/apps/webapp/app/utils/json.ts @@ -0,0 +1,66 @@ +import { type z } from "zod"; + +export function safeJsonParse(json?: string): unknown { + if (!json) { + return; + } + + try { + return JSON.parse(json); + } catch (e) { + return null; + } +} + +export function safeJsonZodParse( + schema: z.Schema, + json: string, +): z.SafeParseReturnType | undefined { + const parsed = safeJsonParse(json); + + if (parsed === null) { + return; + } + + return schema.safeParse(parsed); +} + +export async function safeJsonFromResponse(response: Response) { + const json = await response.text(); + return safeJsonParse(json); +} + +export async function safeBodyFromResponse( + response: Response, + schema: z.Schema, +): Promise { + const json = await response.text(); + const unknownJson = safeJsonParse(json); + + if (!unknownJson) { + return; + } + + const parsedJson = schema.safeParse(unknownJson); + + if (parsedJson.success) { + return parsedJson.data; + } +} + +export async function safeParseBodyFromResponse( + response: Response, + schema: z.Schema, +): Promise | undefined> { + try { + const unknownJson = await response.json(); + + if (!unknownJson) { + return; + } + + const parsedJson = schema.safeParse(unknownJson); + + return parsedJson; + } catch (error) {} +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 24a312e..d56f2c0 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -36,6 +36,7 @@ "lucide-react": "^0.511.0", "morgan": "^1.10.0", "nanoid": "3.3.8", + "jose": "^5.2.3", "non.geist": "^1.0.2", "posthog-js": "^1.116.6", "react": "^18.2.0", diff --git a/helix/config.hx.json b/helix/config.hx.json new file mode 100644 index 0000000..3ae481f --- /dev/null +++ b/helix/config.hx.json @@ -0,0 +1,12 @@ + +{ + "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 new file mode 100644 index 0000000..586a25e --- /dev/null +++ b/helix/queries.hx @@ -0,0 +1,26 @@ +// 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 new file mode 100644 index 0000000..1d4613c --- /dev/null +++ b/helix/schema.hx @@ -0,0 +1,38 @@ +// 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/schema.prisma b/packages/database/prisma/schema.prisma index 39cedb9..60a102c 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -39,6 +39,21 @@ model User { InvitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id]) invitationCodeId String? Space Space[] + Workspace Workspace? +} + +model Workspace { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deleted DateTime? + + name String + slug String @unique + icon String? + + userId String? @unique + user User? @relation(fields: [userId], references: [id]) } enum AuthenticationMethod { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 497bde7..ba7a86e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: isbot: specifier: ^4.1.0 version: 4.4.0 + jose: + specifier: ^5.2.3 + version: 5.10.0 lucide-react: specifier: ^0.511.0 version: 0.511.0(react@18.3.1) @@ -3538,6 +3541,9 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -9408,6 +9414,8 @@ snapshots: jiti@2.4.2: {} + jose@5.10.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: diff --git a/turbo.json b/turbo.json index 4ef0762..b4c1954 100644 --- a/turbo.json +++ b/turbo.json @@ -57,6 +57,7 @@ "AUTH_GOOGLE_CLIENT_ID", "AUTH_GOOGLE_CLIENT_SECRET", "APP_ENV", - "APP_LOG_LEVEL" + "APP_LOG_LEVEL", + "ENCRYPTION_KEY" ] }