Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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.

9 changes: 9 additions & 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 @@ -585,20 +589,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 @@ -303,6 +303,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 @@ -654,8 +655,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 @@ -90,23 +90,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 @@ -232,9 +236,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 @@ -279,8 +279,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 @@ -295,7 +296,8 @@ describe("Integration", () => {
metadata: { code },
}),
),
}),
})
},
}),
)

Expand Down Expand Up @@ -324,6 +326,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 @@ -351,8 +351,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