Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/client/src/effect/api/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion packages/client/src/effect/generated/client.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion packages/client/src/promise/generated/client.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/client/src/promise/generated/types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 20 additions & 2 deletions packages/core/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ export type OAuthAuthorization = {
export interface OAuthImplementation {
readonly integrationID: ID
readonly method: OAuthMethod
readonly authorize: (answer: Form.Answer) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
readonly authorize: (
answer: Form.Answer,
context?: { readonly credential?: Credential.OAuth },
) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
readonly label?: (credential: Credential.OAuth) => string | undefined
}
Expand Down Expand Up @@ -225,6 +228,7 @@ export interface Interface extends State.Transformable<Editor> {
readonly connect: (input: {
readonly integrationID: ID
readonly methodID: MethodID
readonly credentialID?: Credential.ID
readonly answer?: Form.Answer
readonly label?: string
}) => Effect.Effect<Attempt, AuthorizationError>
Expand Down Expand Up @@ -607,20 +611,34 @@ const layer = Layer.effect(
const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: {
readonly integrationID: ID
readonly methodID: MethodID
readonly credentialID?: Credential.ID
readonly answer?: Form.Answer
readonly label?: string
}) {
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
if (!method) {
return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`))
}
const credential = yield* Effect.gen(function* () {
if (input.credentialID === undefined) return undefined
// Reauthorization uses the requested stored value without resolving or refreshing it.
const saved = yield* credentials.get(input.credentialID)
if (!saved) return yield* new AuthorizationError({ cause: new Error("OAuth credential not found") })
if (saved.integrationID !== input.integrationID)
return yield* new AuthorizationError({ cause: new Error("OAuth credential belongs to another integration") })
if (saved.value.type !== "oauth")
return yield* new AuthorizationError({ cause: new Error("Credential is not an OAuth credential") })
// Choosing another OAuth method starts a new connection without reusing the old registration.
if (saved.value.methodID !== input.methodID) return undefined
return saved.value
})
const answer = input.answer ?? {}
if (method.method.form) {
const invalid = Form.validateFields(method.method.form) ?? Form.validateAnswer(method.method.form, answer)
if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) })
}
const attemptScope = yield* Scope.fork(scope)
const authorization = yield* authorize(method.authorize(answer)).pipe(
const authorization = yield* authorize(method.authorize(answer, credential ? { credential } : undefined)).pipe(
Scope.provide(attemptScope),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)),
)
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
integration.oauth.connect({
integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID),
credentialID: input.credentialID === undefined ? undefined : Credential.ID.make(input.credentialID),
answer: input.answer,
label: input.label,
}),
Expand Down Expand Up @@ -692,8 +693,8 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
return {
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
authorize: (answer) =>
input.authorize(answer).pipe(
authorize: (answer, context) =>
input.authorize(answer, context).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
Expand Down
14 changes: 8 additions & 6 deletions packages/core/src/plugin/provider/chatgpt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,23 +92,27 @@ const decodeMetadata = Schema.decodeUnknownOption(
}),
)

const signIn = (app: App.Info, savedClientID: () => string | undefined, storage: Context["storage"]) =>
const signIn = (app: App.Info, storage: Context["storage"]) =>
({
integrationID,
method: {
id: methodID,
type: "oauth",
label: "Sign in with ChatGPT",
},
authorize: () =>
authorize: (_answer, context) =>
Effect.gen(function* () {
const savedID = context?.credential
? Option.getOrUndefined(decodeMetadata(context.credential.metadata))?.clientID
: undefined
if (context?.credential && !savedID?.trim())
return yield* Effect.fail(new Error("This ChatGPT connection has no registered client ID. Connect again."))
const storedHostID = yield* storage.get("chatgpt-agent-host-id")
const hostID = typeof storedHostID === "string" ? storedHostID : `urn:uuid:${crypto.randomUUID()}`
if (typeof storedHostID !== "string") yield* storage.set("chatgpt-agent-host-id", hostID)
const pkce = yield* Effect.promise(generatePKCE)
const state = randomValue()
const nonce = randomValue()
const savedID = savedClientID()
const received = yield* Deferred.make<{ code: string; clientID?: string; response: ServerResponse }, Error>()
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
Expand Down Expand Up @@ -234,9 +238,7 @@ export const ChatGPTPlugin = define({
})

yield* ctx.integration.transform((editor) => {
editor.method.update(
signIn(ctx.app, () => Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))?.clientID, ctx.storage),
)
editor.method.update(signIn(ctx.app, ctx.storage))
})
yield* Effect.forEach(
yield* credentials.list(Integration.ID.make("openai")),
Expand Down
116 changes: 113 additions & 3 deletions packages/core/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,9 @@ describe("Integration", () => {
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
authorize: (answer) => {
expect(answer).toEqual({})
return Effect.succeed({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
Expand All @@ -296,7 +297,8 @@ describe("Integration", () => {
metadata: { code },
}),
),
}),
})
},
}),
)

Expand Down Expand Up @@ -325,6 +327,114 @@ describe("Integration", () => {
}),
)

for (const target of ["missing", "other-integration", "key"] as const) {
it.effect(`rejects an OAuth ${target} target before authorization without changing credentials`, () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
let authorizations = 0
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () => {
authorizations++
return Effect.succeed({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: () => Effect.die("unexpected callback"),
})
},
}),
)
const credential = yield* credentials.create({
integrationID: target === "other-integration" ? Integration.ID.make("other") : integrationID,
value:
target === "key"
? Credential.Key.make({ type: "key", key: "secret" })
: Credential.OAuth.make({
type: "oauth",
methodID,
access: "access",
refresh: "refresh",
expires: 1,
}),
})
const before = yield* credentials.all()
const active = yield* integrations.connection.active(integrationID)

expect(
yield* integrations.oauth
.connect({
integrationID,
methodID,
credentialID: target === "missing" ? Credential.ID.make("cred_missing") : credential.id,
})
.pipe(Effect.flip),
).toBeInstanceOf(Integration.AuthorizationError)
expect(authorizations).toBe(0)
expect(yield* credentials.all()).toEqual(before)
expect(yield* integrations.connection.active(integrationID)).toEqual(active)
}),
)
}

for (const sameMethod of [true, false]) {
it.effect(`authorizes an OAuth target with ${sameMethod ? "the same" : "a different"} method`, () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const value = Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("original"),
access: "old-access",
refresh: "old-refresh",
expires: 1,
})
const target = yield* credentials.create({ integrationID, value })
const methodID = sameMethod ? value.methodID : Integration.MethodID.make("selected")
const answer = { account: "work" }
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: {
id: methodID,
type: "oauth",
label: "Selected",
form: [{ type: "string", key: "account", title: "Account", required: true }],
},
authorize: (received, context) => {
expect(received).toEqual(answer)
expect(context?.credential).toEqual(sameMethod ? value : undefined)
return Effect.succeed({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: (code: string) => Effect.succeed(Credential.OAuth.make({ ...value, methodID, access: code })),
})
},
}),
)
const before = yield* credentials.list(integrationID)
const active = yield* integrations.connection.active(integrationID)
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, credentialID: target.id, answer })
expect(yield* credentials.list(integrationID)).toEqual(before)
expect(yield* integrations.connection.active(integrationID)).toEqual(active)

yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID, code: "new-access" })
const after = yield* credentials.list(integrationID)
expect(after.find((entry) => entry.id === target.id)?.value).toEqual(value)
expect(after.filter((entry) => entry.id !== target.id)).toEqual([
expect.objectContaining({ value: Credential.OAuth.make({ ...value, methodID, access: "new-access" }) }),
])
}),
)
}

it.effect("keeps code attempts open when the code is missing and closes them on cancel", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,8 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
editor.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: methodID },
authorize: (answer) =>
input.authorize(answer).pipe(
authorize: (answer, context) =>
input.authorize(answer, context).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
Expand Down
Loading
Loading