integration.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. export * as Integration from "./integration"
  2. import {
  3. Cause,
  4. Clock,
  5. Context,
  6. Duration,
  7. Effect,
  8. Exit,
  9. Layer,
  10. Schedule,
  11. Schema,
  12. Scope,
  13. SynchronizedRef,
  14. Types,
  15. } from "effect"
  16. import { Integration } from "@opencode-ai/schema/integration"
  17. import { Credential } from "./credential"
  18. import { withStatics } from "./schema"
  19. import { State } from "./state"
  20. import { Identifier } from "./util/identifier"
  21. import { EventV2 } from "./event"
  22. import { IntegrationConnection } from "./integration/connection"
  23. export const ID = Integration.ID
  24. export type ID = Integration.ID
  25. export const MethodID = Integration.MethodID
  26. export type MethodID = Integration.MethodID
  27. export const AttemptID = Schema.String.pipe(
  28. Schema.brand("Integration.AttemptID"),
  29. withStatics((schema) => ({ create: () => schema.make("con_" + Identifier.ascending()) })),
  30. )
  31. export type AttemptID = typeof AttemptID.Type
  32. export const When = Integration.When
  33. export type When = Integration.When
  34. export const TextPrompt = Integration.TextPrompt
  35. export type TextPrompt = Integration.TextPrompt
  36. export const SelectPrompt = Integration.SelectPrompt
  37. export type SelectPrompt = Integration.SelectPrompt
  38. export const Prompt = Integration.Prompt
  39. export type Prompt = Integration.Prompt
  40. export const OAuthMethod = Integration.OAuthMethod
  41. export type OAuthMethod = Integration.OAuthMethod
  42. export const KeyMethod = Integration.KeyMethod
  43. export type KeyMethod = Integration.KeyMethod
  44. export const EnvMethod = Integration.EnvMethod
  45. export type EnvMethod = Integration.EnvMethod
  46. export const Method = Integration.Method
  47. export type Method = Integration.Method
  48. export class Info extends Schema.Class<Info>("Integration.Info")({
  49. id: ID,
  50. name: Schema.String,
  51. methods: Schema.mutable(Schema.Array(Method)),
  52. connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
  53. }) {}
  54. export const Inputs = Integration.Inputs
  55. export type Inputs = Integration.Inputs
  56. export type OAuthAuthorization = {
  57. readonly url: string
  58. readonly instructions: string
  59. } & (
  60. | {
  61. readonly mode: "auto"
  62. readonly callback: Effect.Effect<Credential.OAuth, unknown>
  63. }
  64. | {
  65. readonly mode: "code"
  66. readonly callback: (code: string) => Effect.Effect<Credential.OAuth, unknown>
  67. }
  68. )
  69. export interface OAuthImplementation {
  70. readonly integrationID: ID
  71. readonly method: OAuthMethod
  72. readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
  73. readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
  74. readonly label?: (credential: Credential.OAuth) => string | undefined
  75. }
  76. export interface KeyImplementation {
  77. readonly integrationID: ID
  78. readonly method: KeyMethod
  79. }
  80. export interface EnvImplementation {
  81. readonly integrationID: ID
  82. readonly method: EnvMethod
  83. }
  84. export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
  85. export class Attempt extends Schema.Class<Attempt>("Integration.Attempt")({
  86. attemptID: AttemptID,
  87. url: Schema.String,
  88. instructions: Schema.String,
  89. mode: Schema.Literals(["auto", "code"]),
  90. time: Schema.Struct({
  91. created: Schema.Number,
  92. expires: Schema.Number,
  93. }),
  94. }) {}
  95. const Time = Schema.Struct({
  96. created: Schema.Number,
  97. expires: Schema.Number,
  98. })
  99. export const AttemptStatus = Schema.Union([
  100. Schema.Struct({ status: Schema.Literal("pending"), time: Time }),
  101. Schema.Struct({ status: Schema.Literal("complete"), time: Time }),
  102. Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: Time }),
  103. Schema.Struct({ status: Schema.Literal("expired"), time: Time }),
  104. ]).pipe(Schema.toTaggedUnion("status"))
  105. export type AttemptStatus = typeof AttemptStatus.Type
  106. export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Integration.CodeRequired", {
  107. attemptID: AttemptID,
  108. }) {}
  109. export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationError>()("Integration.Authorization", {
  110. cause: Schema.Defect(),
  111. }) {}
  112. export type Error = CodeRequiredError | AuthorizationError
  113. export const Event = Integration.Event
  114. export const Ref = Integration.Ref
  115. export type Ref = Integration.Ref
  116. type Entry = {
  117. ref: Types.DeepMutable<Ref>
  118. methods: Types.DeepMutable<Method>[]
  119. implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
  120. }
  121. type Data = {
  122. integrations: Map<ID, Entry>
  123. }
  124. export type Draft = {
  125. list: () => readonly Ref[]
  126. get: (id: ID) => Ref | undefined
  127. update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
  128. remove: (id: ID) => void
  129. method: {
  130. list: (integrationID: ID) => readonly Method[]
  131. update: (implementation: Implementation) => void
  132. remove: (integrationID: ID, method: Method) => void
  133. }
  134. }
  135. export interface Interface extends State.Transformable<Draft> {
  136. /** Registers a scoped transform over the integration registry. */
  137. /** Returns one integration with its methods and current connections. */
  138. readonly get: (id: ID) => Effect.Effect<Info | undefined>
  139. /** Returns all integrations with their methods and current connections. */
  140. readonly list: () => Effect.Effect<Info[]>
  141. readonly connection: {
  142. /** Returns the active connection for one integration. */
  143. readonly active: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
  144. /** Resolves a connection into usable credential material. */
  145. readonly resolve: (
  146. connection: IntegrationConnection.Info,
  147. ) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
  148. /** Runs a key method and stores the resulting credential. */
  149. readonly key: (input: {
  150. /** Integration receiving the credential. */
  151. readonly integrationID: ID
  152. /** Secret entered by the user. */
  153. readonly key: string
  154. /** User-facing label for the stored credential. */
  155. readonly label?: string
  156. }) => Effect.Effect<void, AuthorizationError>
  157. /** Starts a stateful OAuth attempt. */
  158. readonly oauth: (input: {
  159. /** Integration being authenticated. */
  160. readonly integrationID: ID
  161. /** OAuth method selected by the caller. */
  162. readonly methodID: MethodID
  163. /** Answers to the method's optional prompts. */
  164. readonly inputs: Inputs
  165. /** User-facing label for the credential created on completion. */
  166. readonly label?: string
  167. }) => Effect.Effect<Attempt, AuthorizationError>
  168. /** Updates a stored credential exposed as a connection. */
  169. readonly update: (
  170. credentialID: Credential.ID,
  171. updates: Partial<Pick<Credential.Info, "label">>,
  172. ) => Effect.Effect<void>
  173. /** Removes a stored credential connection. */
  174. readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
  175. }
  176. readonly attempt: {
  177. /** Returns the current state of an OAuth attempt. */
  178. readonly status: (attemptID: AttemptID) => Effect.Effect<AttemptStatus>
  179. /** Completes the attempt and stores its credential. */
  180. readonly complete: (input: {
  181. /** Opaque handle returned by `oauth`. */
  182. readonly attemptID: AttemptID
  183. /** Authorization code required by attempts in code mode. */
  184. readonly code?: string
  185. }) => Effect.Effect<void, CodeRequiredError | AuthorizationError>
  186. /** Cancels an attempt and releases its resources. */
  187. readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
  188. }
  189. }
  190. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
  191. const attemptLifetime = Duration.toMillis(Duration.minutes(10))
  192. const terminalRetention = Duration.toMillis(Duration.minutes(1))
  193. const scrubInterval = Duration.seconds(30)
  194. type AttemptTime = { created: number; expires: number }
  195. type PendingAttempt = {
  196. status: "pending"
  197. completing: boolean
  198. authorization: OAuthAuthorization
  199. integrationID: ID
  200. methodID: MethodID
  201. label?: string
  202. scope: Scope.Closeable
  203. time: AttemptTime
  204. }
  205. type TerminalAttempt = {
  206. status: "complete" | "failed" | "expired"
  207. message?: string
  208. removeAt: number
  209. time: AttemptTime
  210. }
  211. type AttemptEntry = PendingAttempt | TerminalAttempt
  212. export const locationLayer = Layer.effect(
  213. Service,
  214. Effect.gen(function* () {
  215. const credentials = yield* Credential.Service
  216. const events = yield* EventV2.Service
  217. const scope = yield* Scope.Scope
  218. const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
  219. const state = State.create<Data, Draft>({
  220. initial: () => ({ integrations: new Map<ID, Entry>() }),
  221. draft: (draft) => ({
  222. list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
  223. get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
  224. update: (id, update) => {
  225. const current = draft.integrations.get(id) ?? {
  226. ref: { id, name: id },
  227. methods: [],
  228. implementations: new Map(),
  229. }
  230. if (!draft.integrations.has(id)) draft.integrations.set(id, current)
  231. update(current.ref)
  232. current.ref.id = id
  233. },
  234. remove: (id) => draft.integrations.delete(id),
  235. method: {
  236. list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
  237. update: (implementation) => {
  238. const current = draft.integrations.get(implementation.integrationID) ?? {
  239. ref: {
  240. id: implementation.integrationID,
  241. name: implementation.integrationID,
  242. },
  243. methods: [],
  244. implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
  245. }
  246. if (!draft.integrations.has(implementation.integrationID)) {
  247. draft.integrations.set(implementation.integrationID, current)
  248. }
  249. const index = current.methods.findIndex((method) => {
  250. if (method.type !== implementation.method.type) return false
  251. if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
  252. return method.id === implementation.method.id
  253. })
  254. if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
  255. else current.methods[index] = implementation.method as Types.DeepMutable<Method>
  256. if (implementation.method.type === "oauth") {
  257. current.implementations.set(
  258. implementation.method.id,
  259. implementation as Types.DeepMutable<OAuthImplementation>,
  260. )
  261. }
  262. },
  263. remove: (integrationID, method) => {
  264. const current = draft.integrations.get(integrationID)
  265. if (!current) return
  266. const index = current.methods.findIndex((candidate) => {
  267. if (candidate.type !== method.type) return false
  268. if (candidate.type !== "oauth" || method.type !== "oauth") return true
  269. return candidate.id === method.id
  270. })
  271. if (index !== -1) current.methods.splice(index, 1)
  272. if (method.type === "oauth") current.implementations.delete(method.id)
  273. },
  274. },
  275. }),
  276. finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
  277. })
  278. const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
  279. const credentials = saved
  280. .map((credential) => ({
  281. type: "credential" as const,
  282. id: credential.id,
  283. label: credential.label,
  284. }))
  285. .toReversed()
  286. const env = (entry?.methods ?? [])
  287. .filter((method) => method.type === "env")
  288. .flatMap((method) => method.names.filter((name) => process.env[name]))
  289. .map((name) => ({ type: "env" as const, name }))
  290. return [...credentials, ...env]
  291. }
  292. const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
  293. new Info({
  294. id: entry.ref.id,
  295. name: entry.ref.name,
  296. methods: entry.methods,
  297. connections,
  298. })
  299. const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
  300. effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
  301. const close = (attemptScope: Scope.Closeable) =>
  302. Scope.close(attemptScope, Exit.void).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
  303. const message = (cause: Cause.Cause<unknown>) => {
  304. const error = Cause.squash(cause)
  305. return error instanceof Error ? error.message : String(error)
  306. }
  307. const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
  308. const now = yield* Clock.currentTimeMillis
  309. const result = yield* SynchronizedRef.modify(attempts, (current) => {
  310. const attempt = current.get(attemptID)
  311. if (!attempt || attempt.status !== "pending") return [undefined, current]
  312. const terminal: TerminalAttempt = Exit.isSuccess(exit)
  313. ? { status: "complete", time: attempt.time, removeAt: now + terminalRetention }
  314. : { status: "failed", message: message(exit.cause), time: attempt.time, removeAt: now + terminalRetention }
  315. return [attempt, new Map(current).set(attemptID, terminal)]
  316. })
  317. if (!result) return
  318. if (Exit.isSuccess(exit)) {
  319. const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID)
  320. yield* credentials.create({
  321. integrationID: result.integrationID,
  322. label: result.label ?? implementation?.label?.(exit.value),
  323. value: exit.value,
  324. })
  325. yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID })
  326. yield* events.publish(Event.Updated, {})
  327. }
  328. yield* close(result.scope)
  329. })
  330. const scrub = Effect.fnUntraced(function* () {
  331. const now = yield* Clock.currentTimeMillis
  332. const expired = yield* SynchronizedRef.modify(attempts, (current) => {
  333. const next = new Map(current)
  334. const scopes: Scope.Closeable[] = []
  335. for (const [id, attempt] of current) {
  336. if (attempt.status === "pending" && attempt.time.expires <= now) {
  337. scopes.push(attempt.scope)
  338. next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention })
  339. continue
  340. }
  341. if (attempt.status !== "pending" && attempt.removeAt <= now) next.delete(id)
  342. }
  343. return [scopes, next]
  344. })
  345. yield* Effect.forEach(expired, close, { discard: true })
  346. })
  347. yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
  348. return Service.of({
  349. transform: state.transform,
  350. reload: state.reload,
  351. get: Effect.fn("Integration.get")(function* (id) {
  352. const entry = state.get().integrations.get(id)
  353. if (!entry) return undefined
  354. return project(entry, resolveConnections(entry, yield* credentials.list(id)))
  355. }),
  356. list: Effect.fn("Integration.list")(function* () {
  357. const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
  358. return Array.from(state.get().integrations.values(), (entry) =>
  359. project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
  360. ).toSorted((a, b) => a.name.localeCompare(b.name))
  361. }),
  362. connection: {
  363. active: Effect.fn("Integration.connection.active")(function* (id) {
  364. const entry = state.get().integrations.get(id)
  365. return resolveConnections(entry, yield* credentials.list(id))[0]
  366. }),
  367. resolve: Effect.fn("Integration.connection.resolve")(function* (connection) {
  368. if (connection.type === "env") {
  369. const key = process.env[connection.name]
  370. return key ? Credential.Key.make({ type: "key", key }) : undefined
  371. }
  372. const credential = yield* credentials.get(connection.id)
  373. if (!credential) return undefined
  374. if (credential.value.type === "key") return credential.value
  375. const implementation = state
  376. .get()
  377. .integrations.get(credential.integrationID)
  378. ?.implementations.get(credential.value.methodID)
  379. if (!implementation?.refresh) return credential.value
  380. const now = yield* Clock.currentTimeMillis
  381. if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
  382. const value = yield* authorize(implementation.refresh(credential.value))
  383. yield* credentials.update(credential.id, { value })
  384. return value
  385. }),
  386. key: Effect.fn("Integration.connection.key")(function* (input) {
  387. const method = state
  388. .get()
  389. .integrations.get(input.integrationID)
  390. ?.methods.some((method) => method.type === "key")
  391. if (!method) return yield* Effect.die(`Key method not found: ${input.integrationID}`)
  392. yield* credentials.create({
  393. integrationID: input.integrationID,
  394. label: input.label,
  395. value: Credential.Key.make({ type: "key", key: input.key }),
  396. })
  397. yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
  398. yield* events.publish(Event.Updated, {})
  399. }),
  400. oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
  401. const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
  402. if (!method) {
  403. return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
  404. }
  405. const attemptScope = yield* Scope.fork(scope)
  406. const authorization = yield* authorize(method.authorize(input.inputs)).pipe(
  407. Scope.provide(attemptScope),
  408. Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)),
  409. )
  410. const id = AttemptID.create()
  411. const created = yield* Clock.currentTimeMillis
  412. const time = { created, expires: created + attemptLifetime }
  413. yield* SynchronizedRef.update(attempts, (current) =>
  414. new Map(current).set(id, {
  415. status: "pending",
  416. completing: authorization.mode === "auto",
  417. authorization,
  418. integrationID: input.integrationID,
  419. methodID: input.methodID,
  420. label: input.label,
  421. scope: attemptScope,
  422. time,
  423. }),
  424. )
  425. if (authorization.mode === "auto") {
  426. yield* authorization.callback.pipe(
  427. Effect.exit,
  428. Effect.flatMap((exit) => settle(id, exit)),
  429. Effect.forkIn(attemptScope, { startImmediately: true }),
  430. )
  431. }
  432. return new Attempt({
  433. attemptID: id,
  434. url: authorization.url,
  435. instructions: authorization.instructions,
  436. mode: authorization.mode,
  437. time,
  438. })
  439. }),
  440. update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
  441. const credential = yield* credentials.get(credentialID)
  442. yield* credentials.update(credentialID, updates)
  443. if (credential) {
  444. yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
  445. }
  446. yield* events.publish(Event.Updated, {})
  447. }),
  448. remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
  449. const credential = yield* credentials.get(credentialID)
  450. yield* credentials.remove(credentialID)
  451. if (credential) {
  452. yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
  453. }
  454. yield* events.publish(Event.Updated, {})
  455. }),
  456. },
  457. attempt: {
  458. status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
  459. const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID)
  460. if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${attemptID}`)
  461. if (attempt.status === "failed") {
  462. return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time }
  463. }
  464. return { status: attempt.status, time: attempt.time }
  465. }),
  466. complete: Effect.fn("Integration.attempt.complete")(function* (input) {
  467. const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
  468. const match = current.get(input.attemptID)
  469. if (!match || match.status !== "pending" || match.completing) return [match, current]
  470. if (match.authorization.mode === "code" && input.code === undefined) return [match, current]
  471. return [match, new Map(current).set(input.attemptID, { ...match, completing: true })]
  472. })
  473. if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`)
  474. if (attempt.status !== "pending") return
  475. if (attempt.authorization.mode === "code" && input.code === undefined) {
  476. return yield* new CodeRequiredError({ attemptID: input.attemptID })
  477. }
  478. if (attempt.completing) return yield* Effect.die(`OAuth attempt already completing: ${input.attemptID}`)
  479. const callback =
  480. attempt.authorization.mode === "auto"
  481. ? attempt.authorization.callback
  482. : attempt.authorization.callback(input.code as string)
  483. const exit = yield* authorize(callback).pipe(Effect.exit)
  484. yield* settle(input.attemptID, exit)
  485. if (Exit.isFailure(exit)) return yield* exit
  486. }),
  487. cancel: Effect.fn("Integration.attempt.cancel")(function* (attemptID) {
  488. const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
  489. const match = current.get(attemptID)
  490. if (!match || match.status !== "pending") return [undefined, current]
  491. const next = new Map(current)
  492. next.delete(attemptID)
  493. return [match, next]
  494. })
  495. if (attempt) yield* Scope.close(attempt.scope, Exit.void)
  496. }),
  497. },
  498. })
  499. }),
  500. )