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] 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: