mirror of
https://github.com/stackblitz/bolt.new
synced 2025-02-05 12:35:53 +00:00
feat: remove authentication (#1)
This commit is contained in:
parent
6fb59d2bc5
commit
2a29fbbe82
7
.gitignore
vendored
7
.gitignore
vendored
@ -21,3 +21,10 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
/.cache
|
||||||
|
/build
|
||||||
|
.env*
|
||||||
|
*.vars
|
||||||
|
.wrangler
|
||||||
|
_worker.bundle
|
||||||
|
16
.vscode/launch.json
vendored
16
.vscode/launch.json
vendored
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Debug Current Test File",
|
|
||||||
"autoAttachChildProcesses": true,
|
|
||||||
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
|
|
||||||
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
|
|
||||||
"args": ["run", "${relativeFile}"],
|
|
||||||
"smartStep": true,
|
|
||||||
"console": "integratedTerminal"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -29,17 +29,10 @@ pnpm install
|
|||||||
ANTHROPIC_API_KEY=XXX
|
ANTHROPIC_API_KEY=XXX
|
||||||
```
|
```
|
||||||
|
|
||||||
Optionally, you an set the debug level or disable authentication:
|
Optionally, you an set the debug level:
|
||||||
|
|
||||||
```
|
```
|
||||||
VITE_LOG_LEVEL=debug
|
VITE_LOG_LEVEL=debug
|
||||||
VITE_DISABLE_AUTH=1
|
|
||||||
```
|
|
||||||
|
|
||||||
If you want to run authentication against a local StackBlitz instance, add:
|
|
||||||
|
|
||||||
```
|
|
||||||
VITE_CLIENT_ORIGIN=https://local.stackblitz.com:3000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important**: Never commit your `.env.local` file to version control. It's already included in .gitignore.
|
**Important**: Never commit your `.env.local` file to version control. It's already included in .gitignore.
|
||||||
|
@ -9,7 +9,6 @@ import { Messages } from './Messages.client';
|
|||||||
import { SendButton } from './SendButton.client';
|
import { SendButton } from './SendButton.client';
|
||||||
|
|
||||||
import styles from './BaseChat.module.scss';
|
import styles from './BaseChat.module.scss';
|
||||||
import { useLoaderData } from '@remix-run/react';
|
|
||||||
|
|
||||||
interface BaseChatProps {
|
interface BaseChatProps {
|
||||||
textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
|
textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
|
||||||
@ -59,7 +58,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||||
const { avatar } = useLoaderData<{ avatar?: string }>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -96,7 +94,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
className="flex flex-col w-full flex-1 max-w-chat px-4 pb-6 mx-auto z-1"
|
className="flex flex-col w-full flex-1 max-w-chat px-4 pb-6 mx-auto z-1"
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
avatar={avatar}
|
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
}}
|
}}
|
||||||
|
@ -9,11 +9,10 @@ interface MessagesProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
avatar?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
|
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
|
||||||
const { id, isStreaming = false, messages = [], avatar } = props;
|
const { id, isStreaming = false, messages = [] } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id={id} ref={ref} className={props.className}>
|
<div id={id} ref={ref} className={props.className}>
|
||||||
@ -36,11 +35,7 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
|
|||||||
>
|
>
|
||||||
{isUserMessage && (
|
{isUserMessage && (
|
||||||
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
|
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
|
||||||
{avatar ? (
|
|
||||||
<img className="w-full h-full object-cover" src={avatar} />
|
|
||||||
) : (
|
|
||||||
<div className="i-ph:user-fill text-xl"></div>
|
<div className="i-ph:user-fill text-xl"></div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-col-1 w-full">
|
<div className="grid grid-col-1 w-full">
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
import { json, redirect, type LoaderFunctionArgs, type TypedResponse } from '@remix-run/cloudflare';
|
|
||||||
import { isAuthenticated, type Session } from './sessions';
|
|
||||||
|
|
||||||
type RequestArgs = Pick<LoaderFunctionArgs, 'request' | 'context'>;
|
|
||||||
|
|
||||||
export async function loadWithAuth<T extends RequestArgs>(
|
|
||||||
args: T,
|
|
||||||
handler: (args: T, session: Session) => Promise<Response>,
|
|
||||||
) {
|
|
||||||
return handleWithAuth(args, handler, (response) => redirect('/login', response));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionWithAuth<T extends RequestArgs>(
|
|
||||||
args: T,
|
|
||||||
handler: (args: T, session: Session) => Promise<TypedResponse>,
|
|
||||||
) {
|
|
||||||
return await handleWithAuth(args, handler, (response) => json({}, { status: 401, ...response }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleWithAuth<T extends RequestArgs, R extends TypedResponse>(
|
|
||||||
args: T,
|
|
||||||
handler: (args: T, session: Session) => Promise<R>,
|
|
||||||
fallback: (partial: ResponseInit) => R,
|
|
||||||
) {
|
|
||||||
const { request, context } = args;
|
|
||||||
const { session, response } = await isAuthenticated(request, context.cloudflare.env);
|
|
||||||
|
|
||||||
if (session == null && !import.meta.env.VITE_DISABLE_AUTH) {
|
|
||||||
return fallback(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlerResponse = await handler(args, session || {});
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
for (const [key, value] of Object.entries(response.headers)) {
|
|
||||||
handlerResponse.headers.append(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return handlerResponse;
|
|
||||||
}
|
|
@ -1,240 +0,0 @@
|
|||||||
import { createCookieSessionStorage, redirect, type Session as RemixSession } from '@remix-run/cloudflare';
|
|
||||||
import { decodeJwt } from 'jose';
|
|
||||||
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
|
||||||
import { request as doRequest } from '~/lib/fetch';
|
|
||||||
import { logger } from '~/utils/logger';
|
|
||||||
import type { Identity } from '~/lib/analytics';
|
|
||||||
import { decrypt, encrypt } from '~/lib/crypto';
|
|
||||||
|
|
||||||
const DEV_SESSION_SECRET = import.meta.env.DEV ? 'LZQMrERo3Ewn/AbpSYJ9aw==' : undefined;
|
|
||||||
const DEV_PAYLOAD_SECRET = import.meta.env.DEV ? '2zAyrhjcdFeXk0YEDzilMXbdrGAiR+8ACIUgFNfjLaI=' : undefined;
|
|
||||||
|
|
||||||
const TOKEN_KEY = 't';
|
|
||||||
const EXPIRES_KEY = 'e';
|
|
||||||
const USER_ID_KEY = 'u';
|
|
||||||
const SEGMENT_KEY = 's';
|
|
||||||
const AVATAR_KEY = 'a';
|
|
||||||
const ENCRYPTED_KEY = 'd';
|
|
||||||
|
|
||||||
interface PrivateSession {
|
|
||||||
[TOKEN_KEY]: string;
|
|
||||||
[EXPIRES_KEY]: number;
|
|
||||||
[USER_ID_KEY]?: string;
|
|
||||||
[SEGMENT_KEY]?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PublicSession {
|
|
||||||
[ENCRYPTED_KEY]: string;
|
|
||||||
[AVATAR_KEY]?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
userId?: string;
|
|
||||||
segmentWriteKey?: string;
|
|
||||||
avatar?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function isAuthenticated(request: Request, env: Env) {
|
|
||||||
const { session, sessionStorage } = await getSession(request, env);
|
|
||||||
|
|
||||||
const sessionData: PrivateSession | null = await decryptSessionData(env, session.get(ENCRYPTED_KEY));
|
|
||||||
|
|
||||||
const header = async (cookie: Promise<string>) => ({ headers: { 'Set-Cookie': await cookie } });
|
|
||||||
const destroy = () => header(sessionStorage.destroySession(session));
|
|
||||||
|
|
||||||
if (sessionData?.[TOKEN_KEY] == null) {
|
|
||||||
return { session: null, response: await destroy() };
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresAt = sessionData[EXPIRES_KEY] ?? 0;
|
|
||||||
|
|
||||||
if (Date.now() < expiresAt) {
|
|
||||||
return { session: getSessionData(session, sessionData) };
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('Renewing token');
|
|
||||||
|
|
||||||
let data: Awaited<ReturnType<typeof refreshToken>> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
data = await refreshToken(sessionData[TOKEN_KEY]);
|
|
||||||
} catch (error) {
|
|
||||||
// we can ignore the error here because it's handled below
|
|
||||||
logger.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data != null) {
|
|
||||||
const expiresAt = cookieExpiration(data.expires_in, data.created_at);
|
|
||||||
|
|
||||||
const newSessionData = { ...sessionData, [EXPIRES_KEY]: expiresAt };
|
|
||||||
const encryptedData = await encryptSessionData(env, newSessionData);
|
|
||||||
|
|
||||||
session.set(ENCRYPTED_KEY, encryptedData);
|
|
||||||
|
|
||||||
return {
|
|
||||||
session: getSessionData(session, newSessionData),
|
|
||||||
response: await header(sessionStorage.commitSession(session)),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return { session: null, response: await destroy() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createUserSession(
|
|
||||||
request: Request,
|
|
||||||
env: Env,
|
|
||||||
tokens: { refresh: string; expires_in: number; created_at: number },
|
|
||||||
identity?: Identity,
|
|
||||||
): Promise<ResponseInit> {
|
|
||||||
const { session, sessionStorage } = await getSession(request, env);
|
|
||||||
|
|
||||||
const expiresAt = cookieExpiration(tokens.expires_in, tokens.created_at);
|
|
||||||
|
|
||||||
const sessionData: PrivateSession = {
|
|
||||||
[TOKEN_KEY]: tokens.refresh,
|
|
||||||
[EXPIRES_KEY]: expiresAt,
|
|
||||||
[USER_ID_KEY]: identity?.userId ?? undefined,
|
|
||||||
[SEGMENT_KEY]: identity?.segmentWriteKey ?? undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const encryptedData = await encryptSessionData(env, sessionData);
|
|
||||||
session.set(ENCRYPTED_KEY, encryptedData);
|
|
||||||
session.set(AVATAR_KEY, identity?.avatar);
|
|
||||||
|
|
||||||
return {
|
|
||||||
headers: {
|
|
||||||
'Set-Cookie': await sessionStorage.commitSession(session, {
|
|
||||||
maxAge: 3600 * 24 * 30, // 1 month
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSessionStorage(cloudflareEnv: Env) {
|
|
||||||
return createCookieSessionStorage<PublicSession>({
|
|
||||||
cookie: {
|
|
||||||
name: '__session',
|
|
||||||
httpOnly: true,
|
|
||||||
path: '/',
|
|
||||||
secrets: [DEV_SESSION_SECRET || cloudflareEnv.SESSION_SECRET],
|
|
||||||
secure: import.meta.env.PROD,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout(request: Request, env: Env) {
|
|
||||||
const { session, sessionStorage } = await getSession(request, env);
|
|
||||||
|
|
||||||
const sessionData = await decryptSessionData(env, session.get(ENCRYPTED_KEY));
|
|
||||||
|
|
||||||
if (sessionData) {
|
|
||||||
revokeToken(sessionData[TOKEN_KEY]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect('/login', {
|
|
||||||
headers: {
|
|
||||||
'Set-Cookie': await sessionStorage.destroySession(session),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateAccessToken(access: string) {
|
|
||||||
const jwtPayload = decodeJwt(access);
|
|
||||||
|
|
||||||
return jwtPayload.bolt === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSessionData(session: RemixSession<PublicSession>, data: PrivateSession): Session {
|
|
||||||
return {
|
|
||||||
userId: data?.[USER_ID_KEY],
|
|
||||||
segmentWriteKey: data?.[SEGMENT_KEY],
|
|
||||||
avatar: session.get(AVATAR_KEY),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSession(request: Request, env: Env) {
|
|
||||||
const sessionStorage = getSessionStorage(env);
|
|
||||||
const cookie = request.headers.get('Cookie');
|
|
||||||
|
|
||||||
return { session: await sessionStorage.getSession(cookie), sessionStorage };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshToken(refresh: string): Promise<{ expires_in: number; created_at: number }> {
|
|
||||||
const response = await doRequest(`${CLIENT_ORIGIN}/oauth/token`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: urlParams({ grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: refresh }),
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Unable to refresh token\n${response.status} ${JSON.stringify(body)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { access_token: access } = body;
|
|
||||||
|
|
||||||
if (!validateAccessToken(access)) {
|
|
||||||
throw new Error('User is no longer authorized for Bolt');
|
|
||||||
}
|
|
||||||
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieExpiration(expireIn: number, createdAt: number) {
|
|
||||||
return (expireIn + createdAt - 10 * 60) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeToken(refresh?: string) {
|
|
||||||
if (refresh == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await doRequest(`${CLIENT_ORIGIN}/oauth/revoke`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: urlParams({
|
|
||||||
token: refresh,
|
|
||||||
token_type_hint: 'refresh_token',
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Unable to revoke token: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlParams(data: Record<string, string>) {
|
|
||||||
const encoded = new URLSearchParams();
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
encoded.append(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return encoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decryptSessionData(env: Env, encryptedData?: string) {
|
|
||||||
const decryptedData = encryptedData ? await decrypt(payloadSecret(env), encryptedData) : undefined;
|
|
||||||
const sessionData: PrivateSession | null = JSON.parse(decryptedData ?? 'null');
|
|
||||||
|
|
||||||
return sessionData;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function encryptSessionData(env: Env, sessionData: PrivateSession) {
|
|
||||||
return await encrypt(payloadSecret(env), JSON.stringify(sessionData));
|
|
||||||
}
|
|
||||||
|
|
||||||
function payloadSecret(env: Env) {
|
|
||||||
return DEV_PAYLOAD_SECRET || env.PAYLOAD_SECRET;
|
|
||||||
}
|
|
@ -1,38 +0,0 @@
|
|||||||
import { CLIENT_ORIGIN } from '~/lib/constants';
|
|
||||||
import { request as doRequest } from '~/lib/fetch';
|
|
||||||
|
|
||||||
export interface Identity {
|
|
||||||
userId?: string | null;
|
|
||||||
guestId?: string | null;
|
|
||||||
segmentWriteKey?: string | null;
|
|
||||||
avatar?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MESSAGE_PREFIX = 'Bolt';
|
|
||||||
|
|
||||||
export enum AnalyticsTrackEvent {
|
|
||||||
MessageSent = `${MESSAGE_PREFIX} Message Sent`,
|
|
||||||
MessageComplete = `${MESSAGE_PREFIX} Message Complete`,
|
|
||||||
ChatCreated = `${MESSAGE_PREFIX} Chat Created`,
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function identifyUser(access: string): Promise<Identity | undefined> {
|
|
||||||
const response = await doRequest(`${CLIENT_ORIGIN}/api/identify`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { authorization: `Bearer ${access}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// convert numerical identity values to strings
|
|
||||||
const stringified = Object.entries(body).map(([key, value]) => [
|
|
||||||
key,
|
|
||||||
typeof value === 'number' ? value.toString() : value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return Object.fromEntries(stringified) as Identity;
|
|
||||||
}
|
|
@ -1,4 +0,0 @@
|
|||||||
export function forgetAuth() {
|
|
||||||
// FIXME: use dedicated method
|
|
||||||
localStorage.removeItem('__wc_api_tokens__');
|
|
||||||
}
|
|
@ -1,2 +0,0 @@
|
|||||||
export const CLIENT_ID = 'bolt';
|
|
||||||
export const CLIENT_ORIGIN = import.meta.env.VITE_CLIENT_ORIGIN ?? 'https://stackblitz.com';
|
|
@ -116,7 +116,7 @@ export class FilesStore {
|
|||||||
async #init() {
|
async #init() {
|
||||||
const webcontainer = await this.#webcontainer;
|
const webcontainer = await this.#webcontainer;
|
||||||
|
|
||||||
webcontainer.watchPaths(
|
webcontainer.internal.watchPaths(
|
||||||
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
|
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
|
||||||
bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
|
bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { WebContainer } from '@webcontainer/api';
|
import { WebContainer } from '@webcontainer/api';
|
||||||
import { WORK_DIR_NAME } from '~/utils/constants';
|
import { WORK_DIR_NAME } from '~/utils/constants';
|
||||||
import { forgetAuth } from '~/lib/auth';
|
|
||||||
|
|
||||||
interface WebContainerContext {
|
interface WebContainerContext {
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
@ -23,7 +22,6 @@ if (!import.meta.env.SSR) {
|
|||||||
import.meta.hot?.data.webcontainer ??
|
import.meta.hot?.data.webcontainer ??
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
forgetAuth();
|
|
||||||
return WebContainer.boot({ workdirName: WORK_DIR_NAME });
|
return WebContainer.boot({ workdirName: WORK_DIR_NAME });
|
||||||
})
|
})
|
||||||
.then((webcontainer) => {
|
.then((webcontainer) => {
|
||||||
|
@ -1,17 +1,14 @@
|
|||||||
import { json, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/cloudflare';
|
import { json, type MetaFunction } from '@remix-run/cloudflare';
|
||||||
import { ClientOnly } from 'remix-utils/client-only';
|
import { ClientOnly } from 'remix-utils/client-only';
|
||||||
import { BaseChat } from '~/components/chat/BaseChat';
|
import { BaseChat } from '~/components/chat/BaseChat';
|
||||||
import { Chat } from '~/components/chat/Chat.client';
|
import { Chat } from '~/components/chat/Chat.client';
|
||||||
import { Header } from '~/components/header/Header';
|
import { Header } from '~/components/header/Header';
|
||||||
import { loadWithAuth } from '~/lib/.server/auth';
|
|
||||||
|
|
||||||
export const meta: MetaFunction = () => {
|
export const meta: MetaFunction = () => {
|
||||||
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
|
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loader(args: LoaderFunctionArgs) {
|
export const loader = () => json({});
|
||||||
return loadWithAuth(args, async (_args, session) => json({ avatar: session.avatar }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
return (
|
return (
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
|
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||||
import { actionWithAuth } from '~/lib/.server/auth';
|
|
||||||
import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
|
import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
|
||||||
import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
|
import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
|
||||||
import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text';
|
import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text';
|
||||||
import SwitchableStream from '~/lib/.server/llm/switchable-stream';
|
import SwitchableStream from '~/lib/.server/llm/switchable-stream';
|
||||||
|
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
return actionWithAuth(args, chatAction);
|
return chatAction(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chatAction({ context, request }: ActionFunctionArgs) {
|
async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
|
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||||
import { StreamingTextResponse, parseStreamPart } from 'ai';
|
import { StreamingTextResponse, parseStreamPart } from 'ai';
|
||||||
import { actionWithAuth } from '~/lib/.server/auth';
|
|
||||||
import { streamText } from '~/lib/.server/llm/stream-text';
|
import { streamText } from '~/lib/.server/llm/stream-text';
|
||||||
import { stripIndents } from '~/utils/stripIndent';
|
import { stripIndents } from '~/utils/stripIndent';
|
||||||
|
|
||||||
@ -8,7 +7,7 @@ const encoder = new TextEncoder();
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
return actionWithAuth(args, enhancerAction);
|
return enhancerAction(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
|
import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
|
||||||
import { default as IndexRoute } from './_index';
|
import { default as IndexRoute } from './_index';
|
||||||
import { loadWithAuth } from '~/lib/.server/auth';
|
|
||||||
|
|
||||||
export async function loader(args: LoaderFunctionArgs) {
|
export async function loader(args: LoaderFunctionArgs) {
|
||||||
return loadWithAuth(args, async (_args, session) => json({ id: args.params.id, avatar: session.avatar }));
|
return json({ id: args.params.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IndexRoute;
|
export default IndexRoute;
|
||||||
|
@ -1,201 +0,0 @@
|
|||||||
import {
|
|
||||||
json,
|
|
||||||
redirect,
|
|
||||||
redirectDocument,
|
|
||||||
type ActionFunctionArgs,
|
|
||||||
type LoaderFunctionArgs,
|
|
||||||
} from '@remix-run/cloudflare';
|
|
||||||
import { useFetcher, useLoaderData } from '@remix-run/react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { LoadingDots } from '~/components/ui/LoadingDots';
|
|
||||||
import { createUserSession, isAuthenticated, validateAccessToken } from '~/lib/.server/sessions';
|
|
||||||
import { identifyUser } from '~/lib/analytics';
|
|
||||||
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
|
||||||
import { request as doRequest } from '~/lib/fetch';
|
|
||||||
import { auth, type AuthAPI } from '~/lib/webcontainer/auth.client';
|
|
||||||
import { logger } from '~/utils/logger';
|
|
||||||
|
|
||||||
export async function loader({ request, context }: LoaderFunctionArgs) {
|
|
||||||
const { session, response } = await isAuthenticated(request, context.cloudflare.env);
|
|
||||||
|
|
||||||
if (session != null) {
|
|
||||||
return redirect('/', response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
return json(
|
|
||||||
{
|
|
||||||
redirected: url.searchParams.has('code') || url.searchParams.has('error'),
|
|
||||||
},
|
|
||||||
response,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function action({ request, context }: ActionFunctionArgs) {
|
|
||||||
const formData = await request.formData();
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
access: String(formData.get('access')),
|
|
||||||
refresh: String(formData.get('refresh')),
|
|
||||||
};
|
|
||||||
|
|
||||||
let response: Awaited<ReturnType<typeof doRequest>> | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
response = await doRequest(`${CLIENT_ORIGIN}/oauth/token/info`, {
|
|
||||||
headers: { authorization: `Bearer ${payload.access}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw await response.json();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Authentication failed');
|
|
||||||
logger.warn(error);
|
|
||||||
|
|
||||||
return json({ error: 'invalid-token' as const }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const boltEnabled = validateAccessToken(payload.access);
|
|
||||||
|
|
||||||
if (!boltEnabled) {
|
|
||||||
return json({ error: 'bolt-access' as const }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const identity = await identifyUser(payload.access);
|
|
||||||
|
|
||||||
const tokenInfo: { expires_in: number; created_at: number } = await response.json();
|
|
||||||
|
|
||||||
const init = await createUserSession(request, context.cloudflare.env, { ...payload, ...tokenInfo }, identity);
|
|
||||||
|
|
||||||
return redirectDocument('/', init);
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginState =
|
|
||||||
| {
|
|
||||||
kind: 'error';
|
|
||||||
error: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
| { kind: 'pending' };
|
|
||||||
|
|
||||||
const ERRORS = {
|
|
||||||
'bolt-access': 'You do not have access to Bolt.',
|
|
||||||
'invalid-token': 'Authentication failed.',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
const { redirected } = useLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!import.meta.hot?.data.wcAuth) {
|
|
||||||
auth.init({ clientId: CLIENT_ID, scope: 'public', editorOrigin: CLIENT_ORIGIN });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.hot) {
|
|
||||||
import.meta.hot.data.wcAuth = true;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
{redirected ? (
|
|
||||||
<LoadingDots text="Authenticating" />
|
|
||||||
) : (
|
|
||||||
<div className="max-w-md w-full space-y-8 p-10 bg-white rounded-lg shadow">
|
|
||||||
<div>
|
|
||||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Login</h2>
|
|
||||||
</div>
|
|
||||||
<LoginForm />
|
|
||||||
<p className="mt-4 text-sm text-center text-gray-600">
|
|
||||||
By using Bolt, you agree to the collection of usage data for analytics.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginForm() {
|
|
||||||
const [login, setLogin] = useState<LoginState | null>(null);
|
|
||||||
|
|
||||||
const fetcher = useFetcher<typeof action>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
auth.logout({ ignoreRevokeError: true });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (fetcher.data?.error) {
|
|
||||||
auth.logout({ ignoreRevokeError: true });
|
|
||||||
|
|
||||||
setLogin({
|
|
||||||
kind: 'error' as const,
|
|
||||||
...{ error: fetcher.data.error, description: ERRORS[fetcher.data.error] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [fetcher.data]);
|
|
||||||
|
|
||||||
async function attemptLogin() {
|
|
||||||
startAuthFlow();
|
|
||||||
|
|
||||||
function startAuthFlow() {
|
|
||||||
auth.startAuthFlow({ popup: true });
|
|
||||||
|
|
||||||
Promise.race([authEvent(auth, 'auth-failed'), auth.loggedIn()]).then((error) => {
|
|
||||||
if (error) {
|
|
||||||
setLogin({ kind: 'error', ...error });
|
|
||||||
} else {
|
|
||||||
onTokens();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onTokens() {
|
|
||||||
const tokens = auth.tokens()!;
|
|
||||||
|
|
||||||
fetcher.submit(tokens, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
setLogin({ kind: 'pending' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="w-full text-white bg-accent-600 hover:bg-accent-700 focus:ring-4 focus:outline-none font-medium rounded-lg text-sm px-5 py-2.5 text-center"
|
|
||||||
onClick={attemptLogin}
|
|
||||||
disabled={login?.kind === 'pending'}
|
|
||||||
>
|
|
||||||
{login?.kind === 'pending' ? 'Authenticating...' : 'Continue with StackBlitz'}
|
|
||||||
</button>
|
|
||||||
{login?.kind === 'error' && (
|
|
||||||
<div>
|
|
||||||
<h2>
|
|
||||||
<code>{login.error}</code>
|
|
||||||
</h2>
|
|
||||||
<p>{login.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthError {
|
|
||||||
error: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function authEvent(auth: AuthAPI, event: 'logged-out'): Promise<void>;
|
|
||||||
function authEvent(auth: AuthAPI, event: 'auth-failed'): Promise<AuthError>;
|
|
||||||
function authEvent(auth: AuthAPI, event: 'logged-out' | 'auth-failed') {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const unsubscribe = auth.on(event as any, (arg: any) => {
|
|
||||||
unsubscribe();
|
|
||||||
resolve(arg);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
|
|
||||||
import { logout } from '~/lib/.server/sessions';
|
|
||||||
|
|
||||||
export async function loader({ request, context }: LoaderFunctionArgs) {
|
|
||||||
return logout(request, context.cloudflare.env);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Logout() {
|
|
||||||
return '';
|
|
||||||
}
|
|
12
package.json
12
package.json
@ -50,7 +50,7 @@
|
|||||||
"@remix-run/react": "^2.10.2",
|
"@remix-run/react": "^2.10.2",
|
||||||
"@uiw/codemirror-theme-vscode": "^4.23.0",
|
"@uiw/codemirror-theme-vscode": "^4.23.0",
|
||||||
"@unocss/reset": "^0.61.0",
|
"@unocss/reset": "^0.61.0",
|
||||||
"@webcontainer/api": "^1.3.0-internal.2",
|
"@webcontainer/api": "1.3.0-internal.10",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
@ -76,13 +76,16 @@
|
|||||||
"unist-util-visit": "^5.0.0"
|
"unist-util-visit": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@blitz/eslint-plugin": "0.1.0",
|
||||||
"@cloudflare/workers-types": "^4.20240620.0",
|
"@cloudflare/workers-types": "^4.20240620.0",
|
||||||
"@remix-run/dev": "^2.10.0",
|
"@remix-run/dev": "^2.10.0",
|
||||||
"@types/diff": "^5.2.1",
|
"@types/diff": "^5.2.1",
|
||||||
"@types/react": "^18.2.20",
|
"@types/react": "^18.2.20",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
|
"is-ci": "^3.0.1",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
|
"prettier": "^3.3.2",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^5.5.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"unocss": "^0.61.3",
|
"unocss": "^0.61.3",
|
||||||
@ -90,12 +93,9 @@
|
|||||||
"vite-plugin-node-polyfills": "^0.22.0",
|
"vite-plugin-node-polyfills": "^0.22.0",
|
||||||
"vite-plugin-optimize-css-modules": "^1.1.0",
|
"vite-plugin-optimize-css-modules": "^1.1.0",
|
||||||
"vite-tsconfig-paths": "^4.3.2",
|
"vite-tsconfig-paths": "^4.3.2",
|
||||||
|
"vitest": "^2.0.1",
|
||||||
"wrangler": "^3.63.2",
|
"wrangler": "^3.63.2",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8"
|
||||||
"@blitz/eslint-plugin": "0.1.0",
|
|
||||||
"is-ci": "^3.0.1",
|
|
||||||
"prettier": "^3.3.2",
|
|
||||||
"vitest": "^2.0.1"
|
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@typescript-eslint/utils": "^8.0.0-alpha.30"
|
"@typescript-eslint/utils": "^8.0.0-alpha.30"
|
||||||
|
@ -93,8 +93,8 @@ importers:
|
|||||||
specifier: ^0.61.0
|
specifier: ^0.61.0
|
||||||
version: 0.61.3
|
version: 0.61.3
|
||||||
'@webcontainer/api':
|
'@webcontainer/api':
|
||||||
specifier: ^1.3.0-internal.2
|
specifier: 1.3.0-internal.10
|
||||||
version: 1.3.0-internal.2
|
version: 1.3.0-internal.10
|
||||||
'@xterm/addon-fit':
|
'@xterm/addon-fit':
|
||||||
specifier: ^0.10.0
|
specifier: ^0.10.0
|
||||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||||
@ -1956,8 +1956,8 @@ packages:
|
|||||||
'@web3-storage/multipart-parser@1.0.0':
|
'@web3-storage/multipart-parser@1.0.0':
|
||||||
resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==}
|
resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==}
|
||||||
|
|
||||||
'@webcontainer/api@1.3.0-internal.2':
|
'@webcontainer/api@1.3.0-internal.10':
|
||||||
resolution: {integrity: sha512-lLSlSehbuYc9E7ecK+tMRX4BbWETNX1OgRlS+NerQh3X3sHNbxLD86eScEMAiA5VBnUeSnLtLe7eC/ftM8fR3Q==}
|
resolution: {integrity: sha512-iuqjuDX2uADiJMYZok7+tJqVCJYZ+tU2NwVtxlvakRWSSmIFBGrJ38pD0C5igaOnBV8C9kGDjCE6B03SvLtN4Q==}
|
||||||
|
|
||||||
'@xterm/addon-fit@0.10.0':
|
'@xterm/addon-fit@0.10.0':
|
||||||
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
|
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
|
||||||
@ -7127,7 +7127,7 @@ snapshots:
|
|||||||
|
|
||||||
'@web3-storage/multipart-parser@1.0.0': {}
|
'@web3-storage/multipart-parser@1.0.0': {}
|
||||||
|
|
||||||
'@webcontainer/api@1.3.0-internal.2': {}
|
'@webcontainer/api@1.3.0-internal.10': {}
|
||||||
|
|
||||||
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
2
worker-configuration.d.ts
vendored
2
worker-configuration.d.ts
vendored
@ -1,5 +1,3 @@
|
|||||||
interface Env {
|
interface Env {
|
||||||
ANTHROPIC_API_KEY: string;
|
ANTHROPIC_API_KEY: string;
|
||||||
SESSION_SECRET: string;
|
|
||||||
PAYLOAD_SECRET: string;
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user