import path from "path" import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" import { Identifier } from "./util/identifier" import { NonNegativeInt, withStatics } from "./schema" import { Global } from "./global" import { AppFileSystem } from "./filesystem" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" const AccountID = Schema.String.pipe( Schema.brand("AccountID"), withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), ) export type AccountID = typeof AccountID.Type export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) export type ServiceID = typeof ServiceID.Type export class OAuthCredential extends Schema.Class("AuthV2.OAuthCredential")({ type: Schema.Literal("oauth"), refresh: Schema.String, access: Schema.String, expires: NonNegativeInt, }) {} export class ApiKeyCredential extends Schema.Class("AuthV2.ApiKeyCredential")({ type: Schema.Literal("api"), key: Schema.String, metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) {} export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "AuthV2.Credential", }) export type Credential = Schema.Schema.Type export class Account extends Schema.Class("AuthV2.Account")({ id: AccountID, serviceID: ServiceID, description: Schema.String, credential: Credential, }) {} export class AuthFileWriteError extends Schema.TaggedErrorClass()("AuthV2.FileWriteError", { operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), cause: Schema.Defect, }) {} export type AuthError = AuthFileWriteError interface Writable { version: 2 accounts: Record active: Record } const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) function migrate(old: Record): Writable { const accounts: Record = {} const active: Record = {} for (const [serviceID, value] of Object.entries(old)) { const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) const parsed = (decoded as Record)[serviceID] if (!parsed) continue const id = Identifier.ascending() const accountID = AccountID.make(id) const brandedServiceID = ServiceID.make(serviceID) accounts[id] = new Account({ id: accountID, serviceID: brandedServiceID, description: "default", credential: parsed, }) active[brandedServiceID] = accountID } return { version: 2, accounts, active } } export interface Interface { readonly get: (accountID: AccountID) => Effect.Effect readonly all: () => Effect.Effect readonly create: (input: { serviceID: ServiceID credential: Credential description?: string active?: boolean }) => Effect.Effect readonly update: ( accountID: AccountID, updates: Partial>, ) => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect readonly activate: (accountID: AccountID) => Effect.Effect readonly active: (serviceID: ServiceID) => Effect.Effect readonly forService: (serviceID: ServiceID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Auth") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const fsys = yield* AppFileSystem.Service const global = yield* Global.Service const file = path.join(global.data, "auth-v2.json") const legacyFile = path.join(global.data, "auth.json") const writeMigrated = Effect.fnUntraced(function* (raw: Record) { const migrated = migrate(raw) yield* fsys .writeJson(file, migrated, 0o600) .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause }))) return migrated }) const parseAuthContent = () => { try { return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "") } catch {} } const load: () => Effect.Effect = Effect.fnUntraced(function* () { if (process.env.OPENCODE_AUTH_CONTENT) { const raw = parseAuthContent() if (raw && typeof raw === "object") { if ("version" in raw && raw.version === 2) return raw as Writable return yield* writeMigrated(raw as Record) } return { version: 2, accounts: {}, active: {} } } const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) if (raw && typeof raw === "object") { if ("version" in raw && raw.version === 2) return raw as Writable return yield* writeMigrated(raw as Record) } return { version: 2, accounts: {}, active: {} } }) const write = (data: Writable) => fsys .writeJson(file, data, 0o600) .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause }))) const state = SynchronizedRef.makeUnsafe(yield* load()) const result: Interface = { get: Effect.fn("AuthV2.get")(function* (accountID) { return (yield* SynchronizedRef.get(state)).accounts[accountID] }), all: Effect.fn("AuthV2.all")(function* () { return Object.values((yield* SynchronizedRef.get(state)).accounts) }), active: Effect.fn("AuthV2.active")(function* (serviceID) { const data = yield* SynchronizedRef.get(state) return ( data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) ) }), forService: Effect.fn("AuthV2.list")(function* (serviceID) { return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) }), create: Effect.fn("AuthV2.add")(function* (input) { return yield* SynchronizedRef.modifyEffect( state, Effect.fnUntraced(function* (data) { const account = new Account({ id: AccountID.make(Identifier.ascending()), serviceID: input.serviceID, description: input.description ?? "default", credential: input.credential, }) const next = { ...data, accounts: { ...data.accounts, [account.id]: account }, active: (input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID)) ? { ...data.active, [input.serviceID]: account.id } : data.active, } yield* write(next) return [account, next] as const }), ) }), update: Effect.fn("AuthV2.update")(function* (accountID, updates) { yield* SynchronizedRef.modifyEffect( state, Effect.fnUntraced(function* (data) { const existing = data.accounts[accountID] if (!existing) return [undefined, data] as const const next = { ...data, accounts: { ...data.accounts, [accountID]: new Account({ id: accountID, serviceID: existing.serviceID, description: updates.description ?? existing.description, credential: updates.credential ?? existing.credential, }), }, } yield* write(next) return [undefined, next] as const }), ) }), remove: Effect.fn("AuthV2.remove")(function* (accountID) { yield* SynchronizedRef.modifyEffect( state, Effect.fnUntraced(function* (data) { const accounts = { ...data.accounts } const active = { ...data.active } if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID) delete active[accounts[accountID].serviceID] delete accounts[accountID] const next = { ...data, accounts, active } yield* write(next) return [undefined, next] as const }), ) }), activate: Effect.fn("AuthV2.activate")(function* (accountID) { yield* SynchronizedRef.modifyEffect( state, Effect.fnUntraced(function* (data) { const account = data.accounts[accountID] if (!account) return [undefined, data] as const const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } } yield* write(next) return [undefined, next] as const }), ) }), } return Service.of(result) }), ) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) export * as AuthV2 from "./auth"