auth.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. export * as Auth from "./auth"
  2. import path from "path"
  3. import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
  4. import { Identifier } from "./util/identifier"
  5. import { NonNegativeInt, withStatics } from "./schema"
  6. import { Global } from "./global"
  7. import { FSUtil } from "./fs-util"
  8. import { EventV2 } from "./event"
  9. export const ID = Schema.String.pipe(
  10. Schema.brand("Auth.ID"),
  11. withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
  12. )
  13. export type ID = typeof ID.Type
  14. export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
  15. export type ServiceID = typeof ServiceID.Type
  16. export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
  17. export type OrgID = typeof OrgID.Type
  18. export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
  19. export type AccessToken = typeof AccessToken.Type
  20. export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
  21. export type RefreshToken = typeof RefreshToken.Type
  22. export class OAuthCredential extends Schema.Class<OAuthCredential>("Auth.OAuthCredential")({
  23. type: Schema.Literal("oauth"),
  24. refresh: Schema.String,
  25. access: Schema.String,
  26. expires: NonNegativeInt,
  27. }) {}
  28. export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("Auth.ApiKeyCredential")({
  29. type: Schema.Literal("api"),
  30. key: Schema.String,
  31. metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
  32. }) {}
  33. export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
  34. .pipe(Schema.toTaggedUnion("type"))
  35. .annotate({
  36. identifier: "Auth.Credential",
  37. })
  38. export type Credential = Schema.Schema.Type<typeof Credential>
  39. export class Info extends Schema.Class<Info>("Auth.Info")({
  40. id: ID,
  41. serviceID: ServiceID,
  42. description: Schema.String,
  43. credential: Credential,
  44. }) {}
  45. export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("Auth.FileWriteError", {
  46. operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
  47. cause: Schema.Defect,
  48. }) {}
  49. export type Error = FileWriteError
  50. export const Event = {
  51. Added: EventV2.define({
  52. type: "account.added",
  53. schema: {
  54. account: Info,
  55. },
  56. }),
  57. Removed: EventV2.define({
  58. type: "account.removed",
  59. schema: {
  60. account: Info,
  61. },
  62. }),
  63. Switched: EventV2.define({
  64. type: "account.switched",
  65. schema: {
  66. serviceID: ServiceID,
  67. from: Schema.optional(ID),
  68. to: Schema.optional(ID),
  69. },
  70. }),
  71. }
  72. interface Writable {
  73. version: 2
  74. accounts: Record<string, Info>
  75. active: Record<string, ID>
  76. }
  77. const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
  78. function migrate(old: Record<string, unknown>): Writable {
  79. const accounts: Record<string, Info> = {}
  80. const active: Record<string, ID> = {}
  81. for (const [serviceID, value] of Object.entries(old)) {
  82. const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
  83. const parsed = (decoded as Record<string, Credential>)[serviceID]
  84. if (!parsed) continue
  85. const id = Identifier.ascending()
  86. const account = ID.make(id)
  87. const brandedServiceID = ServiceID.make(serviceID)
  88. accounts[id] = new Info({
  89. id: account,
  90. serviceID: brandedServiceID,
  91. description: "default",
  92. credential: parsed,
  93. })
  94. active[brandedServiceID] = account
  95. }
  96. return { version: 2, accounts, active }
  97. }
  98. export interface Interface {
  99. readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
  100. readonly all: () => Effect.Effect<Info[], Error>
  101. readonly create: (input: {
  102. serviceID: ServiceID
  103. credential: Credential
  104. description?: string
  105. }) => Effect.Effect<Info | undefined, Error>
  106. readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
  107. readonly remove: (id: ID) => Effect.Effect<void, Error>
  108. readonly activate: (id: ID) => Effect.Effect<void, Error>
  109. readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
  110. readonly activeAll: () => Effect.Effect<Map<ServiceID, Info>, Error>
  111. readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
  112. }
  113. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
  114. export const layer = Layer.effect(
  115. Service,
  116. Effect.gen(function* () {
  117. const fsys = yield* FSUtil.Service
  118. const global = yield* Global.Service
  119. const events = yield* EventV2.Service
  120. const file = path.join(global.data, "account.json")
  121. const legacyFile = path.join(global.data, "auth.json")
  122. const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
  123. const migrated = migrate(raw)
  124. yield* fsys
  125. .writeJson(file, migrated, 0o600)
  126. .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
  127. return migrated
  128. })
  129. const parseAuthContent = () => {
  130. try {
  131. return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
  132. } catch {}
  133. }
  134. const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
  135. if (process.env.OPENCODE_AUTH_CONTENT) {
  136. const raw = parseAuthContent()
  137. if (raw && typeof raw === "object") {
  138. if ("version" in raw && raw.version === 2) return raw as Writable
  139. return yield* writeMigrated(raw as Record<string, unknown>)
  140. }
  141. return { version: 2, accounts: {}, active: {} }
  142. }
  143. const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
  144. if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
  145. const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
  146. if (raw && typeof raw === "object") {
  147. if ("version" in raw && raw.version === 2) return raw as Writable
  148. return yield* writeMigrated(raw as Record<string, unknown>)
  149. }
  150. return { version: 2, accounts: {}, active: {} }
  151. })
  152. const write = (data: Writable) =>
  153. fsys
  154. .writeJson(file, data, 0o600)
  155. .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
  156. const state = SynchronizedRef.makeUnsafe(
  157. yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
  158. )
  159. const activate = Effect.fn("Auth.activate")(function* (id: ID) {
  160. const data = yield* SynchronizedRef.get(state)
  161. const account = data.accounts[id]
  162. if (!account) return
  163. const activated = yield* SynchronizedRef.modifyEffect(
  164. state,
  165. Effect.fnUntraced(function* (data) {
  166. const nextAccount = data.accounts[id]
  167. if (!nextAccount) return [undefined, data] as const
  168. const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
  169. yield* write(next)
  170. return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
  171. }),
  172. )
  173. if (activated) yield* events.publish(Event.Switched, activated)
  174. })
  175. const result: Interface = {
  176. get: Effect.fn("Auth.get")(function* (id) {
  177. return (yield* SynchronizedRef.get(state)).accounts[id]
  178. }),
  179. all: Effect.fn("Auth.all")(function* () {
  180. return Object.values((yield* SynchronizedRef.get(state)).accounts)
  181. }),
  182. active: Effect.fn("Auth.active")(function* (serviceID) {
  183. const data = yield* SynchronizedRef.get(state)
  184. return (
  185. data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
  186. )
  187. }),
  188. activeAll: Effect.fn("Auth.activeAll")(function* () {
  189. const data = yield* SynchronizedRef.get(state)
  190. const result = new Map<ServiceID, Info>()
  191. for (const account of Object.values(data.accounts)) {
  192. if (!result.has(account.serviceID)) result.set(account.serviceID, account)
  193. }
  194. for (const [serviceID, id] of Object.entries(data.active)) {
  195. const account = data.accounts[id]
  196. if (account) result.set(ServiceID.make(serviceID), account)
  197. }
  198. return result
  199. }),
  200. forService: Effect.fn("Auth.list")(function* (serviceID) {
  201. return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
  202. }),
  203. create: Effect.fn("Auth.add")(function* (input) {
  204. const id = ID.make(Identifier.ascending())
  205. const account = new Info({
  206. id,
  207. serviceID: input.serviceID,
  208. description: input.description ?? "default",
  209. credential: input.credential,
  210. })
  211. const added = yield* SynchronizedRef.modifyEffect(
  212. state,
  213. Effect.fnUntraced(function* (data) {
  214. const next = {
  215. ...data,
  216. accounts: { ...data.accounts, [account.id]: account },
  217. active: { ...data.active, [account.serviceID]: account.id },
  218. }
  219. yield* write(next)
  220. return [
  221. {
  222. account,
  223. switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
  224. },
  225. next,
  226. ] as const
  227. }),
  228. )
  229. yield* events.publish(Event.Added, { account: added.account })
  230. yield* events.publish(Event.Switched, added.switched)
  231. return added.account
  232. }),
  233. update: Effect.fn("Auth.update")(function* (id, updates) {
  234. const existing = (yield* SynchronizedRef.get(state)).accounts[id]
  235. if (!existing) return
  236. yield* SynchronizedRef.modifyEffect(
  237. state,
  238. Effect.fnUntraced(function* (data) {
  239. if (!data.accounts[id]) return [undefined, data] as const
  240. const next = {
  241. ...data,
  242. accounts: {
  243. ...data.accounts,
  244. [id]: new Info({
  245. id,
  246. serviceID: existing.serviceID,
  247. description: updates.description ?? existing.description,
  248. credential: updates.credential ?? existing.credential,
  249. }),
  250. },
  251. }
  252. yield* write(next)
  253. return [undefined, next] as const
  254. }),
  255. )
  256. }),
  257. remove: Effect.fn("Auth.remove")(function* (id) {
  258. const removed = yield* SynchronizedRef.modifyEffect(
  259. state,
  260. Effect.fnUntraced(function* (data) {
  261. const accounts = { ...data.accounts }
  262. const active = { ...data.active }
  263. const removed = accounts[id]
  264. if (!removed) return [undefined, data] as const
  265. const wasActive = active[removed.serviceID] === id
  266. delete accounts[id]
  267. const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
  268. if (wasActive) {
  269. if (replacement) active[removed.serviceID] = replacement.id
  270. else delete active[removed.serviceID]
  271. }
  272. const next = { ...data, accounts, active }
  273. yield* write(next)
  274. return [
  275. {
  276. account: removed,
  277. switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
  278. },
  279. next,
  280. ] as const
  281. }),
  282. )
  283. if (removed) {
  284. yield* events.publish(Event.Removed, { account: removed.account })
  285. if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
  286. }
  287. }),
  288. activate,
  289. }
  290. return Service.of(result)
  291. }),
  292. )
  293. export const defaultLayer = layer.pipe(
  294. Layer.provide(FSUtil.defaultLayer),
  295. Layer.provide(Global.defaultLayer),
  296. Layer.provide(EventV2.defaultLayer),
  297. )