integration.ts 21 KB

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