diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts new file mode 100644 index 0000000000..a97ce1840c --- /dev/null +++ b/packages/opencode/src/provider/auth-service.ts @@ -0,0 +1,164 @@ +import { Effect, Layer, ServiceMap } from "effect" +import { Instance } from "@/project/instance" +import { Plugin } from "../plugin" +import { filter, fromEntries, map, mapValues, pipe } from "remeda" +import type { AuthOuathResult } from "@opencode-ai/plugin" +import { NamedError } from "@opencode-ai/util/error" +import * as Auth from "@/auth/service" +import { ProviderID } from "./schema" +import z from "zod" + +const state = Instance.state(async () => { + const methods = pipe( + await Plugin.list(), + filter((x) => x.auth?.provider !== undefined), + map((x) => [x.auth!.provider, x.auth!] as const), + fromEntries(), + ) + return { methods, pending: {} as Record } +}) + +export type Method = { + type: "oauth" | "api" + label: string +} + +export type Authorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export const OauthMissing = NamedError.create( + "ProviderAuthOauthMissing", + z.object({ + providerID: ProviderID.zod, + }), +) + +export const OauthCodeMissing = NamedError.create( + "ProviderAuthOauthCodeMissing", + z.object({ + providerID: ProviderID.zod, + }), +) + +export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) + +export type ProviderAuthError = + | Auth.AuthServiceError + | InstanceType + | InstanceType + | InstanceType + +export namespace ProviderAuthService { + export interface Service { + readonly methods: () => Effect.Effect> + readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect + readonly callback: (input: { + providerID: ProviderID + method: number + code?: string + }) => Effect.Effect + readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect + } +} + +export class ProviderAuthService extends ServiceMap.Service()( + "@opencode/ProviderAuth", +) { + static readonly layer = Layer.effect( + ProviderAuthService, + Effect.gen(function* () { + const auth = yield* Auth.AuthService + + const methods = Effect.fn("ProviderAuthService.methods")(() => + Effect.promise(() => + state().then((x) => + mapValues(x.methods, (y) => + y.methods.map( + (z): Method => ({ + type: z.type, + label: z.label, + }), + ), + ), + ), + ), + ) + + const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: { + providerID: ProviderID + method: number + }) { + const item = yield* Effect.promise(() => state().then((x) => x.methods[input.providerID])) + const method = item.methods[input.method] + if (method.type !== "oauth") return + const result = yield* Effect.promise(() => method.authorize()) + yield* Effect.promise(() => + state().then((x) => { + x.pending[input.providerID] = result + }), + ) + return { + url: result.url, + method: result.method, + instructions: result.instructions, + } + }) + + const callback = Effect.fn("ProviderAuthService.callback")(function* (input: { + providerID: ProviderID + method: number + code?: string + }) { + const match = yield* Effect.promise(() => state().then((x) => x.pending[input.providerID])) + if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) + + const result = + match.method === "code" + ? yield* Effect.gen(function* () { + const code = input.code + if (!code) return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) + return yield* Effect.promise(() => match.callback(code)) + }) + : yield* Effect.promise(() => match.callback()) + + if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) + + if ("key" in result) { + yield* auth.set(input.providerID, { + type: "api", + key: result.key, + }) + } + + if ("refresh" in result) { + yield* auth.set(input.providerID, { + type: "oauth", + access: result.access, + refresh: result.refresh, + expires: result.expires, + ...(result.accountId ? { accountId: result.accountId } : {}), + }) + } + }) + + const api = Effect.fn("ProviderAuthService.api")(function* (input: { providerID: ProviderID; key: string }) { + yield* auth.set(input.providerID, { + type: "api", + key: input.key, + }) + }) + + return ProviderAuthService.of({ + methods, + authorize, + callback, + api, + }) + }), + ) + + static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer)) +} diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 29e519e048..bc53d874c4 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,30 +1,17 @@ -import { Instance } from "@/project/instance" -import { runtime } from "@/effect/runtime" -import { AuthService } from "@/auth/service" -import { Plugin } from "../plugin" -import { map, filter, pipe, fromEntries, mapValues } from "remeda" +import { Effect, ManagedRuntime } from "effect" import z from "zod" + import { fn } from "@/util/fn" -import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" -import { NamedError } from "@opencode-ai/util/error" -import { Auth } from "@/auth" +import * as S from "./auth-service" import { ProviderID } from "./schema" +const rt = ManagedRuntime.make(S.ProviderAuthService.defaultLayer) + +function runPromise(f: (service: S.ProviderAuthService.Service) => Effect.Effect) { + return rt.runPromise(S.ProviderAuthService.use(f)) +} + export namespace ProviderAuth { - function set(key: string, info: Auth.Info) { - return runtime.runPromise(AuthService.use((service) => service.set(key, info))) - } - - const state = Instance.state(async () => { - const methods = pipe( - await Plugin.list(), - filter((x) => x.auth?.provider !== undefined), - map((x) => [x.auth!.provider, x.auth!] as const), - fromEntries(), - ) - return { methods, pending: {} as Record } - }) - export const Method = z .object({ type: z.union([z.literal("oauth"), z.literal("api")]), @@ -36,15 +23,7 @@ export namespace ProviderAuth { export type Method = z.infer export async function methods() { - const s = await state().then((x) => x.methods) - return mapValues(s, (x) => - x.methods.map( - (y): Method => ({ - type: y.type, - label: y.label, - }), - ), - ) + return runPromise((service) => service.methods()) } export const Authorization = z @@ -63,19 +42,7 @@ export namespace ProviderAuth { providerID: ProviderID.zod, method: z.number(), }), - async (input): Promise => { - const auth = await state().then((s) => s.methods[input.providerID]) - const method = auth.methods[input.method] - if (method.type === "oauth") { - const result = await method.authorize() - await state().then((s) => (s.pending[input.providerID] = result)) - return { - url: result.url, - method: result.method, - instructions: result.instructions, - } - } - }, + async (input): Promise => runPromise((service) => service.authorize(input)), ) export const callback = fn( @@ -84,44 +51,7 @@ export namespace ProviderAuth { method: z.number(), code: z.string().optional(), }), - async (input) => { - const match = await state().then((s) => s.pending[input.providerID]) - if (!match) throw new OauthMissing({ providerID: input.providerID }) - let result - - if (match.method === "code") { - if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID }) - result = await match.callback(input.code) - } - - if (match.method === "auto") { - result = await match.callback() - } - - if (result?.type === "success") { - if ("key" in result) { - await set(input.providerID, { - type: "api", - key: result.key, - }) - } - if ("refresh" in result) { - const info: Auth.Info = { - type: "oauth", - access: result.access, - refresh: result.refresh, - expires: result.expires, - } - if (result.accountId) { - info.accountId = result.accountId - } - await set(input.providerID, info) - } - return - } - - throw new OauthCallbackFailed({}) - }, + async (input) => runPromise((service) => service.callback(input)), ) export const api = fn( @@ -129,26 +59,10 @@ export namespace ProviderAuth { providerID: ProviderID.zod, key: z.string(), }), - async (input) => { - await set(input.providerID, { - type: "api", - key: input.key, - }) - }, + async (input) => runPromise((service) => service.api(input)), ) - export const OauthMissing = NamedError.create( - "ProviderAuthOauthMissing", - z.object({ - providerID: ProviderID.zod, - }), - ) - export const OauthCodeMissing = NamedError.create( - "ProviderAuthOauthCodeMissing", - z.object({ - providerID: ProviderID.zod, - }), - ) - - export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) + export import OauthMissing = S.OauthMissing + export import OauthCodeMissing = S.OauthCodeMissing + export import OauthCallbackFailed = S.OauthCallbackFailed }