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 +}