mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 10:08: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";
|
||||
|
||||
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"),
|
||||
|
||||
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",
|
||||
"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
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])
|
||||
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
8
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -57,6 +57,7 @@
|
||||
"AUTH_GOOGLE_CLIENT_ID",
|
||||
"AUTH_GOOGLE_CLIENT_SECRET",
|
||||
"APP_ENV",
|
||||
"APP_LOG_LEVEL"
|
||||
"APP_LOG_LEVEL",
|
||||
"ENCRYPTION_KEY"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user