From dcf2d83ea17bb08964b4ea6dbcc325e35228d8be Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Wed, 7 May 2025 10:13:02 +0200 Subject: [PATCH 1/3] feat: update auth --- packages/auth/package.json | 4 + packages/auth/src/index.ts | 58 ++++------ packages/auth/src/provider/discord.ts | 49 ++++++++ packages/auth/src/provider/get-profile.ts | 21 ++++ packages/auth/src/provider/github.ts | 81 ++++++++++++++ packages/auth/src/subject.ts | 7 +- pnpm-lock.yaml | 130 +--------------------- 7 files changed, 186 insertions(+), 164 deletions(-) create mode 100644 packages/auth/src/provider/discord.ts create mode 100644 packages/auth/src/provider/get-profile.ts create mode 100644 packages/auth/src/provider/github.ts diff --git a/packages/auth/package.json b/packages/auth/package.json index 027ac07a0..99e98188a 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -9,6 +9,9 @@ }, "./client": { "default": "./src/client.ts" + }, + "./subject": { + "default": "./src/subject.ts" } }, "scripts": { @@ -25,6 +28,7 @@ "dependencies": { "@openauthjs/openauth": "^0.4.3", "@rectangular-labs/env": "workspace:*", + "@rectangular-labs/result": "workspace:*", "arktype": "^2.1.19", "hono": "^4.7.6" } diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 1a59ca53c..923b88ea6 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,19 +1,12 @@ import { issuer } from "@openauthjs/openauth"; -import { CodeProvider } from "@openauthjs/openauth/provider/code"; import { DiscordProvider } from "@openauthjs/openauth/provider/discord"; import { GithubProvider } from "@openauthjs/openauth/provider/github"; -import { CodeUI } from "@openauthjs/openauth/ui/code"; import { Select } from "@openauthjs/openauth/ui/select"; import { handle } from "hono/aws-lambda"; import { env } from "./env"; +import { getUserProfile } from "./provider/get-profile"; import { subjects } from "./subject"; -async function getUser(_email: string) { - await Promise.resolve(); - // Get user from database and return user ID - return "123"; -} - const authApp = issuer({ subjects, select: Select({ @@ -30,14 +23,6 @@ const authApp = issuer({ }, }), providers: { - code: CodeProvider( - CodeUI({ - sendCode: async (email, code) => { - await Promise.resolve(); - console.log(email, code); - }, - }), - ), github: GithubProvider({ clientID: env().GITHUB_CLIENT_ID, clientSecret: env().GITHUB_CLIENT_SECRET, @@ -54,32 +39,37 @@ const authApp = issuer({ }), }, theme: { - title: "Rectangular Labs", + title: "Galleo", primary: { dark: "#000000", light: "#FFFFFF", }, radius: "sm", + favicon: "https://dev.galleoai.com/galleo-favicon.svg", + logo: "https://dev.galleoai.com/galleo-favicon.svg", + font: { + family: "Inter", + scale: "1", + }, }, success: async (ctx, value) => { - if (value.provider === "code") { - console.log("value", value); - console.log("value.claims", value.claims); - return ctx.subject("user", { - id: await getUser(value.claims.email ?? ""), - name: value.claims.name ?? "", - email: value.claims.email ?? "", - image: value.claims.picture ?? "", - }); + const userProfileResult = await getUserProfile(value); + + if (!userProfileResult.ok) { + console.error( + "Failed to fetch user profile:", + userProfileResult.error.message, + ); + // Return an actual Response object for errors + return new Response( + `Failed to fetch user profile: ${userProfileResult.error.message}`, + { status: 500 }, + ); } - console.log("ctx.", value.tokenset.refresh); - // throw new Error("Invalid provider"); - return ctx.subject("user", { - id: await getUser(""), - name: "", - email: "", - image: "", - }); + + const userProfile = userProfileResult.value; + + return ctx.subject("user", userProfile); }, }); diff --git a/packages/auth/src/provider/discord.ts b/packages/auth/src/provider/discord.ts new file mode 100644 index 000000000..91e5062ea --- /dev/null +++ b/packages/auth/src/provider/discord.ts @@ -0,0 +1,49 @@ +import { type Result, safe, safeFetch } from "@rectangular-labs/result"; +import type { UserSubject } from "../subject"; + +interface DiscordApiResponse { + id: string; + username: string; + global_name?: string | null; + email?: string | null; + avatar?: string | null; +} + +export async function getDiscordUser( + accessToken: string, +): Promise> { + // Use safeFetch for the initial call + const responseResult = await safeFetch("https://discord.com/api/users/@me", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!responseResult.ok) { + return responseResult; + } + + const response = responseResult.value; + + const userResult = await safe( + () => response.json() as Promise, + ); + + if (!userResult.ok) { + return userResult; + } + + const user = userResult.value; + return { + ok: true, + value: { + // Prefix the ID with the provider name + id: `discord_${user.id}`, + name: user.global_name || user.username, + email: user.email ?? null, + image: user.avatar + ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png` + : null, + }, + }; +} diff --git a/packages/auth/src/provider/get-profile.ts b/packages/auth/src/provider/get-profile.ts new file mode 100644 index 000000000..92c76ce81 --- /dev/null +++ b/packages/auth/src/provider/get-profile.ts @@ -0,0 +1,21 @@ +import type { Oauth2Token } from "@openauthjs/openauth/provider/oauth2"; +import { type Result, err } from "@rectangular-labs/result"; +import type { UserSubject } from "../subject"; +import { getDiscordUser } from "./discord"; +import { getGithubUser } from "./github"; + +export async function getUserProfile(value: { + provider: "github" | "discord"; + tokenset: Oauth2Token; +}): Promise> { + switch (value.provider) { + case "github": + return await getGithubUser(value.tokenset.access); + case "discord": + return await getDiscordUser(value.tokenset?.access); + default: { + const _neverReached: never = value.provider; + return err(new Error(`Unsupported provider: ${_neverReached}`)); + } + } +} diff --git a/packages/auth/src/provider/github.ts b/packages/auth/src/provider/github.ts new file mode 100644 index 000000000..32932b170 --- /dev/null +++ b/packages/auth/src/provider/github.ts @@ -0,0 +1,81 @@ +import { type Result, safe, safeFetch } from "@rectangular-labs/result"; +import type { UserSubject } from "../subject"; + +interface GithubApiResponse { + id: number; + login: string; + name?: string | null; + email?: string | null; + avatar_url?: string; +} + +interface GithubEmailResponse { + email: string; + primary: boolean; + verified: boolean; + visibility: string | null; +} + +async function getGithubEmail( + user: GithubApiResponse, + accessToken: string, +): Promise { + if (user.email) { + return user.email; + } + const emailsResponseResult = await safeFetch( + "https://api.github.com/user/emails", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + if (!emailsResponseResult.ok) { + return null; + } + + const emailsResult = await safe( + () => emailsResponseResult.value.json() as Promise, + ); + if (!emailsResult.ok) { + return null; + } + const primaryEmail = emailsResult.value.find((e) => e.primary && e.verified); + return primaryEmail?.email ?? null; +} + +export async function getGithubUser( + accessToken: string, +): Promise> { + const userResponseResult = await safeFetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!userResponseResult.ok) { + return userResponseResult; + } + + const userResponse = userResponseResult.value; + + const userResult = await safe( + () => userResponse.json() as Promise, + ); + if (!userResult.ok) { + return userResult; + } + const user = userResult.value; + const email = await getGithubEmail(user, accessToken); + + return { + ok: true, + value: { + id: `github_${String(user.id)}`, + name: user.name || user.login, + email, + image: user.avatar_url ?? null, + }, + }; +} diff --git a/packages/auth/src/subject.ts b/packages/auth/src/subject.ts index 2805961d7..b34334f9b 100644 --- a/packages/auth/src/subject.ts +++ b/packages/auth/src/subject.ts @@ -4,8 +4,9 @@ import { type } from "arktype"; export const subjects = createSubjects({ user: type({ id: "string", - name: "string", - email: "string", - image: "string", + name: "string|null", + email: "string|null", + image: "string|null", }), }); +export type UserSubject = typeof subjects.user.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c12dfad7..0a59310de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: '@rectangular-labs/env': specifier: workspace:* version: link:../env + '@rectangular-labs/result': + specifier: workspace:* + version: link:../result arktype: specifier: ^2.1.19 version: 2.1.19 @@ -398,30 +401,15 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.4 version: 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-toggle': - specifier: ^1.1.6 - version: 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-toggle-group': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-tooltip': - specifier: ^1.2.0 - version: 1.2.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-controllable-state': specifier: ^1.1.1 version: 1.1.1(@types/react@19.1.2)(react@19.1.0) '@rectangular-labs/typescript': specifier: workspace:* version: link:../../tooling/typescript - '@zag-js/react': - specifier: ^1.11.0 - version: 1.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@zag-js/toggle': - specifier: ^1.11.0 - version: 1.11.0 - '@zag-js/tooltip': - specifier: ^1.11.0 - version: 1.11.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1788,19 +1776,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.0': - resolution: {integrity: sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-progress@1.1.3': resolution: {integrity: sha512-F56aZPGTPb4qJQ/vDjnAq63oTu/DRoIG/Asb5XKOWj8rpefNLtUllR969j5QDN2sRrTk9VXIqQDRj5VvAuquaw==} peerDependencies: @@ -1949,32 +1924,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle@1.1.6': - resolution: {integrity: sha512-3SeJxKeO3TO1zVw1Nl++Cp0krYk6zHDHMCUXXVkosIzl6Nxcvb07EerQpyD2wXQSJ5RZajrYAmPaydU8Hk1IyQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-tooltip@1.2.0': - resolution: {integrity: sha512-b1Sdc75s7zN9B8ONQTGBSHL3XS8+IcjcOIY51fhM4R1Hx8s0YbgqgyNZiri4qcYMVZK8hfCZVBiyCm7N9rs0rw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1993,24 +1942,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-escape-keydown@1.1.1': resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: @@ -7978,15 +7909,6 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-primitive@2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.2 - '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-progress@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) @@ -8168,37 +8090,6 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-toggle@1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.2 - '@types/react-dom': 19.1.2(@types/react@19.1.2) - - '@radix-ui/react-tooltip@1.2.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - optionalDependencies: - '@types/react': 19.1.2 - '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.2)(react@19.1.0)': dependencies: react: 19.1.0 @@ -8212,21 +8103,6 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.2)(react@19.1.0)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0) - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.2 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.2)(react@19.1.0)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0) - react: 19.1.0 - optionalDependencies: - '@types/react': 19.1.2 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.2)(react@19.1.0)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) From 976640855e44ae1d7451211469dff4da5ce11b41 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Wed, 7 May 2025 11:23:12 +0200 Subject: [PATCH 2/3] feat: update auth deployment --- .env | 4 - .env.production | 3 - apps/backend/src/lib/auth/client.ts | 43 ++++++ apps/backend/src/lib/auth/middleware.ts | 23 +++ apps/backend/src/lib/auth/session.ts | 53 +++++++ .../backend/src/routes/api/auth/[...route].ts | 52 +++++++ .../src/routes/api/auth/_lib/client.ts | 8 - .../src/routes/api/auth/_lib/session.ts | 26 ---- apps/backend/src/routes/api/auth/route.ts | 57 ------- .../src/routes/api/{chat.ts => chat/route.ts} | 98 ++++++------ apps/backend/src/routes/api/route.ts | 11 -- apps/backend/src/routes/index.ts | 8 +- apps/www/package.json | 6 +- apps/www/src/lib/auth.ts | 2 +- apps/www/src/routes/index.tsx | 2 +- package.json | 11 +- pnpm-lock.yaml | 77 ++++++---- sst.config.ts | 140 +++++++++++------- 18 files changed, 374 insertions(+), 250 deletions(-) create mode 100644 apps/backend/src/lib/auth/client.ts create mode 100644 apps/backend/src/lib/auth/middleware.ts create mode 100644 apps/backend/src/lib/auth/session.ts create mode 100644 apps/backend/src/routes/api/auth/[...route].ts delete mode 100644 apps/backend/src/routes/api/auth/_lib/client.ts delete mode 100644 apps/backend/src/routes/api/auth/_lib/session.ts delete mode 100644 apps/backend/src/routes/api/auth/route.ts rename apps/backend/src/routes/api/{chat.ts => chat/route.ts} (51%) delete mode 100644 apps/backend/src/routes/api/route.ts diff --git a/.env b/.env index 92d476f83..6a7cf2774 100644 --- a/.env +++ b/.env @@ -5,10 +5,6 @@ DOTENV_PUBLIC_KEY="029b5432287e802a315a922dd9d54d39c60a9c2b90352e6e182c7ebd760852b510" # .env -VITE_APP_URL="encrypted:BKcBVZirrvVu+AvBZ/wTLs/WSV5Um4aoyzFDVZDpxZiUhR+VDEq/sZwSPAFW7sATCFJ2mK4xg1lm0nedVkgUjD9gtxkKnuaDzX/lPJFRxjms1OVfsPR84zgDeOr3PPCSHKJH0IFiuftDty8raz4T0phym6NMWD6r6Cq5czVh/VsBaEo=" -VITE_AUTH_URL="encrypted:BMTSeTP2UnGyYdCPzERU0pgI6QFx0gBAFPEhDnqyZ05gzuE36I3Ug+/xLJCwCP3nTzICjAN5uc6BcWV+ZEv8kx869vmtxx6g50IY1uT1vwhlKYyQ/KCVy0/AOhCXIvLpSBHoD99ZHNJBhIHfhYcWO4HzJysXvTsPJixSMQ==" -VITE_BACKEND_URL="encrypted:BMMG/DnV11/An+JaGXeMl6YsSzwU3DVhf52hVsmx8vvIIMDyW0GcvqoPiqTGCV39YFK+6ljR/MgUfUTiqIl1g+cvCHM+UDRVvdN2F0ZwajKPY2FwJN5KBJFPFwra3JaGgFw5RU/aISnkBwQNPl6lQzPD/sWCXCNeli1YcIDwKkJIqZY=" - BASIC_AUTH_USERNAME="encrypted:BHmmR1xRRdkt+4qh4Y54Q05JwK0R/n9KavesX5kIMscNlekzppOVKG31s9VTrSmjer0601B+zHV3ySQRKlQ79MjeRddi6v8fzsmmzrNy7/lne42RtMpfBGRTiNSt6x3D5mT7S0EI" BASIC_AUTH_PASSWORD="encrypted:BPsyMs9LTwjtb9DvNjANPL9e0Mpv+/GRdcXpQi1H3DshaUEdtfhidRbAlYLCiybCcLY1FSueqGDw38FI4ajX369gzv827+Qq8lktwsydfdQxzckwGR27+8ro4U2NPnfCiGn7kmx56ut1Cu5q4Q==" diff --git a/.env.production b/.env.production index 697745837..740ebeb4e 100644 --- a/.env.production +++ b/.env.production @@ -5,9 +5,6 @@ DOTENV_PUBLIC_KEY_PRODUCTION="032a0e48662d601c31f2f994ec75e746d2d336d58f361245ecf97cbaefb731e401" # .env.production -VITE_APP_URL="encrypted:BMy514mbT+b57c3iY4+3K3ZHCz/xwvnItb1oB9hJZqSeYThRuLjZ1Qb7k2qmMRbAVG6lbhUafXWVIMdPQe3gYQ729MXLiek709MAERZclIMaLdjHBVsebF/W98v6gBYONGNEZ3X+pv5Q8MgDcarSvKi7ykv9Ftk=" -VITE_AUTH_URL="encrypted:BGwAFe+0RQYlBwS/bklQmRggwJ8pzDDfBjDQxCOWg5HMXh2jGmJ/0qXyvilB5m+P0ppN6gaJTK91+fc6dtq40M62uCPv2qJO815p3vW3Ns0fEuHUHR02j5duejsZO2KpMoo1ME7a9pZ/fdPcPIs4EBgrA+tsWdJrq7f68A==" - DATABASE_URL="encrypted:BHxKxSCBnMpR0akH0ppveHcgLRtvX/En5XykATRdWaQQZhuTdQss99EROEK8gQA0bCrqJMEyGRYrB71oYvrhHuFEe+JpNhOeSkk3Yf0S5wso31oa13eI1F8jAR3VuAAdbHJgdVAxT/Wcn6rGqwI2THADM/Wkw1uamAWNn0RAEspv6OdubVKVLofPuHSlUChh2eo=" GOOGL_GENERATIVE_AI_API_KEY="encrypted:BJJytvF8IsqMvKHqrOqT/SSbyzKmk+5I+Zc2URLuHMZWC6vRM+zBvaQsn+n1yH/SmDzPL1oSdtb9vHhAMxCRos5dzFrtnvY63OSkj5RXlp2VtOOr6BLTEI5P+fB+JWJdsajkK0x9cNilM3TsO+9t7dg2twlmShaiTNs50ieatpiZm9Nq0QhNow==" diff --git a/apps/backend/src/lib/auth/client.ts b/apps/backend/src/lib/auth/client.ts new file mode 100644 index 000000000..ff64063ff --- /dev/null +++ b/apps/backend/src/lib/auth/client.ts @@ -0,0 +1,43 @@ +import { createClient } from "@rectangular-labs/auth/client"; +import { type UserSubject, subjects } from "@rectangular-labs/auth/subject"; +import { type Result, safe } from "@rectangular-labs/result"; +import { env } from "../env"; +import { setSession } from "./session"; + +export const authClient = () => { + return createClient({ + clientID: "rectangular-labs-backend", + issuer: env().VITE_AUTH_URL, + }); +}; + +export async function verifySafe({ + access, + refresh, + autoRefresh = true, +}: { + access: string; + refresh: string; + autoRefresh?: boolean; +}): Promise> { + const verifiedResult = await safe(() => + authClient().verify(subjects, access, { + refresh, + }), + ); + if (!verifiedResult.ok) { + console.error("Failed to verify", verifiedResult.error); + return verifiedResult; + } + const verified = verifiedResult.value; + if (verified.err) { + return { + ok: false, + error: verified.err, + }; + } + if (autoRefresh && verified.tokens) { + setSession(verified.tokens.access, verified.tokens.refresh); + } + return { ok: true, value: verified.subject.properties }; +} diff --git a/apps/backend/src/lib/auth/middleware.ts b/apps/backend/src/lib/auth/middleware.ts new file mode 100644 index 000000000..28f957007 --- /dev/null +++ b/apps/backend/src/lib/auth/middleware.ts @@ -0,0 +1,23 @@ +import type { UserSubject } from "@rectangular-labs/auth/subject"; +import { getCookie } from "hono/cookie"; +import { createMiddleware } from "hono/factory"; +import { verifySafe } from "./client"; + +export const authMiddleware = createMiddleware<{ + Variables: { + userSubject: UserSubject; + }; +}>(async (c, next) => { + const access = getCookie(c, "access_token"); + const refresh = getCookie(c, "refresh_token"); + if (!access || !refresh) { + return c.json({ error: "Unauthorized" }, 401); + } + const verified = await verifySafe({ access, refresh }); + if (!verified.ok) { + return c.json({ error: "Unauthorized" }, 401); + } + c.set("userSubject", verified.value); + + return await next(); +}); diff --git a/apps/backend/src/lib/auth/session.ts b/apps/backend/src/lib/auth/session.ts new file mode 100644 index 000000000..b0f645df8 --- /dev/null +++ b/apps/backend/src/lib/auth/session.ts @@ -0,0 +1,53 @@ +import { getContext } from "hono/context-storage"; +import { deleteCookie, setCookie } from "hono/cookie"; +import { env } from "../env"; + +export function setSession(accessToken?: string, refreshToken?: string) { + const context = getContext(); + if (accessToken) { + setCookie(context, "access_token", accessToken, { + httpOnly: true, + secure: true, + sameSite: "Lax", + path: "/", + domain: env().VITE_APP_URL.includes("localhost") + ? "localhost" + : `.${env().VITE_APP_URL.replace("https://", "")}`, + maxAge: 34560000, + }); + } + if (refreshToken) { + setCookie(context, "refresh_token", refreshToken, { + httpOnly: true, + secure: true, + sameSite: "Lax", + path: "/", + domain: env().VITE_APP_URL.includes("localhost") + ? "localhost" + : `.${env().VITE_APP_URL.replace("https://", "")}`, + maxAge: 34560000, + }); + } +} + +export function deleteSession() { + const context = getContext(); + const accessToken = deleteCookie(context, "access_token", { + secure: true, + httpOnly: true, + path: "/", + domain: env().VITE_APP_URL.includes("localhost") + ? "localhost" + : `.${env().VITE_APP_URL.replace("https://", "")}`, + }); + const refreshToken = deleteCookie(context, "refresh_token", { + secure: true, + httpOnly: true, + path: "/", + domain: env().VITE_APP_URL.includes("localhost") + ? "localhost" + : `.${env().VITE_APP_URL.replace("https://", "")}`, + }); + console.log("deleted new", { accessToken, refreshToken }); + return !!accessToken && !!refreshToken; +} diff --git a/apps/backend/src/routes/api/auth/[...route].ts b/apps/backend/src/routes/api/auth/[...route].ts new file mode 100644 index 000000000..46bdb7be4 --- /dev/null +++ b/apps/backend/src/routes/api/auth/[...route].ts @@ -0,0 +1,52 @@ +import { Hono } from "hono"; +import { authClient, verifySafe } from "../../../lib/auth/client"; +import { authMiddleware } from "../../../lib/auth/middleware"; +import { deleteSession, setSession } from "../../../lib/auth/session"; +import { env } from "../../../lib/env"; +import type { HonoEnv } from "../../../lib/hono"; + +export const authRouter = new Hono() + .basePath("/api/auth") + .get("/me", authMiddleware, (c) => { + const userSubject = c.get("userSubject"); + console.log("me", { userSubject }); + + return c.json({ userSubject }); + }) + .get("/authorize", async (c) => { + const callbackUrl = `${env().VITE_APP_URL}/api/auth/callback`; + const { url: redirectUrl } = await authClient().authorize( + callbackUrl, + "code", + ); + return c.redirect(redirectUrl, 302); + }) + .get("/callback", async (c) => { + const pathname = new URL(c.req.url).pathname; + const code = c.req.query("code"); + if (!code) throw new Error("Missing code"); + const exchanged = await authClient().exchange( + code, + `${env().VITE_APP_URL}${pathname}`, + ); + if (exchanged.err) + return new Response(exchanged.err.toString(), { + status: 400, + }); + setSession(exchanged.tokens.access, exchanged.tokens.refresh); + + const verified = await verifySafe({ + access: exchanged.tokens.access, + refresh: exchanged.tokens.refresh, + }); + if (!verified.ok) { + return c.json({ error: "Unauthorized" }, 401); + } + + return c.redirect(`${env().VITE_APP_URL}`, 302); + }) + .post("/logout", authMiddleware, (c) => { + const deleted = deleteSession(); + console.log("deleted", deleted); + return c.json({ message: deleted ? "OK" : "No Session" }, 200); + }); diff --git a/apps/backend/src/routes/api/auth/_lib/client.ts b/apps/backend/src/routes/api/auth/_lib/client.ts deleted file mode 100644 index f5b4af73a..000000000 --- a/apps/backend/src/routes/api/auth/_lib/client.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createClient } from "@rectangular-labs/auth/client"; -import { env } from "../../../../lib/env"; - -export const authClient = () => - createClient({ - clientID: "rectangular-labs-backend", - issuer: env().VITE_AUTH_URL, - }); diff --git a/apps/backend/src/routes/api/auth/_lib/session.ts b/apps/backend/src/routes/api/auth/_lib/session.ts deleted file mode 100644 index 5c85a7968..000000000 --- a/apps/backend/src/routes/api/auth/_lib/session.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getContext } from "hono/context-storage"; -import { setCookie } from "hono/cookie"; - -export function setSession(accessToken?: string, refreshToken?: string) { - const context = getContext(); - if (accessToken) { - setCookie(context, "access_token", accessToken, { - httpOnly: true, - secure: true, - sameSite: "Strict", - path: "/", - domain: ".scalenelab.com", - maxAge: 34560000, - }); - } - if (refreshToken) { - setCookie(context, "refresh_token", refreshToken, { - httpOnly: true, - secure: true, - sameSite: "Strict", - path: "/", - domain: ".scalenelab.com", - maxAge: 34560000, - }); - } -} diff --git a/apps/backend/src/routes/api/auth/route.ts b/apps/backend/src/routes/api/auth/route.ts deleted file mode 100644 index fc940fe52..000000000 --- a/apps/backend/src/routes/api/auth/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { safe } from "@rectangular-labs/result"; -import { Hono } from "hono"; -import { getCookie } from "hono/cookie"; -import { subjects } from "../../../../../../packages/auth/src/subject"; -import { env } from "../../../lib/env"; -import { authClient } from "./_lib/client"; -import { setSession } from "./_lib/session"; - -export const authRouter = new Hono() - .get("/me", async (c) => { - const access = getCookie(c, "access_token"); - const refresh = getCookie(c, "refresh_token"); - if (!access || !refresh) { - return c.json({ error: "Unauthorized" }, 401); - } - const verified = await safe(() => - authClient().verify(subjects, access, { - refresh, - }), - ); - if (!verified.ok) { - return c.json({ error: "Unauthorized" }, 401); - } - if (verified.value.err) { - return c.json({ error: "Unauthorized" }, 401); - } - - if (verified.value.tokens) { - setSession(verified.value.tokens.access, verified.value.tokens.refresh); - } - return c.json(verified.value.subject); - }) - .get("/authorize", async (c) => { - const callbackUrl = `${env().VITE_APP_URL}/api/auth/callback`; - const { url: redirectUrl } = await authClient().authorize( - callbackUrl, - "code", - ); - console.log("redirectUrl", redirectUrl); - return c.redirect(redirectUrl, 302); - }) - .get("/callback", async (c) => { - const pathname = new URL(c.req.url).pathname; - console.log("pathname", pathname); - const code = c.req.query("code"); - if (!code) throw new Error("Missing code"); - const exchanged = await authClient().exchange( - code, - `${env().VITE_APP_URL}${pathname}`, - ); - if (exchanged.err) - return new Response(exchanged.err.toString(), { - status: 400, - }); - setSession(exchanged.tokens.access, exchanged.tokens.refresh); - return c.redirect("/", 302); - }); diff --git a/apps/backend/src/routes/api/chat.ts b/apps/backend/src/routes/api/chat/route.ts similarity index 51% rename from apps/backend/src/routes/api/chat.ts rename to apps/backend/src/routes/api/chat/route.ts index 8cc6e8307..46c212e23 100644 --- a/apps/backend/src/routes/api/chat.ts +++ b/apps/backend/src/routes/api/chat/route.ts @@ -1,11 +1,11 @@ import { streamText } from "ai"; import { Hono } from "hono"; import { stream } from "hono/streaming"; -import { backgroundResearch } from "../../lib/ai/business-research"; -import { markFilingRecommendation } from "../../lib/ai/mark-filing-recommendation"; -import { mainAgentModel } from "../../lib/ai/models"; -import { niceClassification } from "../../lib/ai/nice-classification"; -import { relevantGoodsServices } from "../../lib/ai/relevant-goods-services"; +import { backgroundResearch } from "../../../lib/ai/business-research"; +import { markFilingRecommendation } from "../../../lib/ai/mark-filing-recommendation"; +import { mainAgentModel } from "../../../lib/ai/models"; +import { niceClassification } from "../../../lib/ai/nice-classification"; +import { relevantGoodsServices } from "../../../lib/ai/relevant-goods-services"; // Define the system prompt const systemPrompt = `You are an expert Singapore trademark law assistant working for a prestigious law firm. @@ -32,51 +32,53 @@ Use markdown for formatting the email draft. Always use the tools at your disposal before asking the lawyer for more information.`; // Define the POST route for chat requests -export const chatRouter = new Hono().post("/", async (c) => { - // const { messages } = c.req.valid("json"); - const messages = await c.req.json(); +export const chatRouter = new Hono() + .basePath("/api/chat") + .post("/", async (c) => { + // const { messages } = c.req.valid("json"); + const messages = await c.req.json(); - console.log("messages", messages); + console.log("messages", messages); - // Define and import actual tools - const tools = { - backgroundResearch: backgroundResearch, - niceClassification: niceClassification, - relevantGoodsServices: relevantGoodsServices, - markFilingRecommendation: markFilingRecommendation, - }; + // Define and import actual tools + const tools = { + backgroundResearch: backgroundResearch, + niceClassification: niceClassification, + relevantGoodsServices: relevantGoodsServices, + markFilingRecommendation: markFilingRecommendation, + }; - try { - const result = streamText({ - model: mainAgentModel, // Use the main agent model - system: systemPrompt, - messages: messages.messages, - tools: tools, - maxSteps: 12, - onFinish: (result) => { - console.log("result", result); - }, - onError: (error) => { - console.error("Error calling streamText:", error); - }, - temperature: 0.2, - }); + try { + const result = streamText({ + model: mainAgentModel, // Use the main agent model + system: systemPrompt, + messages: messages.messages, + tools: tools, + maxSteps: 12, + onFinish: (result) => { + console.log("result", result); + }, + onError: (error) => { + console.error("Error calling streamText:", error); + }, + temperature: 0.2, + }); - const dataStream = result.toDataStream({ - sendUsage: true, - sendReasoning: true, - sendSources: true, - }); - c.header("Content-Type", "text/plain; charset=utf-8"); - return stream(c, async (stream) => { - stream.onAbort(() => { - console.log("Stream aborted!"); + const dataStream = result.toDataStream({ + sendUsage: true, + sendReasoning: true, + sendSources: true, + }); + c.header("Content-Type", "text/plain; charset=utf-8"); + return stream(c, async (stream) => { + stream.onAbort(() => { + console.log("Stream aborted!"); + }); + await stream.pipe(dataStream); }); - await stream.pipe(dataStream); - }); - } catch (error) { - console.error("Error calling streamText:", error); - // Consider returning a more informative error response - return c.json({ error: "Failed to process chat request" }, 500); - } -}); + } catch (error) { + console.error("Error calling streamText:", error); + // Consider returning a more informative error response + return c.json({ error: "Failed to process chat request" }, 500); + } + }); diff --git a/apps/backend/src/routes/api/route.ts b/apps/backend/src/routes/api/route.ts deleted file mode 100644 index 7b817f2ef..000000000 --- a/apps/backend/src/routes/api/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Hono } from "hono"; -import type { HonoEnv } from "../../lib/hono"; -import { authRouter } from "./auth/route"; -import { chatRouter } from "./chat"; - -export const apiRouter = new Hono() - .get("/", (c) => { - return c.json({ message: "Hello, World from the backend!" }); - }) - .route("/chat", chatRouter) - .route("/auth", authRouter); diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts index 65c1ed3d9..ee9e9384f 100644 --- a/apps/backend/src/routes/index.ts +++ b/apps/backend/src/routes/index.ts @@ -2,13 +2,17 @@ import { Hono } from "hono"; import { handle, streamHandle } from "hono/aws-lambda"; import { contextStorage } from "hono/context-storage"; import { showRoutes } from "hono/dev"; +import { logger } from "hono/logger"; import { dbContext } from "../lib/hono"; -import { apiRouter } from "./api/route"; +import { authRouter } from "./api/auth/[...route]"; +import { chatRouter } from "./api/chat/route"; const app = new Hono() + .use(logger()) .use(contextStorage()) .use(dbContext) - .route("/api", apiRouter); + .route("/", authRouter) + .route("/", chatRouter); showRoutes(app, { verbose: true, diff --git a/apps/www/package.json b/apps/www/package.json index 29c680d26..170d0fba6 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -4,9 +4,9 @@ "type": "module", "scripts": { "dev": "pnpm with-env-local vite --port 6969", - "build:preview": "pnpm with-env-preview vite build && tsc", + "build:dev": "pnpm with-env-dev vite build && tsc", "build:prod": "pnpm with-env-prod vite build && tsc", - "serve:preview": "pnpm with-env-preview vite preview", + "serve:dev": "pnpm with-env-dev vite preview", "serve:prod": "pnpm with-env-prod vite preview", "test": "vitest run", "clean": "git clean -xdf .turbo node_modules dist .cache", @@ -14,7 +14,7 @@ "lint": "bun x @biomejs/biome lint . --write", "typecheck": "tsc --noEmit --emitDeclarationOnly false", "with-env-local": "dotenvx run -f ../../.env.local -f ../../.env -- ", - "with-env-preview": "dotenvx run -f ../../.env -- ", + "with-env-dev": "dotenvx run -f ../../.env -- ", "with-env-prod": "dotenvx run -f ../../.env.production -- " }, "dependencies": { diff --git a/apps/www/src/lib/auth.ts b/apps/www/src/lib/auth.ts index ab448ffe0..0265bdb29 100644 --- a/apps/www/src/lib/auth.ts +++ b/apps/www/src/lib/auth.ts @@ -7,7 +7,7 @@ export const getSession = async () => { return { user: null }; } const session = await response.value.json(); - return { user: session.properties }; + return { user: session.userSubject }; }; export const authorizeUrl = backend.api.auth.authorize.$url().href; diff --git a/apps/www/src/routes/index.tsx b/apps/www/src/routes/index.tsx index 7be76725f..03ba70c50 100644 --- a/apps/www/src/routes/index.tsx +++ b/apps/www/src/routes/index.tsx @@ -25,7 +25,7 @@ export const Route = createFileRoute("/")({ component: ChatInterface, loader: async () => { try { - const response = await backend.api.$get(); + const response = await backend.api.auth.me.$get(); return response.json(); } catch (error) { console.error("Failed to fetch initial data:", error); diff --git a/package.json b/package.json index 582a009b3..6c313aa19 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,10 @@ "db:migrate-push": "bun run --filter @rectangular-labs/db migrate-push", "db:push": "bun run --filter @rectangular-labs/db push --force", "db:studio": "bun run --filter @rectangular-labs/db studio", - "dev": "pnpm with-env-local pnpx sst dev", + "dev": "pnpm with-env-local pnpm sst dev --mode mono", "dev:packages": "docker compose up -d && turbo run dev --filter=\"./packages/*\"", - "deploy:local": "pnpm with-env-local sst deploy", - "deploy:personal": "pnpm with-env-preview sst deploy", - "deploy:preview": "pnpm with-env-preview sst deploy --stage preview", + "deploy:personal": "pnpm with-env-dev sst deploy", + "deploy:dev": "pnpm with-env-dev sst deploy --stage development", "deploy:prod": "pnpm with-env-prod sst deploy --stage production", "env:get": "bun x dotenvx get", "env:set": "bun x dotenvx set", @@ -29,7 +28,7 @@ "ui:add": "cd ./packages/ui && bun run ui-add && cd ../..", "sso": "aws sso login --sso-session=rectangular-labs", "with-env-local": "dotenvx run -f .env.local -f .env -- ", - "with-env-preview": "dotenvx run -f .env -- ", + "with-env-dev": "dotenvx run -f .env -- ", "with-env-prod": "dotenvx run -f .env.production -- " }, "devDependencies": { @@ -39,7 +38,7 @@ "@rectangular-labs/env": "workspace:*", "@rectangular-labs/typescript": "workspace:*", "@turbo/gen": "^2.5.0", - "sst": "3.13.10", + "sst": "3.14.11", "turbo": "^2.5.0", "typescript": "^5.8.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a59310de..fc4f7b45a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^2.5.0 version: 2.5.0(@swc/core@1.11.22(@swc/helpers@0.5.17))(@types/node@22.14.1)(typescript@5.8.3) sst: - specifier: 3.13.10 - version: 3.13.10 + specifier: 3.14.11 + version: 3.14.11 turbo: specifier: ^2.5.0 version: 2.5.0 @@ -5902,33 +5902,48 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - sst-darwin-arm64@3.13.10: - resolution: {integrity: sha512-fEH72tZU+sE+BIJZt0IFlnOC0eQoSF1grsmn9rWa0PPCq5l9ruP88B5qLFTzirKJpsyoGr4AYneQXSovIYKw/Q==} + sst-darwin-arm64@3.14.11: + resolution: {integrity: sha512-ZaItC22QX+r/ZbewJvYuZtZ3K4/y9k89xkqSX+T5IrXTOLWO53cHaWvgS/VoKpb5dIoS87cwmD3Qh1YvvNiZ+Q==} cpu: [arm64] os: [darwin] - sst-darwin-x64@3.13.10: - resolution: {integrity: sha512-HFsVDUg0IwLON+E9wE5Qh6cyhUWqxGDXvJ0WP6uRj+/lgDAn4adNuqjt52s+GILW1lzE4JoHt/11FngXD0aUBg==} + sst-darwin-x64@3.14.11: + resolution: {integrity: sha512-P2kPLeCc84spjsH90NFrou8yfkonl5USAvRX+Z3esPQV+bOG3ZQSG2NyL+qBj3wq80CfPo3UvYRua3KSt1zIXQ==} cpu: [x64] os: [darwin] - sst-linux-arm64@3.13.10: - resolution: {integrity: sha512-FdrndGFupoiKmlDE0Ng6mfHp+AS2Z1L586ewmfDGt2gchws2m6/wxmnnvHgi7YTM41D2O94TBYgGrFQqTkEleg==} + sst-linux-arm64@3.14.11: + resolution: {integrity: sha512-8wWqJWc2th/jceseKuAiLASS/8OaDqhEdE+rvtXEGruk7TB4e/2vJYvMu+JKDxH8alFd6idxktZfu62gXyfedQ==} cpu: [arm64] os: [linux] - sst-linux-x64@3.13.10: - resolution: {integrity: sha512-97RdiM4xI/SWJ0cpEEx1gZM6dFos3AecN2uNIMjlRPcGA1Qr4zkTfD0WLx/Oa+m/dH6g1hjxmsPkGwx0jIuN+Q==} + sst-linux-x64@3.14.11: + resolution: {integrity: sha512-k27KpDMphNq3AjuQ5hbr8lB6VPWJDONbvojszgP+MJtr0g82MMx6+igAzMQ1HtvpkYT9ck7vdXELqrRx7ohonQ==} cpu: [x64] os: [linux] - sst-linux-x86@3.13.10: - resolution: {integrity: sha512-a2wV4KUQcwfDqQKQO8342qEhDjVpyjGxUQ8nqQ75uT36+3Nvo247AX3THn8M6pJNnmHOBNHLBErGkm8DN1l1YQ==} + sst-linux-x86@3.14.11: + resolution: {integrity: sha512-AhZOpmcpE8N7bG7zFUN1RUKf/MIZsMBgjfhBB6xcdorf9fnTOxQqqgzPt4x++BqtAXGHk9q5gfCYtMRrDME6PA==} cpu: [x86] os: [linux] - sst@3.13.10: - resolution: {integrity: sha512-QUCJtbU1EMZ27NsrjFnOJGjGXjbKS8xfYA8VJgeDxrELCdoODR8t3hz7IbticsxLMJTQuUOnfOQCI2XRHeNHXA==} + sst-win32-arm64@3.14.11: + resolution: {integrity: sha512-aLOqjFLNJ2CD9nZMbrOuYOH43sTl/PT4EmST+f4+TfnM6rrqzsreQJVZ+zeyKfv3t88ilgB+KWsdvGblKu2+bg==} + cpu: [arm64] + os: [win32] + + sst-win32-x64@3.14.11: + resolution: {integrity: sha512-TXBzZRkcZqmuwLOdgU2MDEiYUDw/iKMamw0gfSUKfNDb28mCBEv/hvpV6qBO4gKZjq7dbMEbjmU2Ra9XeHoO9Q==} + cpu: [x64] + os: [win32] + + sst-win32-x86@3.14.11: + resolution: {integrity: sha512-CKXutjc+yID9FGbRQRzCbCIofB9WZ2XTya6l6x/KYhddAGSHWMasKlZbijkHF759zGUsTp4b3gb231xubmBrQA==} + cpu: [x86] + os: [win32] + + sst@3.14.11: + resolution: {integrity: sha512-XF7SdXK1g8mLDW2onDsNzyHmmYvMotXtAWGzyT0bUWf1ZsLUsinVBvts4+85b9t9wXPD5fZKFmZdKzPG5g4a0w==} hasBin: true stackback@0.0.2: @@ -12701,22 +12716,31 @@ snapshots: sprintf-js@1.1.3: {} - sst-darwin-arm64@3.13.10: + sst-darwin-arm64@3.14.11: + optional: true + + sst-darwin-x64@3.14.11: + optional: true + + sst-linux-arm64@3.14.11: + optional: true + + sst-linux-x64@3.14.11: optional: true - sst-darwin-x64@3.13.10: + sst-linux-x86@3.14.11: optional: true - sst-linux-arm64@3.13.10: + sst-win32-arm64@3.14.11: optional: true - sst-linux-x64@3.13.10: + sst-win32-x64@3.14.11: optional: true - sst-linux-x86@3.13.10: + sst-win32-x86@3.14.11: optional: true - sst@3.13.10: + sst@3.14.11: dependencies: aws-sdk: 2.1692.0 aws4fetch: 1.0.18 @@ -12724,11 +12748,14 @@ snapshots: opencontrol: 0.0.6 openid-client: 5.6.4 optionalDependencies: - sst-darwin-arm64: 3.13.10 - sst-darwin-x64: 3.13.10 - sst-linux-arm64: 3.13.10 - sst-linux-x64: 3.13.10 - sst-linux-x86: 3.13.10 + sst-darwin-arm64: 3.14.11 + sst-darwin-x64: 3.14.11 + sst-linux-arm64: 3.14.11 + sst-linux-x64: 3.14.11 + sst-linux-x86: 3.14.11 + sst-win32-arm64: 3.14.11 + sst-win32-x64: 3.14.11 + sst-win32-x86: 3.14.11 transitivePeerDependencies: - supports-color diff --git a/sst.config.ts b/sst.config.ts index 9440acf21..f08e70881 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -4,6 +4,7 @@ export default $config({ app(input) { return { name: "rectangular-labs", + removal: input?.stage === "production" ? "retain" : "remove", protect: ["production"].includes(input?.stage), home: "aws", @@ -28,15 +29,32 @@ export default $config({ zone: process.env.CLOUDFLARE_ZONE_ID, }); - const serverEnv = parseServerEnv(process.env); + const isPermanentStage = ["production", "development"].includes($app.stage); + const domain = (() => { + if ($app.stage === "production") { + return "scalenelab.com"; + } + if ($app.stage === "development") { + return "dev.scalenelab.com"; + } + return `${$app.stage}.dev.scalenelab.com`; + })(); + const frontendDomain = domain; + const backendDomain = `${domain}/api`; + const authDomain = isPermanentStage + ? `auth.${domain}` + : "auth.dev.scalenelab.com"; - const api = new sst.aws.Function("Hono", { - handler: "apps/backend/src/routes/index.handler", - environment: serverEnv, - url: true, - streaming: !$dev, - timeout: "120 seconds", + const serverEnv = parseServerEnv({ + ...process.env, + VITE_BACKEND_URL: + process.env.VITE_BACKEND_URL ?? `https://${frontendDomain}`, //this really is just the hostname + VITE_APP_URL: process.env.VITE_APP_URL ?? `https://${frontendDomain}`, + VITE_AUTH_URL: process.env.VITE_AUTH_URL ?? `https://${authDomain}`, }); + const serverEnvString = Object.entries(serverEnv) + .map(([key, value]) => `${key}=${value}`) + .join(" "); const basicAuth = $resolve([ process.env.BASIC_AUTH_USERNAME, @@ -44,17 +62,16 @@ export default $config({ ]).apply(([username, password]) => Buffer.from(`${username}:${password}`).toString("base64"), ); - const router = new sst.aws.Router("AppRouter", { - domain: { - name: - $app.stage === "production" - ? "scalenelab.com" - : `${$app.stage}.dev.scalenelab.com`, - dns, - }, - edge: { - viewerRequest: { - injection: $interpolate` + const router = isPermanentStage + ? new sst.aws.Router("AppRouter", { + domain: { + name: domain, + aliases: [`*.${domain}`], + dns, + }, + edge: { + viewerRequest: { + injection: $interpolate` if ( !event.request.headers.authorization || event.request.headers.authorization.value !== "Basic ${basicAuth}" @@ -66,10 +83,19 @@ export default $config({ } }; }`, - }, - }, + }, + }, + }) + : sst.aws.Router.get("AppRouter", "E3BTU6T4OWUEPF"); + + const api = new sst.aws.Function("Hono", { + handler: "apps/backend/src/routes/index.handler", + environment: serverEnv, + url: true, + streaming: !$dev, + timeout: "120 seconds", }); - router.route("/api", api.url, { + router.route(backendDomain, api.url, { readTimeout: "30 seconds", keepAliveTimeout: "30 seconds", }); @@ -79,47 +105,46 @@ export default $config({ build: { command: $app.stage === "production" - ? "pnpm build:prod" - : "pnpm build:preview", + ? `${serverEnvString} pnpm build:prod` + : `${serverEnvString} pnpm build:dev`, output: "dist", }, dev: { command: "pnpm dev", }, - route: { router, path: "/" }, + route: { router, domain: frontendDomain }, }); - const authRouter = new sst.aws.Router("AuthRouter", { - domain: { - name: - $app.stage === "production" - ? "auth.scalenelab.com" - : `${$app.stage}.auth.scalenelab.com`, - dns, - }, - }); - const table = new sst.aws.Dynamo("OpenAuthStorage", { - fields: { pk: "string", sk: "string" }, - primaryIndex: { hashKey: "pk", rangeKey: "sk" }, - ttl: "expiry", - }); - new sst.aws.Function("OpenAuthIssuer", { - handler: "packages/auth/src/index.handler", - link: [table], - environment: { - ...serverEnv, - OPENAUTH_STORAGE: $jsonStringify({ - type: "dynamo", - options: { table: table.name }, - }), - }, - url: { - route: { - router: authRouter, - }, - cors: false, - }, - }); + const { authDynamoTable, authIssuer: _ } = (() => { + if (isPermanentStage) { + const authDynamoTable = new sst.aws.Dynamo("OpenAuthStorage", { + fields: { pk: "string", sk: "string" }, + primaryIndex: { hashKey: "pk", rangeKey: "sk" }, + ttl: "expiry", + }); + + const authIssuer = new sst.aws.Function("OpenAuthIssuer", { + handler: "packages/auth/src/index.handler", + link: [authDynamoTable], + environment: { + ...serverEnv, + OPENAUTH_STORAGE: $jsonStringify({ + type: "dynamo", + options: { table: authDynamoTable.name }, + }), + }, + url: { + router: { + instance: router, + domain: authDomain, + }, + cors: false, + }, + }); + return { authIssuer, authDynamoTable }; + } + return { authIssuer: null, authDynamoTable: null }; + })(); new sst.x.DevCommand("Packages", { dev: { @@ -127,5 +152,10 @@ export default $config({ command: "pnpm dev:packages", }, }); + + return { + router: router.distributionID, + authTable: authDynamoTable?.name, + }; }, }); From 6711811d6ca8a4df97aa108d621a090d4d8b85ed Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Wed, 7 May 2025 12:03:01 +0200 Subject: [PATCH 3/3] feat: add ai auto complete --- apps/backend/package.json | 1 + apps/backend/src/lib/ai/models.ts | 2 + .../src/routes/api/completion/route.ts | 49 ++++++ apps/backend/src/routes/index.ts | 4 +- apps/www/src/routes/editor.tsx | 17 +- .../inline-suggestion-extension.ts | 161 ++++++++++++++++++ .../tiptap-extension/loro-extension.ts | 1 + .../inline-suggestion.scss | 6 + .../tiptap-templates/simple/simple-editor.tsx | 12 +- pnpm-lock.yaml | 14 ++ 10 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 apps/backend/src/routes/api/completion/route.ts create mode 100644 packages/editor/src/components/tiptap-extension/inline-suggestion-extension.ts create mode 100644 packages/editor/src/components/tiptap-node/inline-suggestion-node/inline-suggestion.scss diff --git a/apps/backend/package.json b/apps/backend/package.json index 0aa346e14..aba2e6628 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@ai-sdk/google": "^1.2.11", + "@hono/arktype-validator": "^2.0.1", "@rectangular-labs/auth": "workspace:*", "@rectangular-labs/db": "workspace:*", "@rectangular-labs/env": "workspace:*", diff --git a/apps/backend/src/lib/ai/models.ts b/apps/backend/src/lib/ai/models.ts index 2a4d610dc..7d55aacf9 100644 --- a/apps/backend/src/lib/ai/models.ts +++ b/apps/backend/src/lib/ai/models.ts @@ -11,3 +11,5 @@ export const niceClassificationModel = google("gemini-2.5-pro-preview-03-25"); // Model for Goods & Services recommendations (structured output) export const goodsServicesModel = google("gemini-2.5-pro-preview-03-25"); + +export const completionModel = google("gemini-2.0-flash-001"); diff --git a/apps/backend/src/routes/api/completion/route.ts b/apps/backend/src/routes/api/completion/route.ts new file mode 100644 index 000000000..a11e04e36 --- /dev/null +++ b/apps/backend/src/routes/api/completion/route.ts @@ -0,0 +1,49 @@ +import { arktypeValidator } from "@hono/arktype-validator"; +import { generateText } from "ai"; +import { type } from "arktype"; +import { Hono } from "hono"; +import { completionModel } from "../../../lib/ai/models"; +// Define the system prompt +const systemPrompt = + "You are an AI assistant that writes in the style of J.K. Rowling and Susan Collins. The user will provide you with a context and you will complete the text. The completion should not include the context that the user provided."; + +// Define the POST route for chat requests +export const completionRouter = new Hono().basePath("/api/completion").post( + "/", + arktypeValidator( + "json", + type({ + context: "string", + }), + ), + async (c) => { + const { context } = c.req.valid("json"); + + try { + const result = await generateText({ + model: completionModel, + system: systemPrompt, + messages: [ + { + role: "user", + content: context, + }, + ], + maxSteps: 1, + temperature: 1, + }); + + console.log("result.text", result.text); + return c.json( + { + completion: result.text, + }, + 200, + ); + } catch (error) { + console.error("Error calling streamText:", error); + // Consider returning a more informative error response + return c.json({ error: "Failed to process chat request" }, 500); + } + }, +); diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts index ee9e9384f..d0ab2fd1c 100644 --- a/apps/backend/src/routes/index.ts +++ b/apps/backend/src/routes/index.ts @@ -6,13 +6,15 @@ import { logger } from "hono/logger"; import { dbContext } from "../lib/hono"; import { authRouter } from "./api/auth/[...route]"; import { chatRouter } from "./api/chat/route"; +import { completionRouter } from "./api/completion/route"; const app = new Hono() .use(logger()) .use(contextStorage()) .use(dbContext) .route("/", authRouter) - .route("/", chatRouter); + .route("/", chatRouter) + .route("/", completionRouter); showRoutes(app, { verbose: true, diff --git a/apps/www/src/routes/editor.tsx b/apps/www/src/routes/editor.tsx index b590efd78..ba9e50de8 100644 --- a/apps/www/src/routes/editor.tsx +++ b/apps/www/src/routes/editor.tsx @@ -1,3 +1,4 @@ +import { backend } from "@/lib/backend"; import { SimpleEditor } from "@rectangular-labs/editor"; import { createFileRoute } from "@tanstack/react-router"; @@ -6,5 +7,19 @@ export const Route = createFileRoute("/editor")({ }); function RouteComponent() { - return ; + const onCompletion = async (existingText: string) => { + const response = await backend.api.completion.$post({ + json: { + context: existingText, + }, + }); + if (!response.ok) { + throw new Error("Failed to fetch completion"); + } + const result = await response.json(); + console.log("result", result); + return result.completion; + }; + + return ; } diff --git a/packages/editor/src/components/tiptap-extension/inline-suggestion-extension.ts b/packages/editor/src/components/tiptap-extension/inline-suggestion-extension.ts new file mode 100644 index 000000000..12275a49a --- /dev/null +++ b/packages/editor/src/components/tiptap-extension/inline-suggestion-extension.ts @@ -0,0 +1,161 @@ +import { Extension } from "@tiptap/react"; + +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +declare module "@tiptap/react" { + interface Commands { + inlineSuggestion: { + /** + * fetch inline suggestions + */ + fetchSuggestion: () => ReturnType; + }; + } +} + +interface InlineSuggestionOptions { + /** + * fetch inline suggestions + * + * @param existingText - existing text in the node + * @returns {string} - the suggestion to be shown + */ + fetchAutocompletion: (existingText: string) => Promise; +} + +interface InlineSuggestionStorage { + data: { + currentSuggestion?: string; + nodeDetails?: { + from: number; + to: number; + }; + }; +} + +export const InlineSuggestion = Extension.create< + InlineSuggestionOptions, + InlineSuggestionStorage +>({ + name: "inlineSuggestion", + addOptions() { + return { + fetchAutocompletion: async () => { + const message = + "Please add a fetchSuggestion function to fetch suggestions from."; + + console.warn(message); + + return await Promise.resolve(message); + }, + }; + }, + + addStorage() { + return { + data: {}, + }; + }, + + addCommands() { + return { + fetchSuggestion: + () => + ({ state, chain, editor }) => { + const { currentSuggestion } = this.storage.data; + if (currentSuggestion) { + return chain() + .command(() => { + this.storage.data = {}; + editor.chain().insertContent(currentSuggestion).focus().run(); + + return true; + }) + .run(); + } + + const { $from } = state.selection; + + const node = $from.parent; + + const [from, to] = [$from.start() - 1, $from.end() + 1]; + + const existingText = node.textContent; + + if (existingText) { + this.options.fetchAutocompletion(existingText).then((res) => { + this.storage.data = { + currentSuggestion: res, + nodeDetails: { + from, + to, + }, + }; + + editor.view.dispatch( + editor.view.state.tr.setMeta("addToHistory", false), + ); + }); + + return true; + } + + return false; + }, + }; + }, + + addProseMirrorPlugins() { + const getStorage = () => this.storage; + + const fetchSuggestion = () => this.editor.commands.fetchSuggestion(); + + const handleNonTabKey = () => { + this.storage.data = {}; + }; + + return [ + new Plugin({ + key: new PluginKey("inlineSuggestion"), + state: { + init() { + return DecorationSet.empty; + }, + apply(tr) { + const storage = getStorage().data; + + if (storage.currentSuggestion && storage.nodeDetails) { + const { from, to } = storage.nodeDetails; + + const decoration = Decoration.node(from, to, { + "data-inline-suggestion": storage.currentSuggestion, + }); + + return DecorationSet.create(tr.doc, [decoration]); + } + + return DecorationSet.empty; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + handleKeyDown(_, event) { + if (event.key === "Tab") { + event.preventDefault(); + + fetchSuggestion(); + + return true; + } + + handleNonTabKey(); + return false; + }, + }, + }), + ]; + }, +}); diff --git a/packages/editor/src/components/tiptap-extension/loro-extension.ts b/packages/editor/src/components/tiptap-extension/loro-extension.ts index d170cab39..7b13334b4 100644 --- a/packages/editor/src/components/tiptap-extension/loro-extension.ts +++ b/packages/editor/src/components/tiptap-extension/loro-extension.ts @@ -12,6 +12,7 @@ import { } from "loro-prosemirror"; const doc = new LoroDoc<{ + // biome-ignore lint/suspicious/noExplicitAny: this is a workaround to avoid type errors doc: LoroMap; data: LoroMap>; }>(); diff --git a/packages/editor/src/components/tiptap-node/inline-suggestion-node/inline-suggestion.scss b/packages/editor/src/components/tiptap-node/inline-suggestion-node/inline-suggestion.scss new file mode 100644 index 000000000..9c251edef --- /dev/null +++ b/packages/editor/src/components/tiptap-node/inline-suggestion-node/inline-suggestion.scss @@ -0,0 +1,6 @@ +.tiptap.ProseMirror { + [data-inline-suggestion]::after { + content: attr(data-inline-suggestion); + color: var(--muted-foreground); + } +} \ No newline at end of file diff --git a/packages/editor/src/components/tiptap-templates/simple/simple-editor.tsx b/packages/editor/src/components/tiptap-templates/simple/simple-editor.tsx index a1856cc05..7238f23bb 100644 --- a/packages/editor/src/components/tiptap-templates/simple/simple-editor.tsx +++ b/packages/editor/src/components/tiptap-templates/simple/simple-editor.tsx @@ -21,6 +21,7 @@ import "../../tiptap-node/code-block-node/code-block-node.scss"; import "../../tiptap-node/list-node/list-node.scss"; import "../../tiptap-node/image-node/image-node.scss"; import "../../tiptap-node/paragraph-node/paragraph-node.scss"; +import "../../tiptap-node/inline-suggestion-node/inline-suggestion.scss"; import { ThemeToggle } from "@rectangular-labs/ui/components/theme-provider"; // --- Tiptap UI --- @@ -49,6 +50,7 @@ import { useWindowSize } from "@rectangular-labs/ui/hooks/use-window-size"; // --- Styles --- import "./simple-editor.scss"; import { Separator } from "@rectangular-labs/ui/components/ui/separator"; +import { InlineSuggestion } from "../../tiptap-extension/inline-suggestion-extension"; import { LoroCRDT } from "../../tiptap-extension/loro-extension"; const MainToolbarContent = ({ @@ -153,7 +155,11 @@ const MobileToolbarContent = ({ ); -export function SimpleEditor() { +export function SimpleEditor({ + onCompletion, +}: { + onCompletion: (existingText: string) => Promise; +}) { const isMobile = useIsMobile(); const windowSize = useWindowSize(); const [mobileView, setMobileView] = React.useState< @@ -208,7 +214,6 @@ export function SimpleEditor() { Typography, Superscript, Subscript, - Selection, // ImageUploadNode.configure({ // accept: "image/*", @@ -220,6 +225,9 @@ export function SimpleEditor() { TrailingNode, Link.configure({ openOnClick: false }), LoroCRDT, + InlineSuggestion.configure({ + fetchAutocompletion: onCompletion, + }), ], // content: content, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc4f7b45a..70cf26764 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@ai-sdk/google': specifier: ^1.2.11 version: 1.2.11(zod@3.24.2) + '@hono/arktype-validator': + specifier: ^2.0.1 + version: 2.0.1(arktype@2.1.19)(hono@4.7.6) '@rectangular-labs/auth': specifier: workspace:* version: link:../../packages/auth @@ -1254,6 +1257,12 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@hono/arktype-validator@2.0.1': + resolution: {integrity: sha512-Z4PQFtzgbGneBap+TTViRIBAoUWbwEwg8PaKNqALAP6z9N2ksJI81PfcsSQNUzwtrn8LipkMvBb8/D9Pei2GJw==} + peerDependencies: + arktype: ^2.0.0-dev.14 + hono: '*' + '@hookform/resolvers@5.0.1': resolution: {integrity: sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==} peerDependencies: @@ -7349,6 +7358,11 @@ snapshots: '@floating-ui/utils@0.2.9': {} + '@hono/arktype-validator@2.0.1(arktype@2.1.19)(hono@4.7.6)': + dependencies: + arktype: 2.1.19 + hono: 4.7.6 + '@hookform/resolvers@5.0.1(react-hook-form@7.55.0(react@19.1.0))': dependencies: '@standard-schema/utils': 0.3.0