Skip to content

Commit 3fe1a6c

Browse files
kitlangtonshellmind112
authored andcommitted
refactor(provider): effectify ProviderAuthService (#17227)
1 parent 79c2739 commit 3fe1a6c

7 files changed

Lines changed: 520 additions & 117 deletions

File tree

packages/opencode/src/project/instance.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { Effect } from "effect"
12
import { Log } from "@/util/log"
23
import { Context } from "../util/context"
34
import { Project } from "./project"
45
import { State } from "./state"
56
import { iife } from "@/util/iife"
67
import { GlobalBus } from "@/bus/global"
78
import { Filesystem } from "@/util/filesystem"
9+
import { InstanceState } from "@/util/instance-state"
810

911
interface Context {
1012
directory: string
@@ -106,15 +108,15 @@ export const Instance = {
106108
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
107109
const directory = Filesystem.resolve(input.directory)
108110
Log.Default.info("reloading instance", { directory })
109-
await State.dispose(directory)
111+
await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))])
110112
cache.delete(directory)
111113
const next = track(directory, boot({ ...input, directory }))
112114
emit(directory)
113115
return await next
114116
},
115117
async dispose() {
116118
Log.Default.info("disposing instance", { directory: Instance.directory })
117-
await State.dispose(Instance.directory)
119+
await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))])
118120
cache.delete(Instance.directory)
119121
emit(Instance.directory)
120122
},
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
2+
import { Instance } from "@/project/instance"
3+
import { Plugin } from "../plugin"
4+
import { filter, fromEntries, map, pipe } from "remeda"
5+
import type { AuthOuathResult } from "@opencode-ai/plugin"
6+
import { NamedError } from "@opencode-ai/util/error"
7+
import * as Auth from "@/auth/service"
8+
import { InstanceState } from "@/util/instance-state"
9+
import { ProviderID } from "./schema"
10+
import z from "zod"
11+
12+
export const Method = z
13+
.object({
14+
type: z.union([z.literal("oauth"), z.literal("api")]),
15+
label: z.string(),
16+
})
17+
.meta({
18+
ref: "ProviderAuthMethod",
19+
})
20+
export type Method = z.infer<typeof Method>
21+
22+
export const Authorization = z
23+
.object({
24+
url: z.string(),
25+
method: z.union([z.literal("auto"), z.literal("code")]),
26+
instructions: z.string(),
27+
})
28+
.meta({
29+
ref: "ProviderAuthAuthorization",
30+
})
31+
export type Authorization = z.infer<typeof Authorization>
32+
33+
export const OauthMissing = NamedError.create(
34+
"ProviderAuthOauthMissing",
35+
z.object({
36+
providerID: ProviderID.zod,
37+
}),
38+
)
39+
40+
export const OauthCodeMissing = NamedError.create(
41+
"ProviderAuthOauthCodeMissing",
42+
z.object({
43+
providerID: ProviderID.zod,
44+
}),
45+
)
46+
47+
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
48+
49+
export type ProviderAuthError =
50+
| Auth.AuthServiceError
51+
| InstanceType<typeof OauthMissing>
52+
| InstanceType<typeof OauthCodeMissing>
53+
| InstanceType<typeof OauthCallbackFailed>
54+
55+
export namespace ProviderAuthService {
56+
export interface Service {
57+
/** Get available auth methods for each provider (e.g. OAuth, API key). */
58+
readonly methods: () => Effect.Effect<Record<string, Method[]>>
59+
60+
/** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */
61+
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
62+
63+
/** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */
64+
readonly callback: (input: {
65+
providerID: ProviderID
66+
method: number
67+
code?: string
68+
}) => Effect.Effect<void, ProviderAuthError>
69+
70+
/** Set an API key directly for a provider (no OAuth flow). */
71+
readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect<void, Auth.AuthServiceError>
72+
}
73+
}
74+
75+
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
76+
"@opencode/ProviderAuth",
77+
) {
78+
static readonly layer = Layer.effect(
79+
ProviderAuthService,
80+
Effect.gen(function* () {
81+
const auth = yield* Auth.AuthService
82+
const state = yield* InstanceState.make({
83+
lookup: () =>
84+
Effect.promise(async () => {
85+
const methods = pipe(
86+
await Plugin.list(),
87+
filter((x) => x.auth?.provider !== undefined),
88+
map((x) => [x.auth!.provider, x.auth!] as const),
89+
fromEntries(),
90+
)
91+
return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
92+
}),
93+
})
94+
95+
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
96+
const x = yield* InstanceState.get(state)
97+
return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"])))
98+
})
99+
100+
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
101+
providerID: ProviderID
102+
method: number
103+
}) {
104+
const s = yield* InstanceState.get(state)
105+
const method = s.methods[input.providerID].methods[input.method]
106+
if (method.type !== "oauth") return
107+
const result = yield* Effect.promise(() => method.authorize())
108+
s.pending.set(input.providerID, result)
109+
return {
110+
url: result.url,
111+
method: result.method,
112+
instructions: result.instructions,
113+
}
114+
})
115+
116+
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
117+
providerID: ProviderID
118+
method: number
119+
code?: string
120+
}) {
121+
const s = yield* InstanceState.get(state)
122+
const match = s.pending.get(input.providerID)
123+
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
124+
125+
if (match.method === "code" && !input.code)
126+
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
127+
128+
const result = yield* Effect.promise(() =>
129+
match.method === "code" ? match.callback(input.code!) : match.callback(),
130+
)
131+
132+
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
133+
134+
if ("key" in result) {
135+
yield* auth.set(input.providerID, {
136+
type: "api",
137+
key: result.key,
138+
})
139+
}
140+
141+
if ("refresh" in result) {
142+
yield* auth.set(input.providerID, {
143+
type: "oauth",
144+
access: result.access,
145+
refresh: result.refresh,
146+
expires: result.expires,
147+
...(result.accountId ? { accountId: result.accountId } : {}),
148+
})
149+
}
150+
})
151+
152+
const api = Effect.fn("ProviderAuthService.api")(function* (input: { providerID: ProviderID; key: string }) {
153+
yield* auth.set(input.providerID, {
154+
type: "api",
155+
key: input.key,
156+
})
157+
})
158+
159+
return ProviderAuthService.of({
160+
methods,
161+
authorize,
162+
callback,
163+
api,
164+
})
165+
}),
166+
)
167+
168+
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
169+
}
Lines changed: 23 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,36 @@
1-
import { Instance } from "@/project/instance"
2-
import { Plugin } from "../plugin"
3-
import { map, filter, pipe, fromEntries, mapValues } from "remeda"
1+
import { Effect, ManagedRuntime } from "effect"
42
import z from "zod"
3+
54
import { fn } from "@/util/fn"
6-
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
7-
import { NamedError } from "@opencode-ai/util/error"
8-
import { Auth } from "@/auth"
5+
import * as S from "./auth-service"
96
import { ProviderID } from "./schema"
107

