import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" import { makeRunPromise } from "@/effect/run-service" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect" import z from "zod" export namespace ProviderAuth { export const Method = z .object({ type: z.union([z.literal("oauth"), z.literal("api")]), label: z.string(), prompts: z .array( z.union([ z.object({ type: z.literal("text"), key: z.string(), message: z.string(), placeholder: z.string().optional(), when: z .object({ key: z.string(), op: z.union([z.literal("eq"), z.literal("neq")]), value: z.string(), }) .optional(), }), z.object({ type: z.literal("select"), key: z.string(), message: z.string(), options: z.array( z.object({ label: z.string(), value: z.string(), hint: z.string().optional(), }), ), when: z .object({ key: z.string(), op: z.union([z.literal("eq"), z.literal("neq")]), value: z.string(), }) .optional(), }), ]), ) .optional(), }) .meta({ ref: "ProviderAuthMethod", }) export type Method = z.infer export const Authorization = z .object({ url: z.string(), method: z.union([z.literal("auto"), z.literal("code")]), instructions: z.string(), }) .meta({ ref: "ProviderAuthAuthorization", }) export type Authorization = z.infer 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 const ValidationFailed = NamedError.create( "ProviderAuthValidationFailed", z.object({ field: z.string(), message: z.string(), }), ) export type Error = | Auth.AuthError | InstanceType | InstanceType | InstanceType | InstanceType type Hook = NonNullable export interface Interface { readonly methods: () => Effect.Effect> readonly authorize: (input: { providerID: ProviderID method: number inputs?: Record }) => Effect.Effect readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect } interface State { hooks: Record pending: Map } export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service const state = yield* InstanceState.make( Effect.fn("ProviderAuth.state")(() => Effect.promise(async () => { const plugins = await Plugin.list() return { hooks: Record.fromEntries( Arr.filterMap(plugins, (x) => x.auth?.provider !== undefined ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) : Result.failVoid, ), ), pending: new Map(), } }), ), ) const methods = Effect.fn("ProviderAuth.methods")(function* () { const hooks = (yield* InstanceState.get(state)).hooks return Record.map(hooks, (item) => item.methods.map( (method): Method => ({ type: method.type, label: method.label, prompts: method.prompts?.map((prompt) => { if (prompt.type === "select") { return { type: "select" as const, key: prompt.key, message: prompt.message, options: prompt.options, when: prompt.when, } } return { type: "text" as const, key: prompt.key, message: prompt.message, placeholder: prompt.placeholder, when: prompt.when, } }), }), ), ) }) const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: { providerID: ProviderID method: number inputs?: Record }) { const { hooks, pending } = yield* InstanceState.get(state) const method = hooks[input.providerID].methods[input.method] if (method.type !== "oauth") return if (method.prompts && input.inputs) { for (const prompt of method.prompts) { if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) { const error = prompt.validate(input.inputs[prompt.key]) if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error })) } } } const result = yield* Effect.promise(() => method.authorize(input.inputs)) pending.set(input.providerID, result) return { url: result.url, method: result.method, instructions: result.instructions, } }) const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderID method: number code?: string }) { const pending = (yield* InstanceState.get(state)).pending const match = pending.get(input.providerID) if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) if (match.method === "code" && !input.code) { return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) } const result = yield* Effect.promise(() => match.method === "code" ? match.callback(input.code!) : 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 } : {}), }) } }) return Service.of({ methods, authorize, callback }) }), ) export const defaultLayer = layer.pipe(Layer.provide(Auth.layer)) const runPromise = makeRunPromise(Service, defaultLayer) export async function methods() { return runPromise((svc) => svc.methods()) } export async function authorize(input: { providerID: ProviderID method: number inputs?: Record }): Promise { return runPromise((svc) => svc.authorize(input)) } export async function callback(input: { providerID: ProviderID; method: number; code?: string }) { return runPromise((svc) => svc.callback(input)) } }