From 371f5e879b32e6c1f77236765280858947343f51 Mon Sep 17 00:00:00 2001 From: Max Koon <22125083+k2on@users.noreply.github.com> Date: Wed, 26 Nov 2025 00:32:18 -0500 Subject: [PATCH 1/6] feat: add effect api --- apps/api/package.json | 3 + apps/api/src/index.ts | 160 +++++++++++++++++++++++++----------- apps/api/src/index_old.ts | 58 +++++++++++++ apps/tui/src/auth.ts | 6 +- apps/tui/src/index.tsx | 8 +- apps/tui/src/schema.ts | 34 -------- packages/shared/src/auth.ts | 34 ++++++++ pnpm-lock.yaml | 62 ++++++++++++++ 8 files changed, 276 insertions(+), 89 deletions(-) create mode 100644 apps/api/src/index_old.ts delete mode 100644 apps/tui/src/schema.ts diff --git a/apps/api/package.json b/apps/api/package.json index 7632eb6..b7a3278 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,9 +7,12 @@ "start": "tsx src/index.ts" }, "dependencies": { + "@effect/platform": "^0.93.2", + "@effect/platform-node": "^0.101.1", "@hono/node-server": "^1.19.5", "@money/shared": "workspace:*", "better-auth": "^1.3.27", + "effect": "^3.19.4", "hono": "^4.9.12", "plaid": "^39.0.0", "tsx": "^4.20.6" diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 25cee8f..203e90b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,58 +1,122 @@ -import { serve } from "@hono/node-server"; -import { authDataSchema } from "@money/shared/auth"; -import { BASE_URL } from "@money/shared"; -import { cors } from "hono/cors"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as HttpLayerRouter from "@effect/platform/HttpLayerRouter"; +import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; +import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { createServer } from "http"; +import { + HttpApiBuilder, + HttpApiSchema, + HttpApiSecurity, +} from "@effect/platform"; +import { Schema, Data, Console } from "effect"; import { auth } from "./auth"; -import { getHono } from "./hono"; -import { zero } from "./zero"; -import { webhook } from "./webhook"; +import { AuthSchema } from "@money/shared/auth"; -const app = getHono(); +class CurrentSession extends Context.Tag("CurrentSession")< + CurrentSession, + { readonly auth: Schema.Schema.Type | null } +>() {} -app.use( - "/api/*", - cors({ - origin: ["https://money.koon.us", `${BASE_URL}:8081`], - allowMethods: ["POST", "GET", "OPTIONS"], - allowHeaders: ["Content-Type", "Authorization"], - credentials: true, +class Unauthorized extends Schema.TaggedError()( + "Unauthorized", + {}, + HttpApiSchema.annotations({ status: 401 }), +) {} + +const parseAuthorization = (input: string) => + Effect.gen(function* () { + const m = /^Bearer\s+(.+)$/.exec(input); + if (!m) { + return yield* Effect.fail( + new Unauthorized({ message: "Invalid Authorization header" }), + ); + } + return m[1]; + }); + +export class Authorization extends Context.Tag("Authorization")< + Authorization, + AuthorizationImpl +>() {} + +export interface AuthorizationImpl { + use: ( + fn: (client: typeof auth) => Promise, + ) => Effect.Effect; +} + +class AuthorizationUnknownError extends Data.TaggedError( + "AuthClientUnknownError", +) {} +class AuthorizationError extends Data.TaggedError("AuthClientFetchError")<{ + message: string; +}> {} + +export const make = () => + Effect.gen(function* () { + return Authorization.of({ + use: (fn) => + Effect.gen(function* () { + const data = yield* Effect.tryPromise({ + try: () => fn(auth), + catch: (error) => + error instanceof Error + ? new AuthorizationError({ message: error.message }) + : new AuthorizationUnknownError(), + }); + return data; + }), + }); + }); + +export const AuthorizationLayer = Layer.scoped(Authorization, make()); + +const SessionMiddleware = HttpLayerRouter.middleware<{ + provides: CurrentSession; +}>()( + Effect.gen(function* () { + const auth = yield* Authorization; + + return (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const headers = request.headers; + + const session = yield* auth + .use((auth) => auth.api.getSession({ headers })) + .pipe( + Effect.flatMap((s) => + s == null ? Effect.succeed(null) : Schema.decode(AuthSchema)(s), + ), + Effect.tap((s) => Console.debug("Auth result", s)), + ); + + return yield* Effect.provideService(httpEffect, CurrentSession, { + auth: session, + }); + }); }), ); -app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw)); +const HelloRoute = HttpLayerRouter.add( + "GET", + "/hello", -app.use("*", async (c, next) => { - const authHeader = c.req.raw.headers.get("Authorization"); - const cookie = authHeader?.split("Bearer ")[1]; + Effect.gen(function* () { + const { auth } = yield* CurrentSession; + return HttpServerResponse.text(`Hello, your name is ${auth?.user.name}`); + }), +).pipe(Layer.provide(SessionMiddleware.layer)); - const newHeaders = new Headers(c.req.raw.headers); +const AllRoutes = Layer.mergeAll(HelloRoute); - 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("/api/webhook_receiver", webhook); -app.get("/", (c) => c.text("OK")); - -serve( - { - fetch: app.fetch, - port: process.env.PORT ? parseInt(process.env.PORT) : 3000, - }, - (info) => { - console.log(`Server is running on ${info.address}:${info.port}`); - }, +HttpLayerRouter.serve(AllRoutes).pipe( + Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), + Layer.provide(AuthorizationLayer), + Layer.launch, + NodeRuntime.runMain, ); diff --git a/apps/api/src/index_old.ts b/apps/api/src/index_old.ts new file mode 100644 index 0000000..25cee8f --- /dev/null +++ b/apps/api/src/index_old.ts @@ -0,0 +1,58 @@ +import { serve } from "@hono/node-server"; +import { authDataSchema } from "@money/shared/auth"; +import { BASE_URL } from "@money/shared"; +import { cors } from "hono/cors"; +import { auth } from "./auth"; +import { getHono } from "./hono"; +import { zero } from "./zero"; +import { webhook } from "./webhook"; + +const app = getHono(); + +app.use( + "/api/*", + cors({ + origin: ["https://money.koon.us", `${BASE_URL}:8081`], + 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("/api/webhook_receiver", webhook); +app.get("/", (c) => c.text("OK")); + +serve( + { + fetch: app.fetch, + port: process.env.PORT ? parseInt(process.env.PORT) : 3000, + }, + (info) => { + console.log(`Server is running on ${info.address}:${info.port}`); + }, +); diff --git a/apps/tui/src/auth.ts b/apps/tui/src/auth.ts index 799afdd..ef279e2 100644 --- a/apps/tui/src/auth.ts +++ b/apps/tui/src/auth.ts @@ -11,9 +11,9 @@ import { } from "effect"; import { FileSystem } from "@effect/platform"; import { config } from "./config"; -import { AuthState } from "./schema"; import { authClient } from "@/lib/auth-client"; import type { BetterFetchResponse } from "@better-fetch/fetch"; +import { AuthSchema } from "@money/shared/auth"; class AuthClientUnknownError extends Data.TaggedError( "AuthClientUnknownError", @@ -129,7 +129,7 @@ const pollToken = ({ device_code }: { device_code: string }) => const getFromFromDisk = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const content = yield* fs.readFileString(config.authPath); - const auth = yield* Schema.decode(Schema.parseJson(AuthState))(content); + const auth = yield* Schema.decode(Schema.parseJson(AuthSchema))(content); if (auth.session.expiresAt < new Date()) yield* Effect.fail(new AuthClientExpiredToken()); return auth; @@ -160,7 +160,7 @@ const requestAuth = Effect.gen(function* () { ); if (sessionData == null) return yield* Effect.fail(new AuthClientNoData()); - const result = yield* Schema.decodeUnknown(AuthState)(sessionData); + const result = yield* Schema.decodeUnknown(AuthSchema)(sessionData); const fs = yield* FileSystem.FileSystem; yield* fs.writeFileString(config.authPath, JSON.stringify(result)); diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 99cb0fb..0b73c61 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -5,13 +5,13 @@ import { ZeroProvider } from "@rocicorp/zero/react"; import { schema } from "@money/shared"; import { useState } from "react"; import { AuthClientLayer, getAuth } from "./auth"; -import { Effect } from "effect"; +import { Effect, Redacted } from "effect"; import { BunContext } from "@effect/platform-bun"; -import type { AuthData } from "./schema"; import { kvStore } from "./store"; import { config } from "./config"; +import { type AuthSchemaType } from "@money/shared/auth"; -function Main({ auth }: { auth: AuthData }) { +function Main({ auth }: { auth: AuthSchemaType }) { const [route, setRoute] = useState("/"); useKeyboard((key) => { @@ -22,7 +22,7 @@ function Main({ auth }: { auth: AuthData }) { ; + export type Session = z.infer; export type User = z.infer; export type AuthData = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7e1ef8..1ef1dc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,12 @@ importers: apps/api: dependencies: + '@effect/platform': + specifier: ^0.93.2 + version: 0.93.2(effect@3.19.4) + '@effect/platform-node': + specifier: ^0.101.1 + version: 0.101.1(@effect/cluster@0.52.10(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/workflow@0.12.5(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4) '@hono/node-server': specifier: ^1.19.5 version: 1.19.6(hono@4.10.4) @@ -19,6 +25,9 @@ importers: better-auth: specifier: ^1.3.27 version: 1.3.34(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + effect: + specifier: ^3.19.4 + version: 3.19.4 hono: specifier: ^4.9.12 version: 4.10.4 @@ -838,6 +847,24 @@ packages: '@effect/sql': ^0.48.0 effect: ^3.19.0 + '@effect/platform-node-shared@0.54.0': + resolution: {integrity: sha512-prTgG3CXqmrxB4Rg6utfwCTqjlGwjAEvK7R4g3HzVdFpfFRum+FQBpGHUcjyz7EejkDtBY2MWJC3Wr1QKDPjPw==} + peerDependencies: + '@effect/cluster': ^0.53.0 + '@effect/platform': ^0.93.3 + '@effect/rpc': ^0.72.2 + '@effect/sql': ^0.48.0 + effect: ^3.19.5 + + '@effect/platform-node@0.101.1': + resolution: {integrity: sha512-uShujtpWU0VbdhRKhoo6tXzTG1xT0bnj8u5Q1BHpanwKPmzOhf4n0XLlMl5PaihH5Cp7xHuQlwgZlqHzhqSHzw==} + peerDependencies: + '@effect/cluster': ^0.53.4 + '@effect/platform': ^0.93.3 + '@effect/rpc': ^0.72.2 + '@effect/sql': ^0.48.0 + effect: ^3.19.6 + '@effect/platform@0.93.2': resolution: {integrity: sha512-IFWF2xuz37tZbyEsf3hwBlcYYqbqJho+ZM871CG92lWJSjcTgvmjCy77qnV0QhTWVdh9BMs12QKzQCMlqz4cJQ==} peerDependencies: @@ -6888,6 +6915,10 @@ packages: resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} engines: {node: '>=18.17'} + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -7936,6 +7967,35 @@ snapshots: - bufferutil - utf-8-validate + '@effect/platform-node-shared@0.54.0(@effect/cluster@0.52.10(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/workflow@0.12.5(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4)': + dependencies: + '@effect/cluster': 0.52.10(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/workflow@0.12.5(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(effect@3.19.4) + '@effect/platform': 0.93.2(effect@3.19.4) + '@effect/rpc': 0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4) + '@effect/sql': 0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4) + '@parcel/watcher': 2.5.1 + effect: 3.19.4 + multipasta: 0.2.7 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@0.101.1(@effect/cluster@0.52.10(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/workflow@0.12.5(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4)': + dependencies: + '@effect/cluster': 0.52.10(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/workflow@0.12.5(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(effect@3.19.4) + '@effect/platform': 0.93.2(effect@3.19.4) + '@effect/platform-node-shared': 0.54.0(@effect/cluster@0.52.10(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/workflow@0.12.5(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4) + '@effect/rpc': 0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4) + '@effect/sql': 0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4) + effect: 3.19.4 + mime: 3.0.0 + undici: 7.16.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@effect/platform@0.93.2(effect@3.19.4)': dependencies: effect: 3.19.4 @@ -15764,6 +15824,8 @@ snapshots: undici@6.22.0: {} + undici@7.16.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: -- 2.51.2 From ed3e6df4d221b72e58e4829cbf4361ef2e6bdcf5 Mon Sep 17 00:00:00 2001 From: Max Koon <22125083+k2on@users.noreply.github.com> Date: Wed, 26 Nov 2025 02:38:00 -0500 Subject: [PATCH 2/6] feat: better auth api handler --- apps/api/src/index.ts | 87 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 203e90b..a7bd2e4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,15 +6,22 @@ import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; import { createServer } from "http"; import { + HttpApi, HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, HttpApiSchema, HttpApiSecurity, + HttpMiddleware, } from "@effect/platform"; import { Schema, Data, Console } from "effect"; import { auth } from "./auth"; import { AuthSchema } from "@money/shared/auth"; +import { toNodeHandler } from "better-auth/node"; +import { BASE_URL } from "@money/shared"; class CurrentSession extends Context.Tag("CurrentSession")< CurrentSession, @@ -52,7 +59,7 @@ export interface AuthorizationImpl { class AuthorizationUnknownError extends Data.TaggedError( "AuthClientUnknownError", ) {} -class AuthorizationError extends Data.TaggedError("AuthClientFetchError")<{ +class AuthorizationError extends Data.TaggedError("AuthorizationError")<{ message: string; }> {} @@ -112,11 +119,87 @@ const HelloRoute = HttpLayerRouter.add( }), ).pipe(Layer.provide(SessionMiddleware.layer)); -const AllRoutes = Layer.mergeAll(HelloRoute); +const RootRoute = HttpLayerRouter.add( + "GET", + "/", + Effect.gen(function* () { + return HttpServerResponse.text("OK"); + }), +); + +const authHandler = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const nodeRequest = NodeHttpServerRequest.toIncomingMessage(request); + const nodeResponse = NodeHttpServerRequest.toServerResponse(request); + + nodeResponse.setHeader("Access-Control-Allow-Origin", "http://laptop:8081"); + nodeResponse.setHeader( + "Access-Control-Allow-Methods", + "GET, POST, PUT, DELETE, OPTIONS", + ); + nodeResponse.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, B3, traceparent, Cookie", + ); + nodeResponse.setHeader("Access-Control-Max-Age", "600"); + nodeResponse.setHeader("Access-Control-Allow-Credentials", "true"); + + // Handle preflight requests + if (nodeRequest.method === "OPTIONS") { + nodeResponse.statusCode = 200; + nodeResponse.end(); + + return; + // return nodeResponse; + } + + yield* Effect.tryPromise({ + try: () => toNodeHandler(auth)(nodeRequest, nodeResponse), + catch: (error) => { + return new AuthorizationError({ message: `${error}` }); + }, + }); + + // return nodeResponse; +}); + +export class AuthContractGroup extends HttpApiGroup.make("auth") + .add(HttpApiEndpoint.get("get", "/*")) + .add(HttpApiEndpoint.post("post", "/*")) + .add(HttpApiEndpoint.options("options", "/*")) + .prefix("/api/auth") {} + +export class DomainApi extends HttpApi.make("domain").add(AuthContractGroup) {} + +export const Api = HttpApi.make("api").addHttpApi(DomainApi); + +const AuthLive = HttpApiBuilder.group(Api, "auth", (handlers) => + handlers + .handle("get", () => authHandler.pipe(Effect.orDie)) + .handle("post", () => authHandler.pipe(Effect.orDie)) + .handle("options", () => authHandler.pipe(Effect.orDie)), +); + +const CorsMiddleware = HttpLayerRouter.middleware( + HttpMiddleware.cors({ + allowedOrigins: ["https://money.koon.us", `${BASE_URL}:8081`], + allowedMethods: ["POST", "GET", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + credentials: true, + }), + // { + // global: true, + // }, +); + +const AuthRoute = HttpLayerRouter.addHttpApi(Api).pipe(Layer.provide(AuthLive)); + +const AllRoutes = Layer.mergeAll(RootRoute, AuthRoute, HelloRoute); HttpLayerRouter.serve(AllRoutes).pipe( Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), Layer.provide(AuthorizationLayer), + Layer.provide(CorsMiddleware.layer), Layer.launch, NodeRuntime.runMain, ); -- 2.51.2 From cbc220a9689e6fcdbf603cd949a69c03238bee88 Mon Sep 17 00:00:00 2001 From: Max Koon <22125083+k2on@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:09:12 -0500 Subject: [PATCH 3/6] refactor: api routes --- apps/api/src/auth/better-auth.ts | 23 +++ apps/api/src/{auth.ts => auth/config.ts} | 2 +- apps/api/src/auth/context.ts | 14 ++ apps/api/src/auth/errors.ts | 8 + apps/api/src/auth/handler.ts | 70 ++++++++ apps/api/src/hono.ts | 5 - apps/api/src/index.ts | 193 ++------------------- apps/api/src/index_old.ts | 58 ------- apps/api/src/middleware/cors.ts | 11 ++ apps/api/src/middleware/session.ts | 61 +++++++ apps/api/src/webhook.ts | 24 ++- apps/api/src/zero/errors.ts | 6 + apps/api/src/zero/handler.ts | 106 +++++++++++ apps/api/src/{zero.ts => zero/mutators.ts} | 79 ++------- apps/tui/src/auth.ts | 4 +- 15 files changed, 342 insertions(+), 322 deletions(-) create mode 100644 apps/api/src/auth/better-auth.ts rename apps/api/src/{auth.ts => auth/config.ts} (97%) create mode 100644 apps/api/src/auth/context.ts create mode 100644 apps/api/src/auth/errors.ts create mode 100644 apps/api/src/auth/handler.ts delete mode 100644 apps/api/src/hono.ts delete mode 100644 apps/api/src/index_old.ts create mode 100644 apps/api/src/middleware/cors.ts create mode 100644 apps/api/src/middleware/session.ts create mode 100644 apps/api/src/zero/errors.ts create mode 100644 apps/api/src/zero/handler.ts rename apps/api/src/{zero.ts => zero/mutators.ts} (79%) diff --git a/apps/api/src/auth/better-auth.ts b/apps/api/src/auth/better-auth.ts new file mode 100644 index 0000000..d922a10 --- /dev/null +++ b/apps/api/src/auth/better-auth.ts @@ -0,0 +1,23 @@ +import { Effect, Layer } from "effect"; +import { Authorization } from "./context"; +import { auth } from "./config"; +import { AuthorizationError, AuthorizationUnknownError } from "./errors"; + +export const make = () => + Effect.gen(function* () { + return Authorization.of({ + use: (fn) => + Effect.gen(function* () { + const data = yield* Effect.tryPromise({ + try: () => fn(auth), + catch: (error) => + error instanceof Error + ? new AuthorizationError({ message: error.message }) + : new AuthorizationUnknownError(), + }); + return data; + }), + }); + }); + +export const BetterAuthLive = Layer.scoped(Authorization, make()); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth/config.ts similarity index 97% rename from apps/api/src/auth.ts rename to apps/api/src/auth/config.ts index e227df7..d230596 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth/config.ts @@ -3,7 +3,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { bearer, deviceAuthorization, genericOAuth } from "better-auth/plugins"; import { expo } from "@better-auth/expo"; import { drizzleSchema } from "@money/shared/db"; -import { db } from "./db"; +import { db } from "../db"; import { BASE_URL, HOST } from "@money/shared"; export const auth = betterAuth({ diff --git a/apps/api/src/auth/context.ts b/apps/api/src/auth/context.ts new file mode 100644 index 0000000..9fe2e85 --- /dev/null +++ b/apps/api/src/auth/context.ts @@ -0,0 +1,14 @@ +import { Context, type Effect } from "effect"; +import type { AuthorizationError, AuthorizationUnknownError } from "./errors"; +import type { auth } from "./config"; + +export class Authorization extends Context.Tag("Authorization")< + Authorization, + AuthorizationImpl +>() {} + +export interface AuthorizationImpl { + use: ( + fn: (client: typeof auth) => Promise, + ) => Effect.Effect; +} diff --git a/apps/api/src/auth/errors.ts b/apps/api/src/auth/errors.ts new file mode 100644 index 0000000..7ff41e6 --- /dev/null +++ b/apps/api/src/auth/errors.ts @@ -0,0 +1,8 @@ +import { Data } from "effect"; + +export class AuthorizationUnknownError extends Data.TaggedError( + "AuthClientUnknownError", +) {} +export class AuthorizationError extends Data.TaggedError("AuthorizationError")<{ + message: string; +}> {} diff --git a/apps/api/src/auth/handler.ts b/apps/api/src/auth/handler.ts new file mode 100644 index 0000000..bdd642a --- /dev/null +++ b/apps/api/src/auth/handler.ts @@ -0,0 +1,70 @@ +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpLayerRouter, + HttpServerRequest, +} from "@effect/platform"; +import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; +import { toNodeHandler } from "better-auth/node"; +import { Effect, Layer } from "effect"; +import { AuthorizationError } from "./errors"; +import { auth } from "./config"; + +const authHandler = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const nodeRequest = NodeHttpServerRequest.toIncomingMessage(request); + const nodeResponse = NodeHttpServerRequest.toServerResponse(request); + + nodeResponse.setHeader("Access-Control-Allow-Origin", "http://laptop:8081"); + nodeResponse.setHeader( + "Access-Control-Allow-Methods", + "GET, POST, PUT, DELETE, OPTIONS", + ); + nodeResponse.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, B3, traceparent, Cookie", + ); + nodeResponse.setHeader("Access-Control-Max-Age", "600"); + nodeResponse.setHeader("Access-Control-Allow-Credentials", "true"); + + // Handle preflight requests + if (nodeRequest.method === "OPTIONS") { + nodeResponse.statusCode = 200; + nodeResponse.end(); + + return; + // return nodeResponse; + } + + yield* Effect.tryPromise({ + try: () => toNodeHandler(auth)(nodeRequest, nodeResponse), + catch: (error) => { + return new AuthorizationError({ message: `${error}` }); + }, + }); + + // return nodeResponse; +}); + +export class AuthContractGroup extends HttpApiGroup.make("auth") + .add(HttpApiEndpoint.get("get", "/*")) + .add(HttpApiEndpoint.post("post", "/*")) + .add(HttpApiEndpoint.options("options", "/*")) + .prefix("/api/auth") {} + +export class DomainApi extends HttpApi.make("domain").add(AuthContractGroup) {} + +export const Api = HttpApi.make("api").addHttpApi(DomainApi); + +const AuthLive = HttpApiBuilder.group(Api, "auth", (handlers) => + handlers + .handle("get", () => authHandler.pipe(Effect.orDie)) + .handle("post", () => authHandler.pipe(Effect.orDie)) + .handle("options", () => authHandler.pipe(Effect.orDie)), +); + +export const AuthRoute = HttpLayerRouter.addHttpApi(Api).pipe( + Layer.provide(AuthLive), +); diff --git a/apps/api/src/hono.ts b/apps/api/src/hono.ts deleted file mode 100644 index 9142dd2..0000000 --- a/apps/api/src/hono.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { AuthData } from "@money/shared/auth"; -import { Hono } from "hono"; - -export const getHono = () => - new Hono<{ Variables: { auth: AuthData | null } }>(); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a7bd2e4..5c09c22 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,123 +1,15 @@ import * as Layer from "effect/Layer"; import * as Effect from "effect/Effect"; -import * as Context from "effect/Context"; import * as HttpLayerRouter from "@effect/platform/HttpLayerRouter"; import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; -import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; import { createServer } from "http"; -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - HttpApiSchema, - HttpApiSecurity, - HttpMiddleware, -} from "@effect/platform"; -import { Schema, Data, Console } from "effect"; -import { auth } from "./auth"; -import { AuthSchema } from "@money/shared/auth"; -import { toNodeHandler } from "better-auth/node"; -import { BASE_URL } from "@money/shared"; - -class CurrentSession extends Context.Tag("CurrentSession")< - CurrentSession, - { readonly auth: Schema.Schema.Type | null } ->() {} - -class Unauthorized extends Schema.TaggedError()( - "Unauthorized", - {}, - HttpApiSchema.annotations({ status: 401 }), -) {} - -const parseAuthorization = (input: string) => - Effect.gen(function* () { - const m = /^Bearer\s+(.+)$/.exec(input); - if (!m) { - return yield* Effect.fail( - new Unauthorized({ message: "Invalid Authorization header" }), - ); - } - return m[1]; - }); - -export class Authorization extends Context.Tag("Authorization")< - Authorization, - AuthorizationImpl ->() {} - -export interface AuthorizationImpl { - use: ( - fn: (client: typeof auth) => Promise, - ) => Effect.Effect; -} - -class AuthorizationUnknownError extends Data.TaggedError( - "AuthClientUnknownError", -) {} -class AuthorizationError extends Data.TaggedError("AuthorizationError")<{ - message: string; -}> {} - -export const make = () => - Effect.gen(function* () { - return Authorization.of({ - use: (fn) => - Effect.gen(function* () { - const data = yield* Effect.tryPromise({ - try: () => fn(auth), - catch: (error) => - error instanceof Error - ? new AuthorizationError({ message: error.message }) - : new AuthorizationUnknownError(), - }); - return data; - }), - }); - }); - -export const AuthorizationLayer = Layer.scoped(Authorization, make()); - -const SessionMiddleware = HttpLayerRouter.middleware<{ - provides: CurrentSession; -}>()( - Effect.gen(function* () { - const auth = yield* Authorization; - - return (httpEffect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const headers = request.headers; - - const session = yield* auth - .use((auth) => auth.api.getSession({ headers })) - .pipe( - Effect.flatMap((s) => - s == null ? Effect.succeed(null) : Schema.decode(AuthSchema)(s), - ), - Effect.tap((s) => Console.debug("Auth result", s)), - ); - - return yield* Effect.provideService(httpEffect, CurrentSession, { - auth: session, - }); - }); - }), -); - -const HelloRoute = HttpLayerRouter.add( - "GET", - "/hello", - - Effect.gen(function* () { - const { auth } = yield* CurrentSession; - return HttpServerResponse.text(`Hello, your name is ${auth?.user.name}`); - }), -).pipe(Layer.provide(SessionMiddleware.layer)); +import { CorsMiddleware } from "./middleware/cors"; +import { AuthRoute } from "./auth/handler"; +import { BetterAuthLive } from "./auth/better-auth"; +import { WebhookReceiverRoute } from "./webhook"; +import { ZeroMutateRoute, ZeroQueryRoute } from "./zero/handler"; const RootRoute = HttpLayerRouter.add( "GET", @@ -127,78 +19,17 @@ const RootRoute = HttpLayerRouter.add( }), ); -const authHandler = Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const nodeRequest = NodeHttpServerRequest.toIncomingMessage(request); - const nodeResponse = NodeHttpServerRequest.toServerResponse(request); - - nodeResponse.setHeader("Access-Control-Allow-Origin", "http://laptop:8081"); - nodeResponse.setHeader( - "Access-Control-Allow-Methods", - "GET, POST, PUT, DELETE, OPTIONS", - ); - nodeResponse.setHeader( - "Access-Control-Allow-Headers", - "Content-Type, Authorization, B3, traceparent, Cookie", - ); - nodeResponse.setHeader("Access-Control-Max-Age", "600"); - nodeResponse.setHeader("Access-Control-Allow-Credentials", "true"); - - // Handle preflight requests - if (nodeRequest.method === "OPTIONS") { - nodeResponse.statusCode = 200; - nodeResponse.end(); - - return; - // return nodeResponse; - } - - yield* Effect.tryPromise({ - try: () => toNodeHandler(auth)(nodeRequest, nodeResponse), - catch: (error) => { - return new AuthorizationError({ message: `${error}` }); - }, - }); - - // return nodeResponse; -}); - -export class AuthContractGroup extends HttpApiGroup.make("auth") - .add(HttpApiEndpoint.get("get", "/*")) - .add(HttpApiEndpoint.post("post", "/*")) - .add(HttpApiEndpoint.options("options", "/*")) - .prefix("/api/auth") {} - -export class DomainApi extends HttpApi.make("domain").add(AuthContractGroup) {} - -export const Api = HttpApi.make("api").addHttpApi(DomainApi); - -const AuthLive = HttpApiBuilder.group(Api, "auth", (handlers) => - handlers - .handle("get", () => authHandler.pipe(Effect.orDie)) - .handle("post", () => authHandler.pipe(Effect.orDie)) - .handle("options", () => authHandler.pipe(Effect.orDie)), +const AllRoutes = Layer.mergeAll( + RootRoute, + AuthRoute, + ZeroQueryRoute, + ZeroMutateRoute, + WebhookReceiverRoute, ); -const CorsMiddleware = HttpLayerRouter.middleware( - HttpMiddleware.cors({ - allowedOrigins: ["https://money.koon.us", `${BASE_URL}:8081`], - allowedMethods: ["POST", "GET", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization"], - credentials: true, - }), - // { - // global: true, - // }, -); - -const AuthRoute = HttpLayerRouter.addHttpApi(Api).pipe(Layer.provide(AuthLive)); - -const AllRoutes = Layer.mergeAll(RootRoute, AuthRoute, HelloRoute); - HttpLayerRouter.serve(AllRoutes).pipe( Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), - Layer.provide(AuthorizationLayer), + Layer.provide(BetterAuthLive), Layer.provide(CorsMiddleware.layer), Layer.launch, NodeRuntime.runMain, diff --git a/apps/api/src/index_old.ts b/apps/api/src/index_old.ts deleted file mode 100644 index 25cee8f..0000000 --- a/apps/api/src/index_old.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { serve } from "@hono/node-server"; -import { authDataSchema } from "@money/shared/auth"; -import { BASE_URL } from "@money/shared"; -import { cors } from "hono/cors"; -import { auth } from "./auth"; -import { getHono } from "./hono"; -import { zero } from "./zero"; -import { webhook } from "./webhook"; - -const app = getHono(); - -app.use( - "/api/*", - cors({ - origin: ["https://money.koon.us", `${BASE_URL}:8081`], - 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("/api/webhook_receiver", webhook); -app.get("/", (c) => c.text("OK")); - -serve( - { - fetch: app.fetch, - port: process.env.PORT ? parseInt(process.env.PORT) : 3000, - }, - (info) => { - console.log(`Server is running on ${info.address}:${info.port}`); - }, -); diff --git a/apps/api/src/middleware/cors.ts b/apps/api/src/middleware/cors.ts new file mode 100644 index 0000000..713c69c --- /dev/null +++ b/apps/api/src/middleware/cors.ts @@ -0,0 +1,11 @@ +import { HttpLayerRouter, HttpMiddleware } from "@effect/platform"; +import { BASE_URL } from "@money/shared"; + +export const CorsMiddleware = HttpLayerRouter.middleware( + HttpMiddleware.cors({ + allowedOrigins: ["https://money.koon.us", `${BASE_URL}:8081`], + allowedMethods: ["POST", "GET", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + credentials: true, + }), +); diff --git a/apps/api/src/middleware/session.ts b/apps/api/src/middleware/session.ts new file mode 100644 index 0000000..35c671e --- /dev/null +++ b/apps/api/src/middleware/session.ts @@ -0,0 +1,61 @@ +import { AuthSchema } from "@money/shared/auth"; +import { Context, Effect, Schema, Console } from "effect"; +import { Authorization } from "../auth/context"; +import { HttpLayerRouter, HttpServerRequest } from "@effect/platform"; + +export class CurrentSession extends Context.Tag("CurrentSession")< + CurrentSession, + { readonly auth: Schema.Schema.Type | null } +>() {} + +export const SessionMiddleware = HttpLayerRouter.middleware<{ + provides: CurrentSession; +}>()( + Effect.gen(function* () { + const auth = yield* Authorization; + + return (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const headers = { ...request.headers }; + + const token = yield* HttpServerRequest.schemaHeaders( + Schema.Struct({ + authorization: Schema.optional(Schema.String), + }), + ).pipe( + Effect.tap(Console.debug), + Effect.flatMap(({ authorization }) => + authorization != undefined + ? parseAuthorization(authorization) + : Effect.succeed(undefined), + ), + ); + if (token) { + headers["cookie"] = token; + } + + const session = yield* auth + .use((auth) => auth.api.getSession({ headers })) + .pipe( + Effect.flatMap((s) => + s == null ? Effect.succeed(null) : Schema.decode(AuthSchema)(s), + ), + // Effect.tap((s) => Console.debug("Auth result", s)), + ); + + return yield* Effect.provideService(httpEffect, CurrentSession, { + auth: session, + }); + }); + }), +); + +const parseAuthorization = (input: string) => + Effect.gen(function* () { + const m = /^Bearer\s+(.+)$/.exec(input); + if (!m) { + return yield* Effect.fail(new Error("Invalid token")); + } + return m[1]; + }); diff --git a/apps/api/src/webhook.ts b/apps/api/src/webhook.ts index 267f23f..e1eb06b 100644 --- a/apps/api/src/webhook.ts +++ b/apps/api/src/webhook.ts @@ -1,11 +1,21 @@ -import type { Context } from "hono"; +import { + HttpLayerRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; + import { plaidClient } from "./plaid"; // import { LinkSessionFinishedWebhook, WebhookType } from "plaid"; -export const webhook = async (c: Context) => { - console.log("Got webhook"); - const b = await c.req.text(); - console.log("body:", b); +export const WebhookReceiverRoute = HttpLayerRouter.add( + "*", + "/api/webhook_receiver", - return c.text("Hi"); -}; + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const body = yield* request.json; + Effect.log("Got a webhook!", body); + return HttpServerResponse.text("HELLO THERE"); + }), +); diff --git a/apps/api/src/zero/errors.ts b/apps/api/src/zero/errors.ts new file mode 100644 index 0000000..1724e4b --- /dev/null +++ b/apps/api/src/zero/errors.ts @@ -0,0 +1,6 @@ +import { Data } from "effect"; + +export class ZeroUnknownError extends Data.TaggedError("ZeroUnknownError") {} +export class ZeroError extends Data.TaggedError("ZeroError")<{ + error: Error; +}> {} diff --git a/apps/api/src/zero/handler.ts b/apps/api/src/zero/handler.ts new file mode 100644 index 0000000..c39c444 --- /dev/null +++ b/apps/api/src/zero/handler.ts @@ -0,0 +1,106 @@ +import { + HttpLayerRouter, + HttpServerRequest, + HttpServerResponse, + Url, +} from "@effect/platform"; +import { Console, Effect, Layer } from "effect"; +import { CurrentSession, SessionMiddleware } from "../middleware/session"; +import { ZeroError, ZeroUnknownError } from "./errors"; +import { withValidation, type ReadonlyJSONValue } from "@rocicorp/zero"; +import { + handleGetQueriesRequest, + PushProcessor, + ZQLDatabase, +} from "@rocicorp/zero/server"; +import { BASE_URL, queries, schema } from "@money/shared"; +import type { AuthSchemaType } from "@money/shared/auth"; +import { PostgresJSConnection } from "@rocicorp/zero/pg"; +import postgres from "postgres"; +import { createMutators } from "./mutators"; + +const processor = new PushProcessor( + new ZQLDatabase( + new PostgresJSConnection(postgres(process.env.ZERO_UPSTREAM_DB! as string)), + schema, + ), +); + +export const ZeroQueryRoute = HttpLayerRouter.add( + "POST", + "/api/zero/get-queries", + + Effect.gen(function* () { + const { auth } = yield* CurrentSession; + + const request = yield* HttpServerRequest.HttpServerRequest; + const body = yield* request.json; + + const result = yield* Effect.tryPromise({ + try: () => + handleGetQueriesRequest( + (name, args) => ({ query: getQuery(auth, name, args) }), + schema, + body as ReadonlyJSONValue, + ), + catch: (error) => + error instanceof Error + ? new ZeroError({ error }) + : new ZeroUnknownError(), + }).pipe( + Effect.tapErrorTag("ZeroError", (err) => + Console.error("Zero Error", err.error), + ), + ); + + return yield* HttpServerResponse.json(result); + }), +).pipe(Layer.provide(SessionMiddleware.layer)); + +function getQuery( + authData: AuthSchemaType | 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 const validatedQueries = Object.fromEntries( + Object.values(queries).map((q) => [q.queryName, withValidation(q)]), +); + +export const ZeroMutateRoute = HttpLayerRouter.add( + "POST", + "/api/zero/mutate", + + Effect.gen(function* () { + const { auth } = yield* CurrentSession; + + const request = yield* HttpServerRequest.HttpServerRequest; + const url = yield* Url.fromString(`${BASE_URL}${request.url}`); + const body = yield* request.json; + + const result = yield* Effect.tryPromise({ + try: () => + processor.process( + createMutators(auth), + url.searchParams, + body as ReadonlyJSONValue, + ), + catch: (error) => + error instanceof Error + ? new ZeroError({ error }) + : new ZeroUnknownError(), + }).pipe( + Effect.tapErrorTag("ZeroError", (err) => + Console.error("Zero Error", err.error), + ), + ); + + return yield* HttpServerResponse.json(result); + }), +).pipe(Layer.provide(SessionMiddleware.layer)); diff --git a/apps/api/src/zero.ts b/apps/api/src/zero/mutators.ts similarity index 79% rename from apps/api/src/zero.ts rename to apps/api/src/zero/mutators.ts index a59f50e..0529517 100644 --- a/apps/api/src/zero.ts +++ b/apps/api/src/zero/mutators.ts @@ -1,15 +1,3 @@ -import { - type ReadonlyJSONValue, - type Transaction, - withValidation, -} from "@rocicorp/zero"; -import { - handleGetQueriesRequest, - PushProcessor, - ZQLDatabase, -} from "@rocicorp/zero/server"; -import { PostgresJSConnection } from "@rocicorp/zero/pg"; -import postgres from "postgres"; import { createMutators as createMutatorsShared, isLoggedIn, @@ -18,36 +6,27 @@ import { type Mutators, type Schema, } from "@money/shared"; -import type { AuthData } from "@money/shared/auth"; -import { getHono } from "./hono"; +import type { AuthSchemaType } from "@money/shared/auth"; import { - Configuration, - CountryCode, - PlaidApi, - PlaidEnvironments, - Products, -} from "plaid"; -import { randomUUID } from "crypto"; -import { db } from "./db"; + type ReadonlyJSONValue, + type Transaction, + withValidation, +} from "@rocicorp/zero"; +import { plaidClient } from "../plaid"; +import { CountryCode, Products } from "plaid"; import { balance, plaidAccessTokens, plaidLink, transaction, } from "@money/shared/db"; +import { db } from "../db"; +import { randomUUID } from "crypto"; import { and, eq, inArray, sql, type InferInsertModel } from "drizzle-orm"; -import { plaidClient } from "./plaid"; - -const processor = new PushProcessor( - new ZQLDatabase( - new PostgresJSConnection(postgres(process.env.ZERO_UPSTREAM_DB! as string)), - schema, - ), -); type Tx = Transaction; -const createMutators = (authData: AuthData | null) => { +export const createMutators = (authData: AuthSchemaType | null) => { const mutators = createMutatorsShared(authData); return { ...mutators, @@ -221,41 +200,3 @@ const createMutators = (authData: AuthData | null) => { }, } as const satisfies Mutators; }; - -const zero = getHono() - .post("/mutate", async (c) => { - const authData = c.get("auth"); - - const result = await processor.process(createMutators(authData), c.req.raw); - - return c.json(result); - }) - .post("/get-queries", async (c) => { - 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/apps/tui/src/auth.ts b/apps/tui/src/auth.ts index ef279e2..8ff4690 100644 --- a/apps/tui/src/auth.ts +++ b/apps/tui/src/auth.ts @@ -14,6 +14,7 @@ import { config } from "./config"; import { authClient } from "@/lib/auth-client"; import type { BetterFetchResponse } from "@better-fetch/fetch"; import { AuthSchema } from "@money/shared/auth"; +import { encode } from "node:punycode"; class AuthClientUnknownError extends Data.TaggedError( "AuthClientUnknownError", @@ -161,9 +162,10 @@ const requestAuth = Effect.gen(function* () { if (sessionData == null) return yield* Effect.fail(new AuthClientNoData()); const result = yield* Schema.decodeUnknown(AuthSchema)(sessionData); + const encoded = yield* Schema.encode(AuthSchema)(result); const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(config.authPath, JSON.stringify(result)); + yield* fs.writeFileString(config.authPath, JSON.stringify(encoded)); return result; }); -- 2.51.2 From 02dd064d99754dd4046e474b2380d8041b71e419 Mon Sep 17 00:00:00 2001 From: Max Koon <22125083+k2on@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:10:28 -0500 Subject: [PATCH 4/6] chore: remove hono --- apps/api/package.json | 2 -- pnpm-lock.yaml | 22 ---------------------- 2 files changed, 24 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index b7a3278..01709f6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,11 +9,9 @@ "dependencies": { "@effect/platform": "^0.93.2", "@effect/platform-node": "^0.101.1", - "@hono/node-server": "^1.19.5", "@money/shared": "workspace:*", "better-auth": "^1.3.27", "effect": "^3.19.4", - "hono": "^4.9.12", "plaid": "^39.0.0", "tsx": "^4.20.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ef1dc2..371422e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,9 +16,6 @@ importers: '@effect/platform-node': specifier: ^0.101.1 version: 0.101.1(@effect/cluster@0.52.10(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/workflow@0.12.5(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(@effect/rpc@0.72.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/sql@0.48.0(@effect/experimental@0.57.1(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(@effect/platform@0.93.2(effect@3.19.4))(effect@3.19.4))(effect@3.19.4) - '@hono/node-server': - specifier: ^1.19.5 - version: 1.19.6(hono@4.10.4) '@money/shared': specifier: workspace:* version: link:../../packages/shared @@ -28,9 +25,6 @@ importers: effect: specifier: ^3.19.4 version: 3.19.4 - hono: - specifier: ^4.9.12 - version: 4.10.4 plaid: specifier: ^39.0.0 version: 39.1.0 @@ -1561,12 +1555,6 @@ packages: '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} - '@hono/node-server@1.19.6': - resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -4894,10 +4882,6 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - hono@4.10.4: - resolution: {integrity: sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg==} - engines: {node: '>=16.9.0'} - hosted-git-info@7.0.2: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} @@ -8796,10 +8780,6 @@ snapshots: '@hexagon/base64@1.1.28': {} - '@hono/node-server@1.19.6(hono@4.10.4)': - dependencies: - hono: 4.10.4 - '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -13274,8 +13254,6 @@ snapshots: dependencies: react-is: 16.13.1 - hono@4.10.4: {} - hosted-git-info@7.0.2: dependencies: lru-cache: 10.4.3 -- 2.51.2 From 3ebb7ee796193cf5b09c6186a31d3efd2dfa58a5 Mon Sep 17 00:00:00 2001 From: Max Koon <22125083+k2on@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:04:42 -0500 Subject: [PATCH 5/6] feat: add @effect/rpc --- apps/api/package.json | 1 + apps/api/src/index.ts | 14 +++- apps/api/src/middleware/cors.ts | 1 + apps/api/src/rpc/handler.ts | 13 ++++ apps/expo/app/rpc.tsx | 30 +++++++++ apps/expo/package.json | 1 + packages/shared/package.json | 1 + packages/shared/src/rpc.ts | 13 ++++ pnpm-lock.yaml | 112 +++++++++++++++++++++++--------- 9 files changed, 154 insertions(+), 32 deletions(-) create mode 100644 apps/api/src/rpc/handler.ts create mode 100644 apps/expo/app/rpc.tsx create mode 100644 packages/shared/src/rpc.ts diff --git a/apps/api/package.json b/apps/api/package.json index 01709f6..ada9c1c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,6 +9,7 @@ "dependencies": { "@effect/platform": "^0.93.2", "@effect/platform-node": "^0.101.1", + "@effect/rpc": "^0.72.2", "@money/shared": "workspace:*", "better-auth": "^1.3.27", "effect": "^3.19.4", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5c09c22..fe6165a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,11 +5,12 @@ import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { createServer } from "http"; -import { CorsMiddleware } from "./middleware/cors"; import { AuthRoute } from "./auth/handler"; import { BetterAuthLive } from "./auth/better-auth"; import { WebhookReceiverRoute } from "./webhook"; import { ZeroMutateRoute, ZeroQueryRoute } from "./zero/handler"; +import { RpcRoute } from "./rpc/handler"; +import { BASE_URL } from "@money/shared"; const RootRoute = HttpLayerRouter.add( "GET", @@ -24,13 +25,22 @@ const AllRoutes = Layer.mergeAll( AuthRoute, ZeroQueryRoute, ZeroMutateRoute, + RpcRoute, WebhookReceiverRoute, +).pipe( + Layer.provide( + HttpLayerRouter.cors({ + allowedOrigins: ["https://money.koon.us", `${BASE_URL}:8081`], + allowedMethods: ["POST", "GET", "OPTIONS"], + // allowedHeaders: ["Content-Type", "Authorization", ""], + credentials: true, + }), + ), ); HttpLayerRouter.serve(AllRoutes).pipe( Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), Layer.provide(BetterAuthLive), - Layer.provide(CorsMiddleware.layer), Layer.launch, NodeRuntime.runMain, ); diff --git a/apps/api/src/middleware/cors.ts b/apps/api/src/middleware/cors.ts index 713c69c..bcec7b9 100644 --- a/apps/api/src/middleware/cors.ts +++ b/apps/api/src/middleware/cors.ts @@ -8,4 +8,5 @@ export const CorsMiddleware = HttpLayerRouter.middleware( allowedHeaders: ["Content-Type", "Authorization"], credentials: true, }), + { global: true }, ); diff --git a/apps/api/src/rpc/handler.ts b/apps/api/src/rpc/handler.ts new file mode 100644 index 0000000..26e52b1 --- /dev/null +++ b/apps/api/src/rpc/handler.ts @@ -0,0 +1,13 @@ +import { RpcSerialization, RpcServer } from "@effect/rpc"; +import { Effect, Layer, Schema } from "effect"; +import { LinkRpcs, Link } from "@money/shared/rpc"; + +const LinkHandlers = LinkRpcs.toLayer({ + CreateLink: () => Effect.succeed(new Link({ href: "hi" })), +}); + +export const RpcRoute = RpcServer.layerHttpRouter({ + group: LinkRpcs, + path: "/rpc", + protocol: "http", +}).pipe(Layer.provide(LinkHandlers), Layer.provide(RpcSerialization.layerJson)); diff --git a/apps/expo/app/rpc.tsx b/apps/expo/app/rpc.tsx new file mode 100644 index 0000000..65ab3ce --- /dev/null +++ b/apps/expo/app/rpc.tsx @@ -0,0 +1,30 @@ +import { Button, Text, View } from "react-native"; +import { AtomRpc, useAtomSet } from "@effect-atom/atom-react"; +import { Layer } from "effect"; +import { LinkRpcs } from "@money/shared/rpc"; +import { FetchHttpClient } from "@effect/platform"; +import { RpcClient, RpcSerialization } from "@effect/rpc"; + +class Client extends AtomRpc.Tag()("RpcClient", { + group: LinkRpcs, + protocol: RpcClient.layerProtocolHttp({ + url: "http://laptop:3000/rpc", + }).pipe(Layer.provide([RpcSerialization.layerJson, FetchHttpClient.layer])), +}) {} + +export default function Page() { + const create = useAtomSet(Client.mutation("CreateLink")); + + const onPress = () => { + create({ + payload: void 0, + }); + }; + + return ( + + RPC Test + - {link ? ( - <> - - Please click the button to complete setup. - - - - - ) : ( - Loading Plaid Link - )} + {/* {link ? ( */} + {/* <> */} + {/* */} + {/* Please click the button to complete setup. */} + {/* */} + {/**/} + {/* */} + {/* */} + {/* ) : ( */} + {/* Loading Plaid Link */} + {/* )} */} ); } -- 2.51.2