11-
export namespace ProviderAuth {
12-
const state = Instance.state(async () => {
13-
const methods = pipe(
14-
await Plugin.list(),
15-
filter((x) => x.auth?.provider !== undefined),
16-
map((x) => [x.auth!.provider, x.auth!] as const),
17-
fromEntries(),
18-
)
19-
return { methods, pending: {} as Record<string, AuthOuathResult> }
20-
})
8+
// Separate runtime: ProviderAuthService can't join the shared runtime because
9+
// runtime.ts → auth-service.ts → provider/auth.ts creates a circular import.
10+
// AuthService is stateless file I/O so the duplicate instance is harmless.
11+
const rt = ManagedRuntime.make(S.ProviderAuthService.defaultLayer)
2112

22-
export const Method = z
23-
.object({
24-
type: z.union([z.literal("oauth"), z.literal("api")]),
25-
label: z.string(),
26-
})
27-
.meta({
28-
ref: "ProviderAuthMethod",
29-
})
30-
export type Method = z.infer<typeof Method>
13+
function runPromise<A>(f: (service: S.ProviderAuthService.Service) => Effect.Effect<A, S.ProviderAuthError>) {
14+
return rt.runPromise(S.ProviderAuthService.use(f))
15+
}
16+
17+
export namespace ProviderAuth {
18+
export const Method = S.Method
19+
export type Method = S.Method
3120

3221
export async function methods() {
33-
const s = await state().then((x) => x.methods)
34-
return mapValues(s, (x) =>
35-
x.methods.map(
36-
(y): Method => ({
37-
type: y.type,
38-
label: y.label,
39-
}),
40-
),
41-
)
22+
return runPromise((service) => service.methods())
4223
}
4324

44-
export const Authorization = z
45-
.object({
46-
url: z.string(),
47-
method: z.union([z.literal("auto"), z.literal("code")]),
48-
instructions: z.string(),
49-
})
50-
.meta({
51-
ref: "ProviderAuthAuthorization",
52-
})
53-
export type Authorization = z.infer<typeof Authorization>
25+
export const Authorization = S.Authorization
26+
export type Authorization = S.Authorization
5427

5528
export const authorize = fn(
5629
z.object({
5730
providerID: ProviderID.zod,
5831
method: z.number(),
5932
}),
60-
async (input): Promise<Authorization | undefined> => {
61-
const auth = await state().then((s) => s.methods[input.providerID])
62-
const method = auth.methods[input.method]
63-
if (method.type === "oauth") {
64-
const result = await method.authorize()
65-
await state().then((s) => (s.pending[input.providerID] = result))
66-
return {
67-
url: result.url,
68-
method: result.method,
69-
instructions: result.instructions,
70-
}
71-
}
72-
},
33+
async (input): Promise<Authorization | undefined> => runPromise((service) => service.authorize(input)),
7334
)
7435

7536
export const callback = fn(
@@ -78,71 +39,18 @@ export namespace ProviderAuth {
7839
method: z.number(),
7940
code: z.string().optional(),
8041
}),
81-
async (input) => {
82-
const match = await state().then((s) => s.pending[input.providerID])
83-
if (!match) throw new OauthMissing({ providerID: input.providerID })
84-
let result
85-
86-
if (match.method === "code") {
87-
if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID })
88-
result = await match.callback(input.code)
89-
}
90-
91-
if (match.method === "auto") {
92-
result = await match.callback()
93-
}
94-
95-
if (result?.type === "success") {
96-
if ("key" in result) {
97-
await Auth.set(input.providerID, {
98-
type: "api",
99-
key: result.key,
100-
})
101-
}
102-
if ("refresh" in result) {
103-
const info: Auth.Info = {
104-
type: "oauth",
105-
access: result.access,
106-
refresh: result.refresh,
107-
expires: result.expires,
108-
}
109-
if (result.accountId) {
110-
info.accountId = result.accountId
111-
}
112-
await Auth.set(input.providerID, info)
113-
}
114-
return
115-
}
116-
117-
throw new OauthCallbackFailed({})
118-
},
42+
async (input) => runPromise((service) => service.callback(input)),
11943
)
12044

12145
export const api = fn(
12246
z.object({
12347
providerID: ProviderID.zod,
12448
key: z.string(),
12549
}),
126-
async (input) => {
127-
await Auth.set(input.providerID, {
128-
type: "api",
129-
key: input.key,
130-
})
131-
},
132-
)
133-
134-
export const OauthMissing = NamedError.create(
135-
"ProviderAuthOauthMissing",
136-
z.object({
137-
providerID: ProviderID.zod,
138-
}),
139-
)
140-
export const OauthCodeMissing = NamedError.create(
141-
"ProviderAuthOauthCodeMissing",
142-
z.object({
143-
providerID: ProviderID.zod,
144-
}),
50+
async (input) => runPromise((service) => service.api(input)),
14551
)
14652

147-
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
53+
export import OauthMissing = S.OauthMissing
54+
export import OauthCodeMissing = S.OauthCodeMissing
55+
export import OauthCallbackFailed = S.OauthCallbackFailed
14856
}

0 commit comments

Comments
 (0)