diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7cad637 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +DATABASE_URL=postgresql://postgres@localhost:5432/money + +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= +OAUTH_DISCOVERY_URL=https://www.example.com/.well-known/openid-configuration + +ZERO_UPSTREAM_DB=postgresql://postgres@localhost:5432/money +ZERO_REPLICA_FILE="/tmp/sync-replica.db" + +ZERO_GET_QUERIES_URL="http://localhost:3000/api/zero/get-queries" + diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..232a18e --- /dev/null +++ b/api/package.json @@ -0,0 +1,17 @@ +{ + "name": "@money/api", + "type": "module", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@hono/node-server": "^1.19.5", + "@money/shared": "link:../shared", + "better-auth": "^1.3.27", + "hono": "^4.9.12" + }, + "devDependencies": { + "@types/node": "^24.7.2" + } +} diff --git a/lib/auth.ts b/api/src/auth.ts similarity index 91% rename from lib/auth.ts rename to api/src/auth.ts index 00c414a..2fd8666 100644 --- a/lib/auth.ts +++ b/api/src/auth.ts @@ -7,6 +7,7 @@ export const auth = betterAuth({ database: new Pool({ connectionString: process.env.DATABASE_URL, }), + trustedOrigins: ["money://", "http://localhost:8081"], plugins: [ expo(), genericOAuth({ diff --git a/api/src/hono.ts b/api/src/hono.ts new file mode 100644 index 0000000..9142dd2 --- /dev/null +++ b/api/src/hono.ts @@ -0,0 +1,5 @@ +import type { AuthData } from "@money/shared/auth"; +import { Hono } from "hono"; + +export const getHono = () => + new Hono<{ Variables: { auth: AuthData | null } }>(); diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..9c79c36 --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,55 @@ +import { serve } from "@hono/node-server"; +import { authDataSchema } from "@money/shared/auth"; +import { cors } from "hono/cors"; +import { auth } from "./auth"; +import { getHono } from "./hono"; +import { zero } from "./zero"; + +const app = getHono(); + +app.use( + "/api/*", + cors({ + origin: (origin) => origin ?? "", + allowMethods: ["POST", "GET", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization"], + credentials: true, + }), +); + +app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw)); + +app.use("*", async (c, next) => { + const authHeader = c.req.raw.headers.get("Authorization"); + const cookie = authHeader?.split("Bearer ")[1]; + + const newHeaders = new Headers(c.req.raw.headers); + + if (cookie) { + newHeaders.set("Cookie", cookie); + } + + const session = await auth.api.getSession({ headers: newHeaders }); + + if (!session) { + c.set("auth", null); + return next(); + } + c.set("auth", authDataSchema.parse(session)); + return next(); +}); + +app.route("/api/zero", zero); + +app.get("/api", (c) => c.text("OK")); +app.get("/", (c) => c.text("OK")); + +serve( + { + fetch: app.fetch, + port: 3000, + }, + (info) => { + console.log(`Server is running on ${info.address}:${info.port}`); + }, +); diff --git a/api/src/zero.ts b/api/src/zero.ts new file mode 100644 index 0000000..d9c22ab --- /dev/null +++ b/api/src/zero.ts @@ -0,0 +1,96 @@ +import { + type ReadonlyJSONValue, + type ServerTransaction, + withValidation, +} from "@rocicorp/zero"; +import { + handleGetQueriesRequest, + PushProcessor, + ZQLDatabase, +} from "@rocicorp/zero/server"; +import { + // createMutators as createMutatorsShared, + // isLoggedIn, + // type Mutators, + queries, + schema, + type Schema, +} from "@money/shared"; +import type { AuthData } from "@money/shared/auth"; +// import { auditLogs } from "@zslack/shared/db"; +// import { +// NodePgConnection, +// type NodePgZeroTransaction, +// } from "drizzle-zero/node-postgres"; +import crypto from "node:crypto"; +// import { db } from "./db"; +import { getHono } from "./hono"; + +// type ServerTx = ServerTransaction>; + +// const processor = new PushProcessor( +// new ZQLDatabase(new NodePgConnection(db), schema), +// ); +// +// const createMutators = (authData: AuthData | null) => { +// const mutators = createMutatorsShared(authData); +// +// return { +// ...mutators, +// message: { +// ...mutators.message, +// async sendMessage(tx: ServerTx, params) { +// isLoggedIn(authData); +// +// await mutators.message.sendMessage(tx, params); +// +// // we can use the db tx to insert server-only data, like audit logs +// await tx.dbTransaction.wrappedTransaction.insert(auditLogs).values({ +// id: crypto.randomUUID(), +// userId: authData.user.id, +// action: "sendMessage", +// }); +// }, +// }, +// } as const satisfies Mutators; +// }; + +const zero = getHono() + // .post("/mutate", async (c) => { + // // get the auth data from betterauth + // const authData = c.get("auth"); + // + // const result = await processor.process(createMutators(authData), c.req.raw); + // + // return c.json(result); + // }) + .post("/get-queries", async (c) => { + // get the auth data from betterauth + const authData = c.get("auth"); + + const result = await handleGetQueriesRequest( + (name, args) => ({ query: getQuery(authData, name, args) }), + schema, + c.req.raw, + ); + + return c.json(result); + }); + +const validatedQueries = Object.fromEntries( + Object.values(queries).map((q) => [q.queryName, withValidation(q)]), +); + +function getQuery( + authData: AuthData | null, + name: string, + args: readonly ReadonlyJSONValue[], +) { + if (name in validatedQueries) { + const q = validatedQueries[name]; + return q(authData, ...args); + } + throw new Error(`Unknown query: ${name}`); +} + +export { zero }; diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..7ddcbdb --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "outDir": "./dist" + }, + "exclude": ["node_modules"] +} diff --git a/app/_layout.tsx b/app/_layout.tsx index 29f8c70..467bfaa 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,21 +1,20 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { Stack } from 'expo-router'; -import { StatusBar } from 'expo-status-bar'; import 'react-native-reanimated'; -import { useColorScheme } from '@/hooks/use-color-scheme'; import { authClient } from '@/lib/auth-client'; +import { ZeroProvider } from '@rocicorp/zero/react'; +import { zero } from '@/lib/zero'; export const unstable_settings = { anchor: '(tabs)', }; + export default function RootLayout() { - const colorScheme = useColorScheme(); const { data, isPending } = authClient.useSession(); return ( - + @@ -24,7 +23,6 @@ export default function RootLayout() { - - + ); } diff --git a/app/api/auth/[...auth]+api.ts b/app/api/auth/[...auth]+api.ts deleted file mode 100644 index 45b0481..0000000 --- a/app/api/auth/[...auth]+api.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { auth } from "@/lib/auth"; - -const handler = auth.handler; -export { handler as GET, handler as POST }; diff --git a/app/auth.tsx b/app/auth.tsx index fd24e9f..23b0089 100644 --- a/app/auth.tsx +++ b/app/auth.tsx @@ -5,6 +5,7 @@ export default function Auth() { const onLogin = () => { authClient.signIn.oauth2({ providerId: "koon-family", + callbackURL: "http://localhost:8081" }); }; diff --git a/app/index.tsx b/app/index.tsx index 1db3e8f..62db9ac 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,18 +1,21 @@ -import { ThemedText } from '@/components/themed-text'; import { SafeAreaView } from 'react-native-safe-area-context'; import { authClient } from '@/lib/auth-client'; -import { Button } from 'react-native'; +import { Button, Text } from 'react-native'; +import { useQuery, useZero } from "@rocicorp/zero/react"; +import { queries } from '@money/shared'; export default function HomeScreen() { const { data } = authClient.useSession(); const onLogout = () => { authClient.signOut(); } + const [transactions] = useQuery(queries.allTransactions()); return ( - Hello {data?.user.name} + Hello {data?.user.name}