Feat: added API

This commit is contained in:
Harshith Mullapudi 2025-06-03 10:02:36 +05:30
parent 80cf012528
commit 0853a30897
15 changed files with 1112 additions and 4 deletions

View File

@ -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"),

View File

@ -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<PersonalAccessTokenAuthenticationResult | null> {
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");
}

View File

@ -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 };

View File

@ -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<T> = {
[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<ApiAuthenticationResult> {
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<ApiAuthenticationResult> {
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" };
}

View File

@ -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" };
}

View File

@ -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<any, any>;
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<any, any>
? z.infer<TParamsSchema>
: undefined,
authentication: ApiAuthenticationResultSuccess,
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined,
) => Promise<TResource | undefined>;
shouldRetryNotFound?: boolean;
authorization?: {
action: AuthorizationAction;
resource: (
resource: NonNullable<TResource>,
params: TParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined,
headers: THeadersSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: 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<any, any>
? z.infer<TParamsSchema>
: undefined;
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
authentication: ApiAuthenticationResultSuccess;
request: Request;
resource: NonNullable<TResource>;
}) => Promise<Response>;
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<any, any>
? z.infer<TParamsSchema>
: undefined;
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
body: TBodySchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TBodySchema>
: undefined;
authentication: ApiAuthenticationResultSuccess;
request: Request;
}) => Promise<Response>;
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<Response> {
return useCors
? await apiCors(request, response, {
exposedHeaders: ["x-sol-jwt", "x-sol-jwt-claims"],
})
: response;
}

View File

@ -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<Response> {
if (hasCorsHeaders(response)) {
return response;
}
return cors(request, response, options);
}
export function makeApiCors(
request: Request,
options: CorsOptions = { maxAge: 5 * 60 },
): (response: Response) => Promise<Response> {
return (response: Response) => apiCors(request, response, options);
}
function hasCorsHeaders(response: Response) {
return response.headers.has("access-control-allow-origin");
}

View File

@ -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<T>(
schema: z.Schema<T>,
json: string,
): z.SafeParseReturnType<unknown, T> | 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<T>(
response: Response,
schema: z.Schema<T>,
): Promise<T | undefined> {
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<T>(
response: Response,
schema: z.Schema<T>,
): Promise<z.SafeParseReturnType<unknown, T> | undefined> {
try {
const unknownJson = await response.json();
if (!unknownJson) {
return;
}
const parsedJson = schema.safeParse(unknownJson);
return parsedJson;
} catch (error) {}
}

View File

@ -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",

12
helix/config.hx.json Normal file
View File

@ -0,0 +1,12 @@
{
"vector_config": {
"m": 16,
"ef_construction": 128,
"ef_search": 768
},
"graph_config": {
"secondary_indices": []
},
"db_max_size_gb": 10
}

26
helix/queries.hx Normal file
View File

@ -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>(user_id)::Out<Knows>
// 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<Embedding>(vector)
RETURN "Success"
QUERY hnswsearch(query: [F64], k: I32) =>
res <- SearchV<Embedding>(query, k)
RETURN res

38
helix/schema.hx Normal file
View File

@ -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]
}

View File

@ -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 {

8
pnpm-lock.yaml generated
View File

@ -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:

View File

@ -57,6 +57,7 @@
"AUTH_GOOGLE_CLIENT_ID",
"AUTH_GOOGLE_CLIENT_SECRET",
"APP_ENV",
"APP_LOG_LEVEL"
"APP_LOG_LEVEL",
"ENCRYPTION_KEY"
]
}