Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion packages/core/src/credential.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export * as Credential from "./credential"

import { asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Equal, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database"
Expand Down Expand Up @@ -40,8 +40,25 @@ export interface Interface {
readonly value: Value
readonly label?: string
}) => Effect.Effect<Info>
/** Creates a credential only when the integration has no stored credential. */
readonly createIfAbsent: (input: {
readonly integrationID: Integration.ID
readonly value: Value
readonly label?: string
}) => Effect.Effect<{ readonly created: boolean; readonly credential: Info }>
/** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Replaces an OAuth value only when it still matches the expected value. */
readonly compareAndSetOAuth: (
id: ID,
expected: OAuth,
value: OAuth,
) => Effect.Effect<{ readonly updated: boolean; readonly value: Value | undefined }>
/** Removes a credential only when its value still matches the expected value. */
readonly compareAndRemove: (
id: ID,
expected: Value,
) => Effect.Effect<{ readonly removed: boolean; readonly value: Value | undefined }>
/** Removes a stored credential. */
readonly remove: (id: ID) => Effect.Effect<void>
}
Expand All @@ -53,6 +70,8 @@ const layer = Layer.effect(
Effect.gen(function* () {
const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Value)
const decodePersisted = Schema.decodeUnknownSync(Schema.fromJsonString(Value))
const persisted = (value: Value) => decodePersisted(JSON.stringify(value))
const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return
return new Info({
Expand Down Expand Up @@ -119,6 +138,39 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
return credential
}),
createIfAbsent: Effect.fn("Credential.createIfAbsent")(function* (input) {
return yield* db
.transaction(
(tx) =>
Effect.gen(function* () {
const row = yield* tx
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, input.integrationID))
.get()
const current = row ? stored(row) : undefined
if (current) return { created: false, credential: current }
const credential = new Info({
id: ID.create(),
integrationID: input.integrationID,
label: input.label ?? "default",
value: input.value,
})
yield* tx
.insert(CredentialTable)
.values({
id: credential.id,
integration_id: credential.integrationID,
label: credential.label,
value: credential.value,
})
.run()
return { created: true, credential }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
}),
update: Effect.fn("Credential.update")(function* (id, updates) {
if (!updates.label && !updates.value) return
yield* db
Expand All @@ -128,6 +180,37 @@ const layer = Layer.effect(
.run()
.pipe(Effect.orDie)
}),
compareAndSetOAuth: Effect.fn("Credential.compareAndSetOAuth")(function* (id, expected, value) {
return yield* db
.transaction(
(tx) =>
Effect.gen(function* () {
const row = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
const current = row ? stored(row)?.value : undefined
if (current?.type !== "oauth" || !Equal.equals(current, persisted(expected)))
return { updated: false, value: current }
yield* tx.update(CredentialTable).set({ value }).where(eq(CredentialTable.id, id)).run()
return { updated: true, value }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
}),
compareAndRemove: Effect.fn("Credential.compareAndRemove")(function* (id, expected) {
return yield* db
.transaction(
(tx) =>
Effect.gen(function* () {
const row = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
const current = row ? stored(row)?.value : undefined
if (!Equal.equals(current, persisted(expected))) return { removed: false, value: current }
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
return { removed: true, value: undefined }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
}),
remove: Effect.fn("Credential.remove")(function* (id) {
yield* db.delete(CredentialTable).where(eq(CredentialTable.id, id)).run().pipe(Effect.orDie)
}),
Expand Down
84 changes: 74 additions & 10 deletions packages/core/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Cause,
Clock,
Context,
Deferred,
Duration,
Effect,
Exit,
Expand Down Expand Up @@ -195,6 +196,26 @@ export interface Interface extends State.Transformable<Draft> {

export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}

const refreshes = new Map<Credential.ID, Deferred.Deferred<Credential.Value | undefined, AuthorizationError>>()

const refresh = (
credentialID: Credential.ID,
effect: Effect.Effect<Credential.Value | undefined, AuthorizationError>,
) =>
Effect.suspend(() => {
const current = refreshes.get(credentialID)
if (current) return Deferred.await(current)
const deferred = Deferred.makeUnsafe<Credential.Value | undefined, AuthorizationError>()
refreshes.set(credentialID, deferred)
return effect.pipe(
Effect.onExit((exit) =>
Deferred.done(deferred, exit).pipe(Effect.andThen(Effect.sync(() => refreshes.delete(credentialID)))),
),
Effect.forkDetach({ startImmediately: true }),
Effect.andThen(Deferred.await(deferred)),
)
})

const attemptLifetime = Duration.toMillis(Duration.minutes(10))
const terminalRetention = Duration.toMillis(Duration.minutes(1))
const scrubInterval = Duration.seconds(30)
Expand Down Expand Up @@ -390,16 +411,30 @@ export const locationLayer = Layer.effect(
const credential = yield* credentials.get(connection.id)
if (!credential) return undefined
if (credential.value.type === "key") return credential.value
const implementation = state
.get()
.integrations.get(credential.integrationID)
?.implementations.get(credential.value.methodID)
if (!implementation?.refresh) return credential.value
const now = yield* Clock.currentTimeMillis
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
const value = yield* authorize(implementation.refresh(credential.value))
yield* credentials.update(credential.id, { value })
return value
const oauth = credential.value
return yield* refresh(
credential.id,
Effect.gen(function* () {
const latest = yield* credentials.get(credential.id)
if (!latest || latest.value.type === "key") return latest?.value
if (
latest.value.methodID !== oauth.methodID ||
latest.value.access !== oauth.access ||
latest.value.refresh !== oauth.refresh ||
latest.value.expires !== oauth.expires
)
return latest.value
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const implementation = state
.get()
.integrations.get(latest.integrationID)
?.implementations.get(latest.value.methodID)
if (!implementation?.refresh) return latest.value
const now = yield* Clock.currentTimeMillis
if (latest.value.expires > now + Duration.toMillis(Duration.minutes(5))) return latest.value
const value = yield* authorize(implementation.refresh(latest.value))
return yield* persistOAuthRefresh(latest.id, latest.value, value, credentials)
}),
)
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
Expand Down Expand Up @@ -517,4 +552,33 @@ export const locationLayer = Layer.effect(
}),
)

function persistOAuthRefresh(
id: Credential.ID,
expected: Credential.OAuth,
value: Credential.OAuth,
credentials: Credential.Interface,
): Effect.Effect<Credential.Value | undefined> {
return credentials.compareAndSetOAuth(id, expected, value).pipe(
Effect.flatMap((result) => {
if (result.updated) return Effect.succeed(value)
const current = result.value
if (
current?.type !== "oauth" ||
current.methodID !== expected.methodID ||
current.access !== expected.access ||
current.refresh !== expected.refresh ||
current.expires !== expected.expires
) {
return Effect.succeed(current)
}
return persistOAuthRefresh(
id,
current,
Credential.OAuth.make({ ...value, metadata: current.metadata }),
credentials,
)
}),
)
}

export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] })
2 changes: 2 additions & 0 deletions packages/core/src/plugin/provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AlibabaPlugin } from "./provider/alibaba"
import { AmazonBedrockPlugin } from "./provider/amazon-bedrock"
import { AnthropicPlugin } from "./provider/anthropic"
import { AnthropicSubscriptionPlugin } from "./provider/anthropic-subscription"
import { AzureCognitiveServicesPlugin, AzurePlugin } from "./provider/azure"
import { CerebrasPlugin } from "./provider/cerebras"
import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway"
Expand Down Expand Up @@ -37,6 +38,7 @@ export const ProviderPlugins: PluginInternal.Plugin<PluginInternal.Requirements
AlibabaPlugin,
AmazonBedrockPlugin,
AnthropicPlugin,
AnthropicSubscriptionPlugin,
AzureCognitiveServicesPlugin,
AzurePlugin,
CerebrasPlugin,
Expand Down
Loading
Loading