From cd15507cc7e7a7e6acb82ea3935b313b8e1d187b Mon Sep 17 00:00:00 2001 From: lawsonoates Date: Fri, 26 Jun 2026 21:10:43 +1000 Subject: [PATCH] feat: add hosted connector control plane Move managed connector authorization and credential issuance from local CLI paths to the console control plane. Add installation, binding, and credential lease APIs, define managed connector types in core, and wire the CLI to request short-lived leases at runtime instead of holding connector secrets locally. Remove local connector and control implementations, add the installation schema migration, and document the architecture in ADR 0001. --- .gitignore | 3 + bun.lock | 2 + .../0001-hosted-connector-control-plane.md | 259 +++++++ packages/console/api/package.json | 3 +- packages/console/api/script/auth-generate.ts | 3 +- packages/console/api/script/seed-dev.ts | 125 ++++ packages/console/api/src/api.ts | 35 +- packages/console/api/src/connectors/github.ts | 97 +++ packages/console/api/src/connectors/notion.ts | 143 ++++ .../api/src/connectors/oauth-client.ts | 198 ++++++ .../console/api/src/connectors/planetscale.ts | 201 ++++++ .../console/api/src/connectors/registry.ts | 61 ++ .../console/api/src/connectors/schemas.ts | 58 ++ packages/console/api/src/connectors/slack.ts | 150 ++++ packages/console/api/src/connectors/types.ts | 96 +++ packages/console/api/src/connectors/vercel.ts | 184 +++++ packages/console/api/src/device-page.ts | 2 +- .../console/api/src/effect/app-runtime.ts | 12 +- packages/console/api/src/errors.ts | 2 +- packages/console/api/src/installation/flow.ts | 184 +++++ packages/console/api/src/middleware/auth.ts | 7 +- .../console/api/src/routes/auth-forward.ts | 45 +- packages/console/api/src/routes/auth.ts | 43 +- packages/console/api/src/routes/binding.ts | 7 + .../api/src/routes/credential-lease.ts | 33 +- .../console/api/src/routes/installation.ts | 156 +---- packages/console/api/src/schema.ts | 26 +- packages/console/api/src/server.ts | 2 +- packages/console/api/test/auth.test.ts | 136 +++- .../console/api/test/connectors/fixture.ts | 34 + .../api/test/connectors/notion.test.ts | 38 + .../api/test/connectors/planetscale.test.ts | 47 ++ .../console/api/test/connectors/slack.test.ts | 35 + .../api/test/connectors/vercel.test.ts | 35 + .../console/api/test/control-plane.test.ts | 99 ++- .../api/test/github/credential.test.ts | 45 +- packages/console/core/package.json | 1 + .../console/core/src/database/database.ts | 4 +- .../database/migrations/0002_tired_medusa.sql | 1 + .../migrations/meta/0002_snapshot.json | 663 ++++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + .../src/database/schema/installation.sql.ts | 31 +- packages/console/core/src/installation.ts | 72 +- packages/core/package.json | 3 +- packages/core/src/connector.ts | 65 ++ packages/core/src/error.ts | 2 +- packages/core/src/global.ts | 2 +- packages/core/src/manifest/schema.ts | 12 +- packages/core/tsconfig.json | 2 +- packages/hem/package.json | 2 +- packages/hem/src/api/client.ts | 2 +- packages/hem/src/api/poll.ts | 2 +- packages/hem/src/auth/session.ts | 4 +- packages/hem/src/auth/util.ts | 2 +- packages/hem/src/cmd/connect.ts | 17 +- packages/hem/src/cmd/env.ts | 22 +- packages/hem/src/cmd/login.ts | 2 +- packages/hem/src/cmd/run.ts | 4 +- packages/hem/src/connector/cloud/lease.ts | 10 +- packages/hem/src/connector/local.ts | 2 - packages/hem/src/control/cloud/github.ts | 67 -- packages/hem/src/control/cloud/provider.ts | 93 +++ packages/hem/src/control/local.ts | 2 - packages/hem/src/effect/app-runtime.ts | 2 +- packages/hem/src/index.ts | 2 +- packages/hem/src/manifest/resolve.ts | 4 +- packages/hem/src/secret/bun/index.ts | 2 +- packages/hem/src/secret/env.ts | 2 +- 68 files changed, 3316 insertions(+), 398 deletions(-) create mode 100644 docs/adr/0001-hosted-connector-control-plane.md create mode 100644 packages/console/api/script/seed-dev.ts create mode 100644 packages/console/api/src/connectors/github.ts create mode 100644 packages/console/api/src/connectors/notion.ts create mode 100644 packages/console/api/src/connectors/oauth-client.ts create mode 100644 packages/console/api/src/connectors/planetscale.ts create mode 100644 packages/console/api/src/connectors/registry.ts create mode 100644 packages/console/api/src/connectors/schemas.ts create mode 100644 packages/console/api/src/connectors/slack.ts create mode 100644 packages/console/api/src/connectors/types.ts create mode 100644 packages/console/api/src/connectors/vercel.ts create mode 100644 packages/console/api/src/installation/flow.ts create mode 100644 packages/console/api/test/connectors/fixture.ts create mode 100644 packages/console/api/test/connectors/notion.test.ts create mode 100644 packages/console/api/test/connectors/planetscale.test.ts create mode 100644 packages/console/api/test/connectors/slack.test.ts create mode 100644 packages/console/api/test/connectors/vercel.test.ts create mode 100644 packages/console/core/src/database/migrations/0002_tired_medusa.sql create mode 100644 packages/console/core/src/database/migrations/meta/0002_snapshot.json create mode 100644 packages/core/src/connector.ts delete mode 100644 packages/hem/src/connector/local.ts delete mode 100644 packages/hem/src/control/cloud/github.ts create mode 100644 packages/hem/src/control/cloud/provider.ts delete mode 100644 packages/hem/src/control/local.ts diff --git a/.gitignore b/.gitignore index 39fa5d5..d840271 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .turbo .hem + +# local control-plane SQLite database +packages/console/api/hem.db* diff --git a/bun.lock b/bun.lock index 18193eb..6f569d5 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "dependencies": { "@effect/platform-bun": "catalog:", "@hem/console-core": "workspace:*", + "@hem/core": "workspace:*", "better-auth": "^1.6.11", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -36,6 +37,7 @@ "packages/console/core": { "name": "@hem/console-core", "dependencies": { + "@hem/core": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", "zod": "^4.4.3", diff --git a/docs/adr/0001-hosted-connector-control-plane.md b/docs/adr/0001-hosted-connector-control-plane.md new file mode 100644 index 0000000..5bba869 --- /dev/null +++ b/docs/adr/0001-hosted-connector-control-plane.md @@ -0,0 +1,259 @@ +# ADR 0001: Hosted control plane for managed connectors + +- Status: Accepted +- Date: 2026-06-18 +- Amended: 2026-06-22 (GitHub bindings inherit installation scope) + +## Context + +Hem needs to let a user authorize a third-party provider once and then issue +appropriately scoped credentials to their projects at runtime. Provider +credential lifecycles vary: some refresh OAuth access tokens, some mint +installation tokens, and some can only return a static API key. + +GitHub Apps provide a useful first managed connector. A GitHub App is installed +for a user or organization, and its installation can issue short-lived access +tokens. Repository access is selected when the App is installed, and permissions +are defined by the App configuration. Issuing those tokens requires the GitHub +App private key. Shipping that private key in the Hem CLI would compromise every +installation, so a fully local implementation cannot safely provide a +Hem-managed GitHub connector. + +## Decision + +Hem will use a hosted control plane for managed connectors. The CLI remains the +local runtime, or data plane. + +The control plane is responsible for: + +- holding connector-level credentials such as a GitHub App private key; +- completing provider authorization and installation flows; +- recording installation ownership and provider tenant identity; +- authorizing bindings to provider installations; +- issuing credentials within the provider installation's grant; +- recording credential issuance metadata; and +- revoking bindings and installations where the provider supports it. + +The CLI is responsible for: + +- authenticating the user with Hem; +- starting provider installation flows in the browser; +- declaring a project binding for an installation; +- requesting a credential lease immediately before running a command; and +- injecting leased values into the child process. + +The CLI must not receive connector-level credentials. Runtime credentials must +not be written to `.hem/secrets.json`; they should remain in memory for the +command lifetime unless a provider requires a different, explicitly documented +strategy. + +Hem user authentication, provider installation, and runtime credential issuance +are separate operations: + +```text +Hem login -> identifies the Hem user +Provider installation -> authorizes a provider tenant +Credential lease -> authorizes a particular runtime use +``` + +## Architecture + +```text +Provider + ^ + | authorize / mint / revoke + | +Hem control plane + ^ + | authenticated installation and lease API + | +Hem CLI -> child process with temporary environment values +``` + +The control plane is not a general proxy for provider APIs. After issuance, the +child process calls the provider directly. + +## Domain model + +### Connector definition + +A connector definition is server code and configuration that implements a +provider's authorization and issuance behavior. It is not an API resource or +user-created installation state. The first connector will be the Hem-managed +GitHub App. + +### Installation + +An installation represents authorization of one connector for one provider +tenant. It is reusable across projects. + +```ts +interface Installation { + id: string; + connector: 'github'; + providerInstallationId: string; + account: { + id: string; + name: string; + type: 'user' | 'organization'; + }; +} +``` + +Installation responses contain metadata only. Provider secrets and connector +private keys are never included. The control plane associates each installation +with the authenticated Hem owner internally. + +### Binding + +A binding associates a project with an installation. The project stores the +binding ID in its local manifest; there is no server-side project or environment +resource in the initial model. The binding does not narrow the installation: +repository access comes from the choices made during GitHub App installation, +and permissions come from the GitHub App configuration. + +```ts +interface Binding { + id: string; + installationId: string; +} +``` + +The binding inherits ownership through its installation. The control plane +stores the authoritative binding so it can enforce issuance. For GitHub, the +output name is fixed as `GITHUB_TOKEN` rather than configured per binding. + +The project manifest stores only the binding reference and output name: + +```json +{ + "bindingId": "bind_123", + "outputs": ["GITHUB_TOKEN"] +} +``` + +### Credential lease + +A credential lease is a short-lived response produced for one authorized +binding. The returned values are sensitive and are delivered only to the +authenticated CLI that requested them. + +```ts +interface CredentialLease { + values: { + GITHUB_TOKEN: string; + }; + expiresAt: string; +} +``` + +The response contains only what the CLI needs to run the child process. The +server may retain separate issuance metadata for audit purposes, but must never +log or persist the returned credential values. A lease inherits the full grant +of its installation. + +## Initial API surface + +The first vertical slice requires these operations: + +```text +POST /v1/connectors/github/installations +GET /v1/connectors/github/callback +POST /v1/bindings +POST /v1/credential-leases +``` + +Creating a GitHub installation returns a short-lived authorization URL. The +callback validates its state and records the provider installation. Creating a +credential lease authenticates the CLI, loads the binding and installation, +signs a GitHub App JWT on the server, and exchanges it for a GitHub installation +access token without an additional scope body. + +Authentication and error schemas will be specified separately before the API is +implemented. + +## Security invariants + +- Connector-level private keys exist only in the control-plane secret store. +- Every installation has an explicit Hem owner, and bindings inherit that + ownership. +- A binding identifier alone is not authority to request a lease. +- Lease issuance requires an authenticated Hem principal authorized for the + binding. +- GitHub repository access is controlled by the installation, and permissions + are controlled by the GitHub App configuration. +- Credential values are never placed in logs, audit events, or project files. +- Provider tokens are minted just in time and are not cached initially. +- Revoking an installation prevents all of its bindings from issuing new + leases. + +## First vertical slice: GitHub + +The GitHub implementation maps directly to the model: + +```text +Connector definition -> Hem GitHub App +Installation -> GitHub App installation for a user or organization +Binding -> project reference to an installation +Credential lease -> short-lived GitHub installation access token +``` + +The initial end-to-end behavior is: + +1. The user authenticates the CLI with Hem. +2. `hem connect github` opens the server-created GitHub App installation URL. +3. The control plane records the completed installation. +4. The control plane creates an installation binding, and the CLI stores its + reference in the project manifest. +5. `hem run` requests a credential lease for that binding. +6. The CLI injects the returned token into the child process and discards it + when the process exits. + +Cloudflare will not be migrated to this model before the GitHub vertical slice +validates it. The existing Cloudflare implementation can then be removed or +reintroduced later as a connector with an explicitly supported issuance +strategy. + +## Consequences + +### Positive + +- Managed connector secrets are not distributed to clients. +- Bindings receive short-lived credentials bounded by their provider + installation. +- Installation authorization is reusable without copying runtime tokens between + projects. +- Provider differences live behind issuance strategies rather than a false + assumption that every provider implements OAuth the same way. + +### Negative + +- Hem now operates security-sensitive hosted infrastructure. +- The CLI requires network access to issue managed credentials. +- User authentication, authorization, secret storage, auditing, and operational + availability become product responsibilities. +- Offline operation requires a future, explicit policy rather than falling back + silently to durable credentials. + +## Alternatives considered + +### Distribute the GitHub App private key with the CLI + +Rejected. Extracting one distributed key would compromise every Hem-managed +GitHub installation. + +### Require every user to create a GitHub App + +Rejected as the default managed experience. It may later be supported as a +customer-managed connector. + +### Use only GitHub user OAuth tokens + +Rejected as the foundational model. User OAuth is useful for user identity and +user-context operations, but it does not provide the same installation-level +repository policy and server-side re-minting lifecycle. + +### Proxy all provider API traffic through Hem + +Rejected. Hem should issue credentials and enforce their authorization boundary, +not become a universal API gateway. diff --git a/packages/console/api/package.json b/packages/console/api/package.json index e925113..d8a9375 100644 --- a/packages/console/api/package.json +++ b/packages/console/api/package.json @@ -14,13 +14,14 @@ "deploy": "bun script/deploy.ts", "dev": "bun --watch src/server.ts", "dev:public": "NODE_ENV=development bun run script/dev-public.ts", + "seed:dev": "NODE_ENV=development bun script/seed-dev.ts", "start": "bun src/server.ts", "typecheck": "tsgo -b --noEmit" }, "dependencies": { "@effect/platform-bun": "catalog:", - "@hem/console-core": "workspace:*", + "@hem/core": "workspace:*", "better-auth": "^1.6.11", "drizzle-orm": "catalog:", "effect": "catalog:" diff --git a/packages/console/api/script/auth-generate.ts b/packages/console/api/script/auth-generate.ts index 8346864..976ad0b 100644 --- a/packages/console/api/script/auth-generate.ts +++ b/packages/console/api/script/auth-generate.ts @@ -4,7 +4,8 @@ import { $ } from 'bun'; const outputPath = '../core/src/database/schema/auth.sql.ts'; const absoluteOutputPath = decodeURIComponent( - new URL('../../core/src/database/schema/auth.sql.ts', import.meta.url).pathname + new URL('../../core/src/database/schema/auth.sql.ts', import.meta.url) + .pathname ); const configPath = './src/auth.config.ts'; const lintBanner = `/* eslint-disable sort-keys */ diff --git a/packages/console/api/script/seed-dev.ts b/packages/console/api/script/seed-dev.ts new file mode 100644 index 0000000..5e5801d --- /dev/null +++ b/packages/console/api/script/seed-dev.ts @@ -0,0 +1,125 @@ +#!/usr/bin/env bun + +import { Database } from 'bun:sqlite'; + +import * as authSchema from '@hem/console-core/database/schema/auth'; +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { bearer, deviceAuthorization } from 'better-auth/plugins'; +import { eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/bun-sqlite'; +import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; + +// ---- guard ---- +if (process.env.NODE_ENV !== 'development') { + console.error('Refusing to seed outside NODE_ENV=development.'); + process.exit(1); +} + +// ---- config ---- +const packageRoot = decodeURIComponent( + new URL('../', import.meta.url).pathname +); +const migrationsFolder = decodeURIComponent( + new URL('../../core/src/database/migrations', import.meta.url).pathname +); +const databasePath = process.env.HEM_DATABASE_PATH + ? (() => { + if (process.env.HEM_DATABASE_PATH.startsWith('/')) + return process.env.HEM_DATABASE_PATH; + return `${process.cwd()}/${process.env.HEM_DATABASE_PATH}`; + })() + : `${packageRoot}hem.db`; +const apiUrl = process.env.HEM_API_URL ?? 'http://127.0.0.1:3000'; +const secret = + process.env.BETTER_AUTH_SECRET ?? + 'hem-development-secret-with-at-least-32-chars'; + +const seedUser = { + email: process.env.HEM_DEV_SEED_EMAIL ?? 'dev@hem.local', + name: process.env.HEM_DEV_SEED_NAME ?? 'Hem Dev', + password: + process.env.HEM_DEV_SEED_PASSWORD ?? 'correct-horse-battery-staple', +}; + +const legacyConnectorFixtures = [ + 'notion', + 'planetscale', + 'slack', + 'vercel', +] as const; + +// ---- prepare ---- +const sqlite = new Database(databasePath, { create: true }); +sqlite.run('PRAGMA journal_mode = WAL'); +sqlite.run('PRAGMA foreign_keys = ON'); + +const db = drizzle(sqlite, { schema: authSchema }); +migrate(db, { migrationsFolder }); + +const auth = betterAuth({ + basePath: '/v1/auth', + baseURL: apiUrl, + database: drizzleAdapter(drizzle(sqlite, { schema: authSchema }), { + provider: 'sqlite', + }), + emailAndPassword: { enabled: true }, + plugins: [ + bearer(), + deviceAuthorization({ + schema: {}, + verificationUri: new URL('/device', apiUrl).toString(), + }), + ], + secret, +}); + +// ---- seed user ---- +let user = db + .select() + .from(authSchema.user) + .where(eq(authSchema.user.email, seedUser.email)) + .get(); + +if (!user) { + const response = await auth.handler( + new Request(new URL('/v1/auth/sign-up/email', apiUrl).toString(), { + body: JSON.stringify(seedUser), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + ); + if (!response.ok) { + console.error( + `Could not create dev user: HTTP ${response.status} ${await response.text()}` + ); + process.exit(1); + } + user = db + .select() + .from(authSchema.user) + .where(eq(authSchema.user.email, seedUser.email)) + .get(); +} + +if (!user) { + console.error('Could not read seeded dev user.'); + process.exit(1); +} + +// ---- clean legacy fixtures ---- +for (const connector of legacyConnectorFixtures) { + sqlite + .query('delete from binding where id = ?') + .run(`bind_dev_${connector}`); + sqlite + .query('delete from installation where id = ?') + .run(`ins_dev_${connector}`); +} + +sqlite.close(); + +// ---- done ---- +console.log(`Seeded Hem dev database: ${databasePath}`); +console.log(`User: ${seedUser.email}`); +console.log(`Password: ${seedUser.password}`); diff --git a/packages/console/api/src/api.ts b/packages/console/api/src/api.ts index b9a912d..ea51dcc 100644 --- a/packages/console/api/src/api.ts +++ b/packages/console/api/src/api.ts @@ -1,3 +1,4 @@ +import { ManagedConnector as ManagedConnectorSchema } from '@hem/core/connector'; import { Schema } from 'effect'; import { HttpApi, @@ -23,6 +24,7 @@ import { AuthSuccess, AuthUser, Binding, + ConnectorInstallationAuthorization, CreateBindingRequest, CreateCredentialLeaseRequest, CredentialLease, @@ -32,7 +34,6 @@ import { EmailSignInRequest, EmailSignUpRequest, ExchangeDeviceTokenRequest, - GithubInstallationAuthorization, Installation, StartDeviceAuthorizationRequest, } from './schema'; @@ -110,42 +111,46 @@ export class AuthApi extends HttpApiGroup.make('auth').add( signOut ) {} -const startGithubInstallation = HttpApiEndpoint.post( - 'startGithubInstallation', - '/connectors/github/installations', +const startConnectorInstallation = HttpApiEndpoint.post( + 'startConnectorInstallation', + '/connectors/:connector/installations', { error: ProviderUnavailable, - success: GithubInstallationAuthorization, + params: { connector: ManagedConnectorSchema }, + success: ConnectorInstallationAuthorization, } ).middleware(Authorization); -const completeGithubInstallation = HttpApiEndpoint.get( - 'completeGithubInstallation', - '/connectors/github/callback', +const completeConnectorInstallation = HttpApiEndpoint.get( + 'completeConnectorInstallation', + '/connectors/:connector/callback', { error: [InvalidInstallationState, ProviderUnavailable], + params: { connector: ManagedConnectorSchema }, query: { - installation_id: Schema.String, + code: Schema.optional(Schema.String), + installation_id: Schema.optional(Schema.String), state: Schema.String, }, success: Installation, } ); -const getGithubInstallationStatus = HttpApiEndpoint.get( - 'getGithubInstallationStatus', - '/connectors/github/installations/status', +const getConnectorInstallationStatus = HttpApiEndpoint.get( + 'getConnectorInstallationStatus', + '/connectors/:connector/installations/status', { error: [AuthorizationPending, InvalidAuthorization, NotFound], + params: { connector: ManagedConnectorSchema }, query: { request_id: Schema.String }, success: Installation, } ).middleware(Authorization); export class InstallationsApi extends HttpApiGroup.make('installations').add( - startGithubInstallation, - completeGithubInstallation, - getGithubInstallationStatus + startConnectorInstallation, + completeConnectorInstallation, + getConnectorInstallationStatus ) {} const createBinding = HttpApiEndpoint.post('createBinding', '/bindings', { diff --git a/packages/console/api/src/connectors/github.ts b/packages/console/api/src/connectors/github.ts new file mode 100644 index 0000000..11267e4 --- /dev/null +++ b/packages/console/api/src/connectors/github.ts @@ -0,0 +1,97 @@ +import { CONNECTOR_DEFAULT_OUTPUTS } from '@hem/core/connector'; +import { Context, Effect, Layer } from 'effect'; + +import { GithubConnector } from '../github'; +import { ConnectorError, requireGithubInstallationId } from './types'; +import type { + CompletedConnectorInstallation, + ManagedConnectorService, +} from './types'; + +export type Interface = ManagedConnectorService; + +export class Service extends Context.Service()( + '@hem/console-api/connectors/GithubManagedConnector' +) {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const github = yield* GithubConnector.Service; + + const createAuthorizationUrl = Effect.fn( + 'GithubManagedConnector.createAuthorizationUrl' + )((state: string) => + Effect.succeed(github.createInstallationUrl(state)) + ); + + const completeAuthorization = Effect.fn( + 'GithubManagedConnector.completeAuthorization' + )(function* ( + input: Parameters< + ManagedConnectorService['completeAuthorization'] + >[0] + ) { + const providerInstallationId = yield* requireGithubInstallationId( + 'github', + input.callback + ); + const completed = yield* github + .completeInstallation(providerInstallationId) + .pipe( + Effect.mapError( + (error) => + new ConnectorError({ + cause: error.cause, + connector: 'github', + message: error.message, + }) + ) + ); + return { + ...completed, + credentials: null, + } satisfies CompletedConnectorInstallation; + }); + + const issueCredential = Effect.fn( + 'GithubManagedConnector.issueCredential' + )(function* ( + input: Parameters[0] + ) { + const credential = yield* github + .issueCredential({ + providerInstallationId: input.providerInstallationId, + }) + .pipe( + Effect.mapError( + (error) => + new ConnectorError({ + cause: error.cause, + connector: 'github', + message: error.message, + }) + ) + ); + return { + expiresAt: credential.expiresAt, + values: { GITHUB_TOKEN: credential.token }, + }; + }); + + return Service.of({ + completeAuthorization, + connector: 'github', + createAuthorizationUrl, + issueCredential, + outputsForInstallation: () => CONNECTOR_DEFAULT_OUTPUTS.github, + }); + }) +); + +export const defaultLayer = layer.pipe( + Layer.provide(GithubConnector.defaultLayer) +); + +// oxlint-disable-next-line import/no-self-import, oxc/no-barrel-file -- namespace projection for Effect service module +export * as GithubManagedConnector from './github'; diff --git a/packages/console/api/src/connectors/notion.ts b/packages/console/api/src/connectors/notion.ts new file mode 100644 index 0000000..4339fe8 --- /dev/null +++ b/packages/console/api/src/connectors/notion.ts @@ -0,0 +1,143 @@ +import { CONNECTOR_DEFAULT_OUTPUTS } from '@hem/core/connector'; +import { Context, Effect, Layer } from 'effect'; +import { HttpClient, HttpClientRequest } from 'effect/unstable/http'; + +import { + issueOAuthCredential, + permissionsFromScope, + providerCredentials, + providerRedirectUri, + PublicApiUrl, + readProviderSchema, + tokenCredentials, +} from './oauth-client'; +import { NotionTokenResponse } from './schemas'; +import { ConnectorError, requireOAuthCode } from './types'; +import type { + CompletedConnectorInstallation, + ConnectorCredentialLease, + ManagedConnectorService, +} from './types'; + +const NOTION_VERSION = '2026-03-11'; + +export type Interface = ManagedConnectorService; + +export class Service extends Context.Service()( + '@hem/console-api/connectors/NotionConnector' +) {} + +const mapConnectorError = (cause: unknown) => + cause instanceof ConnectorError + ? cause + : new ConnectorError({ + cause, + connector: 'notion', + message: 'Notion connector request failed.', + }); + +const createAuthorizationUrl = Effect.fn( + 'NotionConnector.createAuthorizationUrl' +)(function* (state: string) { + const publicApiUrl = yield* PublicApiUrl; + const redirectUri = yield* providerRedirectUri('notion', publicApiUrl); + const { clientId } = yield* providerCredentials('notion'); + const url = new URL('https://api.notion.com/v1/oauth/authorize'); + + url.searchParams.set('client_id', clientId); + url.searchParams.set('owner', 'user'); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('state', state); + + return url.toString(); +}); + +const completeAuthorization = Effect.fn( + 'NotionConnector.completeAuthorization' +)(function* ( + input: Parameters[0] +) { + const code = yield* requireOAuthCode('notion', input.callback); + const publicApiUrl = yield* PublicApiUrl; + const redirectUri = yield* providerRedirectUri('notion', publicApiUrl); + const { clientId, clientSecret } = yield* providerCredentials('notion'); + const request = HttpClientRequest.post( + 'https://api.notion.com/v1/oauth/token' + ).pipe( + HttpClientRequest.basicAuth(clientId, clientSecret), + HttpClientRequest.bodyJsonUnsafe({ + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }), + HttpClientRequest.setHeader('Notion-Version', NOTION_VERSION) + ); + const response = yield* readProviderSchema({ + connector: 'notion', + message: 'Notion could not exchange the OAuth code.', + request, + schema: NotionTokenResponse, + }); + const scope = response.scope ?? null; + return { + account: { + id: response.workspace_id, + name: response.workspace_name ?? 'Notion workspace', + type: 'workspace', + }, + credentials: tokenCredentials({ + accessToken: response.access_token, + refreshToken: response.refresh_token ?? null, + scope, + tokenType: response.token_type ?? 'bearer', + }), + grantedPermissions: permissionsFromScope(scope), + providerInstallationId: `notion:${response.workspace_id}`, + } satisfies CompletedConnectorInstallation; +}); + +const issueCredential = Effect.fn('NotionConnector.issueCredential')( + (input: Parameters[0]) => + issueOAuthCredential({ + connector: 'notion', + credentials: input.credentials, + }) +); + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const run = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.mapError(mapConnectorError) + ); + + return Service.of({ + completeAuthorization: (input) => + run(completeAuthorization(input)) as Effect.Effect< + CompletedConnectorInstallation, + ConnectorError + >, + connector: 'notion', + createAuthorizationUrl: (state) => + run(createAuthorizationUrl(state)) as Effect.Effect< + string, + ConnectorError + >, + issueCredential: (input) => + run(issueCredential(input)) as Effect.Effect< + ConnectorCredentialLease, + ConnectorError + >, + outputsForInstallation: () => CONNECTOR_DEFAULT_OUTPUTS.notion, + }); + }) +); + +export const defaultLayer = layer; + +// oxlint-disable-next-line import/no-self-import, oxc/no-barrel-file -- namespace projection for Effect service module +export * as NotionConnector from './notion'; \ No newline at end of file diff --git a/packages/console/api/src/connectors/oauth-client.ts b/packages/console/api/src/connectors/oauth-client.ts new file mode 100644 index 0000000..afc737e --- /dev/null +++ b/packages/console/api/src/connectors/oauth-client.ts @@ -0,0 +1,198 @@ +import type { ProviderCredentials } from '@hem/console-core/database/schema/installation'; +import { CONNECTOR_DEFAULT_OUTPUTS } from '@hem/core/connector'; +import type { ManagedConnector, OAuthConnector } from '@hem/core/connector'; +import { Config, Effect, Option, Redacted, Schema } from 'effect'; +import { + HttpClient, + HttpClientResponse, +} from 'effect/unstable/http'; +import type { HttpClientRequest } from 'effect/unstable/http'; + +import { ConnectorError } from './types'; +import type { ConnectorCredentialLease } from './types'; + +const INSTALLATION_LEASE_MS = 60 * 60 * 1000; +const REFRESH_SKEW_MS = 60 * 1000; + +const PROVIDER_PREFIXES = { + notion: 'NOTION_OAUTH', + planetscale: 'PLANETSCALE_OAUTH', + slack: 'SLACK', + vercel: 'VERCEL', +} as const satisfies Record; + +export const permissionsFromScope = ( + scope: string | null | undefined +): Readonly> => (scope ? { scope } : {}); + +export const oneHourFromNow = () => + new Date(Date.now() + INSTALLATION_LEASE_MS).toISOString(); + +export const expiresAtFromSeconds = (seconds: number) => + new Date(Date.now() + seconds * 1000).toISOString(); + +export const connectorCallbackUrl = ( + publicApiUrl: string, + connector: ManagedConnector +) => + new URL(`/v1/connectors/${connector}/callback`, publicApiUrl).toString(); + +export const optionalString = (key: string) => + Config.option(Config.string(key)); + +export const optionalStringWithDefault = Effect.fn( + 'OAuthClient.optionalStringWithDefault' +)(function* (key: string, fallback: string) { + const configured = yield* optionalString(key); + return Option.getOrElse(configured, () => fallback); +}); + +export const providerRedirectUri = Effect.fn('OAuthClient.providerRedirectUri')( + function* (connector: OAuthConnector, publicApiUrl: string) { + const prefix = PROVIDER_PREFIXES[connector]; + return yield* optionalStringWithDefault( + `${prefix}_REDIRECT_URI`, + connectorCallbackUrl(publicApiUrl, connector) + ); + } +); + +export const providerCredentials = Effect.fn('OAuthClient.providerCredentials')( + function* (connector: OAuthConnector) { + const prefix = PROVIDER_PREFIXES[connector]; + const clientId = yield* Config.string(`${prefix}_CLIENT_ID`).pipe( + Effect.mapError( + (cause) => + new ConnectorError({ + cause, + connector, + message: `${connector} OAuth client id is not configured.`, + }) + ) + ); + const clientSecret = yield* Config.redacted( + `${prefix}_CLIENT_SECRET` + ).pipe( + Effect.map(Redacted.value), + Effect.mapError( + (cause) => + new ConnectorError({ + cause, + connector, + message: `${connector} OAuth client secret is not configured.`, + }) + ) + ); + return { clientId, clientSecret } as const; + } +); + +export const readProviderSchema = (input: { + readonly connector: OAuthConnector; + readonly message: string; + readonly request: HttpClientRequest.HttpClientRequest; + readonly schema: Schema.Schema; +}) => + Effect.gen(function* () { + const client = (yield* HttpClient.HttpClient).pipe( + HttpClient.filterStatusOk + ); + const response = yield* client.execute(input.request).pipe( + Effect.mapError( + (cause) => + new ConnectorError({ + cause, + connector: input.connector, + message: input.message, + }) + ) + ); + return yield* HttpClientResponse.schemaBodyJson(input.schema)( + response + ).pipe( + Effect.mapError( + (cause) => + new ConnectorError({ + cause, + connector: input.connector, + message: `${input.message} Provider returned an invalid response.`, + }) + ) + ); + }); + +export const tokenCredentials = (input: { + readonly accessToken: string; + readonly expiresAt?: string | null; + readonly refreshToken?: string | null; + readonly scope?: string | null; + readonly teamId?: string | null; + readonly tokenType?: string | null; +}): ProviderCredentials => ({ + accessToken: input.accessToken, + expiresAt: input.expiresAt ?? null, + refreshToken: input.refreshToken ?? null, + scope: input.scope ?? null, + teamId: input.teamId ?? null, + tokenType: input.tokenType ?? null, +}); + +export const issueOAuthCredential = Effect.fn( + 'OAuthClient.issueOAuthCredential' +)(function* (input: { + readonly connector: OAuthConnector; + readonly credentials: ProviderCredentials | null; + readonly extraValues?: ( + credentials: ProviderCredentials + ) => Readonly>; + readonly refresh?: ( + credentials: ProviderCredentials + ) => Effect.Effect< + ProviderCredentials, + ConnectorError, + HttpClient.HttpClient + >; +}) { + if (!input.credentials) { + return yield* new ConnectorError({ + connector: input.connector, + message: `${input.connector} installation has no stored credentials.`, + }); + } + + const expiry = input.credentials.expiresAt + ? Date.parse(input.credentials.expiresAt) + : undefined; + const shouldRefresh = + expiry !== undefined && expiry <= Date.now() + REFRESH_SKEW_MS; + const credentials = + input.refresh && shouldRefresh + ? yield* input.refresh(input.credentials) + : input.credentials; + + const expiresAt = credentials.expiresAt ?? oneHourFromNow(); + if (Date.parse(expiresAt) <= Date.now()) { + return yield* new ConnectorError({ + connector: input.connector, + message: `${input.connector} OAuth token is expired.`, + }); + } + + return { + credentials: + credentials === input.credentials ? undefined : credentials, + expiresAt, + grantedPermissions: credentials.scope + ? permissionsFromScope(credentials.scope) + : undefined, + values: { + [CONNECTOR_DEFAULT_OUTPUTS[input.connector][0]]: + credentials.accessToken, + ...input.extraValues?.(credentials), + }, + } satisfies ConnectorCredentialLease; +}); + +export const PublicApiUrl = Config.string('PUBLIC_API_URL').pipe( + Config.withDefault('http://127.0.0.1:3000') +); \ No newline at end of file diff --git a/packages/console/api/src/connectors/planetscale.ts b/packages/console/api/src/connectors/planetscale.ts new file mode 100644 index 0000000..8964a13 --- /dev/null +++ b/packages/console/api/src/connectors/planetscale.ts @@ -0,0 +1,201 @@ +import type { ProviderCredentials } from '@hem/console-core/database/schema/installation'; +import { CONNECTOR_DEFAULT_OUTPUTS } from '@hem/core/connector'; +import { Context, Effect, Layer, Option } from 'effect'; +import { HttpClient, HttpClientRequest } from 'effect/unstable/http'; + +import { + expiresAtFromSeconds, + issueOAuthCredential, + permissionsFromScope, + providerCredentials, + providerRedirectUri, + PublicApiUrl, + readProviderSchema, + tokenCredentials, +} from './oauth-client'; +import { + PlanetScaleTokenInfoResponse, + PlanetScaleTokenResponse, +} from './schemas'; +import { ConnectorError, requireOAuthCode } from './types'; +import type { + CompletedConnectorInstallation, + ConnectorCredentialLease, + ManagedConnectorService, +} from './types'; + +export type Interface = ManagedConnectorService; + +export class Service extends Context.Service()( + '@hem/console-api/connectors/PlanetScaleConnector' +) {} + +const mapConnectorError = (cause: unknown) => + cause instanceof ConnectorError + ? cause + : new ConnectorError({ + cause, + connector: 'planetscale', + message: 'PlanetScale connector request failed.', + }); + +const tokenInfo = (accessToken: string) => + readProviderSchema({ + connector: 'planetscale', + message: 'PlanetScale could not inspect the OAuth token.', + request: HttpClientRequest.get( + 'https://auth.planetscale.com/oauth/token/info' + ).pipe(HttpClientRequest.bearerToken(accessToken)), + schema: PlanetScaleTokenInfoResponse, + }).pipe(Effect.option, Effect.map(Option.getOrUndefined)); + +const createAuthorizationUrl = Effect.fn( + 'PlanetScaleConnector.createAuthorizationUrl' +)(function* (state: string) { + const publicApiUrl = yield* PublicApiUrl; + const redirectUri = yield* providerRedirectUri('planetscale', publicApiUrl); + const { clientId } = yield* providerCredentials('planetscale'); + const url = new URL('https://auth.planetscale.com/oauth/authorize'); + + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('state', state); + + return url.toString(); +}); + +const completeAuthorization = Effect.fn( + 'PlanetScaleConnector.completeAuthorization' +)(function* ( + input: Parameters[0] +) { + const code = yield* requireOAuthCode('planetscale', input.callback); + const publicApiUrl = yield* PublicApiUrl; + const redirectUri = yield* providerRedirectUri('planetscale', publicApiUrl); + const { clientId, clientSecret } = + yield* providerCredentials('planetscale'); + const request = HttpClientRequest.post( + 'https://auth.planetscale.com/oauth/token' + ).pipe( + HttpClientRequest.bodyUrlParams({ + client_id: clientId, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }) + ); + const parsed = yield* readProviderSchema({ + connector: 'planetscale', + message: 'PlanetScale could not exchange the OAuth code.', + request, + schema: PlanetScaleTokenResponse, + }); + const info = yield* tokenInfo(parsed.access_token); + const subject = info?.sub ?? crypto.randomUUID(); + const scope = info?.scope ?? parsed.scope ?? null; + return { + account: { + id: subject, + name: `PlanetScale ${subject}`, + type: 'user', + }, + credentials: tokenCredentials({ + accessToken: parsed.access_token, + expiresAt: info?.exp + ? new Date(info.exp * 1000).toISOString() + : parsed.expires_in + ? expiresAtFromSeconds(parsed.expires_in) + : null, + refreshToken: parsed.refresh_token ?? null, + scope, + tokenType: parsed.token_type ?? 'Bearer', + }), + grantedPermissions: permissionsFromScope(scope), + providerInstallationId: `planetscale:${subject}`, + } satisfies CompletedConnectorInstallation; +}); + +const refreshCredentials = Effect.fn('PlanetScaleConnector.refreshCredentials')( + function* (credentials: ProviderCredentials) { + if (!credentials.refreshToken) return credentials; + const { clientId, clientSecret } = + yield* providerCredentials('planetscale'); + const request = HttpClientRequest.post( + 'https://auth.planetscale.com/oauth/token' + ).pipe( + HttpClientRequest.bodyUrlParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + refresh_token: credentials.refreshToken, + }) + ); + const parsed = yield* readProviderSchema({ + connector: 'planetscale', + message: 'PlanetScale could not refresh the OAuth token.', + request, + schema: PlanetScaleTokenResponse, + }); + return tokenCredentials({ + accessToken: parsed.access_token, + expiresAt: parsed.expires_in + ? expiresAtFromSeconds(parsed.expires_in) + : null, + refreshToken: parsed.refresh_token ?? credentials.refreshToken, + scope: parsed.scope ?? credentials.scope, + tokenType: parsed.token_type ?? credentials.tokenType, + }); + } +); + +const issueCredential = Effect.fn('PlanetScaleConnector.issueCredential')( + (input: Parameters[0]) => + issueOAuthCredential({ + connector: 'planetscale', + credentials: input.credentials, + refresh: (credentials) => + refreshCredentials(credentials) as Effect.Effect< + ProviderCredentials, + ConnectorError, + HttpClient.HttpClient + >, + }) +); + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const run = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.mapError(mapConnectorError) + ); + + return Service.of({ + completeAuthorization: (input) => + run(completeAuthorization(input)) as Effect.Effect< + CompletedConnectorInstallation, + ConnectorError + >, + connector: 'planetscale', + createAuthorizationUrl: (state) => + run(createAuthorizationUrl(state)) as Effect.Effect< + string, + ConnectorError + >, + issueCredential: (input) => + run(issueCredential(input)) as Effect.Effect< + ConnectorCredentialLease, + ConnectorError + >, + outputsForInstallation: () => CONNECTOR_DEFAULT_OUTPUTS.planetscale, + }); + }) +); + +export const defaultLayer = layer; + +// oxlint-disable-next-line import/no-self-import, oxc/no-barrel-file -- namespace projection for Effect service module +export * as PlanetScaleConnector from './planetscale'; \ No newline at end of file diff --git a/packages/console/api/src/connectors/registry.ts b/packages/console/api/src/connectors/registry.ts new file mode 100644 index 0000000..5609310 --- /dev/null +++ b/packages/console/api/src/connectors/registry.ts @@ -0,0 +1,61 @@ +import type { ManagedConnector } from '@hem/core/connector'; +import { Context, Effect, Layer } from 'effect'; +import { FetchHttpClient } from 'effect/unstable/http'; + +import { GithubManagedConnector } from './github'; +import { NotionConnector } from './notion'; +import { PlanetScaleConnector } from './planetscale'; +import { SlackConnector } from './slack'; +import type { ManagedConnectorService } from './types'; +import { VercelConnector } from './vercel'; + +export interface Interface { + readonly get: ( + connector: ManagedConnector + ) => Effect.Effect; +} + +export class Service extends Context.Service()( + '@hem/console-api/connectors/ConnectorRegistry' +) {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const github = yield* GithubManagedConnector.Service; + const notion = yield* NotionConnector.Service; + const planetscale = yield* PlanetScaleConnector.Service; + const slack = yield* SlackConnector.Service; + const vercel = yield* VercelConnector.Service; + const connectors = { + github, + notion, + planetscale, + slack, + vercel, + } as const satisfies Record; + + const get = Effect.fn('ConnectorRegistry.get')( + (connector: ManagedConnector) => + Effect.succeed(connectors[connector]) + ); + + return Service.of({ get }); + }) +); + +const ConnectorLayers = Layer.mergeAll( + GithubManagedConnector.defaultLayer, + NotionConnector.defaultLayer, + PlanetScaleConnector.defaultLayer, + SlackConnector.defaultLayer, + VercelConnector.defaultLayer +); + +export const defaultLayer = layer.pipe( + Layer.provide(ConnectorLayers), + Layer.provide(FetchHttpClient.layer) +); + +// oxlint-disable-next-line import/no-self-import, oxc/no-barrel-file -- namespace projection for Effect service module +export * as ConnectorRegistry from './registry'; diff --git a/packages/console/api/src/connectors/schemas.ts b/packages/console/api/src/connectors/schemas.ts new file mode 100644 index 0000000..4832891 --- /dev/null +++ b/packages/console/api/src/connectors/schemas.ts @@ -0,0 +1,58 @@ +import { Schema } from 'effect'; + +const SlackWorkspace = Schema.Struct({ + id: Schema.String, + name: Schema.String, +}); + +export class NotionTokenResponse extends Schema.Class( + '@hem/console-api/connectors/NotionTokenResponse' +)({ + access_token: Schema.String, + refresh_token: Schema.optional(Schema.String), + scope: Schema.optional(Schema.String), + token_type: Schema.optional(Schema.String), + workspace_id: Schema.String, + workspace_name: Schema.optional(Schema.String), +}) {} + +export class SlackOAuthResponse extends Schema.Class( + '@hem/console-api/connectors/SlackOAuthResponse' +)({ + access_token: Schema.String, + app_id: Schema.optional(Schema.String), + enterprise: Schema.optional(SlackWorkspace), + ok: Schema.Literal(true), + scope: Schema.optional(Schema.String), + team: Schema.optional(SlackWorkspace), + token_type: Schema.optional(Schema.String), +}) {} + +export class PlanetScaleTokenResponse extends Schema.Class( + '@hem/console-api/connectors/PlanetScaleTokenResponse' +)({ + access_token: Schema.String, + expires_in: Schema.optional(Schema.Number), + refresh_token: Schema.optional(Schema.String), + scope: Schema.optional(Schema.String), + token_type: Schema.optional(Schema.String), +}) {} + +export class PlanetScaleTokenInfoResponse extends Schema.Class( + '@hem/console-api/connectors/PlanetScaleTokenInfoResponse' +)({ + active: Schema.Literal(true), + exp: Schema.optional(Schema.Number), + scope: Schema.optional(Schema.String), + sub: Schema.optional(Schema.String), +}) {} + +export class VercelTokenResponse extends Schema.Class( + '@hem/console-api/connectors/VercelTokenResponse' +)({ + access_token: Schema.String, + scope: Schema.optional(Schema.String), + team_id: Schema.optional(Schema.String), + token_type: Schema.optional(Schema.String), + user_id: Schema.optional(Schema.String), +}) {} \ No newline at end of file diff --git a/packages/console/api/src/connectors/slack.ts b/packages/console/api/src/connectors/slack.ts new file mode 100644 index 0000000..0f13a5b --- /dev/null +++ b/packages/console/api/src/connectors/slack.ts @@ -0,0 +1,150 @@ +import { CONNECTOR_DEFAULT_OUTPUTS } from '@hem/core/connector'; +import { Config, Context, Effect, Layer } from 'effect'; +import { HttpClient, HttpClientRequest } from 'effect/unstable/http'; + +import { + issueOAuthCredential, + permissionsFromScope, + providerCredentials, + providerRedirectUri, + PublicApiUrl, + readProviderSchema, + tokenCredentials, +} from './oauth-client'; +import { SlackOAuthResponse } from './schemas'; +import { ConnectorError, requireOAuthCode } from './types'; +import type { + CompletedConnectorInstallation, + ConnectorCredentialLease, + ManagedConnectorService, +} from './types'; + +export type Interface = ManagedConnectorService; + +export class Service extends Context.Service()( + '@hem/console-api/connectors/SlackConnector' +) {} + +const mapConnectorError = (cause: unknown) => + cause instanceof ConnectorError + ? cause + : new ConnectorError({ + cause, + connector: 'slack', + message: 'Slack connector request failed.', + }); + +const createAuthorizationUrl = Effect.fn( + 'SlackConnector.createAuthorizationUrl' +)(function* (state: string) { + const publicApiUrl = yield* PublicApiUrl; + const redirectUri = yield* providerRedirectUri('slack', publicApiUrl); + const { clientId } = yield* providerCredentials('slack'); + const scopes = yield* Config.string('SLACK_BOT_SCOPES').pipe( + Config.withDefault('chat:write') + ); + const url = new URL('https://slack.com/oauth/v2/authorize'); + + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('scope', scopes); + url.searchParams.set('state', state); + + return url.toString(); +}); + +const completeAuthorization = Effect.fn('SlackConnector.completeAuthorization')( + function* ( + input: Parameters[0] + ) { + const code = yield* requireOAuthCode('slack', input.callback); + const publicApiUrl = yield* PublicApiUrl; + const redirectUri = yield* providerRedirectUri('slack', publicApiUrl); + const { clientId, clientSecret } = yield* providerCredentials('slack'); + const request = HttpClientRequest.post( + 'https://slack.com/api/oauth.v2.access' + ).pipe( + HttpClientRequest.bodyUrlParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }) + ); + const response = yield* readProviderSchema({ + connector: 'slack', + message: 'Slack could not exchange the OAuth code.', + request, + schema: SlackOAuthResponse, + }); + const teamId = + response.team?.id ?? + response.enterprise?.id ?? + response.app_id ?? + 'slack'; + const teamName = + response.team?.name ?? + response.enterprise?.name ?? + 'Slack workspace'; + const scope = response.scope ?? null; + return { + account: { + id: teamId, + name: teamName, + type: response.team ? 'workspace' : 'enterprise', + }, + credentials: tokenCredentials({ + accessToken: response.access_token, + scope, + tokenType: response.token_type ?? 'bot', + }), + grantedPermissions: permissionsFromScope(scope), + providerInstallationId: `slack:${teamId}`, + } satisfies CompletedConnectorInstallation; + } +); + +const issueCredential = Effect.fn('SlackConnector.issueCredential')( + (input: Parameters[0]) => + issueOAuthCredential({ + connector: 'slack', + credentials: input.credentials, + }) +); + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const run = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.mapError(mapConnectorError) + ); + + return Service.of({ + completeAuthorization: (input) => + run(completeAuthorization(input)) as Effect.Effect< + CompletedConnectorInstallation, + ConnectorError + >, + connector: 'slack', + createAuthorizationUrl: (state) => + run(createAuthorizationUrl(state)) as Effect.Effect< + string, + ConnectorError + >, + issueCredential: (input) => + run(issueCredential(input)) as Effect.Effect< + ConnectorCredentialLease, + ConnectorError + >, + outputsForInstallation: () => CONNECTOR_DEFAULT_OUTPUTS.slack, + }); + }) +); + +export const defaultLayer = layer; + +// oxlint-disable-next-line import/no-self-import, oxc/no-barrel-file -- namespace projection for Effect service module +export * as SlackConnector from './slack'; \ No newline at end of file diff --git a/packages/console/api/src/connectors/types.ts b/packages/console/api/src/connectors/types.ts new file mode 100644 index 0000000..fb466b1 --- /dev/null +++ b/packages/console/api/src/connectors/types.ts @@ -0,0 +1,96 @@ +import type { ProviderCredentials } from '@hem/console-core/database/schema/installation'; +import { ManagedConnector as ManagedConnectorSchema } from '@hem/core/connector'; +import type { ManagedConnector } from '@hem/core/connector'; +import { Effect, Schema } from 'effect'; + +export type ConnectorOutputs = readonly [string, ...string[]]; + +export interface ConnectorAccount { + readonly id: string; + readonly name: string; + readonly type: string; +} + +export type ConnectorAuthorizationCallback = + | { + readonly _tag: 'github'; + readonly providerInstallationId: string; + } + | { + readonly _tag: 'oauth'; + readonly code: string; + }; + +export interface CompletedConnectorInstallation { + readonly account: ConnectorAccount; + readonly credentials: ProviderCredentials | null; + readonly grantedPermissions: Readonly>; + readonly providerInstallationId: string; +} + +export interface IssueConnectorCredentialInput { + readonly credentials: ProviderCredentials | null; + readonly grantedPermissions: Readonly>; + readonly providerInstallationId: string; +} + +export interface ConnectorCredentialLease { + readonly credentials?: ProviderCredentials; + readonly expiresAt: string; + readonly grantedPermissions?: Readonly>; + readonly values: Readonly>; +} + +export class ConnectorError extends Schema.TaggedErrorClass()( + 'ConnectorError', + { + cause: Schema.optional(Schema.Defect), + connector: ManagedConnectorSchema, + message: Schema.String, + } +) {} + +export interface ManagedConnectorService { + readonly completeAuthorization: (input: { + readonly callback: ConnectorAuthorizationCallback; + }) => Effect.Effect; + readonly connector: ManagedConnector; + readonly createAuthorizationUrl: ( + state: string + ) => Effect.Effect; + readonly issueCredential: ( + input: IssueConnectorCredentialInput + ) => Effect.Effect; + readonly outputsForInstallation: ( + account: ConnectorAccount + ) => ConnectorOutputs; +} + +export const requireOAuthCode = ( + connector: ManagedConnector, + callback: ConnectorAuthorizationCallback +) => { + if (callback._tag === 'oauth') return Effect.succeed(callback.code); + + return Effect.fail( + new ConnectorError({ + connector, + message: `${connector} requires an OAuth code callback.`, + }) + ); +}; + +export const requireGithubInstallationId = ( + connector: ManagedConnector, + callback: ConnectorAuthorizationCallback +) => { + if (callback._tag === 'github') + return Effect.succeed(callback.providerInstallationId); + + return Effect.fail( + new ConnectorError({ + connector, + message: `${connector} requires a GitHub installation callback.`, + }) + ); +}; diff --git a/packages/console/api/src/connectors/vercel.ts b/packages/console/api/src/connectors/vercel.ts new file mode 100644 index 0000000..c7bc404 --- /dev/null +++ b/packages/console/api/src/connectors/vercel.ts @@ -0,0 +1,184 @@ +import { CONNECTOR_DEFAULT_OUTPUTS } from '@hem/core/connector'; +import { Config, Context, Effect, Layer, Option } from 'effect'; +import { HttpClient, HttpClientRequest } from 'effect/unstable/http'; + +import { + issueOAuthCredential, + optionalString, + permissionsFromScope, + providerCredentials, + providerRedirectUri, + PublicApiUrl, + readProviderSchema, + tokenCredentials, +} from './oauth-client'; +import { VercelTokenResponse } from './schemas'; +import { ConnectorError, requireOAuthCode } from './types'; +import type { + CompletedConnectorInstallation, + ConnectorCredentialLease, + ManagedConnectorService, +} from './types'; + +export type Interface = ManagedConnectorService; + +export class Service extends Context.Service()( + '@hem/console-api/connectors/VercelConnector' +) {} + +const mapConnectorError = (cause: unknown) => + cause instanceof ConnectorError + ? cause + : new ConnectorError({ + cause, + connector: 'vercel', + message: 'Vercel connector request failed.', + }); + +const createVercelInstallUrl = Effect.fn('VercelConnector.createInstallUrl')( + function* (state: string, redirectUri: string) { + const explicitTemplate = yield* optionalString('VERCEL_INSTALL_URL'); + const template = Option.isSome(explicitTemplate) + ? explicitTemplate.value + : `https://vercel.com/integrations/${yield* Config.string( + 'VERCEL_INTEGRATION_SLUG' + ).pipe( + Effect.mapError( + (cause) => + new ConnectorError({ + cause, + connector: 'vercel', + message: + 'Vercel install URL is not configured. Set VERCEL_INSTALL_URL or VERCEL_INTEGRATION_SLUG.', + }) + ) + )}`; + + return yield* Effect.try({ + catch: (cause) => + new ConnectorError({ + cause, + connector: 'vercel', + message: 'Vercel install URL is invalid.', + }), + try: () => { + const rendered = template + .replaceAll('{state}', encodeURIComponent(state)) + .replaceAll( + '{redirect_uri}', + encodeURIComponent(redirectUri) + ); + const url = new URL(rendered); + if (!template.includes('{state}')) + url.searchParams.set('state', state); + return url.toString(); + }, + }); + } +); + +const createAuthorizationUrl = Effect.fn( + 'VercelConnector.createAuthorizationUrl' +)(function* (state: string) { + const publicApiUrl = yield* PublicApiUrl; + const redirectUri = yield* providerRedirectUri('vercel', publicApiUrl); + return yield* createVercelInstallUrl(state, redirectUri); +}); + +const completeAuthorization = Effect.fn( + 'VercelConnector.completeAuthorization' +)(function* ( + input: Parameters[0] +) { + const code = yield* requireOAuthCode('vercel', input.callback); + const publicApiUrl = yield* PublicApiUrl; + const redirectUri = yield* providerRedirectUri('vercel', publicApiUrl); + const { clientId, clientSecret } = yield* providerCredentials('vercel'); + const request = HttpClientRequest.post( + 'https://api.vercel.com/v2/oauth/access_token' + ).pipe( + HttpClientRequest.bodyUrlParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }) + ); + const response = yield* readProviderSchema({ + connector: 'vercel', + message: 'Vercel could not exchange the OAuth code.', + request, + schema: VercelTokenResponse, + }); + const teamId = response.team_id ?? null; + const userId = response.user_id ?? null; + const ownerId = teamId ?? userId ?? crypto.randomUUID(); + const scope = response.scope ?? null; + return { + account: { + id: ownerId, + name: teamId ? `Vercel team ${teamId}` : 'Vercel account', + type: teamId ? 'team' : 'user', + }, + credentials: tokenCredentials({ + accessToken: response.access_token, + scope, + teamId, + tokenType: response.token_type ?? 'Bearer', + }), + grantedPermissions: permissionsFromScope(scope), + providerInstallationId: `vercel:${ownerId}`, + } satisfies CompletedConnectorInstallation; +}); + +const issueCredential = Effect.fn('VercelConnector.issueCredential')( + (input: Parameters[0]) => + issueOAuthCredential({ + connector: 'vercel', + credentials: input.credentials, + extraValues: (credentials) => + credentials.teamId + ? { VERCEL_TEAM_ID: credentials.teamId } + : ({} as Readonly>), + }) +); + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const run = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.mapError(mapConnectorError) + ); + + return Service.of({ + completeAuthorization: (input) => + run(completeAuthorization(input)) as Effect.Effect< + CompletedConnectorInstallation, + ConnectorError + >, + connector: 'vercel', + createAuthorizationUrl: (state) => + run(createAuthorizationUrl(state)) as Effect.Effect< + string, + ConnectorError + >, + issueCredential: (input) => + run(issueCredential(input)) as Effect.Effect< + ConnectorCredentialLease, + ConnectorError + >, + outputsForInstallation: (account) => + account.type === 'team' + ? ['VERCEL_TOKEN', 'VERCEL_TEAM_ID'] + : CONNECTOR_DEFAULT_OUTPUTS.vercel, + }); + }) +); + +export const defaultLayer = layer; + +// oxlint-disable-next-line import/no-self-import, oxc/no-barrel-file -- namespace projection for Effect service module +export * as VercelConnector from './vercel'; \ No newline at end of file diff --git a/packages/console/api/src/device-page.ts b/packages/console/api/src/device-page.ts index f7727d9..4cdceb1 100644 --- a/packages/console/api/src/device-page.ts +++ b/packages/console/api/src/device-page.ts @@ -100,4 +100,4 @@ export const deviceAuthorizationPage = ` } -`; \ No newline at end of file +`; diff --git a/packages/console/api/src/effect/app-runtime.ts b/packages/console/api/src/effect/app-runtime.ts index 59cfa31..55eb178 100644 --- a/packages/console/api/src/effect/app-runtime.ts +++ b/packages/console/api/src/effect/app-runtime.ts @@ -1,16 +1,16 @@ -import { GithubConnector } from '../github'; import { Database } from '@hem/console-core/database/database'; -import { Effect, Layer, ManagedRuntime } from 'effect'; +import { Layer, ManagedRuntime } from 'effect'; import { HttpApiBuilder } from 'effect/unstable/httpapi'; import { HemApi } from '../api'; import { HemAuth } from '../auth'; +import { ConnectorRegistry } from '../connectors/registry'; import { AuthorizationLive } from '../middleware/auth'; import { HandlersLive } from '../routes'; export const ServicesLayer = Layer.mergeAll( Database.defaultLayer, - GithubConnector.defaultLayer, + ConnectorRegistry.defaultLayer, HemAuth.defaultLayer ); @@ -31,7 +31,9 @@ type Runtime = Pick< 'runPromise' | 'runPromiseExit' | 'runFork' | 'dispose' >; -export type AppServices = ManagedRuntime.ManagedRuntime.Services; +export type AppServices = ManagedRuntime.ManagedRuntime.Services< + typeof runtime +>; export const makeRuntime = (layer: Layer.Layer) => { const rt = ManagedRuntime.make(layer); @@ -49,4 +51,4 @@ export const AppRuntime: Runtime = { runPromise: (effect, options) => runtime.runPromise(effect, options), runPromiseExit: (effect, options) => runtime.runPromiseExit(effect, options), -}; \ No newline at end of file +}; diff --git a/packages/console/api/src/errors.ts b/packages/console/api/src/errors.ts index 140f0f6..263db6d 100644 --- a/packages/console/api/src/errors.ts +++ b/packages/console/api/src/errors.ts @@ -58,4 +58,4 @@ export class DeviceAuthorizationSlowDown extends Schema.TaggedErrorClass + new Installation({ + account: new ProviderAccount(row.account), + connector: row.connector, + id: InstallationId.make(row.id), + providerInstallationId: row.providerInstallationId, + }); + +export const startConnectorInstallation = ( + ownerId: string, + connectorName: ManagedConnector +) => + Effect.gen(function* () { + const registry = yield* ConnectorRegistry.Service; + const connector = yield* registry.get(connectorName); + const state = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + INSTALLATION_TTL_MS); + yield* InstallationRequest.create({ expiresAt, ownerId, state }).pipe( + Effect.orDie + ); + const authorizationUrl = yield* connector + .createAuthorizationUrl(state) + .pipe( + Effect.mapError( + (error) => + new ProviderUnavailable({ + message: error.message, + }) + ) + ); + return new ConnectorInstallationAuthorization({ + authorizationUrl, + expiresAt: expiresAt.toISOString(), + requestId: state, + }); + }); + +export const callbackFromQuery = ( + connectorName: ManagedConnector, + query: { + readonly code?: string; + readonly installation_id?: string; + } +): Effect.Effect => { + if (isOAuthConnector(connectorName)) { + if (!query.code) { + return Effect.fail( + new InvalidInstallationState({ + message: `${connectorName} callback is missing OAuth code.`, + }) + ); + } + return Effect.succeed({ _tag: 'oauth', code: query.code }); + } + if (!query.installation_id) { + return Effect.fail( + new InvalidInstallationState({ + message: 'GitHub callback is missing installation_id.', + }) + ); + } + return Effect.succeed({ + _tag: 'github', + providerInstallationId: query.installation_id, + }); +}; + +export const completeConnectorInstallation = ( + connectorName: ManagedConnector, + input: { + readonly callback: ConnectorAuthorizationCallback; + readonly state: string; + } +) => + Effect.gen(function* () { + const ownerId = yield* InstallationRequest.owner(input.state).pipe( + Effect.orDie + ); + if (!ownerId) { + return yield* new InvalidInstallationState({ + message: 'Installation state is invalid or expired.', + }); + } + const registry = yield* ConnectorRegistry.Service; + const connector = yield* registry.get(connectorName); + const completed = yield* connector + .completeAuthorization({ callback: input.callback }) + .pipe( + Effect.mapError( + (error) => + new ProviderUnavailable({ + message: error.message, + }) + ) + ); + const existing = yield* InstallationCore.fromProviderId( + completed.providerInstallationId + ).pipe(Effect.orDie); + if (existing && existing.ownerId !== ownerId) { + return yield* new InvalidInstallationState({ + message: `${connector.connector} installation belongs to another Hem user.`, + }); + } + const installation = yield* InstallationCore.save({ + account: completed.account, + connector: connector.connector, + credentials: completed.credentials, + grantedPermissions: completed.grantedPermissions, + id: existing?.id, + ownerId, + providerInstallationId: completed.providerInstallationId, + }).pipe(Effect.orDie); + if (!installation) { + return yield* Effect.die( + new Error( + `${connector.connector} installation insert returned no row.` + ) + ); + } + yield* InstallationRequest.complete({ + installationId: installation.id, + state: input.state, + }).pipe(Effect.orDie); + return toInstallation(installation); + }); + +export const getConnectorInstallationStatus = ( + ownerId: string, + requestId: string +) => + Effect.gen(function* () { + const status = yield* InstallationRequest.poll({ + ownerId, + state: requestId, + }).pipe(Effect.orDie); + if (status._tag === 'Invalid') { + return yield* new InvalidAuthorization({ + message: 'Installation request is invalid or expired.', + }); + } + if (status._tag === 'Pending') { + return yield* new AuthorizationPending({ + message: 'Provider installation is not complete yet.', + }); + } + const installation = yield* InstallationCore.fromId( + status.installationId + ).pipe(Effect.orDie); + if (!installation) { + return yield* new NotFound({ + message: 'Installation was not found.', + }); + } + return toInstallation(installation); + }); \ No newline at end of file diff --git a/packages/console/api/src/middleware/auth.ts b/packages/console/api/src/middleware/auth.ts index 06b4cab..8b3efd6 100644 --- a/packages/console/api/src/middleware/auth.ts +++ b/packages/console/api/src/middleware/auth.ts @@ -1,9 +1,6 @@ import { Context, Effect, Layer } from 'effect'; import { HttpServerRequest } from 'effect/unstable/http'; -import { - HttpApiMiddleware, - HttpApiSecurity, -} from 'effect/unstable/httpapi'; +import { HttpApiMiddleware, HttpApiSecurity } from 'effect/unstable/httpapi'; import { HemAuth } from '../auth'; import { Unauthorized } from '../errors'; @@ -54,4 +51,4 @@ export const AuthorizationLive = Layer.effect( }), }); }) -); \ No newline at end of file +); diff --git a/packages/console/api/src/routes/auth-forward.ts b/packages/console/api/src/routes/auth-forward.ts index 6b90fe7..7d12d57 100644 --- a/packages/console/api/src/routes/auth-forward.ts +++ b/packages/console/api/src/routes/auth-forward.ts @@ -1,5 +1,5 @@ import { Config, Effect, Schema } from 'effect'; -import { HttpServerRequest } from 'effect/unstable/http'; +import { HttpServerRequest, HttpServerResponse } from 'effect/unstable/http'; import { HemAuth } from '../auth'; import { @@ -13,15 +13,21 @@ const AuthErrorBody = Schema.Struct({ error_description: Schema.optional(Schema.String), }); +interface AuthForwardInput { + readonly body?: unknown; + readonly method: 'GET' | 'POST'; + readonly path: string; + readonly searchParams?: Readonly>; +} + const authErrorMessage = (body: unknown, status: number) => { if ( typeof body === 'object' && body !== null && 'message' in body && typeof body.message === 'string' - ) { + ) return body.message; - } const decoded = Schema.decodeUnknownOption(AuthErrorBody)(body); if (decoded._tag === 'Some') { @@ -56,41 +62,46 @@ const apiUrl = Config.string('HEM_API_URL').pipe( ) ); -export const forwardAuth = (input: { - readonly body?: unknown; - readonly method: 'GET' | 'POST'; - readonly path: string; - readonly searchParams?: Readonly>; -}) => +const forwardAuthWeb = (input: AuthForwardInput) => Effect.gen(function* () { const auth = yield* HemAuth.Service; const baseUrl = yield* apiUrl; const serverRequest = yield* HttpServerRequest.HttpServerRequest; const url = new URL(`${baseUrl}${input.path}`); if (input.searchParams) { - for (const [key, value] of Object.entries(input.searchParams)) { + for (const [key, value] of Object.entries(input.searchParams)) url.searchParams.set(key, value); - } } const headers = new Headers(serverRequest.headers); - if (input.body !== undefined && !headers.has('content-type')) { + if (input.body !== undefined && !headers.has('content-type')) headers.set('content-type', 'application/json'); - } const request = new Request(url.href, { body: - input.body === undefined ? undefined : JSON.stringify(input.body), + input.body === undefined + ? undefined + : JSON.stringify(input.body), headers, method: input.method, }); - const response = yield* Effect.promise(() => auth.handler(request)); + return yield* Effect.promise(() => auth.handler(request)); + }); + +export const forwardAuth = (input: AuthForwardInput) => + Effect.gen(function* () { + const response = yield* forwardAuthWeb(input); const body = yield* readJsonBody(response).pipe( - Effect.catchTag('AuthRequestError', () => Effect.succeed(undefined)) + Effect.catchTag('AuthRequestError', () => Effect.void) ); return { body, response } as const; }); +export const forwardAuthResponse = (input: AuthForwardInput) => + Effect.map(forwardAuthWeb(input), (response) => + HttpServerResponse.fromWeb(response) + ); + export const decodeAuthSuccess = ( schema: Schema.Schema, response: Response, @@ -144,4 +155,4 @@ export const mapDeviceTokenResponse = ( } return decodeAuthSuccess(schema, response, body); -}; \ No newline at end of file +}; diff --git a/packages/console/api/src/routes/auth.ts b/packages/console/api/src/routes/auth.ts index c1f4de6..7fe2f1d 100644 --- a/packages/console/api/src/routes/auth.ts +++ b/packages/console/api/src/routes/auth.ts @@ -12,7 +12,6 @@ import type { import { AuthSession, AuthSuccess, - AuthUser, DeviceAccessToken, DeviceAuthorization, DeviceClaim, @@ -20,11 +19,10 @@ import { import { decodeAuthSuccess, forwardAuth, + forwardAuthResponse, mapDeviceTokenResponse, } from './auth-forward'; -const AuthUserResponse = Schema.Struct({ user: AuthUser }); - export const startDeviceAuthorization = ( payload: StartDeviceAuthorizationRequest ) => @@ -44,11 +42,7 @@ export const exchangeDeviceToken = (payload: ExchangeDeviceTokenRequest) => method: 'POST', path: '/v1/auth/device/token', }); - return yield* mapDeviceTokenResponse( - DeviceAccessToken, - response, - body - ); + return yield* mapDeviceTokenResponse(DeviceAccessToken, response, body); }); export const getDeviceClaim = (userCode: string) => @@ -72,23 +66,17 @@ export const approveDevice = (payload: ApproveDeviceRequest) => }); export const signInEmail = (payload: EmailSignInRequest) => - Effect.gen(function* () { - const { body, response } = yield* forwardAuth({ - body: payload, - method: 'POST', - path: '/v1/auth/sign-in/email', - }); - return yield* decodeAuthSuccess(AuthUserResponse, response, body); + forwardAuthResponse({ + body: payload, + method: 'POST', + path: '/v1/auth/sign-in/email', }); export const signUpEmail = (payload: EmailSignUpRequest) => - Effect.gen(function* () { - const { body, response } = yield* forwardAuth({ - body: payload, - method: 'POST', - path: '/v1/auth/sign-up/email', - }); - return yield* decodeAuthSuccess(AuthUserResponse, response, body); + forwardAuthResponse({ + body: payload, + method: 'POST', + path: '/v1/auth/sign-up/email', }); export const getSession = () => @@ -106,12 +94,9 @@ export const getSession = () => }); export const signOut = () => - Effect.gen(function* () { - const { body, response } = yield* forwardAuth({ - method: 'POST', - path: '/v1/auth/sign-out', - }); - return yield* decodeAuthSuccess(AuthSuccess, response, body); + forwardAuthResponse({ + method: 'POST', + path: '/v1/auth/sign-out', }); export const AuthLive = HttpApiBuilder.group(HemApi, 'auth', (handlers) => @@ -130,4 +115,4 @@ export const AuthLive = HttpApiBuilder.group(HemApi, 'auth', (handlers) => .handle('signUpEmail', ({ payload }) => signUpEmail(payload)) .handle('getSession', () => getSession()) .handle('signOut', () => signOut()) -); \ No newline at end of file +); diff --git a/packages/console/api/src/routes/binding.ts b/packages/console/api/src/routes/binding.ts index 74feb42..60f39b5 100644 --- a/packages/console/api/src/routes/binding.ts +++ b/packages/console/api/src/routes/binding.ts @@ -4,6 +4,7 @@ import { Effect } from 'effect'; import { HttpApiBuilder } from 'effect/unstable/httpapi'; import { HemApi } from '../api'; +import { ConnectorRegistry } from '../connectors/registry'; import { Forbidden, NotFound } from '../errors'; import { CurrentUser } from '../middleware/auth'; import { Binding, BindingId, InstallationId } from '../schema'; @@ -24,12 +25,18 @@ export const createBinding = (ownerId: string, request: CreateBindingRequest) => message: 'Installation belongs to another user.', }); } + const registry = yield* ConnectorRegistry.Service; + const connector = yield* registry + .get(installation.connector) + .pipe(Effect.orDie); const binding = yield* BindingCore.create({ installationId: installation.id, }).pipe(Effect.orDie); return new Binding({ + connector: installation.connector, id: BindingId.make(binding.id), installationId: InstallationId.make(binding.installationId), + outputs: connector.outputsForInstallation(installation.account), }); }); diff --git a/packages/console/api/src/routes/credential-lease.ts b/packages/console/api/src/routes/credential-lease.ts index aee1dbe..5cdca26 100644 --- a/packages/console/api/src/routes/credential-lease.ts +++ b/packages/console/api/src/routes/credential-lease.ts @@ -1,10 +1,10 @@ -import { GithubConnector } from '../github'; import { Binding as BindingCore } from '@hem/console-core/binding'; import { Installation as InstallationCore } from '@hem/console-core/installation'; import { Effect } from 'effect'; import { HttpApiBuilder } from 'effect/unstable/httpapi'; import { HemApi } from '../api'; +import { ConnectorRegistry } from '../connectors/registry'; import { Forbidden, NotFound, ProviderUnavailable } from '../errors'; import { CurrentUser } from '../middleware/auth'; import { CredentialLease } from '../schema'; @@ -18,9 +18,11 @@ export const createCredentialLease = ( const binding = yield* BindingCore.fromId(request.bindingId).pipe( Effect.orDie ); - if (!binding) - return yield* new NotFound({ message: 'Binding was not found.' }); - + if (!binding) { + return yield* new NotFound({ + message: 'Binding was not found.', + }); + } const installation = yield* InstallationCore.fromId( binding.installationId ).pipe(Effect.orDie); @@ -34,23 +36,34 @@ export const createCredentialLease = ( message: 'Binding belongs to another user.', }); } - const github = yield* GithubConnector.Service; - const credential = yield* github + const registry = yield* ConnectorRegistry.Service; + const connector = yield* registry.get(installation.connector); + const credential = yield* connector .issueCredential({ + credentials: installation.credentials ?? null, + grantedPermissions: installation.grantedPermissions, providerInstallationId: installation.providerInstallationId, }) .pipe( Effect.mapError( - () => + (error) => new ProviderUnavailable({ - message: - 'GitHub could not issue an installation token.', + message: error.message, }) ) ); + if (credential.credentials) { + yield* InstallationCore.updateCredentials({ + credentials: credential.credentials, + grantedPermissions: + credential.grantedPermissions ?? + installation.grantedPermissions, + id: installation.id, + }).pipe(Effect.orDie); + } return new CredentialLease({ expiresAt: credential.expiresAt, - values: { GITHUB_TOKEN: credential.token }, + values: credential.values, }); }); diff --git a/packages/console/api/src/routes/installation.ts b/packages/console/api/src/routes/installation.ts index cd1e08d..0d707e3 100644 --- a/packages/console/api/src/routes/installation.ts +++ b/packages/console/api/src/routes/installation.ts @@ -1,153 +1,45 @@ -import { GithubConnector } from '../github'; -import { - Installation as InstallationCore, - InstallationRequest, -} from '@hem/console-core/installation'; -import type { InstallationRow } from '@hem/console-core/installation'; import { Effect } from 'effect'; import { HttpApiBuilder } from 'effect/unstable/httpapi'; import { HemApi } from '../api'; -import { CurrentUser } from '../middleware/auth'; import { - AuthorizationPending, - InvalidAuthorization, - InvalidInstallationState, - NotFound, - ProviderUnavailable, -} from '../errors'; -import { - GithubAccount, - GithubInstallationAuthorization, - Installation, - InstallationId, -} from '../schema'; - -const INSTALLATION_TTL_MS = 10 * 60 * 1000; - -const toInstallation = (row: InstallationRow) => - new Installation({ - account: new GithubAccount(row.account), - connector: 'github', - id: InstallationId.make(row.id), - providerInstallationId: row.providerInstallationId, - }); - -export const startGithubInstallation = (ownerId: string) => - Effect.gen(function* () { - const github = yield* GithubConnector.Service; - const state = crypto.randomUUID(); - const expiresAt = new Date(Date.now() + INSTALLATION_TTL_MS); - yield* InstallationRequest.create({ expiresAt, ownerId, state }).pipe( - Effect.orDie - ); - return new GithubInstallationAuthorization({ - authorizationUrl: github.createInstallationUrl(state), - expiresAt: expiresAt.toISOString(), - requestId: state, - }); - }); - -export const completeGithubInstallation = ( - providerInstallationId: string, - state: string -) => - Effect.gen(function* () { - const ownerId = yield* InstallationRequest.owner(state).pipe( - Effect.orDie - ); - if (!ownerId) { - return yield* new InvalidInstallationState({ - message: 'Installation state is invalid or expired.', - }); - } - const github = yield* GithubConnector.Service; - const completed = yield* github - .completeInstallation(providerInstallationId) - .pipe( - Effect.mapError( - () => - new ProviderUnavailable({ - message: - 'GitHub could not complete the installation.', - }) - ) - ); - const existing = yield* InstallationCore.fromProviderId( - completed.providerInstallationId - ).pipe(Effect.orDie); - if (existing && existing.ownerId !== ownerId) { - return yield* new InvalidInstallationState({ - message: 'GitHub installation belongs to another Hem user.', - }); - } - const installation = yield* InstallationCore.save({ - account: completed.account, - grantedPermissions: completed.grantedPermissions, - id: existing?.id, - ownerId, - providerInstallationId: completed.providerInstallationId, - }).pipe(Effect.orDie); - if (!installation) { - return yield* Effect.die( - new Error('GitHub installation insert returned no row.') - ); - } - yield* InstallationRequest.complete({ - installationId: installation.id, - state, - }).pipe(Effect.orDie); - return toInstallation(installation); - }); - -export const getGithubInstallationStatus = ( - ownerId: string, - requestId: string -) => - Effect.gen(function* () { - const status = yield* InstallationRequest.poll({ - ownerId, - state: requestId, - }).pipe(Effect.orDie); - if (status._tag === 'Invalid') { - return yield* new InvalidAuthorization({ - message: 'Installation request is invalid or expired.', - }); - } - if (status._tag === 'Pending') { - return yield* new AuthorizationPending({ - message: 'GitHub installation is not complete yet.', - }); - } - const installation = yield* InstallationCore.fromId( - status.installationId - ).pipe(Effect.orDie); - if (!installation) { - return yield* new NotFound({ - message: 'Installation was not found.', - }); - } - return toInstallation(installation); - }); + callbackFromQuery, + completeConnectorInstallation, + getConnectorInstallationStatus, + startConnectorInstallation, +} from '../installation/flow'; +import { CurrentUser } from '../middleware/auth'; export const InstallationLive = HttpApiBuilder.group( HemApi, 'installations', (handlers) => handlers - .handle('startGithubInstallation', () => + .handle('startConnectorInstallation', ({ params }) => Effect.gen(function* () { const user = yield* CurrentUser; - return yield* startGithubInstallation(user.id); + return yield* startConnectorInstallation( + user.id, + params.connector + ); }) ) - .handle('completeGithubInstallation', ({ query }) => - completeGithubInstallation(query.installation_id, query.state) + .handle('completeConnectorInstallation', ({ params, query }) => + Effect.gen(function* () { + const callback = yield* callbackFromQuery( + params.connector, + query + ); + return yield* completeConnectorInstallation( + params.connector, + { callback, state: query.state } + ); + }) ) - .handle('getGithubInstallationStatus', ({ query }) => + .handle('getConnectorInstallationStatus', ({ params, query }) => Effect.gen(function* () { const user = yield* CurrentUser; - return yield* getGithubInstallationStatus( + return yield* getConnectorInstallationStatus( user.id, query.request_id ); diff --git a/packages/console/api/src/schema.ts b/packages/console/api/src/schema.ts index be77941..c0cce51 100644 --- a/packages/console/api/src/schema.ts +++ b/packages/console/api/src/schema.ts @@ -1,3 +1,7 @@ +import { + ManagedConnector as ManagedConnectorSchema, + OAuthConnector as OAuthConnectorSchema, +} from '@hem/core/connector'; import { Schema } from 'effect'; export const HemUserId = Schema.String.pipe(Schema.brand('HemUserId')); @@ -11,26 +15,34 @@ export type InstallationId = typeof InstallationId.Type; export const BindingId = Schema.String.pipe(Schema.brand('BindingId')); export type BindingId = typeof BindingId.Type; -export class GithubAccount extends Schema.Class( - '@hem/console-api/GithubAccount' +export const Connector = ManagedConnectorSchema; +export type Connector = typeof Connector.Type; + +export const OAuthConnector = OAuthConnectorSchema; +export type OAuthConnector = typeof OAuthConnector.Type; + +export class ProviderAccount extends Schema.Class( + '@hem/console-api/ProviderAccount' )({ id: Schema.String, name: Schema.String, - type: Schema.Literals(['user', 'organization']), + type: Schema.String, }) {} export class Installation extends Schema.Class( '@hem/console-api/Installation' )({ - account: GithubAccount, - connector: Schema.Literal('github'), + account: ProviderAccount, + connector: Connector, id: InstallationId, providerInstallationId: Schema.String, }) {} export class Binding extends Schema.Class('@hem/console-api/Binding')({ + connector: Connector, id: BindingId, installationId: InstallationId, + outputs: Schema.NonEmptyArray(Schema.String), }) {} export class CredentialLease extends Schema.Class( @@ -40,8 +52,8 @@ export class CredentialLease extends Schema.Class( values: Schema.Record(Schema.String, Schema.String), }) {} -export class GithubInstallationAuthorization extends Schema.Class( - '@hem/console-api/GithubInstallationAuthorization' +export class ConnectorInstallationAuthorization extends Schema.Class( + '@hem/console-api/ConnectorInstallationAuthorization' )({ authorizationUrl: Schema.String, expiresAt: Schema.String, diff --git a/packages/console/api/src/server.ts b/packages/console/api/src/server.ts index 48a2f9d..970b92e 100644 --- a/packages/console/api/src/server.ts +++ b/packages/console/api/src/server.ts @@ -31,4 +31,4 @@ BunRuntime.runMain( Layer.launch( ServerLive.pipe(Layer.provide(ServicesLayer)) ) as unknown as Effect.Effect -); \ No newline at end of file +); diff --git a/packages/console/api/test/auth.test.ts b/packages/console/api/test/auth.test.ts index 21ff91d..2c40e03 100644 --- a/packages/console/api/test/auth.test.ts +++ b/packages/console/api/test/auth.test.ts @@ -1,15 +1,21 @@ import { Database } from 'bun:sqlite'; import { afterAll, expect, test } from 'bun:test'; +import { Database as HemDatabase } from '@hem/console-core/database/database'; import * as authSchema from '@hem/console-core/database/schema/auth'; import * as bindingSchema from '@hem/console-core/database/schema/binding'; import * as installationSchema from '@hem/console-core/database/schema/installation'; -import { drizzle } from 'drizzle-orm/bun-sqlite'; -import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; - import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { bearer, deviceAuthorization } from 'better-auth/plugins'; +import { drizzle } from 'drizzle-orm/bun-sqlite'; +import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; +import { ConfigProvider, Effect, Layer } from 'effect'; +import { HttpRouter } from 'effect/unstable/http'; + +import { HemAuth } from '../src/auth'; +import { ConnectorRegistry } from '../src/connectors/registry'; +import { ApiLayer } from '../src/effect/app-runtime'; const databasePath = `${import.meta.dir}/auth.test.db`; const publicUrl = 'http://127.0.0.1:3000'; @@ -41,6 +47,19 @@ const testAuth = betterAuth({ secret: 'this-is-a-test-secret-with-at-least-32-characters', }); +const ConnectorRegistryTest = Layer.succeed( + ConnectorRegistry.Service, + ConnectorRegistry.Service.of({ + get: () => Effect.die('unused in auth tests'), + }) +); + +const cookieHeader = (response: Response) => + response.headers + .getSetCookie() + .map((cookie) => cookie.split(';', 1)[0]) + .join('; '); + test('creates a Hem account with email and password', async () => { const response = await testAuth.handler( new Request('http://127.0.0.1:3000/v1/auth/sign-up/email', { @@ -117,3 +136,114 @@ test('issues and polls a Better Auth device authorization', async () => { error: 'authorization_pending', }); }); + +test('preserves browser session cookies when approving a device request', async () => { + const apiDatabasePath = `${import.meta.dir}/auth-api.${crypto.randomUUID()}.db`; + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + BETTER_AUTH_SECRET: + 'this-is-a-test-secret-with-at-least-32-characters', + HEM_API_URL: publicUrl, + HEM_DATABASE_PATH: apiDatabasePath, + }) + ); + const servicesLayer = Layer.mergeAll( + HemDatabase.layerFromPath(apiDatabasePath), + HemAuth.defaultLayer, + ConnectorRegistryTest + ); + const appLayer = ApiLayer.pipe( + Layer.provide(servicesLayer), + Layer.provide(configLayer) + ) as Layer.Layer; + const { dispose, handler: appHandler } = HttpRouter.toWebHandler(appLayer, { + disableLogger: true, + }); + const handler: (request: Request) => Promise = appHandler; + + try { + const authorization = await handler( + new Request('http://127.0.0.1:3000/v1/auth/device/code', { + body: JSON.stringify({ client_id: 'hem-cli' }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + ); + expect(authorization.status).toBe(200); + const authorizationBody = (await authorization.json()) as { + device_code: string; + user_code: string; + }; + + const signUp = await handler( + new Request('http://127.0.0.1:3000/v1/auth/sign-up/email', { + body: JSON.stringify({ + email: 'browser@hem.dev', + name: 'Browser User', + password: 'correct-horse-battery-staple', + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + ); + expect(signUp.status).toBe(200); + expect(cookieHeader(signUp)).toContain('better-auth.session_token='); + + const claim = await handler( + new Request( + `http://127.0.0.1:3000/v1/auth/device?user_code=${authorizationBody.user_code}`, + { + headers: { + cookie: cookieHeader(signUp), + }, + } + ) + ); + expect(claim.status).toBe(200); + + const approval = await handler( + new Request('http://127.0.0.1:3000/v1/auth/device/approve', { + body: JSON.stringify({ + userCode: authorizationBody.user_code, + }), + headers: { + 'content-type': 'application/json', + cookie: cookieHeader(signUp), + origin: publicUrl, + }, + method: 'POST', + }) + ); + expect(approval.status).toBe(200); + expect(await approval.json()).toMatchObject({ success: true }); + + const token = await handler( + new Request('http://127.0.0.1:3000/v1/auth/device/token', { + body: JSON.stringify({ + client_id: 'hem-cli', + device_code: authorizationBody.device_code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + ); + expect(token.status).toBe(200); + expect(await token.json()).toMatchObject({ + token_type: 'Bearer', + }); + } finally { + await dispose(); + await Promise.all( + [ + apiDatabasePath, + `${apiDatabasePath}-shm`, + `${apiDatabasePath}-wal`, + ].map((path) => + Bun.file(path) + .delete() + .catch(() => false) + ) + ); + } +}); diff --git a/packages/console/api/test/connectors/fixture.ts b/packages/console/api/test/connectors/fixture.ts new file mode 100644 index 0000000..9e66d08 --- /dev/null +++ b/packages/console/api/test/connectors/fixture.ts @@ -0,0 +1,34 @@ +import { ConfigProvider, Effect, Layer } from 'effect'; +import { FetchHttpClient } from 'effect/unstable/http'; + +export const testHttpClientLayer = ( + handler: (request: Request) => Response | Promise +) => { + const testFetch = ((url: string | URL | Request, init?: RequestInit) => { + const request = + url instanceof Request + ? url + : new Request(url.toString(), init); + return Promise.resolve(handler(request)); + }) as typeof globalThis.fetch; + + return FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, testFetch)) + ); +}; + +export const runWithLayer = ( + layer: Layer.Layer, + config: Readonly>, + handler: (request: Request) => Response | Promise, + effect: Effect.Effect +) => + Effect.runPromise( + effect.pipe( + Effect.provide(layer.pipe(Layer.provide(testHttpClientLayer(handler)))), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown(config) + ) + ) as Effect.Effect + ); \ No newline at end of file diff --git a/packages/console/api/test/connectors/notion.test.ts b/packages/console/api/test/connectors/notion.test.ts new file mode 100644 index 0000000..cfbef15 --- /dev/null +++ b/packages/console/api/test/connectors/notion.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'bun:test'; + +import { Effect } from 'effect'; + +import { defaultLayer, NotionConnector } from '../../src/connectors/notion'; +import { runWithLayer } from './fixture'; + +test('exchanges a Notion OAuth code', async () => { + const completed = await runWithLayer( + defaultLayer, + { + NOTION_OAUTH_CLIENT_ID: 'notion-client', + NOTION_OAUTH_CLIENT_SECRET: 'notion-secret', + PUBLIC_API_URL: 'http://127.0.0.1:3000', + }, + async (request) => { + if (!request.url.includes('/v1/oauth/token')) + return new Response('not found', { status: 404 }); + const body = (await request.json()) as { code?: string }; + expect(body.code).toBe('notion-code'); + return Response.json({ + access_token: 'secret_notion', + workspace_id: 'ws_123', + workspace_name: 'Acme Notion', + }); + }, + Effect.gen(function* () { + const notion = yield* NotionConnector.Service; + return yield* notion.completeAuthorization({ + callback: { _tag: 'oauth', code: 'notion-code' }, + }); + }) + ); + + expect(completed.providerInstallationId).toBe('notion:ws_123'); + expect(completed.account.name).toBe('Acme Notion'); + expect(completed.credentials?.accessToken).toBe('secret_notion'); +}); \ No newline at end of file diff --git a/packages/console/api/test/connectors/planetscale.test.ts b/packages/console/api/test/connectors/planetscale.test.ts new file mode 100644 index 0000000..a7fe52a --- /dev/null +++ b/packages/console/api/test/connectors/planetscale.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from 'bun:test'; + +import { Effect } from 'effect'; + +import { + defaultLayer, + PlanetScaleConnector, +} from '../../src/connectors/planetscale'; +import { runWithLayer } from './fixture'; + +test('exchanges a PlanetScale OAuth code', async () => { + const completed = await runWithLayer( + defaultLayer, + { + PLANETSCALE_OAUTH_CLIENT_ID: 'ps-client', + PLANETSCALE_OAUTH_CLIENT_SECRET: 'ps-secret', + PUBLIC_API_URL: 'http://127.0.0.1:3000', + }, + async (request) => { + if (request.url.includes('/oauth/token/info')) { + return Response.json({ + active: true, + exp: 1_900_000_000, + scope: 'read_databases', + sub: 'user_42', + }); + } + return Response.json({ + access_token: 'ps_token', + expires_in: 3600, + refresh_token: 'ps_refresh', + scope: 'read_databases', + token_type: 'Bearer', + }); + }, + Effect.gen(function* () { + const planetscale = yield* PlanetScaleConnector.Service; + return yield* planetscale.completeAuthorization({ + callback: { _tag: 'oauth', code: 'ps-code' }, + }); + }) + ); + + expect(completed.providerInstallationId).toBe('planetscale:user_42'); + expect(completed.credentials?.accessToken).toBe('ps_token'); + expect(completed.grantedPermissions).toEqual({ scope: 'read_databases' }); +}); \ No newline at end of file diff --git a/packages/console/api/test/connectors/slack.test.ts b/packages/console/api/test/connectors/slack.test.ts new file mode 100644 index 0000000..e013574 --- /dev/null +++ b/packages/console/api/test/connectors/slack.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'bun:test'; + +import { Effect } from 'effect'; + +import { defaultLayer, SlackConnector } from '../../src/connectors/slack'; +import { runWithLayer } from './fixture'; + +test('exchanges a Slack OAuth code', async () => { + const completed = await runWithLayer( + defaultLayer, + { + PUBLIC_API_URL: 'http://127.0.0.1:3000', + SLACK_CLIENT_ID: 'slack-client', + SLACK_CLIENT_SECRET: 'slack-secret', + }, + async () => + Response.json({ + access_token: 'xoxb_test', + ok: true, + scope: 'chat:write', + team: { id: 'T123', name: 'Hem Workspace' }, + token_type: 'bot', + }), + Effect.gen(function* () { + const slack = yield* SlackConnector.Service; + return yield* slack.completeAuthorization({ + callback: { _tag: 'oauth', code: 'slack-code' }, + }); + }) + ); + + expect(completed.providerInstallationId).toBe('slack:T123'); + expect(completed.account.name).toBe('Hem Workspace'); + expect(completed.credentials?.accessToken).toBe('xoxb_test'); +}); \ No newline at end of file diff --git a/packages/console/api/test/connectors/vercel.test.ts b/packages/console/api/test/connectors/vercel.test.ts new file mode 100644 index 0000000..1cb880d --- /dev/null +++ b/packages/console/api/test/connectors/vercel.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'bun:test'; + +import { Effect } from 'effect'; + +import { defaultLayer, VercelConnector } from '../../src/connectors/vercel'; +import { runWithLayer } from './fixture'; + +test('exchanges a Vercel OAuth code', async () => { + const completed = await runWithLayer( + defaultLayer, + { + PUBLIC_API_URL: 'http://127.0.0.1:3000', + VERCEL_CLIENT_ID: 'vercel-client', + VERCEL_CLIENT_SECRET: 'vercel-secret', + VERCEL_INTEGRATION_SLUG: 'hem-test', + }, + async () => + Response.json({ + access_token: 'vercel_token', + scope: 'read', + team_id: 'team_9', + token_type: 'Bearer', + }), + Effect.gen(function* () { + const vercel = yield* VercelConnector.Service; + return yield* vercel.completeAuthorization({ + callback: { _tag: 'oauth', code: 'vercel-code' }, + }); + }) + ); + + expect(completed.providerInstallationId).toBe('vercel:team_9'); + expect(completed.account.type).toBe('team'); + expect(completed.credentials?.teamId).toBe('team_9'); +}); \ No newline at end of file diff --git a/packages/console/api/test/control-plane.test.ts b/packages/console/api/test/control-plane.test.ts index a02e638..14f8e38 100644 --- a/packages/console/api/test/control-plane.test.ts +++ b/packages/console/api/test/control-plane.test.ts @@ -1,50 +1,75 @@ import { expect, test } from 'bun:test'; -import { GithubConnector } from '../src/github'; import { Database } from '@hem/console-core/database/database'; import { user } from '@hem/console-core/database/schema/auth'; import { Effect, Layer } from 'effect'; +import { ConnectorRegistry } from '../src/connectors/registry'; +import { ConnectorError } from '../src/connectors/types'; import { makeRuntime } from '../src/effect/app-runtime'; +import { + completeConnectorInstallation, + getConnectorInstallationStatus, + startConnectorInstallation, +} from '../src/installation/flow'; import { createBinding } from '../src/routes/binding'; import { createCredentialLease } from '../src/routes/credential-lease'; -import { - completeGithubInstallation, - getGithubInstallationStatus, - startGithubInstallation, -} from '../src/routes/installation'; import { CreateBindingRequest, CreateCredentialLeaseRequest, HemUserId, } from '../src/schema'; -const GithubConnectorTest = Layer.succeed( - GithubConnector.Service, - GithubConnector.Service.of({ - completeInstallation: (providerInstallationId) => - Effect.succeed({ - account: { - id: '42', - name: 'acme', - type: 'organization', - }, - grantedPermissions: { contents: 'write', issues: 'read' }, - providerInstallationId, - }), - createInstallationUrl: (state) => - `https://github.test/install?state=${state}`, - issueCredential: ({ providerInstallationId }) => - Effect.succeed({ - expiresAt: '2026-06-18T01:00:00.000Z', - token: `ghs_${providerInstallationId}`, - }), +const ConnectorRegistryTest = Layer.succeed( + ConnectorRegistry.Service, + ConnectorRegistry.Service.of({ + get: (connector) => + connector === 'github' + ? Effect.succeed({ + completeAuthorization: ({ callback }) => + callback._tag === 'github' + ? Effect.succeed({ + account: { + id: '42', + name: 'acme', + type: 'organization', + }, + credentials: null, + grantedPermissions: { + contents: 'write', + issues: 'read', + }, + providerInstallationId: + callback.providerInstallationId, + }) + : Effect.fail( + new ConnectorError({ + connector: 'github', + message: + 'Expected GitHub callback.', + }) + ), + connector: 'github' as const, + createAuthorizationUrl: (state) => + Effect.succeed( + `https://github.test/install?state=${state}` + ), + issueCredential: ({ providerInstallationId }) => + Effect.succeed({ + expiresAt: '2026-06-18T01:00:00.000Z', + values: { + GITHUB_TOKEN: `ghs_${providerInstallationId}`, + }, + }), + outputsForInstallation: () => ['GITHUB_TOKEN'] as const, + }) + : Effect.die(`Unexpected connector: ${connector}`), }) ); const TestLayer = Layer.mergeAll( Database.layerFromPath(':memory:'), - GithubConnectorTest + ConnectorRegistryTest ); const testRuntime = makeRuntime(TestLayer); @@ -66,11 +91,14 @@ test('creates an installation binding and credential lease', async () => { }) ); - const authorization = yield* startGithubInstallation(userId); + const authorization = yield* startConnectorInstallation( + userId, + 'github' + ); const state = new URL( authorization.authorizationUrl ).searchParams.get('state'); - const pending = yield* getGithubInstallationStatus( + const pending = yield* getConnectorInstallationStatus( userId, authorization.requestId ).pipe( @@ -80,8 +108,14 @@ test('creates an installation binding and credential lease', async () => { ) ); expect(pending).toBe('pending'); - yield* completeGithubInstallation('1001', state ?? ''); - const installation = yield* getGithubInstallationStatus( + yield* completeConnectorInstallation('github', { + callback: { + _tag: 'github', + providerInstallationId: '1001', + }, + state: state ?? '', + }); + const installation = yield* getConnectorInstallationStatus( userId, authorization.requestId ); @@ -91,6 +125,7 @@ test('creates an installation binding and credential lease', async () => { installationId: installation.id, }) ); + expect(binding.outputs).toEqual(['GITHUB_TOKEN']); return yield* createCredentialLease( userId, new CreateCredentialLeaseRequest({ bindingId: binding.id }) @@ -99,4 +134,4 @@ test('creates an installation binding and credential lease', async () => { ); expect(result.values.GITHUB_TOKEN).toBe('ghs_1001'); -}); +}); \ No newline at end of file diff --git a/packages/console/api/test/github/credential.test.ts b/packages/console/api/test/github/credential.test.ts index c5ae264..6336f2f 100644 --- a/packages/console/api/test/github/credential.test.ts +++ b/packages/console/api/test/github/credential.test.ts @@ -5,21 +5,40 @@ import { ConfigProvider, Effect, Layer } from 'effect'; import { defaultLayer, GithubConnector } from '../../src/github'; +const serveTestGithubApi = ( + fetch: (request: Request) => Response | Promise +) => { + for (let port = 49_152; port < 49_252; port += 1) { + try { + return Bun.serve({ fetch, hostname: '127.0.0.1', port }); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EADDRINUSE' + ) + continue; + + throw error; + } + } + + throw new Error('Could not start test GitHub API server.'); +}; + test('issues a credential without narrowing the installation grant', async () => { const requests: { body: string; method: string; pathname: string }[] = []; - const server = Bun.serve({ - fetch: async (request) => { - requests.push({ - body: await request.text(), - method: request.method, - pathname: new URL(request.url).pathname, - }); - return Response.json({ - expires_at: '2026-06-22T01:00:00.000Z', - token: 'ghs_test', - }); - }, - port: 0, + const server = serveTestGithubApi(async (request) => { + requests.push({ + body: await request.text(), + method: request.method, + pathname: new URL(request.url).pathname, + }); + return Response.json({ + expires_at: '2026-06-22T01:00:00.000Z', + token: 'ghs_test', + }); }); try { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index ad437c9..aabe259 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -18,6 +18,7 @@ "typecheck": "tsgo -b --noEmit" }, "dependencies": { + "@hem/core": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", "zod": "^4.4.3" diff --git a/packages/console/core/src/database/database.ts b/packages/console/core/src/database/database.ts index 1e13b13..9f27ed5 100644 --- a/packages/console/core/src/database/database.ts +++ b/packages/console/core/src/database/database.ts @@ -10,7 +10,7 @@ import * as bindingSchema from './schema/binding.sql'; import * as installationSchema from './schema/installation.sql'; const migrationsFolder = decodeURIComponent( - new URL('./migrations', import.meta.url).pathname + new URL('migrations', import.meta.url).pathname ); const schema = { @@ -74,4 +74,4 @@ export class DbError extends Data.TaggedError('DbError')<{ }> {} // oxlint-disable-next-line import/no-self-import, oxc/no-barrel-file -- namespace projection for Effect service module -export * as Database from './database'; \ No newline at end of file +export * as Database from './database'; diff --git a/packages/console/core/src/database/migrations/0002_tired_medusa.sql b/packages/console/core/src/database/migrations/0002_tired_medusa.sql new file mode 100644 index 0000000..c0a2237 --- /dev/null +++ b/packages/console/core/src/database/migrations/0002_tired_medusa.sql @@ -0,0 +1 @@ +ALTER TABLE `installation` ADD `credentials` text; \ No newline at end of file diff --git a/packages/console/core/src/database/migrations/meta/0002_snapshot.json b/packages/console/core/src/database/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..9452f30 --- /dev/null +++ b/packages/console/core/src/database/migrations/meta/0002_snapshot.json @@ -0,0 +1,663 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cf882d09-8ad1-42fa-8ebe-83632ddedeee", + "prevId": "5af51bcc-398d-47ea-83be-508f26e3f868", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_code": { + "name": "device_code", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "binding": { + "name": "binding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "binding_installation_id_idx": { + "name": "binding_installation_id_idx", + "columns": ["installation_id"], + "isUnique": false + } + }, + "foreignKeys": { + "binding_installation_id_installation_id_fk": { + "name": "binding_installation_id_installation_id_fk", + "tableFrom": "binding", + "tableTo": "installation", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "installation_request": { + "name": "installation_request", + "columns": { + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "installation_request_state_unique": { + "name": "installation_request_state_unique", + "columns": ["state"], + "isUnique": true + }, + "installation_request_owner_id_idx": { + "name": "installation_request_owner_id_idx", + "columns": ["owner_id"], + "isUnique": false + } + }, + "foreignKeys": { + "installation_request_installation_id_installation_id_fk": { + "name": "installation_request_installation_id_installation_id_fk", + "tableFrom": "installation_request", + "tableTo": "installation", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "installation_request_owner_id_user_id_fk": { + "name": "installation_request_owner_id_user_id_fk", + "tableFrom": "installation_request", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "installation": { + "name": "installation", + "columns": { + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connector": { + "name": "connector", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_permissions": { + "name": "granted_permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_installation_id": { + "name": "provider_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "installation_provider_installation_id_unique": { + "name": "installation_provider_installation_id_unique", + "columns": ["provider_installation_id"], + "isUnique": true + }, + "installation_owner_id_idx": { + "name": "installation_owner_id_idx", + "columns": ["owner_id"], + "isUnique": false + } + }, + "foreignKeys": { + "installation_owner_id_user_id_fk": { + "name": "installation_owner_id_user_id_fk", + "tableFrom": "installation", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/console/core/src/database/migrations/meta/_journal.json b/packages/console/core/src/database/migrations/meta/_journal.json index 1fe99d7..56e2fda 100644 --- a/packages/console/core/src/database/migrations/meta/_journal.json +++ b/packages/console/core/src/database/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1782096882008, "tag": "0001_medical_starjammers", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1782266043476, + "tag": "0002_tired_medusa", + "breakpoints": true } ] } diff --git a/packages/console/core/src/database/schema/installation.sql.ts b/packages/console/core/src/database/schema/installation.sql.ts index 2dec44f..a6af258 100644 --- a/packages/console/core/src/database/schema/installation.sql.ts +++ b/packages/console/core/src/database/schema/installation.sql.ts @@ -1,27 +1,46 @@ +import type { ManagedConnector } from '@hem/core/connector'; import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { user } from './auth.sql'; import { id, timestamps } from './utils'; -export interface GithubAccount { +export type { ManagedConnector } from '@hem/core/connector'; + +export interface ProviderAccount { readonly id: string; readonly name: string; - readonly type: 'user' | 'organization'; + readonly type: string; } -export type GithubPermissions = Readonly>; +export type ConnectorPermissions = Readonly>; + +export interface ProviderCredentials { + readonly accessToken: string; + readonly expiresAt?: string | null; + readonly refreshToken?: string | null; + readonly scope?: string | null; + readonly teamId?: string | null; + readonly tokenType?: string | null; +} export const InstallationTable = sqliteTable( 'installation', { account: text('account', { mode: 'json' }) - .$type() + .$type() .notNull(), - connector: text('connector', { enum: ['github'] }).notNull(), + connector: text('connector', { + enum: ['github', 'notion', 'planetscale', 'slack', 'vercel'], + }) + .$type() + .notNull(), + credentials: text('credentials', { + mode: 'json', + }).$type(), grantedPermissions: text('granted_permissions', { mode: 'json', }) - .$type() + .$type() .notNull(), id: id('ins'), ownerId: text('owner_id') diff --git a/packages/console/core/src/installation.ts b/packages/console/core/src/installation.ts index 419860a..f7ca9a4 100644 --- a/packages/console/core/src/installation.ts +++ b/packages/console/core/src/installation.ts @@ -7,6 +7,10 @@ import { InstallationRequestTable, InstallationTable, } from './database/schema/installation.sql'; +import type { + ManagedConnector, + ProviderCredentials, +} from './database/schema/installation.sql'; import { fn } from './util/fn'; export type InstallationRow = typeof InstallationTable.$inferSelect; @@ -170,8 +174,26 @@ export namespace Installation { account: z.object({ id: z.string(), name: z.string(), - type: z.enum(['user', 'organization']), + type: z.string(), }), + connector: z.enum([ + 'github', + 'notion', + 'planetscale', + 'slack', + 'vercel', + ]), + credentials: z + .object({ + accessToken: z.string(), + expiresAt: z.string().nullable().optional(), + refreshToken: z.string().nullable().optional(), + scope: z.string().nullable().optional(), + teamId: z.string().nullable().optional(), + tokenType: z.string().nullable().optional(), + }) + .nullable() + .optional(), grantedPermissions: z.record(z.string(), z.string()), id: z.string().optional(), ownerId: z.string(), @@ -185,10 +207,14 @@ export namespace Installation { try: () => db .insert(InstallationTable) - .values({ ...values, connector: 'github' }) + .values({ + ...values, + credentials: values.credentials ?? null, + }) .onConflictDoUpdate({ set: { account: values.account, + credentials: values.credentials ?? null, grantedPermissions: values.grantedPermissions, }, @@ -199,4 +225,46 @@ export namespace Installation { }); }) ); + + export const updateCredentials = fn( + z.object({ + credentials: z + .object({ + accessToken: z.string(), + expiresAt: z.string().nullable().optional(), + refreshToken: z.string().nullable().optional(), + scope: z.string().nullable().optional(), + teamId: z.string().nullable().optional(), + tokenType: z.string().nullable().optional(), + }) + .nullable(), + grantedPermissions: z.record(z.string(), z.string()).optional(), + id: z.string(), + }), + (values) => + Effect.gen(function* () { + const { db } = yield* Database.Service; + return yield* Effect.try({ + catch: (cause) => new DbError({ cause }), + try: () => + db + .update(InstallationTable) + .set({ + credentials: values.credentials, + ...(values.grantedPermissions + ? { + grantedPermissions: + values.grantedPermissions, + } + : {}), + }) + .where(eq(InstallationTable.id, values.id)) + .returning() + .get(), + }); + }) + ); } + +export type ConnectorCredentials = ProviderCredentials; +export type ConnectorName = ManagedConnector; diff --git a/packages/core/package.json b/packages/core/package.json index 16b5a73..0e8d463 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "exports": { + "./connector": "./src/connector.ts", "./error": "./src/error.ts", "./global": "./src/global.ts", "./manifest/schema": "./src/manifest/schema.ts" @@ -20,4 +21,4 @@ "@typescript/native-preview": "catalog:", "typescript": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts new file mode 100644 index 0000000..3555e0d --- /dev/null +++ b/packages/core/src/connector.ts @@ -0,0 +1,65 @@ +import { Schema } from 'effect'; + +export const ManagedConnector = Schema.Literals([ + 'github', + 'notion', + 'planetscale', + 'slack', + 'vercel', +]); +export type ManagedConnector = typeof ManagedConnector.Type; + +export const OAuthConnector = Schema.Literals([ + 'notion', + 'planetscale', + 'slack', + 'vercel', +]); +export type OAuthConnector = typeof OAuthConnector.Type; + +export const MANAGED_CONNECTORS = [ + 'github', + 'notion', + 'planetscale', + 'slack', + 'vercel', +] as const satisfies readonly ManagedConnector[]; + +export const OAUTH_CONNECTORS = [ + 'notion', + 'planetscale', + 'slack', + 'vercel', +] as const satisfies readonly OAuthConnector[]; + +export const CONNECTOR_LABELS = { + github: 'GitHub', + notion: 'Notion', + planetscale: 'PlanetScale', + slack: 'Slack', + vercel: 'Vercel', +} as const satisfies Record; + +export const CONNECTOR_DEFAULT_OUTPUTS = { + github: ['GITHUB_TOKEN'], + notion: ['NOTION_TOKEN'], + planetscale: ['PLANETSCALE_TOKEN'], + slack: ['SLACK_BOT_TOKEN'], + vercel: ['VERCEL_TOKEN'], +} as const satisfies Record; + +export const CONNECTOR_POSSIBLE_OUTPUTS = { + github: ['GITHUB_TOKEN'], + notion: ['NOTION_TOKEN'], + planetscale: ['PLANETSCALE_TOKEN'], + slack: ['SLACK_BOT_TOKEN'], + vercel: ['VERCEL_TOKEN', 'VERCEL_TEAM_ID'], +} as const satisfies Record; + +export const isOAuthConnector = ( + connector: ManagedConnector +): connector is OAuthConnector => + connector === 'notion' || + connector === 'planetscale' || + connector === 'slack' || + connector === 'vercel'; diff --git a/packages/core/src/error.ts b/packages/core/src/error.ts index 35e6e8d..a731727 100644 --- a/packages/core/src/error.ts +++ b/packages/core/src/error.ts @@ -19,4 +19,4 @@ export class InvalidSecretsManifest extends Data.TaggedError( override get message() { return `Invalid secrets manifest at ${this.path}`; } -} \ No newline at end of file +} diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 5191c88..17e189e 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -9,4 +9,4 @@ export const Path = { auth: path.join(home, '.local', 'share', app, 'auth.json'), config: path.join(home, '.config', app), data: path.join(home, '.local', 'share', app), -}; \ No newline at end of file +}; diff --git a/packages/core/src/manifest/schema.ts b/packages/core/src/manifest/schema.ts index 5d6cd1b..b989ab2 100644 --- a/packages/core/src/manifest/schema.ts +++ b/packages/core/src/manifest/schema.ts @@ -1,5 +1,9 @@ import { Brand, Schema } from 'effect'; +import { ManagedConnector } from '../connector'; + +export type { ManagedConnector } from '../connector'; + export const KeychainSource = Schema.Struct({ name: Schema.String, service: Schema.String, @@ -13,9 +17,7 @@ export type VarId = string & Brand.Brand<'VarId'>; const VarId = Brand.make((id) => id.startsWith('var_')); export const newVarId = (): VarId => VarId(`var_${crypto.randomUUID()}`); -export const VarIdSchema = Schema.String.pipe( - Schema.fromBrand('VarId', VarId) -); +export const VarIdSchema = Schema.String.pipe(Schema.fromBrand('VarId', VarId)); export type EnvLabel = string & Brand.Brand<'EnvLabel'>; const EnvLabel = Brand.nominal(); @@ -51,7 +53,7 @@ export type Entry = typeof Entry.Type; export const ManagedBinding = Schema.Struct({ bindingId: Schema.String, - connector: Schema.Literal('github'), + connector: ManagedConnector, outputs: Schema.NonEmptyArray(Schema.String), }); export type ManagedBinding = typeof ManagedBinding.Type; @@ -61,4 +63,4 @@ export const Manifest = Schema.Struct({ secrets: Schema.Array(Entry), version: Schema.Literal(1), }); -export type Manifest = typeof Manifest.Type; \ No newline at end of file +export type Manifest = typeof Manifest.Type; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 00b7183..664bdcb 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -5,4 +5,4 @@ "types": ["bun"] }, "exclude": ["dist"] -} \ No newline at end of file +} diff --git a/packages/hem/package.json b/packages/hem/package.json index c9dd5ff..25851d2 100644 --- a/packages/hem/package.json +++ b/packages/hem/package.json @@ -24,4 +24,4 @@ "@typescript/native-preview": "catalog:", "typescript": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/hem/src/api/client.ts b/packages/hem/src/api/client.ts index 27e5b59..d92a648 100644 --- a/packages/hem/src/api/client.ts +++ b/packages/hem/src/api/client.ts @@ -40,4 +40,4 @@ export const layerHemApiClient = Layer.unwrap( const baseUrl = yield* apiBaseUrl; return layerHemApiClientFor(baseUrl); }) -); \ No newline at end of file +); diff --git a/packages/hem/src/api/poll.ts b/packages/hem/src/api/poll.ts index b897faf..5e50953 100644 --- a/packages/hem/src/api/poll.ts +++ b/packages/hem/src/api/poll.ts @@ -15,4 +15,4 @@ export const pollUntilComplete = (input: { yield* Effect.sleep(`${input.interval ?? 1} seconds`); } return yield* new HemError({ message: input.timeoutMessage }); - }); \ No newline at end of file + }); diff --git a/packages/hem/src/auth/session.ts b/packages/hem/src/auth/session.ts index 252570f..8646fa7 100644 --- a/packages/hem/src/auth/session.ts +++ b/packages/hem/src/auth/session.ts @@ -1,5 +1,5 @@ -import { Path as GlobalPath } from '@hem/core/global'; import { HemError } from '@hem/core/error'; +import { Path as GlobalPath } from '@hem/core/global'; import { Config, Effect, FileSystem, Schema } from 'effect'; const StoredSession = Schema.Struct({ @@ -86,4 +86,4 @@ export const getSession = Effect.gen(function* () { }); } return { baseUrl, ...session }; -}); \ No newline at end of file +}); diff --git a/packages/hem/src/auth/util.ts b/packages/hem/src/auth/util.ts index 5e7488c..561acac 100644 --- a/packages/hem/src/auth/util.ts +++ b/packages/hem/src/auth/util.ts @@ -13,4 +13,4 @@ export const openBrowser = (url: string) => `Open this URL in your browser to authorize Hem:\n${url}` ); } - }); \ No newline at end of file + }); diff --git a/packages/hem/src/cmd/connect.ts b/packages/hem/src/cmd/connect.ts index b450dad..21c9a4a 100644 --- a/packages/hem/src/cmd/connect.ts +++ b/packages/hem/src/cmd/connect.ts @@ -1,14 +1,17 @@ +import { CONNECTOR_LABELS, MANAGED_CONNECTORS } from '@hem/core/connector'; +import type { ManagedConnector } from '@hem/core/connector'; import { Command } from 'effect/unstable/cli'; -import { connectGithub } from '../control/cloud/github'; +import { connectProvider } from '../control/cloud/provider'; -const github = Command.make('github', {}, () => connectGithub).pipe( - Command.withDescription( - 'Install the Hem GitHub App and connect it to this project' - ) -); +const providerCommand = (connector: ManagedConnector) => + Command.make(connector, {}, () => connectProvider(connector)).pipe( + Command.withDescription( + `Connect ${CONNECTOR_LABELS[connector]} to this project` + ) + ); export const connectCommand = Command.make('connect').pipe( Command.withDescription('Connect Hem to a provider'), - Command.withSubcommands([github]) + Command.withSubcommands(MANAGED_CONNECTORS.map(providerCommand)) ); \ No newline at end of file diff --git a/packages/hem/src/cmd/env.ts b/packages/hem/src/cmd/env.ts index 06bfdc9..b34aa51 100644 --- a/packages/hem/src/cmd/env.ts +++ b/packages/hem/src/cmd/env.ts @@ -1,12 +1,18 @@ import { createInterface } from 'node:readline/promises'; +import { + CONNECTOR_DEFAULT_OUTPUTS, + CONNECTOR_LABELS, + MANAGED_CONNECTORS, +} from '@hem/core/connector'; +import type { ManagedConnector } from '@hem/core/connector'; import { HemError } from '@hem/core/error'; import { envLabel, newVarId } from '@hem/core/manifest/schema'; import type { EnvLabel, Source, Var } from '@hem/core/manifest/schema'; import { Console, Effect, Option } from 'effect'; import { Argument, Command, Prompt } from 'effect/unstable/cli'; -import { connectGithub } from '../control/cloud/github'; +import { connectProvider } from '../control/cloud/provider'; import { Manifest } from '../manifest'; import { BunSecret } from '../secret/bun'; import { EnvSecret } from '../secret/env'; @@ -119,15 +125,17 @@ const promptForManualName = Prompt.text({ ), }); +const connectorChoice = (connector: ManagedConnector) => ({ + description: `Connect ${CONNECTOR_LABELS[connector]} for ${CONNECTOR_DEFAULT_OUTPUTS[connector].join(', ')}`, + title: `${CONNECTOR_LABELS[connector]} connector`, + value: connector, +}); + const addInteractively = Effect.gen(function* () { const source = yield* Prompt.run( Prompt.select({ choices: [ - { - description: 'Install the Hem GitHub App for GITHUB_TOKEN', - title: 'GitHub connector', - value: 'github' as const, - }, + ...MANAGED_CONNECTORS.map(connectorChoice), { description: 'Store a named value in the local keychain', title: 'Manual token', @@ -138,7 +146,7 @@ const addInteractively = Effect.gen(function* () { }) ); - if (source === 'github') return yield* connectGithub; + if (source !== 'manual') return yield* connectProvider(source); const envName = yield* Prompt.run(promptForManualName); return yield* addManual(envName); diff --git a/packages/hem/src/cmd/login.ts b/packages/hem/src/cmd/login.ts index 2f55fbc..7054617 100644 --- a/packages/hem/src/cmd/login.ts +++ b/packages/hem/src/cmd/login.ts @@ -62,4 +62,4 @@ const login = Effect.gen(function* () { export const loginCommand = Command.make('login', {}, () => login).pipe( Command.withDescription('Sign in with Hem') -); \ No newline at end of file +); diff --git a/packages/hem/src/cmd/run.ts b/packages/hem/src/cmd/run.ts index 39d06c6..35e8af4 100644 --- a/packages/hem/src/cmd/run.ts +++ b/packages/hem/src/cmd/run.ts @@ -2,8 +2,8 @@ import { HemError } from '@hem/core/error'; import { Effect } from 'effect'; import { resolveManagedBindings } from '../connector/cloud/lease'; -import { resolveEntry } from '../manifest/resolve'; import { Manifest } from '../manifest'; +import { resolveEntry } from '../manifest/resolve'; const spawnCommand = (input: { readonly args: readonly string[]; @@ -58,4 +58,4 @@ export const runCommandWithInjectedSecrets = (args: readonly string[]) => ...Object.fromEntries(managedEnv), }, }); - }); \ No newline at end of file + }); diff --git a/packages/hem/src/connector/cloud/lease.ts b/packages/hem/src/connector/cloud/lease.ts index 1665523..0b825e6 100644 --- a/packages/hem/src/connector/cloud/lease.ts +++ b/packages/hem/src/connector/cloud/lease.ts @@ -1,17 +1,15 @@ -import { HemError } from '@hem/core/error'; import { BindingId, CreateCredentialLeaseRequest, } from '@hem/console-api/schema'; +import { HemError } from '@hem/core/error'; +import type { ManagedBinding } from '@hem/core/manifest/schema'; import { Effect } from 'effect'; import { HemApiClient, withAccessToken } from '../../api/client'; import { getSession } from '../../auth/session'; -import type { ManagedBinding } from '@hem/core/manifest/schema'; -export const resolveManagedBindings = ( - bindings: readonly ManagedBinding[] -) => +export const resolveManagedBindings = (bindings: readonly ManagedBinding[]) => Effect.gen(function* () { if (bindings.length === 0) return []; @@ -44,4 +42,4 @@ export const resolveManagedBindings = ( ), { concurrency: 'unbounded' } ).pipe(Effect.map((resolved) => resolved.flat())); - }); \ No newline at end of file + }); diff --git a/packages/hem/src/connector/local.ts b/packages/hem/src/connector/local.ts deleted file mode 100644 index 9196125..0000000 --- a/packages/hem/src/connector/local.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Local credential issuance (manual keychain values only for now). */ -export {}; \ No newline at end of file diff --git a/packages/hem/src/control/cloud/github.ts b/packages/hem/src/control/cloud/github.ts deleted file mode 100644 index 904ecb8..0000000 --- a/packages/hem/src/control/cloud/github.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { CreateBindingRequest } from '@hem/console-api/schema'; -import { HemError } from '@hem/core/error'; -import { Console, Effect, Option } from 'effect'; - -import { HemApiClient, withAccessToken } from '../../api/client'; -import { pollUntilComplete } from '../../api/poll'; -import { getSession } from '../../auth/session'; -import { openBrowser } from '../../auth/util'; -import { Manifest } from '../../manifest'; - -export const connectGithub = Effect.gen(function* () { - const manifest = yield* Manifest.Service; - const current = yield* manifest.read(); - const hasManualGithubToken = current.secrets.some((entry) => - entry.vars.some((variable) => variable.label === 'GITHUB_TOKEN') - ); - if (hasManualGithubToken) { - return yield* new HemError({ - message: - 'Env var "GITHUB_TOKEN" is already managed manually. Run `hem env rm GITHUB_TOKEN` first.', - }); - } - - const session = yield* getSession; - const client = yield* HemApiClient; - const authorization = yield* withAccessToken( - session.accessToken, - client.installations.startGithubInstallation() - ); - - yield* Console.log('Opening GitHub to install the Hem app…'); - yield* openBrowser(authorization.authorizationUrl); - - const installation = yield* pollUntilComplete({ - attempt: withAccessToken( - session.accessToken, - client.installations.getGithubInstallationStatus({ - query: { request_id: authorization.requestId }, - }) - ).pipe( - Effect.map(Option.some), - Effect.catchTag('AuthorizationPending', () => - Effect.succeed(Option.none()) - ) - ), - expiresAt: authorization.expiresAt, - timeoutMessage: - 'GitHub installation expired. Run `hem connect github` again.', - }); - const binding = yield* withAccessToken( - session.accessToken, - client.bindings.createBinding({ - payload: new CreateBindingRequest({ - installationId: installation.id, - }), - }) - ); - - yield* manifest.upsertManagedBinding({ - bindingId: binding.id, - connector: 'github', - outputs: ['GITHUB_TOKEN'], - }); - yield* Console.log( - `✓ Connected GitHub account ${installation.account.name}` - ); -}); diff --git a/packages/hem/src/control/cloud/provider.ts b/packages/hem/src/control/cloud/provider.ts new file mode 100644 index 0000000..9bb195b --- /dev/null +++ b/packages/hem/src/control/cloud/provider.ts @@ -0,0 +1,93 @@ +import { BindingId, CreateBindingRequest } from '@hem/console-api/schema'; +import { CONNECTOR_POSSIBLE_OUTPUTS, CONNECTOR_LABELS } from '@hem/core/connector'; +import type { ManagedConnector } from '@hem/core/connector'; +import { HemError } from '@hem/core/error'; +import { Console, Effect, Option } from 'effect'; + +import { HemApiClient, withAccessToken } from '../../api/client'; +import { pollUntilComplete } from '../../api/poll'; +import { getSession } from '../../auth/session'; +import { openBrowser } from '../../auth/util'; +import { Manifest } from '../../manifest'; + +const ensureNoConflictingOutputs = Effect.fn(function* ( + connector: ManagedConnector, + outputs: readonly string[] +) { + const manifest = yield* Manifest.Service; + const current = yield* manifest.read(); + const manualLabels = new Set( + current.secrets.flatMap((entry) => + entry.vars.map((variable) => String(variable.label)) + ) + ); + const bindingLabels = new Set( + (current.bindings ?? []) + .filter((binding) => binding.connector !== connector) + .flatMap((binding) => binding.outputs) + ); + const conflicts = outputs.filter( + (output) => manualLabels.has(output) || bindingLabels.has(output) + ); + if (conflicts.length > 0) { + return yield* new HemError({ + message: `Env var "${conflicts[0]}" is already managed. Run \`hem env rm ${conflicts[0]}\` first.`, + }); + } +}); + +export const connectProvider = (connector: ManagedConnector) => + Effect.gen(function* () { + const label = CONNECTOR_LABELS[connector]; + yield* ensureNoConflictingOutputs( + connector, + CONNECTOR_POSSIBLE_OUTPUTS[connector] + ); + + const manifest = yield* Manifest.Service; + const session = yield* getSession; + const client = yield* HemApiClient; + const authorization = yield* withAccessToken( + session.accessToken, + client.installations.startConnectorInstallation({ + params: { connector }, + }) + ); + + yield* Console.log(`Opening ${label} to connect Hem...`); + yield* openBrowser(authorization.authorizationUrl); + + const installation = yield* pollUntilComplete({ + attempt: withAccessToken( + session.accessToken, + client.installations.getConnectorInstallationStatus({ + params: { connector }, + query: { request_id: authorization.requestId }, + }) + ).pipe( + Effect.map(Option.some), + Effect.catchTag('AuthorizationPending', () => + Effect.succeed(Option.none()) + ) + ), + expiresAt: authorization.expiresAt, + timeoutMessage: `${label} authorization expired. Run \`hem connect ${connector}\` again.`, + }); + const binding = yield* withAccessToken( + session.accessToken, + client.bindings.createBinding({ + payload: new CreateBindingRequest({ + installationId: installation.id, + }), + }) + ); + + yield* manifest.upsertManagedBinding({ + bindingId: BindingId.make(binding.id), + connector: binding.connector, + outputs: binding.outputs, + }); + yield* Console.log( + `Connected ${label} account ${installation.account.name}` + ); + }); \ No newline at end of file diff --git a/packages/hem/src/control/local.ts b/packages/hem/src/control/local.ts deleted file mode 100644 index 60b4887..0000000 --- a/packages/hem/src/control/local.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Local-only control operations (no console-api dependency). */ -export {}; \ No newline at end of file diff --git a/packages/hem/src/effect/app-runtime.ts b/packages/hem/src/effect/app-runtime.ts index b95755f..35e2604 100644 --- a/packages/hem/src/effect/app-runtime.ts +++ b/packages/hem/src/effect/app-runtime.ts @@ -22,4 +22,4 @@ export const CliAppLayer = Layer.mergeAll( ); /** Domain services for scripts and tests. */ -export const AppLayer = Layer.mergeAll(LocalLayer, CloudLayer); \ No newline at end of file +export const AppLayer = Layer.mergeAll(LocalLayer, CloudLayer); diff --git a/packages/hem/src/index.ts b/packages/hem/src/index.ts index cd2e6de..929c207 100644 --- a/packages/hem/src/index.ts +++ b/packages/hem/src/index.ts @@ -75,4 +75,4 @@ if (shouldRunHemCli(process.argv[2])) { ); } else { runExternalCommand(); -} \ No newline at end of file +} diff --git a/packages/hem/src/manifest/resolve.ts b/packages/hem/src/manifest/resolve.ts index 5879fa8..545f9b8 100644 --- a/packages/hem/src/manifest/resolve.ts +++ b/packages/hem/src/manifest/resolve.ts @@ -1,7 +1,7 @@ import { HemError } from '@hem/core/error'; +import type { Entry } from '@hem/core/manifest/schema'; import { Effect } from 'effect'; -import type { Entry } from '@hem/core/manifest/schema'; import { BunSecret } from '../secret/bun'; export const resolveEntry = (entry: Entry) => @@ -33,4 +33,4 @@ export const resolveEntry = (entry: Entry) => ), { concurrency: 'unbounded' } ); - }); \ No newline at end of file + }); diff --git a/packages/hem/src/secret/bun/index.ts b/packages/hem/src/secret/bun/index.ts index 88241f2..798c1e9 100644 --- a/packages/hem/src/secret/bun/index.ts +++ b/packages/hem/src/secret/bun/index.ts @@ -84,4 +84,4 @@ export const layer = Layer.effect( export const defaultLayer = layer; // oxlint-disable-next-line import/no-self-import, oxc/no-barrel-file -- namespace projection for Effect service module -export * as BunSecret from '.'; \ No newline at end of file +export * as BunSecret from '.'; diff --git a/packages/hem/src/secret/env.ts b/packages/hem/src/secret/env.ts index 3cdcab2..72745a6 100644 --- a/packages/hem/src/secret/env.ts +++ b/packages/hem/src/secret/env.ts @@ -4,4 +4,4 @@ export namespace EnvSecret { export const service = 'hem.env'; export const manualName = (env: EnvLabel) => `manual:${env}`; -} \ No newline at end of file +}