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] 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; });