Feat: OAuth support for external apps (#22)

* Feat: OAuth support for external apps

* Fix: OAuth screen

---------

Co-authored-by: Manoj K <saimanoj58@gmail.com>
This commit is contained in:
Harshith Mullapudi 2025-07-19 16:44:15 +05:30 committed by GitHub
parent b7b24aecdd
commit 714399cf41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1742 additions and 168 deletions

View File

@ -3,6 +3,7 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { AutoSizer, List, type ListRowRenderer } from "react-virtualized";
import { cn } from "~/lib/utils";
import { Button } from "../ui";
import { LoaderCircle } from "lucide-react";
type ConversationItem = {
id: string;
@ -179,28 +180,27 @@ export const ConversationList = ({
return (
<div className="flex h-full flex-col pt-1 pl-1">
<div className="group grow overflow-hidden">
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
rowCount={rowCount}
rowHeight={32} // Slightly taller for better click area
rowRenderer={rowRenderer}
overscanRowCount={5}
/>
)}
</AutoSizer>
</div>
{!isLoading && conversations.length > 0 && (
<div className="group grow overflow-hidden">
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
rowCount={rowCount}
rowHeight={32} // Slightly taller for better click area
rowRenderer={rowRenderer}
overscanRowCount={5}
/>
)}
</AutoSizer>
</div>
)}
{isLoading && conversations.length === 0 && (
<div className="flex items-center justify-center p-8">
<div className="flex items-center space-x-2">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
<span className="text-muted-foreground text-sm">
Loading conversations...
</span>
<div className="flex flex-col items-center gap-2">
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
</div>
</div>
)}

View File

@ -0,0 +1,19 @@
import type { IconProps } from "./types";
export function Arrows({ size = 18, className }: IconProps) {
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 16 16"
fill="lch(38.893% 1 282.863 / 1)"
role="img"
focusable="false"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.378 7.5a.6.6 0 0 1 .6.6l-.002 1.774c2.375.224 8.027.917 8.027 1.328 0 .311-2.675.796-8.024 1.453l-.001 1.747a.6.6 0 0 1-.992.454L1.14 11.548a.4.4 0 0 1 0-.607l3.848-3.297a.6.6 0 0 1 .39-.144Zm4.79-6.291a.6.6 0 0 1 .846-.064l3.847 3.309a.4.4 0 0 1 0 .607l-3.848 3.296a.6.6 0 0 1-.99-.455V6.128C7.65 5.904 1.998 5.21 1.998 4.799c0-.31 2.675-.795 8.024-1.452l.001-1.747a.6.6 0 0 1 .145-.391Z"></path>
</svg>
);
}

View File

@ -1,2 +1,3 @@
export * from "./slack-icon";
export * from "./linear-icon";
export * from "./arrows";

View File

@ -1,5 +1,5 @@
import React, { useMemo } from "react";
import { Search } from "lucide-react";
import { Layout, LayoutGrid, Search } from "lucide-react";
import { IntegrationCard } from "./integration-card";
interface IntegrationGridProps {
@ -25,8 +25,8 @@ export function IntegrationGrid({
if (integrations.length === 0) {
return (
<div className="mt-20 flex flex-col items-center justify-center">
<Search className="text-muted-foreground mb-2 h-12 w-12" />
<h3 className="text-lg font-medium">No integrations found</h3>
<LayoutGrid className="text-muted-foreground mb-2 h-10 w-10" />
<h3 className="text-lg">No integrations found</h3>
</div>
);
}

View File

@ -1,11 +1,5 @@
import { useState } from "react";
import {
ChevronsUpDown,
Filter,
FilterIcon,
ListFilter,
X,
} from "lucide-react";
import { ListFilter, X } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Popover,

View File

@ -3,7 +3,6 @@ import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "../ui/sidebar";
import { useLocation, useNavigate } from "@remix-run/react";

View File

@ -8,20 +8,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "../ui/sidebar";
import { SidebarMenu, SidebarMenuItem, useSidebar } from "../ui/sidebar";
import type { User } from "~/models/user.server";
import { Button } from "../ui";
import { cn } from "~/lib/utils";
import { useLocation, useNavigate } from "@remix-run/react";
import { useNavigate } from "@remix-run/react";
export function NavUser({ user }: { user: User }) {
const { isMobile } = useSidebar();
const location = useLocation();
const navigate = useNavigate();
return (
@ -55,7 +48,7 @@ export function NavUser({ user }: { user: User }) {
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex gap-2"
onClick={() => navigate("/settings")}
onClick={() => navigate("/settings/api")}
>
<Settings size={16} />
Settings

View File

@ -0,0 +1,186 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/node";
import { PrismaClient } from "@prisma/client";
import { requireAuth } from "~/utils/auth-helper";
import crypto from "crypto";
const prisma = new PrismaClient();
// GET /api/oauth/clients/:clientId - Get specific OAuth client
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireAuth(request);
const { clientId } = params;
if (!clientId) {
return json({ error: "Client ID is required" }, { status: 400 });
}
try {
// Get user's workspace
const userRecord = await prisma.user.findUnique({
where: { id: user.id },
include: { Workspace: true },
});
if (!userRecord?.Workspace) {
return json({ error: "No workspace found" }, { status: 404 });
}
const client = await prisma.oAuthClient.findFirst({
where: {
id: clientId,
workspaceId: userRecord.Workspace.id,
},
select: {
id: true,
clientId: true,
name: true,
description: true,
redirectUris: true,
allowedScopes: true,
requirePkce: true,
logoUrl: true,
homepageUrl: true,
isActive: true,
createdAt: true,
updatedAt: true,
createdBy: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
if (!client) {
return json({ error: "OAuth client not found" }, { status: 404 });
}
return json({ client });
} catch (error) {
console.error("Error fetching OAuth client:", error);
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireAuth(request);
const { clientId } = params;
const method = request.method;
if (!clientId) {
return json({ error: "Client ID is required" }, { status: 400 });
}
try {
// Get user's workspace
const userRecord = await prisma.user.findUnique({
where: { id: user.id },
include: { Workspace: true },
});
if (!userRecord?.Workspace) {
return json({ error: "No workspace found" }, { status: 404 });
}
// Verify client exists and belongs to user's workspace
const existingClient = await prisma.oAuthClient.findFirst({
where: {
id: clientId,
workspaceId: userRecord.Workspace.id,
},
});
if (!existingClient) {
return json({ error: "OAuth client not found" }, { status: 404 });
}
// PATCH - Update OAuth client
if (method === "PATCH") {
const body = await request.json();
const { name, description, redirectUris, allowedScopes, requirePkce, logoUrl, homepageUrl, isActive } = body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (redirectUris !== undefined) {
updateData.redirectUris = Array.isArray(redirectUris) ? redirectUris.join(',') : redirectUris;
}
if (allowedScopes !== undefined) {
updateData.allowedScopes = Array.isArray(allowedScopes) ? allowedScopes.join(',') : allowedScopes;
}
if (requirePkce !== undefined) updateData.requirePkce = requirePkce;
if (logoUrl !== undefined) updateData.logoUrl = logoUrl;
if (homepageUrl !== undefined) updateData.homepageUrl = homepageUrl;
if (isActive !== undefined) updateData.isActive = isActive;
const updatedClient = await prisma.oAuthClient.update({
where: { id: clientId },
data: updateData,
select: {
id: true,
clientId: true,
name: true,
description: true,
redirectUris: true,
allowedScopes: true,
requirePkce: true,
logoUrl: true,
homepageUrl: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
return json({ success: true, client: updatedClient });
}
// POST - Regenerate client secret
if (method === "POST") {
const body = await request.json();
const { action } = body;
if (action === "regenerate_secret") {
const newClientSecret = crypto.randomBytes(32).toString('hex');
const updatedClient = await prisma.oAuthClient.update({
where: { id: clientId },
data: { clientSecret: newClientSecret },
select: {
id: true,
clientId: true,
clientSecret: true,
name: true,
},
});
return json({
success: true,
client: updatedClient,
message: "Client secret regenerated successfully. Save it securely - it won't be shown again."
});
}
return json({ error: "Invalid action" }, { status: 400 });
}
// DELETE - Delete OAuth client
if (method === "DELETE") {
await prisma.oAuthClient.delete({
where: { id: clientId },
});
return json({ success: true, message: "OAuth client deleted successfully" });
}
return json({ error: "Method not allowed" }, { status: 405 });
} catch (error) {
console.error("Error managing OAuth client:", error);
return json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@ -0,0 +1,148 @@
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
json,
} from "@remix-run/node";
import { PrismaClient } from "@prisma/client";
import { requireAuth } from "~/utils/auth-helper";
import crypto from "crypto";
const prisma = new PrismaClient();
// GET /api/oauth/clients - List OAuth clients for user's workspace
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireAuth(request);
try {
// Get user's workspace
const userRecord = await prisma.user.findUnique({
where: { id: user.id },
include: { Workspace: true },
});
if (!userRecord?.Workspace) {
return json({ error: "No workspace found" }, { status: 404 });
}
const clients = await prisma.oAuthClient.findMany({
where: { workspaceId: userRecord.Workspace.id },
select: {
id: true,
clientId: true,
name: true,
description: true,
redirectUris: true,
allowedScopes: true,
requirePkce: true,
logoUrl: true,
homepageUrl: true,
isActive: true,
createdAt: true,
updatedAt: true,
createdBy: {
select: {
id: true,
email: true,
name: true,
},
},
},
orderBy: { createdAt: "desc" },
});
return json({ clients });
} catch (error) {
console.error("Error fetching OAuth clients:", error);
return json({ error: "Internal server error" }, { status: 500 });
}
};
// POST /api/oauth/clients - Create new OAuth client
export const action = async ({ request }: ActionFunctionArgs) => {
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}
const user = await requireAuth(request);
try {
const body = await request.json();
const {
name,
description,
redirectUris,
allowedScopes,
requirePkce,
logoUrl,
homepageUrl,
} = body;
// Validate required fields
if (!name || !redirectUris) {
return json(
{ error: "Name and redirectUris are required" },
{ status: 400 },
);
}
// Get user's workspace
const userRecord = await prisma.user.findUnique({
where: { id: user.id },
include: { Workspace: true },
});
if (!userRecord?.Workspace) {
return json({ error: "No workspace found" }, { status: 404 });
}
// Generate client credentials
const clientId = crypto.randomUUID();
const clientSecret = crypto.randomBytes(32).toString("hex");
// Create OAuth client
const client = await prisma.oAuthClient.create({
data: {
clientId,
clientSecret,
name,
description: description || null,
redirectUris: Array.isArray(redirectUris)
? redirectUris.join(",")
: redirectUris,
allowedScopes: Array.isArray(allowedScopes)
? allowedScopes.join(",")
: allowedScopes || "read",
requirePkce: requirePkce || false,
logoUrl: logoUrl || null,
homepageUrl: homepageUrl || null,
workspaceId: userRecord.Workspace.id,
createdById: user.id,
},
select: {
id: true,
clientId: true,
clientSecret: true,
name: true,
description: true,
redirectUris: true,
allowedScopes: true,
requirePkce: true,
logoUrl: true,
homepageUrl: true,
isActive: true,
createdAt: true,
},
});
return json({
success: true,
client,
message:
"OAuth client created successfully. Save the client_secret securely - it won't be shown again.",
});
} catch (error) {
console.error("Error creating OAuth client:", error);
return json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@ -0,0 +1,33 @@
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { requireOAuth2, requireScope } from "~/utils/oauth2-middleware";
export const loader = async ({ request }: LoaderFunctionArgs) => {
try {
// Require OAuth2 authentication
const oauth2Context = await requireOAuth2(request);
// Require 'read' scope
requireScope(oauth2Context, 'read');
// Return user profile information
return json({
user: oauth2Context.user,
client: {
name: oauth2Context.client.name,
id: oauth2Context.client.clientId,
},
scope: oauth2Context.token.scope,
});
} catch (error) {
// Error responses are already formatted by the middleware
throw error;
}
};
// This endpoint only supports GET
export const action = () => {
return json(
{ error: "method_not_allowed", error_description: "Only GET method is allowed" },
{ status: 405 }
);
};

View File

@ -8,7 +8,6 @@ import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.
import { addToQueue } from "~/lib/ingest.server";
import { SearchService } from "~/services/search.server";
import { handleTransport } from "~/utils/mcp";
import { IngestBodyRequest } from "~/trigger/ingest/ingest";
// Map to store transports by session ID with cleanup tracking
const transports: {
@ -39,16 +38,14 @@ const MCPRequestSchema = z.object({}).passthrough();
// Search parameters schema for MCP tool
const SearchParamsSchema = z.object({
query: z.string(),
startTime: z.string().optional(),
endTime: z.string().optional(),
spaceId: z.string().optional(),
limit: z.number().optional(),
maxBfsDepth: z.number().optional(),
includeInvalidated: z.boolean().optional(),
entityTypes: z.array(z.string()).optional(),
scoreThreshold: z.number().optional(),
minResults: z.number().optional(),
query: z.string().describe("The search query in third person perspective"),
validAt: z.string().optional().describe("The valid at time in ISO format"),
startTime: z.string().optional().describe("The start time in ISO format"),
endTime: z.string().optional().describe("The end time in ISO format"),
});
const IngestSchema = z.object({
message: z.string().describe("The data to ingest in text format"),
});
const searchService = new SearchService();
@ -60,6 +57,22 @@ const handleMCPRequest = async (
authentication: any,
) => {
const sessionId = request.headers.get("mcp-session-id") as string | undefined;
const source = request.headers.get("source") as string | undefined;
if (!source) {
return json(
{
jsonrpc: "2.0",
error: {
code: -32601,
message: "No source found",
},
id: null,
},
{ status: 400 },
);
}
let transport: StreamableHTTPServerTransport;
try {
@ -104,14 +117,18 @@ const handleMCPRequest = async (
{
title: "Ingest Data",
description: "Ingest data into the memory system",
inputSchema: IngestBodyRequest.shape,
inputSchema: IngestSchema.shape,
},
async (args) => {
try {
const userId = authentication.userId;
const response = addToQueue(
args as z.infer<typeof IngestBodyRequest>,
{
episodeBody: args.message,
referenceTime: new Date().toISOString(),
source,
},
userId,
);
return {

View File

@ -57,7 +57,7 @@ export default function LogsActivity() {
},
]}
/>
<div className="flex h-[calc(100vh_-_56px)] flex-col space-y-6 p-4 px-5">
<div className="flex h-[calc(100vh_-_56px)] flex-col items-center space-y-6 p-4 px-5">
{isInitialLoad ? (
<>
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}

View File

@ -45,7 +45,7 @@ export default function LogsAll() {
},
]}
/>
<div className="h-[calc(100vh_-_56px)] space-y-6 p-4 px-5">
<div className="flex h-[calc(100vh_-_56px)] flex-col items-center space-y-6 p-4 px-5">
{isInitialLoad ? (
<>
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />{" "}

View File

@ -13,7 +13,7 @@ import {
CardTitle,
} from "~/components/ui/card";
import { Form, useNavigation } from "@remix-run/react";
import { Inbox, Loader, Mail } from "lucide-react";
import { Inbox, Loader, LoaderCircle, Mail } from "lucide-react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { LoginPageLayout } from "~/components/layout/login-page-layout";
@ -203,7 +203,7 @@ export default function LoginMagicLinkPage() {
data-action="send a magic link"
>
{isLoading ? (
<Loader className="mr-2 size-5" color="white" />
<LoaderCircle className="text-primary h-4 w-4 animate-spin" />
) : (
<Mail className="text-text-bright mr-2 size-5" />
)}

View File

@ -0,0 +1,279 @@
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
} from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { getUser } from "~/services/session.server";
import {
oauth2Service,
OAuth2Errors,
type OAuth2AuthorizeRequest,
} from "~/services/oauth2.server";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Arrows } from "~/components/icons";
import Logo from "~/components/logo/logo";
import { AlignLeft, LayoutGrid, Pen } from "lucide-react";
export const loader = async ({ request }: LoaderFunctionArgs) => {
// Check if user is authenticated
const user = await getUser(request);
if (!user) {
// Redirect to login with return URL
const url = new URL(request.url);
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirectTo", url.pathname + url.search);
return redirect(loginUrl.toString());
}
const url = new URL(request.url);
let scopeParam = url.searchParams.get("scope") || undefined;
// If scope is present, remove spaces after commas (e.g., "read, write" -> "read,write")
if (scopeParam) {
scopeParam = scopeParam
.split(",")
.map((s) => s.trim())
.join(",");
} else {
throw new Error("Scope is not found");
}
const params: OAuth2AuthorizeRequest = {
client_id: url.searchParams.get("client_id") || "",
redirect_uri: url.searchParams.get("redirect_uri") || "",
response_type: url.searchParams.get("response_type") || "",
scope: scopeParam,
state: url.searchParams.get("state") || undefined,
code_challenge: url.searchParams.get("code_challenge") || undefined,
code_challenge_method:
url.searchParams.get("code_challenge_method") || undefined,
};
// Validate required parameters
if (!params.client_id || !params.redirect_uri || !params.response_type) {
return redirect(
`${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Missing required parameters${params.state ? `&state=${params.state}` : ""}`,
);
}
// Only support authorization code flow
if (params.response_type !== "code") {
return redirect(
`${params.redirect_uri}?error=${OAuth2Errors.UNSUPPORTED_RESPONSE_TYPE}&error_description=Only authorization code flow is supported${params.state ? `&state=${params.state}` : ""}`,
);
}
try {
// Validate client
const client = await oauth2Service.validateClient(params.client_id);
// Validate redirect URI
if (!oauth2Service.validateRedirectUri(client, params.redirect_uri)) {
return redirect(
`${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Invalid redirect URI${params.state ? `&state=${params.state}` : ""}`,
);
}
return {
user,
client,
params,
};
} catch (error) {
return redirect(
`${params.redirect_uri}?error=${OAuth2Errors.INVALID_CLIENT}&error_description=Invalid client${params.state ? `&state=${params.state}` : ""}`,
);
}
};
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await getUser(request);
if (!user) {
return redirect("/login");
}
const formData = await request.formData();
const action = formData.get("action");
const params: OAuth2AuthorizeRequest = {
client_id: formData.get("client_id") as string,
redirect_uri: formData.get("redirect_uri") as string,
response_type: formData.get("response_type") as string,
scope: (formData.get("scope") as string) || undefined,
state: (formData.get("state") as string) || undefined,
code_challenge: (formData.get("code_challenge") as string) || undefined,
code_challenge_method:
(formData.get("code_challenge_method") as string) || undefined,
};
if (action === "deny") {
return redirect(
`${params.redirect_uri}?error=${OAuth2Errors.ACCESS_DENIED}&error_description=User denied access${params.state ? `&state=${params.state}` : ""}`,
);
}
if (action === "allow") {
try {
// Validate client again
const client = await oauth2Service.validateClient(params.client_id);
if (!oauth2Service.validateRedirectUri(client, params.redirect_uri)) {
return redirect(
`${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Invalid redirect URI${params.state ? `&state=${params.state}` : ""}`,
);
}
// Create authorization code
const authCode = await oauth2Service.createAuthorizationCode({
clientId: params.client_id,
userId: user.id,
redirectUri: params.redirect_uri,
scope: params.scope,
state: params.state,
codeChallenge: params.code_challenge,
codeChallengeMethod: params.code_challenge_method,
});
// Redirect back to client with authorization code
const redirectUrl = new URL(params.redirect_uri);
redirectUrl.searchParams.set("code", authCode);
if (params.state) {
redirectUrl.searchParams.set("state", params.state);
}
return redirect(redirectUrl.toString());
} catch (error) {
return redirect(
`${params.redirect_uri}?error=${OAuth2Errors.SERVER_ERROR}&error_description=Failed to create authorization code${params.state ? `&state=${params.state}` : ""}`,
);
}
}
return redirect(
`${params.redirect_uri}?error=${OAuth2Errors.INVALID_REQUEST}&error_description=Invalid action${params.state ? `&state=${params.state}` : ""}`,
);
};
export default function OAuthAuthorize() {
const { user, client, params } = useLoaderData<typeof loader>();
const getIcon = (scope: string) => {
if (scope === "read") {
return <AlignLeft size={16} />;
}
return <Pen size={16} />;
};
return (
<div className="bg-background-2 flex min-h-screen items-center justify-center">
<Card className="bg-background-3 shadow-1 w-full max-w-md rounded-lg p-5">
<CardContent>
<div className="flex items-center justify-center gap-4">
{client.logoUrl ? (
<img
src={client.logoUrl}
alt={client.name}
className="h-[40px] w-[40px] rounded"
/>
) : (
<LayoutGrid size={40} />
)}
<Arrows size={16} />
<Logo width={40} height={40} />
</div>
<div className="mt-4 space-y-4">
<div className="flex items-center justify-center space-x-3 text-center">
<div>
<p className="text-lg font-normal">
{client.name} is requesting access
</p>
<p className="text-muted-foreground text-sm">
Authenticating with your {user.name} workspace
</p>
</div>
</div>
<p className="text-muted-foreground mb-2 text-sm">Permissions</p>
<ul className="text-muted-foreground text-sm">
{params.scope?.split(",").map((scope, index, arr) => {
const isFirst = index === 0;
const isLast = index === arr.length - 1;
return (
<li
key={index}
className={`border-border flex items-center gap-2 border-x border-t p-2 ${isLast ? "border-b" : ""} ${isFirst ? "rounded-tl-md rounded-tr-md" : ""} ${isLast ? "rounded-br-md rounded-bl-md" : ""} `}
>
<div>{getIcon(scope)}</div>
<div>
{scope.charAt(0).toUpperCase() + scope.slice(1)} access to
your workspace
</div>
</li>
);
})}
</ul>
<Form method="post" className="space-y-3">
<input type="hidden" name="client_id" value={params.client_id} />
<input
type="hidden"
name="redirect_uri"
value={params.redirect_uri}
/>
<input
type="hidden"
name="response_type"
value={params.response_type}
/>
{params.scope && (
<input type="hidden" name="scope" value={params.scope} />
)}
{params.state && (
<input type="hidden" name="state" value={params.state} />
)}
{params.code_challenge && (
<input
type="hidden"
name="code_challenge"
value={params.code_challenge}
/>
)}
{params.code_challenge_method && (
<input
type="hidden"
name="code_challenge_method"
value={params.code_challenge_method}
/>
)}
<div className="flex justify-end space-x-3">
<Button
type="submit"
name="action"
value="deny"
size="lg"
variant="secondary"
>
Deny
</Button>
<Button
type="submit"
name="action"
value="allow"
size="lg"
className="shadow-none"
>
Allow Access
</Button>
</div>
</Form>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,143 @@
import { type ActionFunctionArgs, json } from "@remix-run/node";
import { oauth2Service, OAuth2Errors, type OAuth2TokenRequest } from "~/services/oauth2.server";
export const action = async ({ request }: ActionFunctionArgs) => {
if (request.method !== "POST") {
return json(
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Only POST method is allowed" },
{ status: 405 }
);
}
try {
const contentType = request.headers.get("content-type");
let body: any;
let tokenRequest: OAuth2TokenRequest;
// Support both JSON and form-encoded data
if (contentType?.includes("application/json")) {
body = await request.json();
tokenRequest = {
grant_type: body.grant_type,
code: body.code || undefined,
redirect_uri: body.redirect_uri || undefined,
client_id: body.client_id,
client_secret: body.client_secret || undefined,
code_verifier: body.code_verifier || undefined,
};
} else {
// Fall back to form data for compatibility
const formData = await request.formData();
body = Object.fromEntries(formData);
tokenRequest = {
grant_type: formData.get("grant_type") as string,
code: formData.get("code") as string || undefined,
redirect_uri: formData.get("redirect_uri") as string || undefined,
client_id: formData.get("client_id") as string,
client_secret: formData.get("client_secret") as string || undefined,
code_verifier: formData.get("code_verifier") as string || undefined,
};
}
// Validate required parameters
if (!tokenRequest.grant_type || !tokenRequest.client_id) {
return json(
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing required parameters" },
{ status: 400 }
);
}
// Handle authorization code grant
if (tokenRequest.grant_type === "authorization_code") {
if (!tokenRequest.code || !tokenRequest.redirect_uri) {
return json(
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing code or redirect_uri" },
{ status: 400 }
);
}
// Validate client
try {
await oauth2Service.validateClient(tokenRequest.client_id, tokenRequest.client_secret);
} catch (error) {
return json(
{ error: OAuth2Errors.INVALID_CLIENT, error_description: "Invalid client credentials" },
{ status: 401 }
);
}
// Exchange code for tokens
try {
const tokens = await oauth2Service.exchangeCodeForTokens({
code: tokenRequest.code,
clientId: tokenRequest.client_id,
redirectUri: tokenRequest.redirect_uri,
codeVerifier: tokenRequest.code_verifier,
});
return json(tokens);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return json(
{ error: errorMessage, error_description: "Failed to exchange code for tokens" },
{ status: 400 }
);
}
}
// Handle refresh token grant
if (tokenRequest.grant_type === "refresh_token") {
const refreshToken = body.refresh_token;
if (!refreshToken) {
return json(
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Missing refresh_token" },
{ status: 400 }
);
}
// Validate client
try {
await oauth2Service.validateClient(tokenRequest.client_id, tokenRequest.client_secret);
} catch (error) {
return json(
{ error: OAuth2Errors.INVALID_CLIENT, error_description: "Invalid client credentials" },
{ status: 401 }
);
}
// Refresh access token
try {
const tokens = await oauth2Service.refreshAccessToken(refreshToken, tokenRequest.client_id);
return json(tokens);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return json(
{ error: errorMessage, error_description: "Failed to refresh access token" },
{ status: 400 }
);
}
}
// Unsupported grant type
return json(
{ error: OAuth2Errors.UNSUPPORTED_GRANT_TYPE, error_description: "Unsupported grant type" },
{ status: 400 }
);
} catch (error) {
console.error("OAuth2 token endpoint error:", error);
return json(
{ error: OAuth2Errors.SERVER_ERROR, error_description: "Internal server error" },
{ status: 500 }
);
}
};
// This endpoint only supports POST
export const loader = () => {
return json(
{ error: OAuth2Errors.INVALID_REQUEST, error_description: "Only POST method is allowed" },
{ status: 405 }
);
};

View File

@ -0,0 +1,44 @@
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { oauth2Service } from "~/services/oauth2.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
try {
// Get authorization header
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return json(
{ error: "invalid_token", error_description: "Missing or invalid authorization header" },
{ status: 401 }
);
}
const token = authHeader.substring(7); // Remove "Bearer " prefix
// Validate token and get user info
try {
const userInfo = await oauth2Service.getUserInfo(token);
return json(userInfo);
} catch (error) {
return json(
{ error: "invalid_token", error_description: "Invalid or expired access token" },
{ status: 401 }
);
}
} catch (error) {
console.error("OAuth2 userinfo endpoint error:", error);
return json(
{ error: "server_error", error_description: "Internal server error" },
{ status: 500 }
);
}
};
// This endpoint only supports GET
export const action = () => {
return json(
{ error: "invalid_request", error_description: "Only GET method is allowed" },
{ status: 405 }
);
};

View File

@ -1,4 +1,5 @@
import { findUserByToken } from "~/models/personal-token.server";
import { oauth2Service } from "~/services/oauth2.server";
// See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20
export type Prettify<T> = {
@ -12,10 +13,14 @@ export type ApiAuthenticationResult =
export type ApiAuthenticationResultSuccess = {
ok: true;
apiKey: string;
type: "PRIVATE";
type: "PRIVATE" | "OAUTH2";
userId: string;
scopes?: string[];
oneTimeUse?: boolean;
oauth2?: {
clientId: string;
scope: string | null;
};
};
export type ApiAuthenticationResultFailure = {
@ -53,6 +58,27 @@ export async function authenticateApiKeyWithFailure(
apiKey: string,
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {},
): Promise<ApiAuthenticationResult> {
// First try OAuth2 access token
try {
const accessToken = await oauth2Service.validateAccessToken(apiKey);
if (accessToken) {
return {
ok: true,
apiKey,
type: "OAUTH2",
userId: accessToken.user.id,
scopes: accessToken.scope ? accessToken.scope.split(' ') : undefined,
oauth2: {
clientId: accessToken.client.clientId,
scope: accessToken.scope,
},
};
}
} catch (error) {
// If OAuth2 token validation fails, continue to PAT validation
}
// Fall back to PAT authentication
const result = getApiKeyResult(apiKey);
if (!result) {

View File

@ -7,7 +7,7 @@ export type AuthorizationResources = {
};
export type AuthorizationEntity = {
type: "PRIVATE";
type: "PRIVATE" | "OAUTH2";
scopes?: string[];
};
@ -26,5 +26,10 @@ export function checkAuthorization(
return { authorized: true };
}
// "OAUTH2" tokens are also authorized (scope-based authorization can be added later)
if (entity.type === "OAUTH2") {
return { authorized: true };
}
return { authorized: false, reason: "No key" };
}

View File

@ -0,0 +1,336 @@
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
const prisma = new PrismaClient();
export interface OAuth2AuthorizeRequest {
client_id: string;
redirect_uri: string;
response_type: string;
scope?: string;
state?: string;
code_challenge?: string;
code_challenge_method?: string;
}
export interface OAuth2TokenRequest {
grant_type: string;
code?: string;
redirect_uri?: string;
client_id: string;
client_secret?: string;
code_verifier?: string;
}
export interface OAuth2TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope?: string;
}
export interface OAuth2ErrorResponse {
error: string;
error_description?: string;
error_uri?: string;
state?: string;
}
// OAuth2 Error types
export const OAuth2Errors = {
INVALID_REQUEST: "invalid_request",
INVALID_CLIENT: "invalid_client",
INVALID_GRANT: "invalid_grant",
UNAUTHORIZED_CLIENT: "unauthorized_client",
UNSUPPORTED_GRANT_TYPE: "unsupported_grant_type",
INVALID_SCOPE: "invalid_scope",
ACCESS_DENIED: "access_denied",
UNSUPPORTED_RESPONSE_TYPE: "unsupported_response_type",
SERVER_ERROR: "server_error",
TEMPORARILY_UNAVAILABLE: "temporarily_unavailable",
} as const;
export class OAuth2Service {
// Generate secure random string
private generateSecureToken(length: number = 32): string {
return crypto.randomBytes(length).toString("hex");
}
// Validate OAuth2 client
async validateClient(clientId: string, clientSecret?: string): Promise<any> {
const client = await prisma.oAuthClient.findUnique({
where: {
clientId,
isActive: true,
},
include: {
workspace: true,
},
});
if (!client) {
throw new Error(OAuth2Errors.INVALID_CLIENT);
}
// If client secret is provided, validate it
if (clientSecret && client.clientSecret !== clientSecret) {
throw new Error(OAuth2Errors.INVALID_CLIENT);
}
return client;
}
// Validate redirect URI
validateRedirectUri(client: any, redirectUri: string): boolean {
const allowedUris = client.redirectUris
.split(",")
.map((uri: string) => uri.trim());
return allowedUris.includes(redirectUri);
}
// Validate PKCE challenge
validatePkceChallenge(
codeVerifier: string,
codeChallenge: string,
method: string = "S256",
): boolean {
if (method === "S256") {
const hash = crypto.createHash("sha256").update(codeVerifier).digest();
const challenge = hash.toString("base64url");
return challenge === codeChallenge;
} else if (method === "plain") {
return codeVerifier === codeChallenge;
}
return false;
}
// Create authorization code
async createAuthorizationCode(params: {
clientId: string;
userId: string;
redirectUri: string;
scope?: string;
state?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
}): Promise<string> {
const code = this.generateSecureToken(32);
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
// Find the client to get the internal database ID
const client = await prisma.oAuthClient.findUnique({
where: { clientId: params.clientId },
select: { id: true },
});
if (!client) {
throw new Error(OAuth2Errors.INVALID_CLIENT);
}
await prisma.oAuthAuthorizationCode.create({
data: {
code,
clientId: client.id, // Use internal database ID
userId: params.userId,
redirectUri: params.redirectUri,
scope: params.scope,
state: params.state,
codeChallenge: params.codeChallenge,
codeChallengeMethod: params.codeChallengeMethod,
expiresAt,
},
});
return code;
}
// Exchange authorization code for tokens
async exchangeCodeForTokens(params: {
code: string;
clientId: string;
redirectUri: string;
codeVerifier?: string;
}): Promise<OAuth2TokenResponse> {
// Find the client first to get the internal database ID
const client = await prisma.oAuthClient.findUnique({
where: { clientId: params.clientId },
select: { id: true },
});
if (!client) {
throw new Error(OAuth2Errors.INVALID_CLIENT);
}
// Find and validate authorization code
const authCode = await prisma.oAuthAuthorizationCode.findFirst({
where: {
code: params.code,
clientId: client.id, // Use internal database ID
redirectUri: params.redirectUri,
used: false,
expiresAt: { gt: new Date() },
},
include: {
client: true,
user: true,
},
});
if (!authCode) {
throw new Error(OAuth2Errors.INVALID_GRANT);
}
// Validate PKCE if required
if (authCode.codeChallenge) {
if (!params.codeVerifier) {
throw new Error(OAuth2Errors.INVALID_REQUEST);
}
if (
!this.validatePkceChallenge(
params.codeVerifier,
authCode.codeChallenge,
authCode.codeChallengeMethod || "S256",
)
) {
throw new Error(OAuth2Errors.INVALID_GRANT);
}
}
// Mark code as used
await prisma.oAuthAuthorizationCode.update({
where: { id: authCode.id },
data: { used: true },
});
// Generate access token
const accessToken = this.generateSecureToken(64);
const refreshToken = this.generateSecureToken(64);
const expiresIn = 3600; // 1 hour
const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
const refreshTokenExpiresAt = new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000,
); // 30 days
// Store tokens
await prisma.oAuthAccessToken.create({
data: {
token: accessToken,
clientId: client.id, // Use internal database ID
userId: authCode.userId,
scope: authCode.scope,
expiresAt: accessTokenExpiresAt,
},
});
await prisma.oAuthRefreshToken.create({
data: {
token: refreshToken,
clientId: client.id, // Use internal database ID
userId: authCode.userId,
scope: authCode.scope,
expiresAt: refreshTokenExpiresAt,
},
});
return {
access_token: accessToken,
token_type: "Bearer",
expires_in: expiresIn,
refresh_token: refreshToken,
scope: authCode.scope || undefined,
};
}
// Validate access token
async validateAccessToken(token: string): Promise<any> {
const accessToken = await prisma.oAuthAccessToken.findFirst({
where: {
token,
revoked: false,
expiresAt: { gt: new Date() },
},
include: {
client: true,
user: true,
},
});
if (!accessToken) {
throw new Error("Invalid or expired token");
}
return accessToken;
}
// Get user info from access token
async getUserInfo(token: string): Promise<any> {
const accessToken = await this.validateAccessToken(token);
return {
sub: accessToken.user.id,
email: accessToken.user.email,
name: accessToken.user.name,
display_name: accessToken.user.displayName,
avatar_url: accessToken.user.avatarUrl,
email_verified: true, // Assuming email is verified if user exists
};
}
// Refresh access token
async refreshAccessToken(
refreshToken: string,
clientId: string,
): Promise<OAuth2TokenResponse> {
// Find the client first to get the internal database ID
const client = await prisma.oAuthClient.findUnique({
where: { clientId },
select: { id: true },
});
if (!client) {
throw new Error(OAuth2Errors.INVALID_CLIENT);
}
const storedRefreshToken = await prisma.oAuthRefreshToken.findFirst({
where: {
token: refreshToken,
clientId: client.id, // Use internal database ID
revoked: false,
expiresAt: { gt: new Date() },
},
include: {
client: true,
user: true,
},
});
if (!storedRefreshToken) {
throw new Error(OAuth2Errors.INVALID_GRANT);
}
// Generate new access token
const accessToken = this.generateSecureToken(64);
const expiresIn = 3600; // 1 hour
const accessTokenExpiresAt = new Date(Date.now() + expiresIn * 1000);
await prisma.oAuthAccessToken.create({
data: {
token: accessToken,
clientId: client.id, // Use internal database ID
userId: storedRefreshToken.userId,
scope: storedRefreshToken.scope,
expiresAt: accessTokenExpiresAt,
},
});
return {
access_token: accessToken,
token_type: "Bearer",
expires_in: expiresIn,
scope: storedRefreshToken.scope || undefined,
};
}
}
export const oauth2Service = new OAuth2Service();

View File

@ -305,7 +305,7 @@
--radius-full: 9999px;
--shadow: 0px 6px 20px 0px rgba(0, 0, 0, 0.15), 0px 0px 2px 0px rgba(0, 0, 0, 0.2);
--shadow-1: 0px 6px 20px 0px rgba(0, 0, 0, 0.15), 0px 0px 2px 0px rgba(0, 0, 0, 0.2);
--shadow-1: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
--font-sans: "Geist Variable", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
--font-mono: "Geist Mono Variable", monaco, Consolas, "Lucida Console", monospace;

View File

@ -12,7 +12,6 @@ export interface SearchMemoryParams {
export interface AddMemoryParams {
message: string;
referenceTime?: string;
source?: string;
spaceId?: string;
sessionId?: string;
metadata?: any;
@ -38,7 +37,7 @@ export const addMemory = async (params: AddMemoryParams) => {
...params,
episodeBody: params.message,
referenceTime: params.referenceTime || new Date().toISOString(),
source: params.source || "CORE",
source: "CORE",
};
const response = await axios.post(

View File

@ -0,0 +1,109 @@
import { json, redirect } from "@remix-run/node";
import { getUser } from "~/services/session.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { oauth2Service } from "~/services/oauth2.server";
import { getUserById } from "~/models/user.server";
export type AuthenticatedUser = {
id: string;
email: string;
name: string | null;
displayName: string | null;
avatarUrl: string | null;
admin: boolean;
createdAt: Date;
updatedAt: Date;
confirmedBasicDetails: boolean;
authMethod: 'session' | 'pat' | 'oauth2';
oauth2?: {
clientId: string;
scope: string | null;
};
};
/**
* Authenticates a request using session, PAT, or OAuth2 access token
* Returns the authenticated user or throws an error response
*/
export async function requireAuth(request: Request): Promise<AuthenticatedUser> {
const authHeader = request.headers.get("authorization");
// Try OAuth2 access token authentication
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.substring(7);
// Check if it's a PAT token first
const patAuth = await authenticateApiRequestWithPersonalAccessToken(request);
if (patAuth) {
const user = await getUserById(patAuth.userId);
if (user) {
return {
id: user.id,
email: user.email,
name: user.name,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
admin: user.admin,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
confirmedBasicDetails: user.confirmedBasicDetails,
authMethod: 'pat',
};
}
}
// Try OAuth2 access token
try {
const accessToken = await oauth2Service.validateAccessToken(token);
return {
id: accessToken.user.id,
email: accessToken.user.email,
name: accessToken.user.name,
displayName: accessToken.user.displayName,
avatarUrl: accessToken.user.avatarUrl,
admin: accessToken.user.admin,
createdAt: accessToken.user.createdAt,
updatedAt: accessToken.user.updatedAt,
confirmedBasicDetails: accessToken.user.confirmedBasicDetails,
authMethod: 'oauth2',
oauth2: {
clientId: accessToken.client.clientId,
scope: accessToken.scope,
},
};
} catch (error) {
// OAuth2 token validation failed, continue to session auth
}
}
// Try session authentication
const sessionUser = await getUser(request);
if (sessionUser) {
return {
id: sessionUser.id,
email: sessionUser.email,
name: sessionUser.name,
displayName: sessionUser.displayName,
avatarUrl: sessionUser.avatarUrl,
admin: sessionUser.admin,
createdAt: sessionUser.createdAt,
updatedAt: sessionUser.updatedAt,
confirmedBasicDetails: sessionUser.confirmedBasicDetails,
authMethod: 'session',
};
}
// If no authentication method worked, return 401
throw json({ error: "Unauthorized" }, { status: 401 });
}
/**
* Optional authentication - returns user if authenticated, null otherwise
*/
export async function getAuthenticatedUser(request: Request): Promise<AuthenticatedUser | null> {
try {
return await requireAuth(request);
} catch (error) {
return null;
}
}

View File

@ -0,0 +1,92 @@
import { json } from "@remix-run/node";
import { oauth2Service } from "~/services/oauth2.server";
export interface OAuth2Context {
user: {
id: string;
email: string;
name: string | null;
displayName: string | null;
avatarUrl: string | null;
};
client: {
id: string;
clientId: string;
name: string;
};
token: {
id: string;
token: string;
scope: string | null;
expiresAt: Date;
};
}
export async function requireOAuth2(request: Request): Promise<OAuth2Context> {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw json(
{ error: "invalid_token", error_description: "Missing or invalid authorization header" },
{ status: 401 }
);
}
const token = authHeader.substring(7); // Remove "Bearer " prefix
try {
const accessToken = await oauth2Service.validateAccessToken(token);
return {
user: {
id: accessToken.user.id,
email: accessToken.user.email,
name: accessToken.user.name,
displayName: accessToken.user.displayName,
avatarUrl: accessToken.user.avatarUrl,
},
client: {
id: accessToken.client.id,
clientId: accessToken.client.clientId,
name: accessToken.client.name,
},
token: {
id: accessToken.id,
token: accessToken.token,
scope: accessToken.scope,
expiresAt: accessToken.expiresAt,
},
};
} catch (error) {
throw json(
{ error: "invalid_token", error_description: "Invalid or expired access token" },
{ status: 401 }
);
}
}
export async function getOAuth2Context(request: Request): Promise<OAuth2Context | null> {
try {
return await requireOAuth2(request);
} catch (error) {
return null;
}
}
export function hasScope(context: OAuth2Context, requiredScope: string): boolean {
if (!context.token.scope) {
return false;
}
const scopes = context.token.scope.split(' ');
return scopes.includes(requiredScope);
}
export function requireScope(context: OAuth2Context, requiredScope: string): void {
if (!hasScope(context, requiredScope)) {
throw json(
{ error: "insufficient_scope", error_description: `Required scope: ${requiredScope}` },
{ status: 403 }
);
}
}

View File

@ -35,19 +35,23 @@ npm install -g @redplanethq/core
### Initial Setup
1. **Run the initialization command:**
1. **Clone the Core repository:**
```bash
git clone https://github.com/redplanethq/core.git
cd core
```
2. **Run the initialization command:**
```bash
core init
```
2. **The CLI will guide you through the complete setup process:**
3. **The CLI will guide you through the complete setup process:**
#### Step 1: Repository Validation
- The CLI checks if you're in the Core repository
- If not, it offers to clone the repository for you
- Choose **Yes** to clone automatically, or **No** to clone manually
#### Step 1: Prerequisites Check
- The CLI shows a checklist of required tools
- Confirms you're in the Core repository directory
- Exits with instructions if prerequisites aren't met
#### Step 2: Environment Configuration
@ -130,9 +134,9 @@ After setup, these services will be available:
If you run commands outside the Core repository:
- The CLI will offer to clone the repository automatically
- Choose **Yes** to clone in the current directory
- Or navigate to the Core repository manually
- The CLI will ask you to confirm you're in the Core repository
- If not, it provides instructions to clone the repository
- Navigate to the Core repository directory before running commands again
### Docker Issues

View File

@ -1,5 +1,4 @@
import { intro, outro, text, confirm, spinner, note, log } from "@clack/prompts";
import { isValidCoreRepo } from "../utils/git.js";
import { fileExists, updateEnvFile } from "../utils/file.js";
import { checkPostgresHealth } from "../utils/docker.js";
import { executeDockerCommandInteractive } from "../utils/docker-interactive.js";
@ -14,42 +13,17 @@ export async function initCommand() {
intro("🚀 Core Development Environment Setup");
// Step 1: Validate repository
if (!isValidCoreRepo()) {
log.warning("This directory is not a Core repository");
note(
"The Core repository is required to run the development environment.\nWould you like to clone it in the current directory?",
"🔍 Repository Not Found"
);
// Step 1: Confirm this is the Core repository
note("Please ensure you have:\n• Docker and Docker Compose installed\n• Git installed\n• pnpm package manager installed\n• You are in the Core repository directory", "📋 Prerequisites");
const isCoreRepo = await confirm({
message: "Are you currently in the Core repository directory?",
});
const shouldClone = await confirm({
message: "Clone the Core repository here?",
});
if (!shouldClone) {
outro("❌ Setup cancelled. Please navigate to the Core repository or clone it first.");
process.exit(1);
}
// Clone the repository
try {
await executeDockerCommandInteractive("git clone https://github.com/redplanethq/core.git .", {
cwd: process.cwd(),
message: "Cloning Core repository...",
showOutput: true,
});
log.success("Core repository cloned successfully!");
note(
'Please run "core init" again to initialize the development environment.',
"✅ Repository Ready"
);
outro("🎉 Core repository is now available!");
process.exit(0);
} catch (error: any) {
outro(`❌ Failed to clone repository: ${error.message}`);
process.exit(1);
}
if (!isCoreRepo) {
note("Please clone the Core repository first:\n\ngit clone https://github.com/redplanethq/core.git\ncd core\n\nThen run 'core init' again.", "📥 Clone Repository");
outro("❌ Setup cancelled. Please navigate to the Core repository first.");
process.exit(1);
}
const rootDir = process.cwd();

View File

@ -1,5 +1,4 @@
import { intro, outro, note, log, confirm } from '@clack/prompts';
import { isValidCoreRepo } from '../utils/git.js';
import { executeDockerCommandInteractive } from '../utils/docker-interactive.js';
import { printCoreBrainLogo } from '../utils/ascii.js';
import path from 'path';
@ -10,36 +9,15 @@ export async function startCommand() {
intro('🚀 Starting Core Development Environment');
// Step 1: Validate repository
if (!isValidCoreRepo()) {
log.warning('This directory is not a Core repository');
note('The Core repository is required to run the development environment.\nWould you like to clone it in the current directory?', '🔍 Repository Not Found');
const shouldClone = await confirm({
message: 'Clone the Core repository here?',
});
// Step 1: Confirm this is the Core repository
const isCoreRepo = await confirm({
message: 'Are you currently in the Core repository directory?',
});
if (!shouldClone) {
outro('❌ Setup cancelled. Please navigate to the Core repository or clone it first.');
process.exit(1);
}
// Clone the repository
try {
await executeDockerCommandInteractive('git clone https://github.com/redplanethq/core.git .', {
cwd: process.cwd(),
message: 'Cloning Core repository...',
showOutput: true
});
log.success('Core repository cloned successfully!');
note('You can now run "core start" to start the development environment.', '✅ Repository Ready');
outro('🎉 Core repository is now available!');
process.exit(0);
} catch (error: any) {
outro(`❌ Failed to clone repository: ${error.message}`);
process.exit(1);
}
if (!isCoreRepo) {
note('Please navigate to the Core repository first:\n\ngit clone https://github.com/redplanethq/core.git\ncd core\n\nThen run "core start" again.', '📥 Core Repository Required');
outro('❌ Please navigate to the Core repository first.');
process.exit(1);
}
const rootDir = process.cwd();

View File

@ -1,5 +1,4 @@
import { intro, outro, log, confirm, note } from '@clack/prompts';
import { isValidCoreRepo } from '../utils/git.js';
import { executeDockerCommandInteractive } from '../utils/docker-interactive.js';
import { printCoreBrainLogo } from '../utils/ascii.js';
import path from 'path';
@ -10,36 +9,15 @@ export async function stopCommand() {
intro('🛑 Stopping Core Development Environment');
// Step 1: Validate repository
if (!isValidCoreRepo()) {
log.warning('This directory is not a Core repository');
note('The Core repository is required to stop the development environment.\nWould you like to clone it in the current directory?', '🔍 Repository Not Found');
const shouldClone = await confirm({
message: 'Clone the Core repository here?',
});
// Step 1: Confirm this is the Core repository
const isCoreRepo = await confirm({
message: 'Are you currently in the Core repository directory?',
});
if (!shouldClone) {
outro('❌ Setup cancelled. Please navigate to the Core repository or clone it first.');
process.exit(1);
}
// Clone the repository
try {
await executeDockerCommandInteractive('git clone https://github.com/redplanethq/core.git .', {
cwd: process.cwd(),
message: 'Cloning Core repository...',
showOutput: true
});
log.success('Core repository cloned successfully!');
note('You can now run "core stop" to stop the development environment.', '✅ Repository Ready');
outro('🎉 Core repository is now available!');
process.exit(0);
} catch (error: any) {
outro(`❌ Failed to clone repository: ${error.message}`);
process.exit(1);
}
if (!isCoreRepo) {
note('Please navigate to the Core repository first:\n\ngit clone https://github.com/redplanethq/core.git\ncd core\n\nThen run "core stop" again.', '📥 Core Repository Required');
outro('❌ Please navigate to the Core repository first.');
process.exit(1);
}
const rootDir = process.cwd();

View File

@ -0,0 +1,106 @@
-- CreateTable
CREATE TABLE "OAuthAuthorizationCode" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"redirectUri" TEXT NOT NULL,
"scope" TEXT,
"state" TEXT,
"codeChallenge" TEXT,
"codeChallengeMethod" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthClient" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"clientSecret" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"redirectUris" TEXT NOT NULL,
"allowedScopes" TEXT NOT NULL DEFAULT 'read',
"grantTypes" TEXT NOT NULL DEFAULT 'authorization_code',
"requirePkce" BOOLEAN NOT NULL DEFAULT false,
"logoUrl" TEXT,
"homepageUrl" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"workspaceId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthClient_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthAccessToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scope" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthAccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthRefreshToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scope" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OAuthAuthorizationCode_code_key" ON "OAuthAuthorizationCode"("code");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthClient_clientId_key" ON "OAuthClient"("clientId");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthAccessToken_token_key" ON "OAuthAccessToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthRefreshToken_token_key" ON "OAuthRefreshToken"("token");
-- AddForeignKey
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthClient" ADD CONSTRAINT "OAuthClient_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -46,6 +46,110 @@ model AuthorizationCode {
updatedAt DateTime @updatedAt
}
model OAuthAuthorizationCode {
id String @id @default(cuid())
code String @unique
// OAuth2 specific fields
clientId String
userId String
redirectUri String
scope String?
state String?
codeChallenge String?
codeChallengeMethod String?
expiresAt DateTime
used Boolean @default(false)
// Relations
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthClient {
id String @id @default(cuid())
clientId String @unique
clientSecret String
name String
description String?
// Redirect URIs (comma-separated for simplicity)
redirectUris String
// Allowed scopes (comma-separated)
allowedScopes String @default("read")
// Grant types allowed
grantTypes String @default("authorization_code")
// PKCE support
requirePkce Boolean @default(false)
// Client metadata
logoUrl String?
homepageUrl String?
// GitHub-style features
isActive Boolean @default(true)
// Workspace relationship (like GitHub orgs)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspaceId String
// Created by user (for audit trail)
createdBy User @relation(fields: [createdById], references: [id])
createdById String
// Relations
oauthAuthorizationCodes OAuthAuthorizationCode[]
accessTokens OAuthAccessToken[]
refreshTokens OAuthRefreshToken[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthAccessToken {
id String @id @default(cuid())
token String @unique
clientId String
userId String
scope String?
expiresAt DateTime
revoked Boolean @default(false)
// Relations
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OAuthRefreshToken {
id String @id @default(cuid())
token String @unique
clientId String
userId String
scope String?
expiresAt DateTime
revoked Boolean @default(false)
// Relations
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Conversation {
id String @id @default(uuid())
createdAt DateTime @default(now())
@ -319,6 +423,12 @@ model User {
Conversation Conversation[]
ConversationHistory ConversationHistory[]
IngestionRule IngestionRule[]
// OAuth2 relations
oauthAuthorizationCodes OAuthAuthorizationCode[]
oauthAccessTokens OAuthAccessToken[]
oauthRefreshTokens OAuthRefreshToken[]
oauthClientsCreated OAuthClient[]
}
model WebhookConfiguration {
@ -375,6 +485,7 @@ model Workspace {
WebhookConfiguration WebhookConfiguration[]
Conversation Conversation[]
IngestionRule IngestionRule[]
OAuthClient OAuthClient[]
}
enum AuthenticationMethod {