refactor(provider): extract ProviderAuthService

Move ProviderAuth state and persistence logic into a real Effect service so the provider auth module follows the same facade-over-service migration pattern as account and auth. Keep the existing zod-facing ProviderAuth API as a thin promise bridge over the new service.
This commit is contained in:
Kit Langton
2026-03-12 14:51:02 -04:00
parent f96f235dcf
commit 3cfdb07fb8
2 changed files with 180 additions and 102 deletions
@@ -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<string, AuthOuathResult> }
})
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<typeof OauthMissing>
| InstanceType<typeof OauthCodeMissing>
| InstanceType<typeof OauthCallbackFailed>
export namespace ProviderAuthService {
export interface Service {
readonly methods: () => Effect.Effect<Record<string, Method[]>>
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
readonly callback: (input: {
providerID: ProviderID
method: number
code?: string
}) => Effect.Effect<void, ProviderAuthError>
readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect<void, Auth.AuthServiceError>
}
}
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.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))
}
+16 -102
View File
@@ -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<A>(f: (service: S.ProviderAuthService.Service) => Effect.Effect<A, S.ProviderAuthError>) {
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<string, AuthOuathResult> }
})
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<typeof Method>
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<Authorization | undefined> => {
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<Authorization | undefined> => 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
}