mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 18:38:27 +00:00
Feat: added API
This commit is contained in:
parent
80cf012528
commit
0853a30897
@ -2,12 +2,16 @@ import { z } from "zod";
|
|||||||
import { isValidDatabaseUrl } from "./utils/db";
|
import { isValidDatabaseUrl } from "./utils/db";
|
||||||
|
|
||||||
const EnvironmentSchema = z.object({
|
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
|
DATABASE_URL: z
|
||||||
.string()
|
.string()
|
||||||
.refine(
|
.refine(
|
||||||
isValidDatabaseUrl,
|
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_CONNECTION_LIMIT: z.coerce.number().int().default(10),
|
||||||
DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60),
|
DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60),
|
||||||
@ -16,10 +20,11 @@ const EnvironmentSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.refine(
|
.refine(
|
||||||
isValidDatabaseUrl,
|
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(),
|
DATABASE_READ_REPLICA_URL: z.string().optional(),
|
||||||
SESSION_SECRET: z.string(),
|
SESSION_SECRET: z.string(),
|
||||||
|
ENCRYPTION_KEY: z.string(),
|
||||||
|
|
||||||
APP_ENV: z.string().default(process.env.NODE_ENV),
|
APP_ENV: z.string().default(process.env.NODE_ENV),
|
||||||
LOGIN_ORIGIN: z.string().default("http://localhost:5173"),
|
LOGIN_ORIGIN: z.string().default("http://localhost:5173"),
|
||||||
|
|||||||
95
apps/webapp/app/models/personal-token.server.ts
Normal file
95
apps/webapp/app/models/personal-token.server.ts
Normal 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");
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
|||||||
109
apps/webapp/app/services/apiAuth.server.ts
Normal file
109
apps/webapp/app/services/apiAuth.server.ts
Normal 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" };
|
||||||
|
}
|
||||||
30
apps/webapp/app/services/authorization.server.ts
Normal file
30
apps/webapp/app/services/authorization.server.ts
Normal 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" };
|
||||||
|
}
|
||||||
631
apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
Normal file
631
apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
Normal 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;
|
||||||
|
}
|
||||||
35
apps/webapp/app/utils/apiCors.ts
Normal file
35
apps/webapp/app/utils/apiCors.ts
Normal 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");
|
||||||
|
}
|
||||||
66
apps/webapp/app/utils/json.ts
Normal file
66
apps/webapp/app/utils/json.ts
Normal 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) {}
|
||||||
|
}
|
||||||
@ -36,6 +36,7 @@
|
|||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"nanoid": "3.3.8",
|
"nanoid": "3.3.8",
|
||||||
|
"jose": "^5.2.3",
|
||||||
"non.geist": "^1.0.2",
|
"non.geist": "^1.0.2",
|
||||||
"posthog-js": "^1.116.6",
|
"posthog-js": "^1.116.6",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
12
helix/config.hx.json
Normal file
12
helix/config.hx.json
Normal 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
26
helix/queries.hx
Normal 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
38
helix/schema.hx
Normal 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]
|
||||||
|
}
|
||||||
@ -39,6 +39,21 @@ model User {
|
|||||||
InvitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id])
|
InvitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id])
|
||||||
invitationCodeId String?
|
invitationCodeId String?
|
||||||
Space Space[]
|
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 {
|
enum AuthenticationMethod {
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -96,6 +96,9 @@ importers:
|
|||||||
isbot:
|
isbot:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
|
jose:
|
||||||
|
specifier: ^5.2.3
|
||||||
|
version: 5.10.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.511.0
|
specifier: ^0.511.0
|
||||||
version: 0.511.0(react@18.3.1)
|
version: 0.511.0(react@18.3.1)
|
||||||
@ -3538,6 +3541,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@5.10.0:
|
||||||
|
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@ -9408,6 +9414,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.4.2: {}
|
jiti@2.4.2: {}
|
||||||
|
|
||||||
|
jose@5.10.0: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@3.14.1:
|
js-yaml@3.14.1:
|
||||||
|
|||||||
@ -57,6 +57,7 @@
|
|||||||
"AUTH_GOOGLE_CLIENT_ID",
|
"AUTH_GOOGLE_CLIENT_ID",
|
||||||
"AUTH_GOOGLE_CLIENT_SECRET",
|
"AUTH_GOOGLE_CLIENT_SECRET",
|
||||||
"APP_ENV",
|
"APP_ENV",
|
||||||
"APP_LOG_LEVEL"
|
"APP_LOG_LEVEL",
|
||||||
|
"ENCRYPTION_KEY"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user