diff --git a/.env.example b/.env.example index 8955e09d41..6dd14b9040 100644 --- a/.env.example +++ b/.env.example @@ -139,16 +139,6 @@ INGEST_REQUIRE_TLS=false # space and angle brackets, which an unquoted dotenv value can't hold. # EMAIL_FROM="Maple " -# Hazel integration (OAuth) -# Maple acts as an OIDC client against Hazel's Clerk-hosted OAuth provider. -# Register Maple as an OAuth Application in Hazel's Clerk dashboard, then drop -# the credentials here. Defaults target https://clerk.hazel.sh + https://api.hazel.sh. -# HAZEL_API_BASE_URL=https://api.hazel.sh -# HAZEL_OAUTH_DISCOVERY_URL=https://clerk.hazel.sh/.well-known/openid-configuration -# HAZEL_OAUTH_CLIENT_ID= -# HAZEL_OAUTH_CLIENT_SECRET= -# HAZEL_OAUTH_SCOPES=openid email profile organizations:read channels:read channel-webhooks:write - # GitHub integration (GitHub App) # Maple connects to GitHub via a GitHub App for repo access, OAuth sign-in, and # webhooks. Create one at https://github.com/settings/apps (or your org's diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0907c2f4bd..aae8795d94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -350,4 +350,4 @@ Vitest uses embedded PGlite — no Docker Postgres required for unit tests. - [docs/persistence.md](docs/persistence.md) — Postgres migrations - [docs/self-hosted-clickhouse.md](docs/self-hosted-clickhouse.md) — ClickHouse BYO + schema - [docs/local-mode.md](docs/local-mode.md) — single-binary local mode -- [.env.example](.env.example) — full env reference with optional integrations (GitHub App, Hazel, Autumn, email) +- [.env.example](.env.example): full env reference with optional integrations (GitHub App, Autumn, email) diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index c0e058ed9d..b6d9d01e9f 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -66,11 +66,6 @@ export const apiConfiguredEnv = (stage: MapleStage, region: MapleRegion, domains optionalPlain("MAPLE_CLERK_DASHBOARD_URL"), optionalSecret("SD_INTERNAL_TOKEN"), optionalSecret("INTERNAL_SERVICE_TOKEN"), - optionalPlain("HAZEL_API_BASE_URL"), - optionalPlain("HAZEL_OAUTH_DISCOVERY_URL"), - optionalPlain("HAZEL_OAUTH_CLIENT_ID"), - optionalSecret("HAZEL_OAUTH_CLIENT_SECRET"), - optionalPlain("HAZEL_OAUTH_SCOPES"), optionalPlain("LINEAR_OAUTH_CLIENT_ID"), optionalSecret("LINEAR_OAUTH_CLIENT_SECRET"), // Each connector's declared install and outbound config; unset means unavailable. diff --git a/apps/api/src/routes/integrations-callback-page.test.ts b/apps/api/src/routes/integrations-callback-page.test.ts index 395851ea18..828b461902 100644 --- a/apps/api/src/routes/integrations-callback-page.test.ts +++ b/apps/api/src/routes/integrations-callback-page.test.ts @@ -11,8 +11,8 @@ const render = (returnTo: string | null, targetOrigin = "https://web.maple.dev") status: "success", message: "Connected", returnTo, - messageType: "maple:integration:hazel", - label: "Hazel", + messageType: "maple:integration:linear", + label: "Linear", targetOrigin, }) diff --git a/apps/api/src/routes/integrations-callback.http.ts b/apps/api/src/routes/integrations-callback.http.ts index eb36a6aa3c..197c8cdd8c 100644 --- a/apps/api/src/routes/integrations-callback.http.ts +++ b/apps/api/src/routes/integrations-callback.http.ts @@ -9,12 +9,10 @@ import { Env } from "@maple/backend/platform/Env" import { PLANETSCALE_CALLBACK_PATH } from "@maple/backend/services/auth/PlanetScaleOAuthService" import { SUPABASE_CALLBACK_PATH } from "@maple/backend/services/auth/SupabaseOAuthService" -export const HAZEL_CALLBACK_PATH = "/api/integrations/hazel/callback" export const LINEAR_CALLBACK_PATH = "/api/integrations/linear/callback" export const GITHUB_CALLBACK_PATH = "/api/integrations/github/callback" export const CLOUDFLARE_CALLBACK_PATH = "/api/integrations/cloudflare/callback" -const HAZEL_MESSAGE_TYPE = "maple:integration:hazel" const LINEAR_MESSAGE_TYPE = "maple:integration:linear" const GITHUB_MESSAGE_TYPE = "maple:integration:github" const CLOUDFLARE_MESSAGE_TYPE = "maple:integration:cloudflare" @@ -243,15 +241,6 @@ const requireInstallation = (params: URLSearchParams): string | null => : null const ROUTES: ReadonlyArray = [ - { - path: HAZEL_CALLBACK_PATH, - flow: "hazel", - provider: "hazel", - label: "Hazel", - page: "/integrations", - messageType: HAZEL_MESSAGE_TYPE, - precheck: requireCodeAndState, - }, { path: LINEAR_CALLBACK_PATH, flow: "linear", diff --git a/apps/api/src/routes/internal/integrations.http.ts b/apps/api/src/routes/internal/integrations.http.ts index 02c91185a1..f5422892da 100644 --- a/apps/api/src/routes/internal/integrations.http.ts +++ b/apps/api/src/routes/internal/integrations.http.ts @@ -10,7 +10,6 @@ import { CloudflareTopTrafficRow, CloudflareUsageResponse, CurrentTenant, - ExternalUserId, GcpResourcesResponse, GithubDeleteRepositoryResponse, GithubDisconnectResponse, @@ -21,11 +20,6 @@ import { GithubPrReviewSettingsResponse, GithubSetTrackedBranchResponse, GithubStartConnectResponse, - HazelChannelsListResponse, - HazelDisconnectResponse, - HazelIntegrationStatus, - HazelOrganizationsListResponse, - HazelStartConnectResponse, IntegrationsForbiddenError, IntegrationsUpstreamError, LinearDisconnectResponse, @@ -38,7 +32,6 @@ import { RoleName, SupabaseConnectResponse, SupabaseDisconnectResponse, - UserId, VCS_COMMIT_DETAILS_MAX_SHAS, VCS_COMMIT_RANGES_MAX, VcsCommitDetailResponse, @@ -49,7 +42,7 @@ import { VcsPullRequestsResponse, } from "@maple/domain/http" import { EdgeCacheService } from "@maple/cache" -import { Effect, Option, Schema } from "effect" +import { Effect, Option } from "effect" import { Env } from "@maple/backend/platform/Env" import { graphqlQuery } from "@maple/backend/services/integrations/CloudflareApi" import { CloudflareAnalyticsService } from "@maple/backend/services/integrations/CloudflareAnalyticsService" @@ -74,21 +67,16 @@ import { import { GithubConnectService } from "@maple/backend/services/integrations/vcs/vendor/github/GithubConnectService" import { VcsCommitService } from "@maple/backend/services/integrations/vcs/VcsCommitService" import { VcsSourceService } from "@maple/backend/services/integrations/vcs/VcsSourceService" -import { HazelOAuthService } from "@maple/backend/services/auth/HazelOAuthService" import { LinearOAuthService } from "@maple/backend/services/auth/LinearOAuthService" import { requireAdmin as requireAdminRole } from "@maple/backend/services/auth/auth" import { CLOUDFLARE_CALLBACK_PATH, GITHUB_CALLBACK_PATH, - HAZEL_CALLBACK_PATH, LINEAR_CALLBACK_PATH, } from "@/routes/integrations-callback.http" import { isTrustedCallbackOrigin, resolveRequestOrigin } from "@/routes/v2/integrations.http" import { makeOAuthCompleter } from "@/routes/oauth-complete" -const asExternalUserId = Schema.decodeUnknownSync(ExternalUserId) -const asUserId = Schema.decodeUnknownSync(UserId) - /** * How long `cloudflarePrime` spends on the post-connect poll. Long enough for zone discovery plus * a first window on an ordinary account; whatever a slow or many-zoned one does not finish resumes @@ -105,7 +93,6 @@ const requireAdmin = (roles: ReadonlyArray) => export const HttpIntegrationsLive = HttpApiBuilder.group(MapleInternalApi, "integrations", (handlers) => Effect.gen(function* () { - const hazel = yield* HazelOAuthService const linear = yield* LinearOAuthService const github = yield* GithubConnectService const vcsCommits = yield* VcsCommitService @@ -144,76 +131,6 @@ export const HttpIntegrationsLive = HttpApiBuilder.group(MapleInternalApi, "inte return yield* completeOAuth(payload, { orgId: tenant.orgId, userId: tenant.userId }) }), ) - .handle("hazelStatus", () => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - const status = yield* hazel.getStatus(tenant.orgId) - if (!status.connected) { - return new HazelIntegrationStatus({ - connected: false, - externalUserId: null, - externalUserEmail: null, - connectedByUserId: null, - scope: null, - }) - } - return new HazelIntegrationStatus({ - connected: true, - externalUserId: asExternalUserId(status.externalUserId), - externalUserEmail: status.externalUserEmail, - connectedByUserId: asUserId(status.connectedByUserId), - scope: status.scope, - }) - }), - ) - .handle("hazelStart", ({ payload }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - yield* requireAdmin(tenant.roles) - const callbackUrl = yield* trustedCallbackUrl(HAZEL_CALLBACK_PATH) - const result = yield* hazel.startConnect(tenant.orgId, tenant.userId, { - callbackUrl, - returnTo: payload.returnTo, - }) - return new HazelStartConnectResponse(result) - }), - ) - .handle("hazelOrganizations", () => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - const organizations = yield* hazel.listOrganizations(tenant.orgId) - return new HazelOrganizationsListResponse({ - organizations: organizations.map((o) => ({ - id: o.id, - name: o.name, - slug: o.slug, - logoUrl: o.logoUrl, - })), - }) - }), - ) - .handle("hazelChannels", ({ params }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - const channels = yield* hazel.listChannels(tenant.orgId, params.organizationId) - return new HazelChannelsListResponse({ - channels: channels.map((c) => ({ - id: c.id, - name: c.name, - type: c.type, - organizationId: c.organizationId, - })), - }) - }), - ) - .handle("hazelDisconnect", () => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - yield* requireAdmin(tenant.roles) - const result = yield* hazel.disconnect(tenant.orgId) - return new HazelDisconnectResponse(result) - }), - ) .handle("linearStatus", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/api/src/routes/oauth-complete.ts b/apps/api/src/routes/oauth-complete.ts index 1eab45ede7..18abf75482 100644 --- a/apps/api/src/routes/oauth-complete.ts +++ b/apps/api/src/routes/oauth-complete.ts @@ -8,7 +8,6 @@ import { Effect } from "effect" import { summarizeCause } from "@maple/backend/platform/describe-cause" import type { OAuthFlowInitiator } from "@maple/backend/services/auth/oauth/oauth-state" import { CloudflareOAuthService } from "@maple/backend/services/auth/CloudflareOAuthService" -import { HazelOAuthService } from "@maple/backend/services/auth/HazelOAuthService" import { LinearOAuthService } from "@maple/backend/services/auth/LinearOAuthService" import { PlanetScaleOAuthService } from "@maple/backend/services/auth/PlanetScaleOAuthService" import { SupabaseOAuthService } from "@maple/backend/services/auth/SupabaseOAuthService" @@ -20,7 +19,6 @@ import { GithubConnectService } from "@maple/backend/services/integrations/vcs/v const LABELS = { cloudflare: "Cloudflare", - hazel: "Hazel", linear: "Linear", github: "GitHub", planetscale: "PlanetScale", @@ -77,7 +75,6 @@ const failure = (message: string) => new OAuthCompleteResponse({ status: "error" * `initiator`, so a callback query carried into another person's session completes nothing. */ export const makeOAuthCompleter = Effect.gen(function* () { - const hazel = yield* HazelOAuthService const linear = yield* LinearOAuthService const github = yield* GithubConnectService const cloudflare = yield* CloudflareOAuthService @@ -239,10 +236,6 @@ export const makeOAuthCompleter = Effect.gen(function* () { switch (request.flow) { case "cloudflare": return withCode(params, (code, state) => completeCloudflare(code, state, initiator)) - case "hazel": - return withCode(params, (code, state) => - hazel.completeConnect(code, state, initiator).pipe(Effect.as(success(CLOSE_HINT))), - ) case "linear": return withCode(params, (code, state) => linear.completeConnect(code, state, initiator).pipe(Effect.as(success(CLOSE_HINT))), diff --git a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts index f2b9a4e8ca..9319658b08 100644 --- a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts +++ b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts @@ -49,7 +49,6 @@ import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertRead import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" import { LinearOAuthService } from "@maple/backend/services/auth/LinearOAuthService" import { LinearIssueTracker } from "@maple/backend/services/alerts/LinearIssueTracker" -import { HazelOAuthService } from "@maple/backend/services/auth/HazelOAuthService" import { OrgClickHouseSettingsService } from "@maple/backend/services/org/OrgClickHouseSettingsService" import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngineService" @@ -121,7 +120,6 @@ const makeHarness = () => { schedulerEvalTimeoutMs: () => 15_000, schedulerSpreadMs: () => 0, }) - const hazelOAuthLive = HazelOAuthService.layer.pipe(Layer.provide(Layer.mergeAll(envLive, testDb.layer))) const linearOAuthLive = LinearOAuthService.layer.pipe( Layer.provide(Layer.mergeAll(envLive, testDb.layer)), ) @@ -152,7 +150,6 @@ const makeHarness = () => { envLive, testDb.layer, runtimeLive, - hazelOAuthLive, linearOAuthLive, linearIssuesLive, emailLive, @@ -183,7 +180,6 @@ const makeHarness = () => { queryEngineLive, warehouseLive, runtimeLive, - hazelOAuthLive, linearOAuthLive, linearIssuesLive, emailLive, diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index 87cdb4fbb1..0ea543e15b 100644 --- a/apps/api/src/routes/v2/alert-destinations.http.ts +++ b/apps/api/src/routes/v2/alert-destinations.http.ts @@ -6,7 +6,6 @@ import { CurrentTenant, DiscordAlertDestinationConfig, EmailAlertDestinationConfig, - HazelOAuthAlertDestinationConfig, LinearAlertDestinationConfig, AlertDestinationNotFoundError, PagerDutyAlertDestinationConfig, @@ -68,21 +67,6 @@ const toCreateRequest = (params: V2AlertDestinationCreateParams) => { : undefined), ...(params.enabled !== undefined ? { enabled: params.enabled } : undefined), }) - case "hazel-oauth": - return new HazelOAuthAlertDestinationConfig({ - type: "hazel-oauth", - name: params.name, - hazelOrganizationId: params.hazel_organization_id, - hazelOrganizationName: params.hazel_organization_name, - ...(params.hazel_organization_logo_url !== undefined - ? { - hazelOrganizationLogoUrl: params.hazel_organization_logo_url, - } - : undefined), - hazelChannelId: params.hazel_channel_id, - hazelChannelName: params.hazel_channel_name, - ...(params.enabled !== undefined ? { enabled: params.enabled } : undefined), - }) case "discord": return new DiscordAlertDestinationConfig({ type: "discord", @@ -153,34 +137,6 @@ const toUpdateRequest = (params: V2AlertDestinationUpdateParams): AlertDestinati ? { signingSecret: params.signing_secret } : undefined), } - case "hazel-oauth": - return { - type: "hazel-oauth", - ...shared, - ...(params.hazel_organization_id !== undefined - ? { - hazelOrganizationId: params.hazel_organization_id, - } - : undefined), - ...(params.hazel_organization_name !== undefined - ? { - hazelOrganizationName: params.hazel_organization_name, - } - : undefined), - ...(params.hazel_organization_logo_url !== undefined - ? { - hazelOrganizationLogoUrl: params.hazel_organization_logo_url, - } - : undefined), - ...(params.hazel_channel_id !== undefined - ? { hazelChannelId: params.hazel_channel_id } - : undefined), - ...(params.hazel_channel_name !== undefined - ? { - hazelChannelName: params.hazel_channel_name, - } - : undefined), - } case "discord": return { type: "discord", @@ -242,18 +198,7 @@ export const destinationAuditDiff = auditDiff({ writeOnly: ["integration_key", "signing_secret", "url", "webhook_url", "bot_token"], // Provider-side handles. Not secret, but not readable back off the document // either — recorded as touched so the change is not invisible. - opaque: [ - "channel_id", - "channel_name", - "chat_id", - "workspace_id", - "hazel_organization_id", - "hazel_organization_name", - "hazel_organization_logo_url", - "hazel_channel_id", - "hazel_channel_name", - "team_id", - ], + opaque: ["channel_id", "channel_name", "chat_id", "workspace_id", "team_id"], }) export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "alertDestinations", (handlers) => diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index fde3205299..953067da98 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -34,9 +34,11 @@ import { UptimeAlertSource } from "@maple/backend/services/uptime/UptimeAlertSou import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDestinationsService" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" import { AlertRulesService } from "@maple/backend/services/alerts/AlertRulesService" -import { LinearOAuthService } from "@maple/backend/services/auth/LinearOAuthService" +import { + LinearOAuthService, + type LinearOAuthServiceApi, +} from "@maple/backend/services/auth/LinearOAuthService" import { LinearIssueTracker } from "@maple/backend/services/alerts/LinearIssueTracker" -import { HazelOAuthService, type HazelOAuthServiceApi } from "@maple/backend/services/auth/HazelOAuthService" import { OrgClickHouseSettingsService } from "@maple/backend/services/org/OrgClickHouseSettingsService" import { OrgMembersService, type OrgMembersServiceApi } from "@maple/backend/services/org/OrgMembersService" import { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngineService" @@ -83,7 +85,7 @@ const warehouseStub = makeWarehouseServiceStub({ const makeHarness = ( warehouseService: WarehouseQueryServiceApi = warehouseStub, - hazelOAuthService?: HazelOAuthServiceApi, + linearOAuthService?: LinearOAuthServiceApi, orgMembersService?: OrgMembersServiceApi, ) => { const testDb = createTestDb(createdDbs) @@ -106,13 +108,10 @@ const makeHarness = ( schedulerEvalTimeoutMs: () => 15_000, schedulerSpreadMs: () => 0, }) - const hazelOAuthLive = - hazelOAuthService === undefined - ? HazelOAuthService.layer.pipe(Layer.provide(Layer.mergeAll(envLive, testDb.layer))) - : Layer.succeed(HazelOAuthService, hazelOAuthService) - const linearOAuthLive = LinearOAuthService.layer.pipe( - Layer.provide(Layer.mergeAll(envLive, testDb.layer)), - ) + const linearOAuthLive = + linearOAuthService === undefined + ? LinearOAuthService.layer.pipe(Layer.provide(Layer.mergeAll(envLive, testDb.layer))) + : Layer.succeed(LinearOAuthService, linearOAuthService) const linearIssuesLive = Layer.effect(LinearIssueTracker, LinearIssueTracker.make).pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(Layer.mergeAll(linearOAuthLive, testDb.layer)), @@ -141,7 +140,6 @@ const makeHarness = ( envLive, testDb.layer, runtimeLive, - hazelOAuthLive, linearOAuthLive, linearIssuesLive, emailLive, @@ -172,7 +170,6 @@ const makeHarness = ( queryEngineLive, warehouseLive, runtimeLive, - hazelOAuthLive, linearOAuthLive, linearIssuesLive, emailLive, @@ -1019,33 +1016,29 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) - it("preserves the exact Hazel integration failure on destination create", async () => { - const unavailable = () => Effect.die("unexpected Hazel OAuth method") - const hazelOAuth: HazelOAuthServiceApi = { + it("preserves the exact Linear integration failure on destination create", async () => { + const unavailable = () => Effect.die("unexpected Linear OAuth method") + const linearOAuth: LinearOAuthServiceApi = { startConnect: unavailable, completeConnect: unavailable, getStatus: unavailable, - getValidAccessToken: unavailable, - listOrganizations: unavailable, - listChannels: unavailable, - createChannelWebhook: () => + withAccessToken: unavailable, + listTeams: unavailable, + getTeam: () => Effect.fail( new IntegrationsRevokedError({ - message: "Hazel rejected the access token — reconnect required", + message: "Linear rejected the access token, reconnect required", }), ), disconnect: unavailable, } - const harness = makeHarness(warehouseStub, hazelOAuth) + const harness = makeHarness(warehouseStub, linearOAuth) const key = await harness.bootstrapKey(["alerts:write"]) const response = await harness.request("POST", "/v2/alerts/destinations", key.secret, { - type: "hazel-oauth", - name: "Hazel incidents", - hazel_organization_id: "hazel-org", - hazel_organization_name: "Maple", - hazel_channel_id: "hazel-channel", - hazel_channel_name: "incidents", + type: "linear", + name: "Linear incidents", + team_id: "9cfb482a-81e3-4154-b5b9-2c805e70a02d", }) expect(response.status).toBe(401) diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 570eac9ac3..518c56db41 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -13,7 +13,6 @@ import { PlanetScaleOAuthService } from "@maple/backend/services/auth/PlanetScal import { AuthService } from "@maple/backend/services/auth/AuthService" import { CliDeviceAuthService } from "@maple/backend/services/auth/CliDeviceAuthService" import { CloudflareOAuthService } from "@maple/backend/services/auth/CloudflareOAuthService" -import { HazelOAuthService } from "@maple/backend/services/auth/HazelOAuthService" import { LinearOAuthService } from "@maple/backend/services/auth/LinearOAuthService" import { McpConnectionsService } from "@maple/backend/services/auth/McpConnectionsService" import { McpOAuthService } from "@maple/backend/services/auth/McpOAuthService" @@ -116,7 +115,6 @@ export const HttpServicesLive = Layer.mergeAll( CloudflareOAuthService.layer, DashboardPersistenceService.layer, SharedDashboardService.layer, - HazelOAuthService.layer, LinearOAuthService.layer, OnboardingService.layer, OnboardingChecklistService.layer, diff --git a/apps/electric-sync/src/shapes/registry.ts b/apps/electric-sync/src/shapes/registry.ts index 40bb2052a7..bf6a5de37d 100644 --- a/apps/electric-sync/src/shapes/registry.ts +++ b/apps/electric-sync/src/shapes/registry.ts @@ -54,8 +54,8 @@ const SUBSCRIPTIONS = { "created_by_email", ], }, - // `config_json` holds only public config (summary / channel label / hazel - // metadata); the encrypted webhook secrets live in separate `secret_*` columns + // `config_json` holds only public config (summary / channel label / chat + // workspace); the encrypted webhook secrets live in separate `secret_*` columns // that MUST NOT reach the browser, so the projection drops them (and the // unused `created_by`/`updated_by`). The PK `id` is required in the projection. alert_destinations: { diff --git a/apps/ios/Maple/Components/AlertFormatting.swift b/apps/ios/Maple/Components/AlertFormatting.swift index 8ff322d722..6a3f6b97f7 100644 --- a/apps/ios/Maple/Components/AlertFormatting.swift +++ b/apps/ios/Maple/Components/AlertFormatting.swift @@ -166,7 +166,6 @@ extension AlertDestinationType { case .chat: "Chat" case .pagerduty: "PagerDuty" case .webhook: "Webhook" - case .hazelOauth: "Hazel" case .discord: "Discord" case .telegram: "Telegram" case .googleChat: "Google Chat" diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index b6c0f9d803..33ef66dbfe 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -6796,7 +6796,6 @@ "enum": [ "pagerduty", "webhook", - "hazel-oauth", "discord", "telegram", "google-chat", @@ -13949,7 +13948,7 @@ "name": "Alert Deliveries" }, { - "description": "Notification channels for alert rules — PagerDuty, generic webhooks, Hazel OAuth, Discord, Telegram, Google Chat, workspace-member email, and channels in linked chat workspaces. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", + "description": "Notification channels for alert rules: PagerDuty, generic webhooks, Discord, Telegram, Google Chat, workspace-member email, and channels in linked chat workspaces. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", "name": "Alert Destinations" }, { diff --git a/apps/landing/src/content/docs/alerting/alert-rules.md b/apps/landing/src/content/docs/alerting/alert-rules.md index f8b3e773b8..0e7de4faf7 100644 --- a/apps/landing/src/content/docs/alerting/alert-rules.md +++ b/apps/landing/src/content/docs/alerting/alert-rules.md @@ -131,7 +131,7 @@ Click **Add destination** to attach an existing destination, or create one from **Send test** delivers a test notification for this rule to the selected destinations. -**Message template** customizes the **Title** and **Body (Markdown)** of Slack, Discord, Telegram and PagerDuty notifications with `{{ variable }}` substitution, for example `{{ rule.name }}`, `{{ value }}` or `{{ links.app }}`. Leave it blank for the built-in format. Email, webhook and Hazel destinations always use the built-in format. +**Message template** customizes the **Title** and **Body (Markdown)** of Slack, Hazel, Discord, Telegram and PagerDuty notifications with `{{ variable }}` substitution, for example `{{ rule.name }}`, `{{ value }}` or `{{ links.app }}`. Leave it blank for the built-in format. Email and webhook destinations always use the built-in format. ## Name it diff --git a/apps/landing/src/content/docs/alerting/incidents.md b/apps/landing/src/content/docs/alerting/incidents.md index 4353e4b153..bfdc0a969f 100644 --- a/apps/landing/src/content/docs/alerting/incidents.md +++ b/apps/landing/src/content/docs/alerting/incidents.md @@ -53,7 +53,7 @@ The built-in format carries: - The evaluation window. - An **Open in Maple** link to the incident (or, without one, the affected service or the alerts list). -A rule's **Message template** replaces the title and body for Slack, Discord, Telegram and PagerDuty destinations. Email, webhook and Hazel destinations use the built-in format. PagerDuty receives a stable `dedup_key` so the trigger and resolve land on the same PagerDuty incident. The webhook payload is in the [alert webhooks reference](/docs/reference/webhooks). +A rule's **Message template** replaces the title and body for Slack, Hazel, Discord, Telegram and PagerDuty destinations. Email and webhook destinations use the built-in format. PagerDuty receives a stable `dedup_key` so the trigger and resolve land on the same PagerDuty incident. The webhook payload is in the [alert webhooks reference](/docs/reference/webhooks). If a delivery fails with an error that can be retried, Maple retries it up to 5 attempts, with a delay that doubles from 1 minute up to 15 minutes. diff --git a/apps/landing/src/content/docs/alerting/notification-destinations.md b/apps/landing/src/content/docs/alerting/notification-destinations.md index 7be4508970..ab97a303dd 100644 --- a/apps/landing/src/content/docs/alerting/notification-destinations.md +++ b/apps/landing/src/content/docs/alerting/notification-destinations.md @@ -114,7 +114,13 @@ The payload, headers, signature verification and retry behavior are in the [aler ## Hazel -Connect [Hazel](https://hazel.sh/docs/integrations/maple) through OAuth and pick a workspace channel to route alerts into. See Hazel's [Maple integration guide](https://hazel.sh/docs/integrations/maple). +Add the Maple bot to your Hazel workspace, then choose the channel alerts go to. The same bot answers `@Maple` questions in Hazel threads. + +1. Open **Settings → Integrations → Hazel**, click **Add to Hazel** and approve the install for your workspace. You need to be an owner or admin in Hazel. +2. On the **Routing** tab, click **New destination** and pick the linked Hazel workspace. +3. Pick a **Channel** and save. The list shows every public channel and the private channels the bot was added to. + +See Hazel's [Maple integration guide](https://hazel.sh/docs/integrations/maple). ## Linear diff --git a/apps/landing/src/content/docs/iac/alchemy-resources.md b/apps/landing/src/content/docs/iac/alchemy-resources.md index 139f200937..3993ba5eaa 100644 --- a/apps/landing/src/content/docs/iac/alchemy-resources.md +++ b/apps/landing/src/content/docs/iac/alchemy-resources.md @@ -77,7 +77,7 @@ Effect.gen(function* () { - Channel secrets (`integration_key`, `signing_secret`, `webhook_url`, `bot_token`) are write-only: the API never returns them. Pass them as `Redacted` values so they stay out of logs and plan output. Because they cannot be read back, the provider re-sends them on every update. - Changing `type` replaces the destination with a new ID. -- Slack, other chat-connector and Hazel destinations depend on integrations linked in Maple, so manage them in [Notification destinations](/docs/alerting/notification-destinations), not here. +- Slack and other chat-connector destinations depend on integrations linked in Maple, so manage them in [Notification destinations](/docs/alerting/notification-destinations), not here. - Deleting a destination that rules still reference fails with a conflict. When the rules live in the same stack, Alchemy deletes them first. Outputs: `destinationId` (`dest_…`), `name`, `type`, `enabled`. diff --git a/apps/web/src/page/alerts/destination/dialog.ts b/apps/web/src/page/alerts/destination/dialog.ts index 6639a54e35..a6fb1fb488 100644 --- a/apps/web/src/page/alerts/destination/dialog.ts +++ b/apps/web/src/page/alerts/destination/dialog.ts @@ -33,7 +33,6 @@ import * as ChatConnect from "../shared/chat-connect" import * as ChatConnectStream from "../shared/chat-connect-stream" import * as ChatFields from "./chat-fields" import * as EmailFields from "./email-fields" -import * as HazelFields from "./hazel-fields" import * as LinearFields from "./linear-fields" import { DestinationForm, TextField, toFormState } from "./form" @@ -60,8 +59,6 @@ export const Model = Schema.Struct({ chatConnect: ChatConnect.Model, /** `EmailMemberPicker`: the org's members, read from Clerk once the picker mounts. */ email: EmailFields.Model, - /** `HazelOAuthFields`: the connection, its organizations and the picked one's channels. */ - hazel: HazelFields.Model, /** `LinearFields`: the org's Linear connection and its teams. */ linear: LinearFields.Model, brokenLogos: Schema.Array(Schema.String), @@ -83,7 +80,6 @@ export const init = (id: string): Model => ({ chat: ChatFields.init(), chatConnect: ChatConnect.init(), email: EmailFields.init(), - hazel: HazelFields.init(), linear: LinearFields.init(), }) @@ -118,7 +114,6 @@ export const Message = defineMessageUnion({ ClickedConnectChat: { connector: Schema.String }, GotChatConnectMessage: { message: ChatConnect.Message }, GotEmailFieldsMessage: { message: EmailFields.Message }, - GotHazelFieldsMessage: { message: HazelFields.Message }, GotLinearFieldsMessage: { message: LinearFields.Message }, }) export type Message = typeof Message.Type @@ -179,8 +174,6 @@ const createRequest = (payload: V2AlertDestinationCreateParams) => { return { payload } case "webhook": return { payload } - case "hazel-oauth": - return { payload } case "discord": return { payload } case "telegram": @@ -203,8 +196,6 @@ const updateRequest = (id: AlertDestinationId, payload: V2AlertDestinationUpdate return { params, payload } case "webhook": return { params, payload } - case "hazel-oauth": - return { params, payload } case "discord": return { params, payload } case "telegram": @@ -263,8 +254,6 @@ export const isValidGoogleChatWebhookUrl = (raw: string): boolean => export const isFormReady = (form: DestinationForm, isEditing: boolean): boolean => { if (isEditing && form.name.trim().length === 0) return false switch (form.type) { - case "hazel-oauth": - return form.hazelOrganizationId.trim().length > 0 && form.hazelChannelId.trim().length > 0 case "discord": return isEditing || form.webhookUrl.trim().length > 0 case "google-chat": @@ -322,15 +311,6 @@ const liftEmail = (model: Model, step: EmailFields.Return): Return => ({ commands: Command.mapMessages(step.commands, (message) => Message.GotEmailFieldsMessage({ message })), }) -const liftHazel = (model: Model, step: HazelFields.Return): Return => ({ - model: { - ...model, - hazel: step.model, - form: step.form === undefined ? model.form : { ...model.form, ...step.form }, - }, - commands: Command.mapMessages(step.commands, (message) => Message.GotHazelFieldsMessage({ message })), -}) - const liftLinear = (model: Model, step: LinearFields.Return): Return => ({ model: { ...model, @@ -345,8 +325,6 @@ const mountFields = (step: Return): Return => { const { form } = step.model if (form.type === "chat") return withCommands(liftChat(step.model, ChatFields.reset(step.model.chat, form)), step.commands) - if (form.type === "hazel-oauth") - return withCommands(liftHazel(step.model, HazelFields.reset(step.model.hazel, form)), step.commands) if (form.type === "linear") return withCommands( liftLinear(step.model, LinearFields.reset(step.model.linear, form)), @@ -573,8 +551,6 @@ export const update = (model: Model, message: Message): Return => GotChatConnectMessage: ({ message: inner }) => chatConnectStep(model, inner), GotEmailFieldsMessage: ({ message: inner }) => liftEmail(model, EmailFields.update(model.email, inner)), - GotHazelFieldsMessage: ({ message: inner }) => - liftHazel(model, HazelFields.update(model.hazel, inner, model.form)), GotLinearFieldsMessage: ({ message: inner }) => liftLinear(model, LinearFields.update(model.linear, inner, model.form)), }) diff --git a/apps/web/src/page/alerts/destination/form.ts b/apps/web/src/page/alerts/destination/form.ts index 305dfb0274..c1f7a97357 100644 --- a/apps/web/src/page/alerts/destination/form.ts +++ b/apps/web/src/page/alerts/destination/form.ts @@ -13,11 +13,6 @@ export const DestinationForm = Schema.Struct({ signingSecret: Schema.String, telegramBotToken: Schema.String, telegramChatId: Schema.String, - hazelOrganizationId: Schema.String, - hazelOrganizationName: Schema.String, - hazelOrganizationLogoUrl: Schema.NullOr(Schema.String), - hazelChannelId: Schema.String, - hazelChannelName: Schema.String, memberUserIds: Schema.Array(Schema.String), chatWorkspaceId: Schema.NullOr(ChatWorkspaceId), chatConnector: Schema.String, diff --git a/apps/web/src/page/alerts/destination/hazel-fields-view.ts b/apps/web/src/page/alerts/destination/hazel-fields-view.ts deleted file mode 100644 index a195927966..0000000000 --- a/apps/web/src/page/alerts/destination/hazel-fields-view.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { AsyncData } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { Option } from "effect" -import { button } from "../../../ui/button" -import { confirmDialog } from "../../../ui/confirm-dialog" -import { field, fieldError, type FieldIds, fieldLabel } from "../../../ui/field" -import { icon } from "../../../ui/icon" -import type { FieldState } from "../../../ui/input" -import { panel } from "../../../ui/panel" -import * as SelectView from "../../../ui/select-view" -import { providerOf } from "../shared/providers" -import type { DestinationForm } from "./form" -import { - CHANNEL_SELECT_ID, - type HazelOrganization, - Message as HazelMessage, - type Model as HazelModel, - ORGANIZATION_SELECT_ID, -} from "./hazel-fields" - -// `HazelOAuthFields`' view. TODO(destination-dialog): an org logo that fails to load keeps its -// broken image; it should fall back to the Hazel mark. - -const IDLE: FieldState = { - isInvalid: false, - isDirty: false, - isFilled: false, - isFocused: false, - isTouched: false, -} -const idsOf = (id: string): FieldIds => ({ - control: id, - label: SelectView.selectLabelId(id), - error: `${id}-error`, -}) - -type Inputs = Readonly<{ - model: HazelModel - form: DestinationForm - isEditing: boolean - toMessage: (message: HazelMessage) => M -}> - -const orgAvatar = (h: HtmlBuilder, logoUrl: string | null, name: string, size = 16): Html => - logoUrl !== null - ? h.img([ - h.Src(logoUrl), - h.Alt(`${name} logo`), - h.Width(String(size)), - h.Height(String(size)), - h.Attribute("loading", "lazy"), - h.Attribute("referrerpolicy", "no-referrer"), - h.Class("shrink-0 rounded-sm object-cover"), - h.Style({ width: `${size}px`, height: `${size}px` }), - ]) - : h.span( - [ - h.Class("flex shrink-0 items-center justify-center rounded-sm"), - h.Style({ - width: `${size}px`, - height: `${size}px`, - background: "rgba(244,111,15,0.16)", - color: "#F46F0F", - }), - ], - [icon(h, "HazelIcon", { size: Math.round(size * 0.7) })], - ) - -const isFailed = (data: AsyncData.AsyncData) => - AsyncData.isFailure(data) || AsyncData.isStale(data) - -const connectPanel = (h: HtmlBuilder, inputs: Inputs): Html => { - const accent = providerOf("hazel-oauth") - return h.div( - [h.Class("space-y-2 rounded-md border border-dashed border-border-subtle p-3")], - [ - ...(isFailed(inputs.model.status.data) - ? [ - h.p( - [h.Class("text-xs text-severity-error")], - [ - "Couldn't check your Hazel connection status. This may be a temporary issue, so try connecting again.", - ], - ), - ] - : []), - h.p( - [h.Class("text-xs text-muted-foreground")], - [ - "Connect Maple to your Hazel account via OAuth. We'll fetch the organizations and channels you can post into and provision a dedicated webhook for this destination.", - ], - ), - button( - h, - { - size: "sm", - isLoading: inputs.model.isConnecting, - isDisabled: inputs.model.isDisconnecting, - attributes: [ - h.OnClick(inputs.toMessage(HazelMessage.ClickedConnect())), - h.Style({ - background: accent.accent, - "border-color": accent.accent, - color: inputs.model.isConnecting ? "transparent" : accent.accentOn, - }), - ], - }, - ["Connect Hazel"], - ), - ], - ) -} - -const note = (h: HtmlBuilder, text: string): Html => - h.p([h.Class("text-2xs text-muted-foreground")], [text]) - -const organizationField = ( - h: HtmlBuilder, - inputs: Inputs, - organizations: ReadonlyArray, -): Html => { - const { form, model } = inputs - const ids = idsOf(ORGANIZATION_SELECT_ID) - const selected = organizations.find((org) => org.id === form.hazelOrganizationId) - const name = selected?.name ?? form.hazelOrganizationName - const logoUrl = selected?.logoUrl ?? form.hazelOrganizationLogoUrl ?? null - return field(h, { className: "items-stretch gap-1.5", state: IDLE }, [ - fieldLabel(h, { ids, className: "text-xs", state: IDLE }, "Hazel organization"), - h.submodel({ - slotId: ORGANIZATION_SELECT_ID, - model: model.organizationSelect, - view: SelectView.view, - toParentMessage: (message) => - inputs.toMessage(HazelMessage.GotOrganizationSelectMessage({ message })), - viewInputs: { - toView: (parts) => h.div([h.Class("contents")], parts), - entries: [ - { - _tag: "Group", - label: "", - items: organizations.map((org) => ({ value: org.id, label: org.name })), - }, - ], - labelledBy: ids.label, - className: "w-full", - renderValue: () => - name === "" - ? [h.span([h.Class("text-muted-foreground")], ["Pick an organization"])] - : [ - h.span( - [h.Class("flex items-center gap-2")], - [orgAvatar(h, logoUrl, name), h.span([h.Class("truncate")], [name])], - ), - ], - renderItem: (option) => { - const org = organizations.find((candidate) => candidate.id === option.value) - return [ - h.span( - [h.Class("flex items-center gap-2")], - [ - orgAvatar(h, org?.logoUrl ?? null, option.label), - h.span([h.Class("truncate")], [option.label]), - ], - ), - ] - }, - }, - }), - ...(isFailed(model.organizations.data) - ? [ - fieldError( - h, - { ids, className: "text-2xs", state: IDLE }, - "Couldn't load your Hazel organizations. Try reconnecting or refreshing.", - ), - ] - : organizations.length === 0 - ? [ - note( - h, - "No organizations returned. Make sure your Hazel account is a member of at least one organization.", - ), - ] - : []), - ]) -} - -const channelField = (h: HtmlBuilder, inputs: Inputs): Html => { - const { form, model } = inputs - const ids = idsOf(CHANNEL_SELECT_ID) - const organizationId = form.hazelOrganizationId.trim() - const channels = AsyncData.getOrElse(model.channels.data, () => []) - const isLoading = - organizationId.length > 0 && - (AsyncData.isLoading(model.channels.data) || AsyncData.isRefreshing(model.channels.data)) - const placeholder = - organizationId.length === 0 - ? "Pick an organization first" - : isLoading - ? "Loading channels…" - : inputs.isEditing && form.hazelChannelName - ? `#${form.hazelChannelName}` - : "Pick a channel" - const items = channels.map((channel) => ({ - value: channel.id, - label: channel.isPrivate ? `${channel.name} (private)` : channel.name, - })) - const label = items.find((item) => item.value === form.hazelChannelId)?.label - return field(h, { className: "items-stretch gap-1.5", state: IDLE }, [ - fieldLabel(h, { ids, className: "text-xs", state: IDLE }, "Hazel channel"), - h.submodel({ - slotId: CHANNEL_SELECT_ID, - model: model.channelSelect, - view: SelectView.view, - toParentMessage: (message) => inputs.toMessage(HazelMessage.GotChannelSelectMessage({ message })), - viewInputs: { - toView: (parts) => h.div([h.Class("contents")], parts), - entries: [{ _tag: "Group", label: "", items }], - labelledBy: ids.label, - className: "w-full", - isDisabled: organizationId.length === 0 || isLoading, - renderValue: () => - label === undefined - ? [h.span([h.Class("text-muted-foreground")], [placeholder])] - : [label], - renderItem: (option) => [`#${option.label}`], - }, - }), - ...(organizationId.length > 0 && !isLoading && isFailed(model.channels.data) - ? [ - fieldError( - h, - { ids, className: "text-2xs", state: IDLE }, - "Couldn't load channels for this organization. Try reselecting the organization.", - ), - ] - : organizationId.length > 0 && !isLoading && channels.length === 0 - ? [ - note( - h, - "No channels. Make sure your account is in at least one channel of this organization.", - ), - ] - : []), - ]) -} - -export const hazelFields = (h: HtmlBuilder, inputs: Inputs): Html => { - const { model } = inputs - const status = Option.getOrNull(AsyncData.getData(model.status.data)) - if (status === null || !status.connected) return connectPanel(h, inputs) - const organizations = AsyncData.getOrElse(model.organizations.data, () => []) - return h.div( - [h.Class("space-y-3")], - [ - panel(h, { className: "flex-row items-center justify-between px-3 py-2 text-xs" }, [ - h.div( - [h.Class("space-y-0.5")], - [ - h.div([h.Class("font-medium")], ["Connected to Hazel"]), - h.div([h.Class("text-muted-foreground")], [status.account]), - ], - ), - button( - h, - { - size: "sm", - variant: "outline", - isLoading: model.isDisconnecting, - isDisabled: model.isConnecting, - attributes: [h.OnClick(inputs.toMessage(HazelMessage.ClickedDisconnect()))], - }, - ["Disconnect"], - ), - ]), - organizationField(h, inputs, organizations), - channelField(h, inputs), - confirmDialog(h, { - slotId: "destination-hazel-disconnect", - model: model.confirmDisconnect, - toParentMessage: (message) => inputs.toMessage(HazelMessage.GotConfirmMessage({ message })), - title: "Disconnect Hazel", - description: - "Maple loses access to your Hazel organizations and channels until you reconnect.", - confirmLabel: "Disconnect Hazel", - onConfirm: inputs.toMessage(HazelMessage.ConfirmedDisconnect()), - isNested: true, - }), - ], - ) -} diff --git a/apps/web/src/page/alerts/destination/hazel-fields.scene.test.ts b/apps/web/src/page/alerts/destination/hazel-fields.scene.test.ts deleted file mode 100644 index 21d1b0c39f..0000000000 --- a/apps/web/src/page/alerts/destination/hazel-fields.scene.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { defaultDestinationForm } from "@maple/web-data/alerts/form-utils" -import { Result } from "effect" -import { describe, expect, test } from "vitest" -import * as Select from "../../../ui/select" -import { isFormReady } from "./dialog" -import type { DestinationForm } from "./form" -import { init, Message, type Model, reset, update } from "./hazel-fields" - -const names = (step: { readonly commands: ReadonlyArray<{ readonly name: string }> }) => step.commands.map(({ name }) => name) -const form = defaultDestinationForm("hazel-oauth") -const org = { id: "org_hz_1", name: "Acme", logoUrl: null } - -const connected = (): Model => { - const mounted = reset(init(), form).model - const status = update( - mounted, - Message.CompletedFetchStatus({ key: mounted.status.key ?? "", result: Result.succeed({ connected: true, account: "ada@acme.test" }) }), - form, - ).model - return update( - status, - Message.CompletedFetchOrganizations({ key: status.organizations.key ?? "", result: Result.succeed([org]) }), - form, - ).model -} - -const pick = (model: Model, current: DestinationForm, select: "organization" | "channel", value: string) => { - const message = Select.Message.SelectedItem({ index: 0, value }) - return select === "organization" - ? update(model, Message.GotOrganizationSelectMessage({ message }), current) - : update(model, Message.GotChannelSelectMessage({ message }), current) -} - -describe("hazel destination fields", () => { - test("mounting reads the status and organizations; an organization's channels follow its pick", () => { - expect(names(reset(init(), form))).toEqual( - expect.arrayContaining(["FetchAlertHazelStatus", "FetchAlertHazelOrganizations"]), - ) - const picked = pick(connected(), form, "organization", org.id) - expect(names(picked)).toEqual(["FetchAlertHazelChannels"]) - const withOrg = { ...form, ...picked.form } - expect(withOrg.hazelOrganizationName).toBe("Acme") - const listed = update( - picked.model, - Message.CompletedFetchChannels({ - key: picked.model.channels.key ?? "", - result: Result.succeed([{ id: "ch_1", name: "alerts", isPrivate: false }]), - }), - withOrg, - ).model - const chosen = { ...withOrg, ...pick(listed, withOrg, "channel", "ch_1").form } - expect(isFormReady(chosen, false)).toBe(true) - }) - - test("Connect starts once, and a started connect re-reads what it invalidates", () => { - const started = update(connected(), Message.ClickedConnect(), form) - expect(names(started)).toEqual(["ConnectAlertHazel"]) - expect(update(started.model, Message.ClickedConnect(), form).commands).toEqual([]) - const done = update(started.model, Message.CompletedConnect({ isStarted: true }), form) - expect(names(done)).toEqual( - expect.arrayContaining(["FetchAlertHazelStatus", "FetchAlertHazelOrganizations"]), - ) - }) - - test("a confirmed disconnect clears the picked organization and channel", () => { - const confirmed = update(connected(), Message.ConfirmedDisconnect(), form) - expect(names(confirmed)).toContain("DisconnectAlertHazel") - const done = update(confirmed.model, Message.CompletedDisconnect(), { ...form, hazelOrganizationId: "org_hz_1" }) - expect(done.form?.hazelOrganizationId).toBe("") - }) -}) diff --git a/apps/web/src/page/alerts/destination/hazel-fields.ts b/apps/web/src/page/alerts/destination/hazel-fields.ts deleted file mode 100644 index 3ff8c3ece0..0000000000 --- a/apps/web/src/page/alerts/destination/hazel-fields.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { Effect, Option, Schema } from "effect" -import { AsyncData, Command } from "foldkit" -import { defineMessageUnion } from "foldkit/message" -import * as Keyed from "../../../data/keyed" -import { LoadFailure, toLoadFailure } from "../../../data/load-failure" -import { MapleInternalApiClient } from "../../../platform/api-clients" -import type { Resources } from "../../../platform/resources" -import * as Dialog from "../../../ui/dialog" -import * as Select from "../../../ui/select" -import { currentReturnPath, startConnect } from "../../integrations/connect" -import type { DestinationForm } from "./form" - -// `HazelOAuthFields`: connect Hazel over OAuth, then pick -// the organization and channel this destination posts into. - -export const HazelStatus = Schema.Struct({ connected: Schema.Boolean, account: Schema.String }) -export const HazelOrganization = Schema.Struct({ id: Schema.String, name: Schema.String, logoUrl: Schema.NullOr(Schema.String) }) -export type HazelOrganization = typeof HazelOrganization.Type -export const HazelChannel = Schema.Struct({ id: Schema.String, name: Schema.String, isPrivate: Schema.Boolean }) -export type HazelChannel = typeof HazelChannel.Type - -export const Model = Schema.Struct({ - status: Keyed.KeyedSchema(HazelStatus, LoadFailure), - organizations: Keyed.KeyedSchema(Schema.Array(HazelOrganization), LoadFailure), - channels: Keyed.KeyedSchema(Schema.Array(HazelChannel), LoadFailure), - organizationSelect: Select.Model, - channelSelect: Select.Model, - confirmDisconnect: Dialog.Model, - isConnecting: Schema.Boolean, - isDisconnecting: Schema.Boolean, -}) -export type Model = typeof Model.Type - -export const ORGANIZATION_SELECT_ID = "destination-hazel-organization" -export const CHANNEL_SELECT_ID = "destination-hazel-channel" - -export const init = (): Model => ({ - status: Keyed.idle(), - organizations: Keyed.idle(), - channels: Keyed.idle(), - organizationSelect: Select.init(ORGANIZATION_SELECT_ID, { value: "", selectedIndex: null }), - channelSelect: Select.init(CHANNEL_SELECT_ID, { value: "", selectedIndex: null }), - confirmDisconnect: Dialog.init("destination-hazel-disconnect", "alert-dialog"), - isConnecting: false, - isDisconnecting: false, -}) - -export const Message = defineMessageUnion({ - CompletedFetchStatus: { key: Schema.String, result: Schema.Result(HazelStatus, LoadFailure) }, - CompletedFetchOrganizations: { key: Schema.String, result: Schema.Result(Schema.Array(HazelOrganization), LoadFailure) }, - CompletedFetchChannels: { key: Schema.String, result: Schema.Result(Schema.Array(HazelChannel), LoadFailure) }, - ClickedConnect: {}, - CompletedConnect: { isStarted: Schema.Boolean }, - ClickedDisconnect: {}, - ConfirmedDisconnect: {}, - CompletedDisconnect: {}, - GotConfirmMessage: { message: Dialog.Message }, - GotOrganizationSelectMessage: { message: Select.Message }, - GotChannelSelectMessage: { message: Select.Message }, -}) -export type Message = typeof Message.Type - -const settle = (effect: Effect.Effect) => Effect.result(Effect.mapError(effect, toLoadFailure)) - -const FetchStatus = Command.define("FetchAlertHazelStatus", { - args: { key: Schema.String }, - messages: [Message.CompletedFetchStatus], - execute: ({ key }) => - Effect.gen(function* () { - const client = yield* MapleInternalApiClient - const status = yield* client.integrations.hazelStatus() - return { connected: status.connected, account: status.externalUserEmail ?? status.externalUserId ?? "Authorized" } - }).pipe(settle, Effect.map((result) => Message.CompletedFetchStatus({ key, result }))), -}) - -const FetchOrganizations = Command.define("FetchAlertHazelOrganizations", { - args: { key: Schema.String }, - messages: [Message.CompletedFetchOrganizations], - execute: ({ key }) => - Effect.gen(function* () { - const client = yield* MapleInternalApiClient - const response = yield* client.integrations.hazelOrganizations() - return response.organizations.map((org) => ({ id: org.id, name: org.name, logoUrl: org.logoUrl })) - }).pipe(settle, Effect.map((result) => Message.CompletedFetchOrganizations({ key, result }))), -}) - -const FetchChannels = Command.define("FetchAlertHazelChannels", { - args: { key: Schema.String, organizationId: Schema.String }, - messages: [Message.CompletedFetchChannels], - execute: ({ key, organizationId }) => - Effect.gen(function* () { - const client = yield* MapleInternalApiClient - const response = yield* client.integrations.hazelChannels({ params: { organizationId } }) - return response.channels.map((channel) => ({ id: channel.id, name: channel.name, isPrivate: channel.type === "private" })) - }).pipe(settle, Effect.map((result) => Message.CompletedFetchChannels({ key, result }))), -}) - -const WINDOW_NAME = "maple-hazel-connect" -const WINDOW_FEATURES = "popup,width=520,height=640" - -/** `handleConnect`: the popup opens in the click, then goes to the authorize URL once it is known. */ -export const ConnectHazel = Command.define("ConnectAlertHazel", { - messages: [Message.CompletedConnect], - execute: Effect.gen(function* () { - const popup = yield* Effect.sync(() => window.open("", WINDOW_NAME, WINDOW_FEATURES)) - const returnTo = yield* currentReturnPath - const started = yield* Effect.result(startConnect("hazel", returnTo)) - if (started._tag === "Failure") { - yield* Effect.sync(() => popup?.close()) - return Message.CompletedConnect({ isStarted: false }) - } - const url = started.success.redirectUrl - yield* Effect.sync(() => { - if (popup !== null) popup.location.href = url - else window.open(url, WINDOW_NAME, WINDOW_FEATURES) - }) - return Message.CompletedConnect({ isStarted: true }) - }), -}) - -const DisconnectHazel = Command.define("DisconnectAlertHazel", { - messages: [Message.CompletedDisconnect], - execute: Effect.gen(function* () { - const client = yield* MapleInternalApiClient - yield* client.integrations.hazelDisconnect() - }).pipe(Effect.ignore, Effect.as(Message.CompletedDisconnect())), -}) - -export type Return = Readonly<{ - model: Model - commands: ReadonlyArray> - form?: Partial -}> - -const STATUS_KEY = "hazel\u0000integrations.hazelStatus" -const ORGANIZATIONS_KEY = "hazel\u0000integrations.hazelOrganizations" -const channelsKey = (organizationId: string) => `${organizationId}\u0000integrations.hazelChannels` - -type Step = Readonly<{ patch: Partial; command: Command.Command }> - -const slot = ( - current: Keyed.Keyed, - key: string, - mode: Keyed.RequestMode, - toPatch: (next: Keyed.Keyed) => Partial, - command: Command.Command, -): ReadonlyArray => - Option.match(Keyed.request(current, key, mode), { onNone: () => [], onSome: (next) => [{ patch: toPatch(next), command }] }) - -/** The three reads, as their atoms: `Refresh` is the mutations' `reactivityKeys` invalidation. */ -const reads = (model: Model, organizationId: string, mode: Keyed.RequestMode): Return => { - const steps = [ - ...slot(model.status, STATUS_KEY, mode, (status) => ({ status }), FetchStatus({ key: STATUS_KEY })), - ...slot(model.organizations, ORGANIZATIONS_KEY, mode, (organizations) => ({ organizations }), FetchOrganizations({ key: ORGANIZATIONS_KEY })), - ...(organizationId === "" - ? [] - : slot(model.channels, channelsKey(organizationId), mode, (channels) => ({ channels }), FetchChannels({ key: channelsKey(organizationId), organizationId }))), - ] - return { - model: steps.reduce((next, step) => ({ ...next, ...step.patch }), model), - commands: steps.map((step) => step.command), - } -} - -const liftDialog = (model: Model, step: ReturnType): Return => ({ - model: { ...model, confirmDisconnect: step.model }, - commands: Command.mapMessages(step.commands ?? [], (message) => Message.GotConfirmMessage({ message })), -}) - -const liftSelect = ( - model: Model, - field: "organizationSelect" | "channelSelect", - step: ReturnType, - toMessage: (message: Select.Message) => Message, -): Return => ({ model: { ...model, [field]: step.model }, commands: Command.mapMessages(step.commands ?? [], toMessage) }) - -const withCommands = (step: Return, commands: Return["commands"]): Return => ({ ...step, commands: [...step.commands, ...commands] }) - -const CLEARED: Partial = { - hazelOrganizationId: "", - hazelOrganizationName: "", - hazelOrganizationLogoUrl: null, - hazelChannelId: "", - hazelChannelName: "", -} - -export const update = (model: Model, message: Message, form: DestinationForm): Return => - Message.match(message, { - CompletedFetchStatus: ({ key, result }) => ({ model: { ...model, status: Keyed.accept(model.status, key, result) }, commands: [] }), - CompletedFetchOrganizations: ({ key, result }) => ({ - model: { ...model, organizations: Keyed.accept(model.organizations, key, result) }, - commands: [], - }), - CompletedFetchChannels: ({ key, result }) => ({ model: { ...model, channels: Keyed.accept(model.channels, key, result) }, commands: [] }), - ClickedConnect: () => - model.isConnecting ? { model, commands: [] } : { model: { ...model, isConnecting: true }, commands: [ConnectHazel()] }, - CompletedConnect: ({ isStarted }) => { - const settled = { ...model, isConnecting: false } - return isStarted ? reads(settled, form.hazelOrganizationId.trim(), "Refresh") : { model: settled, commands: [] } - }, - ClickedDisconnect: () => liftDialog(model, Dialog.open(model.confirmDisconnect)), - ConfirmedDisconnect: () => - model.isDisconnecting - ? { model, commands: [] } - : withCommands(liftDialog({ ...model, isDisconnecting: true }, Dialog.close(model.confirmDisconnect)), [DisconnectHazel()]), - CompletedDisconnect: () => ({ ...reads({ ...model, isDisconnecting: false }, form.hazelOrganizationId.trim(), "Refresh"), form: CLEARED }), - GotConfirmMessage: ({ message: inner }) => liftDialog(model, Dialog.update(model.confirmDisconnect, inner)), - GotOrganizationSelectMessage: ({ message: inner }) => { - const step = Select.update(model.organizationSelect, inner) - const lifted = liftSelect(model, "organizationSelect", step, (child) => Message.GotOrganizationSelectMessage({ message: child })) - if (step.outMessage === undefined) return lifted - const value = step.outMessage.value - const organization = Option.getOrElse(AsyncData.getData(model.organizations.data), () => []).find((org) => org.id === value) - const picked: Partial = { - hazelOrganizationId: value, - hazelOrganizationName: organization?.name ?? "", - hazelOrganizationLogoUrl: organization?.logoUrl ?? null, - hazelChannelId: "", - hazelChannelName: "", - } - const loaded = reads( - { ...lifted.model, channelSelect: Select.init(CHANNEL_SELECT_ID, { value: "", selectedIndex: null }) }, - value.trim(), - "Load", - ) - return { ...withCommands(loaded, lifted.commands), form: picked } - }, - GotChannelSelectMessage: ({ message: inner }) => { - const step = Select.update(model.channelSelect, inner) - const lifted = liftSelect(model, "channelSelect", step, (child) => Message.GotChannelSelectMessage({ message: child })) - if (step.outMessage === undefined) return lifted - const value = step.outMessage.value - const channel = Option.getOrElse(AsyncData.getData(model.channels.data), () => []).find((candidate) => candidate.id === value) - return { ...lifted, form: { hazelChannelId: value, hazelChannelName: channel?.name ?? form.hazelChannelName } } - }, - }) - -/** The fields mount: the selects read the form, and the reads they need go out. */ -export const reset = (model: Model, form: DestinationForm): Return => - reads( - { - ...model, - organizationSelect: Select.init(ORGANIZATION_SELECT_ID, { value: form.hazelOrganizationId, selectedIndex: null }), - channelSelect: Select.init(CHANNEL_SELECT_ID, { value: form.hazelChannelId, selectedIndex: null }), - }, - form.hazelOrganizationId.trim(), - "Load", - ) diff --git a/apps/web/src/page/alerts/destination/view.ts b/apps/web/src/page/alerts/destination/view.ts index 82a6a94f5b..ec9e7b9889 100644 --- a/apps/web/src/page/alerts/destination/view.ts +++ b/apps/web/src/page/alerts/destination/view.ts @@ -27,13 +27,12 @@ import { } from "./dialog" import { chatFields } from "./chat-fields-view" import { emailFields } from "./email-fields-view" -import { hazelFields } from "./hazel-fields-view" import { linearFields } from "./linear-fields-view" import { waitingFor } from "../shared/chat-connect" import { spinner } from "../../../ui/spinner" import { type TextField, toFormState } from "./form" -// The destination dialog. The Email, Hazel, Linear and chat fields live in `*-fields-view.ts`. +// The destination dialog. The Email, Linear and chat fields live in `*-fields-view.ts`. type H = HtmlBuilder @@ -264,7 +263,9 @@ const connectionFields = (h: H, model: Model, isEditing: boolean): ReadonlyArray ), h.Target("_blank"), h.Rel("noreferrer"), - h.Class("underline-offset-2 hover:text-foreground active:text-foreground hover:underline active:underline"), + h.Class( + "underline-offset-2 hover:text-foreground active:text-foreground hover:underline active:underline", + ), ], ["Events API v2"], ), @@ -390,15 +391,6 @@ const connectionFields = (h: H, model: Model, isEditing: boolean): ReadonlyArray ], ), ] - case "hazel-oauth": - return [ - hazelFields(h, { - model: model.hazel, - form, - isEditing, - toMessage: (message) => Message.GotHazelFieldsMessage({ message }), - }), - ] case "linear": return [ linearFields(h, { diff --git a/apps/web/src/page/alerts/editor/derive.ts b/apps/web/src/page/alerts/editor/derive.ts index ff32287f1b..c824364ed1 100644 --- a/apps/web/src/page/alerts/editor/derive.ts +++ b/apps/web/src/page/alerts/editor/derive.ts @@ -170,7 +170,7 @@ export const destinationTarget = ( ): { primary: string; secondary: string } => { const provider = destinationProvider(destination.type, destination.chatConnector ?? undefined).label const primary = - destination.type === "chat" || destination.type === "hazel-oauth" + destination.type === "chat" ? (destination.channelLabel ?? destination.name) : destination.type === "webhook" ? destination.summary.replace(/^POST /, "") diff --git a/apps/web/src/page/alerts/shared/commands.ts b/apps/web/src/page/alerts/shared/commands.ts index f1807097ec..309e9e22cc 100644 --- a/apps/web/src/page/alerts/shared/commands.ts +++ b/apps/web/src/page/alerts/shared/commands.ts @@ -47,8 +47,6 @@ export const updateDestination = ( return yield* client.alertDestinations.update({ params, payload }) case "webhook": return yield* client.alertDestinations.update({ params, payload }) - case "hazel-oauth": - return yield* client.alertDestinations.update({ params, payload }) case "discord": return yield* client.alertDestinations.update({ params, payload }) case "telegram": diff --git a/apps/web/src/page/alerts/shared/providers.ts b/apps/web/src/page/alerts/shared/providers.ts index c6c7e60b04..bf4c783c7d 100644 --- a/apps/web/src/page/alerts/shared/providers.ts +++ b/apps/web/src/page/alerts/shared/providers.ts @@ -9,7 +9,6 @@ import { LINEAR_ACCENT } from "../../integrations/catalog" // `PROVIDERS` and `ProviderLogo`: the destination providers and their marks. const BRANDFETCH_CLIENT_ID = "1id0IQ-4i8Z46-n-DfQ" -const HAZEL_ACCENT_TEXT = "light-dark(#A34209, #F46F0F)" const INK_ON_DARK_ACCENT = "#FFFFFF" const INK_ON_BRIGHT_ACCENT = "#1E1B17" @@ -62,21 +61,6 @@ export const PROVIDERS = new Map([ fallbackIcon: "CodeIcon", }, ], - [ - "hazel-oauth", - { - type: "hazel-oauth", - label: "Hazel", - description: "Connect Hazel via OAuth and pick a workspace to route alerts into.", - accent: "#F46F0F", - accentBg: "rgba(244,111,15,0.16)", - accentText: HAZEL_ACCENT_TEXT, - accentOn: INK_ON_BRIGHT_ACCENT, - fallbackIcon: "HazelIcon", - docsUrl: "https://hazel.sh/docs/integrations/maple", - docsLabel: "Hazel integration guide", - }, - ], [ "linear", { @@ -212,7 +196,6 @@ export const DESTINATION_TYPES: ReadonlyArray = [ "webhook", "telegram", "google-chat", - "hazel-oauth", "linear", ] export const destinationTypesFor = (isDiscordLinked: boolean): ReadonlyArray => diff --git a/apps/web/src/page/integrations/catalog.ts b/apps/web/src/page/integrations/catalog.ts index dcd19c40ab..a44eb6dd3b 100644 --- a/apps/web/src/page/integrations/catalog.ts +++ b/apps/web/src/page/integrations/catalog.ts @@ -18,13 +18,11 @@ export type IntegrationId = | "railway" | "supabase" | "warpstream" - | "hazel" | "linear" | "github" | ChatIntegrationId export const GITHUB_ACCENT = "#181717" -export const HAZEL_ACCENT = "#F46F0F" export const LINEAR_ACCENT = "#5E6AD5" export const CLOUDFLARE_ACCENT = "#F38020" export const RAILWAY_ACCENT = "#0B0D0E" @@ -165,16 +163,6 @@ export const CATALOG: ReadonlyArray = [ accent: "#E52344", docsUrl: docsUrl("warpstream"), }, - { - id: "hazel", - category: "notifications", - name: "Hazel", - description: - "Forward Maple alerts into a Hazel workspace via OAuth. Pick destinations per notification.", - icon: named("HazelIcon"), - accent: HAZEL_ACCENT, - docsUrl: "https://hazel.sh/docs/integrations/maple", - }, { id: "linear", category: "notifications", diff --git a/apps/web/src/page/integrations/command.ts b/apps/web/src/page/integrations/command.ts index 1709557da5..667675e5de 100644 --- a/apps/web/src/page/integrations/command.ts +++ b/apps/web/src/page/integrations/command.ts @@ -138,19 +138,6 @@ export const LoadPlanetscaleDatabases = Command.define("LoadIntegrationsPlanetsc ), }) -export const LoadHazel = Command.define("LoadIntegrationsHazelStatus", { - args: Args, - messages: [Message.CompletedLoadHazel], - execute: ({ key }) => - Effect.gen(function* () { - const client = yield* MapleInternalApiClient - return yield* client.integrations.hazelStatus() - }).pipe( - settle, - Effect.map((result) => Message.CompletedLoadHazel({ key, result })), - ), -}) - export const LoadLinear = Command.define("LoadIntegrationsLinearStatus", { args: Args, messages: [Message.CompletedLoadLinear], diff --git a/apps/web/src/page/integrations/connect.ts b/apps/web/src/page/integrations/connect.ts index f051746483..27fd3e7fb8 100644 --- a/apps/web/src/page/integrations/connect.ts +++ b/apps/web/src/page/integrations/connect.ts @@ -1,7 +1,6 @@ import { CloudflareStartConnectRequest, GithubStartConnectRequest, - HazelStartConnectRequest, IntegrationReturnPath, LinearStartConnectRequest, SupabaseConnectRequest, @@ -12,14 +11,7 @@ import { MapleApiV2Client, MapleInternalApiClient } from "../../platform/api-cli // `IntegrationConnectProvider` and `useOAuthPopupFlow`. -export const ConnectProvider = Schema.Literals([ - "cloudflare", - "hazel", - "linear", - "github", - "planetscale", - "supabase", -]) +export const ConnectProvider = Schema.Literals(["cloudflare", "linear", "github", "planetscale", "supabase"]) export type ConnectProvider = typeof ConnectProvider.Type /** One attempt: the popup is out while this is set; a new attempt restarts the flow. */ @@ -63,14 +55,6 @@ export const PROVIDERS = { closeGraceMs: 15_000, polls: true, }, - hazel: { - label: "Hazel", - windowName: "maple-hazel-connect", - windowFeatures: "popup,width=520,height=640", - startErrorTitle: "Couldn't start Hazel connect flow", - closeGraceMs: 0, - polls: false, - }, linear: { label: "Linear", windowName: "maple-linear-connect", @@ -126,10 +110,6 @@ export const startConnect = (provider: ConnectProvider, returnTo: IntegrationRet return yield* internal.integrations.cloudflareStart({ payload: new CloudflareStartConnectRequest({ returnTo }), }) - case "hazel": - return yield* internal.integrations.hazelStart({ - payload: new HazelStartConnectRequest({ returnTo }), - }) case "linear": return yield* internal.integrations.linearStart({ payload: new LinearStartConnectRequest({ returnTo }), diff --git a/apps/web/src/page/integrations/derive.ts b/apps/web/src/page/integrations/derive.ts index 37877bab49..c31c0ed140 100644 --- a/apps/web/src/page/integrations/derive.ts +++ b/apps/web/src/page/integrations/derive.ts @@ -278,8 +278,6 @@ export const cardStatusOf = (statuses: Statuses, id: IntegrationId, nowMs: numbe ) case "warpstream": return { label: "Via Prometheus", variant: "outline" } - case "hazel": - return fold(statuses.hazel, (status) => connectedOrNot(status.connected), unavailableStatus) case "linear": return fold( statuses.linear, @@ -553,24 +551,6 @@ const planetscaleOverview = (statuses: Statuses, nowMs: number) => { ) } -const hazelOverview = (statuses: Statuses) => - fold( - statuses.hazel, - (status): IntegrationOverview => - status.connected - ? { - kind: "connected", - health: "healthy", - stateLabel: "Healthy", - context: status.externalUserEmail, - stat: "Alert delivery ready", - lastSyncLabel: null, - issue: null, - } - : CONNECT, - unavailableOverview, - ) - const linearOverview = (statuses: Statuses) => fold( statuses.linear, @@ -680,8 +660,6 @@ export const overviewOf = (statuses: Statuses, nowMs: number, id: IntegrationId) return planetscaleOverview(statuses, nowMs) case "warpstream": return SET_UP - case "hazel": - return hazelOverview(statuses) case "linear": return linearOverview(statuses) case "github": diff --git a/apps/web/src/page/integrations/disconnect.ts b/apps/web/src/page/integrations/disconnect.ts index 75fdac53ff..92acb0b08b 100644 --- a/apps/web/src/page/integrations/disconnect.ts +++ b/apps/web/src/page/integrations/disconnect.ts @@ -17,17 +17,6 @@ export interface DisconnectSpec { } export const DISCONNECTS = new Map([ - [ - "hazel", - { - title: "Disconnect Hazel", - description: "Alerts routed to Hazel stop being delivered until you reconnect.", - confirmLabel: "Disconnect", - success: "Hazel disconnected", - error: "Couldn't disconnect Hazel", - refresh: ["hazel"], - }, - ], [ "linear", { @@ -105,8 +94,6 @@ const mutation = (integration: string) => const internal = yield* MapleInternalApiClient const v2 = yield* MapleApiV2Client switch (integration) { - case "hazel": - return yield* Effect.asVoid(internal.integrations.hazelDisconnect()) case "linear": return yield* Effect.asVoid(internal.integrations.linearDisconnect()) case "github": diff --git a/apps/web/src/page/integrations/hub.scene.test.ts b/apps/web/src/page/integrations/hub.scene.test.ts index b7b301895a..acc336d431 100644 --- a/apps/web/src/page/integrations/hub.scene.test.ts +++ b/apps/web/src/page/integrations/hub.scene.test.ts @@ -1,11 +1,10 @@ import { OrgId } from "@maple/domain" -import { HazelIntegrationStatus, LinearIntegrationStatus } from "@maple/domain/http" +import { LinearIntegrationStatus } from "@maple/domain/http" import { V2ChatConnectorList } from "@maple/domain/http/v2" import { Option, Result, Schema } from "effect" import { AsyncData } from "foldkit" -import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { all, expect as expectView, expectAll, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" -import { toLoadFailure } from "../../data/load-failure" import { PageOutMessage, type PageContext } from "../contract" import { integrationOfSearch } from "./catalog" import { cardStatusOf } from "./derive" @@ -24,23 +23,28 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = const steps = (model: Model, messages: ReadonlyArray) => messages.reduce((current, message) => pageUpdate(current, message).model, model) -const hazel = (connected: boolean) => - Schema.decodeUnknownSync(HazelIntegrationStatus)({ - connected, - externalUserId: null, - externalUserEmail: connected ? "ada@acme.test" : null, - connectedByUserId: null, - scope: connected ? "openid" : null, - }) - const entered = (search: Model["search"] = {}) => init({ search }, context).model -const withHazel = (connected: boolean, search: Model["search"] = {}): Model => { +/** The Hazel chat connector, available and not yet installed. */ +const withHazelConnector = (search: Model["search"]): Model => { const model = entered(search) + const connectors = Schema.decodeUnknownSync(V2ChatConnectorList)({ + object: "chat_connector_list", + data: [ + { + id: "hazel", + object: "chat_connector", + name: "Hazel", + available: true, + supports_identity: true, + workspaces: [], + }, + ], + }) return steps(model, [ - Message.CompletedLoadHazel({ - key: model.statuses.hazel.key ?? "", - result: Result.succeed(hazel(connected)), + Message.CompletedLoadChat({ + key: model.statuses.chat.key ?? "", + result: Result.succeed(connectors), }), ]) } @@ -53,8 +57,11 @@ const linear = (state: "absent" | "connected" | "revoked") => revoked: state === "revoked", }) -const withLinear = (state: "absent" | "connected" | "revoked"): Model => { - const model = entered({ integration: "linear" }) +const withLinear = ( + state: "absent" | "connected" | "revoked", + search: Model["search"] = { integration: "linear" }, +): Model => { + const model = entered(search) return steps(model, [ Message.CompletedLoadLinear({ key: model.statuses.linear.key ?? "", @@ -78,7 +85,6 @@ describe("integrations hub", () => { "LoadIntegrationsAwsStatus", "LoadIntegrationsSupabaseStatus", "LoadIntegrationsLinearStatus", - "LoadIntegrationsHazelStatus", "LoadIntegrationsGithubStatus", "LoadIntegrationsChatConnectors", "LoadIntegrationsScrapeTargets", @@ -92,48 +98,49 @@ describe("integrations hub", () => { const model = entered() const late = pageUpdate( model, - Message.CompletedLoadHazel({ key: "stale", result: Result.succeed(hazel(true)) }), + Message.CompletedLoadLinear({ key: "stale", result: Result.succeed(linear("connected")) }), ) - expect(late.model.statuses.hazel).toEqual(model.statuses.hazel) + expect(late.model.statuses.linear).toEqual(model.statuses.linear) }) test("a write's re-read supersedes the read in flight, so the older answer cannot land last", () => { const model = entered() - const preWriteKey = model.statuses.hazel.key ?? "" - const retried = pageUpdate(model, Message.ClickedRetryStatus({ name: "hazel" })) - expect(names(retried.commands)).toEqual(["LoadIntegrationsHazelStatus"]) + const preWriteKey = model.statuses.linear.key ?? "" + const retried = pageUpdate(model, Message.ClickedRetryStatus({ name: "linear" })) + expect(names(retried.commands)).toEqual(["LoadIntegrationsLinearStatus"]) const fresh = pageUpdate( retried.model, - Message.CompletedLoadHazel({ - key: retried.model.statuses.hazel.key ?? "", - result: Result.succeed(hazel(false)), + Message.CompletedLoadLinear({ + key: retried.model.statuses.linear.key ?? "", + result: Result.succeed(linear("absent")), }), ).model const late = pageUpdate( fresh, - Message.CompletedLoadHazel({ key: preWriteKey, result: Result.succeed(hazel(true)) }), + Message.CompletedLoadLinear({ key: preWriteKey, result: Result.succeed(linear("connected")) }), ).model - expect(Option.map(AsyncData.getData(late.statuses.hazel.data), (status) => status.connected)).toEqual( - Option.some(false), - ) + expect( + Option.map(AsyncData.getData(late.statuses.linear.data), (status) => status.connected), + ).toEqual(Option.some(false)) }) test("a connected status lists the integration under Connected; selecting it navigates", () => { scene( { update: pageUpdate, view }, - given(withHazel(true)), + given(withLinear("connected", {})), expectView(text("Connected")).toExist(), - expectView(text("ada@acme.test")).toExist(), + expectView(text("Acme Eng")).toExist(), ) expect( - pageUpdate(withHazel(true), Message.ClickedIntegration({ integration: "hazel" })).outMessage, + pageUpdate(withLinear("connected", {}), Message.ClickedIntegration({ integration: "linear" })) + .outMessage, ).toEqual( - PageOutMessage.RequestedNavigation({ url: "/integrations?integration=hazel", isReplace: false }), + PageOutMessage.RequestedNavigation({ url: "/integrations?integration=linear", isReplace: false }), ) }) test("the back button returns to the hub", () => { - const model = withHazel(true, { integration: "hazel" }) + const model = withLinear("connected") expect(pageUpdate(model, Message.ClickedIntegration({ integration: null })).outMessage).toEqual( PageOutMessage.RequestedNavigation({ url: "/integrations", isReplace: false }), ) @@ -209,78 +216,21 @@ describe("integrations hub", () => { expect(integrationOfSearch("chat-discord")).toBe("chat-discord") expect(integrationOfSearch("chat-nope")).toBeNull() }) -}) -describe("hazel card", () => { - test("not connected shows the connect card; connect starts one attempt at a time", () => { - const model = withHazel(false, { integration: "hazel" }) + test("the hub lists Hazel once, as its chat connector", () => { scene( { update: pageUpdate, view }, - given(model), - expectView( - text("Alert destinations will appear here after connecting your workspace."), - ).toExist(), + given(withHazelConnector({})), + expectAll(all.role("button", { name: /Hazel/ })).toHaveCount(1), + expectView(text(/Add the Maple bot to a Hazel workspace/)).toExist(), ) - const started = pageUpdate(model, Message.ClickedConnect()) - expect(started.model.connect.active).toEqual({ provider: "hazel", attempt: 1 }) - expect(pageUpdate(started.model, Message.ClickedConnect()).model.connect.attempts).toBe(1) - const answered = pageUpdate(started.model, Message.SucceededStartConnect({ attempt: 1 })) - expect(answered.model.connect.isStarting).toBe(false) - expect(names(answered.commands)).toEqual(["LoadIntegrationsHazelStatus"]) - const closed = pageUpdate(answered.model, Message.ClosedConnectPopup({ attempt: 1 })) - expect(closed.model.connect.active).toBeNull() - }) - - test("a failed start toasts the provider's title and ends the attempt", () => { - const started = pageUpdate(withHazel(false, { integration: "hazel" }), Message.ClickedConnect()).model - const failed = pageUpdate( - started, - Message.FailedStartConnect({ - attempt: 1, - title: "Couldn't start Hazel connect flow", - description: "nope", - }), - ) - expect(failed.model.connect.active).toBeNull() - expect(failed.outMessage).toEqual( - PageOutMessage.RequestedToast({ - type: "error", - title: "Couldn't start Hazel connect flow", - description: "nope", - }), - ) - }) - - test("disconnect confirms first, sends once, toasts and re-reads the status", () => { - const model = withHazel(true, { integration: "hazel" }) - const opened = pageUpdate(model, Message.ClickedDisconnect()).model - expect(opened.disconnect.dialog.isOpen).toBe(true) - const confirmed = pageUpdate(opened, Message.ConfirmedDisconnect()) - expect(names(confirmed.commands)).toContain("DisconnectIntegration") - expect(names(pageUpdate(confirmed.model, Message.ConfirmedDisconnect()).commands)).toEqual([]) - const done = pageUpdate( - confirmed.model, - Message.CompletedDisconnect({ integration: "hazel", failure: null }), - ) - expect(done.outMessage).toEqual( - PageOutMessage.RequestedToast({ type: "success", title: "Hazel disconnected", description: "" }), - ) - expect(names(done.commands)).toEqual(["LoadIntegrationsHazelStatus"]) - }) - - test("a failed status read offers a retry", () => { - const model = entered({ integration: "hazel" }) - const failed = steps(model, [ - Message.CompletedLoadHazel({ - key: model.statuses.hazel.key ?? "", - result: Result.fail(toLoadFailure("boom")), + expect( + pageUpdate(entered(), Message.ClickedIntegration({ integration: "chat-hazel" })).outMessage, + ).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/integrations?integration=chat-hazel", + isReplace: false, }), - ]) - scene( - { update: pageUpdate, view }, - given(failed), - expectView(text("Couldn't load the Hazel integration")).toExist(), - expectView(role("button", { name: /Retry|Try again/ })).toExist(), ) }) }) diff --git a/apps/web/src/page/integrations/message.ts b/apps/web/src/page/integrations/message.ts index fc07d13041..c89d5f3262 100644 --- a/apps/web/src/page/integrations/message.ts +++ b/apps/web/src/page/integrations/message.ts @@ -4,7 +4,6 @@ import { CloudflareUsageResponse, GcpResourcesResponse, GithubIntegrationStatus, - HazelIntegrationStatus, LinearIntegrationStatus, RailwayIntegrationStatus, ScrapeTargetId, @@ -62,7 +61,6 @@ export const StatusName = Schema.Literals([ "gcp", "aws", "supabase", - "hazel", "linear", "github", "chat", @@ -84,7 +82,6 @@ export const Message = defineMessageUnion({ CompletedLoadGcp: result(V2GcpIntegration), CompletedLoadAws: result(V2AwsIntegration), CompletedLoadSupabase: result(SupabaseIntegrationStatus), - CompletedLoadHazel: result(HazelIntegrationStatus), CompletedLoadLinear: result(LinearIntegrationStatus), CompletedLoadGithub: result(GithubIntegrationStatus), CompletedLoadChat: result(V2ChatConnectorList), diff --git a/apps/web/src/page/integrations/model.ts b/apps/web/src/page/integrations/model.ts index a12b4a8094..44babc449c 100644 --- a/apps/web/src/page/integrations/model.ts +++ b/apps/web/src/page/integrations/model.ts @@ -2,7 +2,6 @@ import { BillingCustomer, CloudflareIntegrationStatus, GithubIntegrationStatus, - HazelIntegrationStatus, LinearIntegrationStatus, RailwayIntegrationStatus, SupabaseIntegrationStatus, @@ -44,7 +43,6 @@ export const Statuses = Schema.Struct({ gcp: keyed(V2GcpIntegration), aws: keyed(V2AwsIntegration), supabase: keyed(SupabaseIntegrationStatus), - hazel: keyed(HazelIntegrationStatus), linear: keyed(LinearIntegrationStatus), github: keyed(GithubIntegrationStatus), chat: keyed(V2ChatConnectorList), diff --git a/apps/web/src/page/integrations/oauth-return.scene.test.ts b/apps/web/src/page/integrations/oauth-return.scene.test.ts index e57e21f01e..139b503481 100644 --- a/apps/web/src/page/integrations/oauth-return.scene.test.ts +++ b/apps/web/src/page/integrations/oauth-return.scene.test.ts @@ -77,8 +77,8 @@ describe("a provider return finished by the dashboard", () => { test("a refusal with no opener to tell is shown here", () => { const result = finished( - opened("hazel"), - outcome({ provider: "hazel", status: "error", message: "OAuth state not recognized." }), + opened("linear"), + outcome({ provider: "linear", status: "error", message: "OAuth state not recognized." }), ) expect(result.outMessage?._tag).toBe("RequestedToast") if (result.outMessage?._tag !== "RequestedToast") return diff --git a/apps/web/src/page/integrations/oauth-return.ts b/apps/web/src/page/integrations/oauth-return.ts index 071c938c18..99f8fcd676 100644 --- a/apps/web/src/page/integrations/oauth-return.ts +++ b/apps/web/src/page/integrations/oauth-return.ts @@ -17,7 +17,6 @@ import { ConnectProvider } from "./connect" /** The provider a popup reports to (`ReceivedConnectMessage`); the chat flows report via search. */ const PROVIDER_OF = { cloudflare: "cloudflare", - hazel: "hazel", linear: "linear", github: "github", planetscale: "planetscale", diff --git a/apps/web/src/page/integrations/update-connect.ts b/apps/web/src/page/integrations/update-connect.ts index 2b292ec1fb..ed809a7327 100644 --- a/apps/web/src/page/integrations/update-connect.ts +++ b/apps/web/src/page/integrations/update-connect.ts @@ -17,7 +17,6 @@ type Return = PageReturn const STATUS_OF = { cloudflare: "cloudflare", - hazel: "hazel", linear: "linear", github: "github", planetscale: "planetscale", @@ -115,7 +114,6 @@ export const endedGrace = (model: Model, attempt: number): Return => const CONNECTED_TITLE = { cloudflare: "Cloudflare account connected", - hazel: "Hazel connected", linear: "Linear connected", github: "GitHub connected", planetscale: null, diff --git a/apps/web/src/page/integrations/update-status.ts b/apps/web/src/page/integrations/update-status.ts index a097a67024..b9e0bdf143 100644 --- a/apps/web/src/page/integrations/update-status.ts +++ b/apps/web/src/page/integrations/update-status.ts @@ -10,7 +10,6 @@ import { LoadCloudflare, LoadGcp, LoadGithub, - LoadHazel, LoadLinear, LoadPlanetscale, LoadPlanetscaleDatabases, @@ -34,7 +33,6 @@ const OPERATIONS = { gcp: "gcpIntegration.status", aws: "awsIntegration.status", supabase: "integrations.supabaseStatus", - hazel: "integrations.hazelStatus", linear: "integrations.linearStatus", github: "integrations.githubStatus", chat: "chatIntegration.connectors", @@ -118,12 +116,6 @@ export const requestStatus = ( (all, supabase) => ({ ...all, supabase }), (slotKey) => LoadSupabase({ key: slotKey, refresh }), ) - case "hazel": - return go( - s.hazel, - (all, hazel) => ({ ...all, hazel }), - (slotKey) => LoadHazel({ key: slotKey }), - ) case "linear": return go( s.linear, @@ -173,7 +165,6 @@ export const STATUS_NAMES: ReadonlyArray = [ "gcp", "aws", "supabase", - "hazel", "linear", "github", "chat", diff --git a/apps/web/src/page/integrations/update.ts b/apps/web/src/page/integrations/update.ts index 8b2f877a50..1c0e894752 100644 --- a/apps/web/src/page/integrations/update.ts +++ b/apps/web/src/page/integrations/update.ts @@ -119,7 +119,6 @@ export const init = (route: { readonly search: Search }, context: PageContext): gcp: Keyed.idle(), aws: Keyed.idle(), supabase: Keyed.idle(), - hazel: Keyed.idle(), linear: Keyed.idle(), github: Keyed.idle(), chat: Keyed.idle(), @@ -236,8 +235,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re ) }, CompletedLoadSupabaseMetrics: ({ key, result }) => Supabase.completedMetricsRows(model, key, result), - CompletedLoadHazel: ({ key, result }) => - accepted(model, { ...s, hazel: Keyed.accept(s.hazel, key, result) }), CompletedLoadLinear: ({ key, result }) => accepted(model, { ...s, linear: Keyed.accept(s.linear, key, result) }), CompletedLoadGithub: ({ key, result }) => diff --git a/apps/web/src/page/integrations/view-hazel.ts b/apps/web/src/page/integrations/view-hazel.ts deleted file mode 100644 index 49d84a9e37..0000000000 --- a/apps/web/src/page/integrations/view-hazel.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { AsyncData } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { button } from "../../ui/button" -import { badge } from "../../ui/display" -import { errorState } from "../../ui/error-state" -import { icon } from "../../ui/icon" -import { item, itemContent, itemMedia } from "../../ui/item" -import { skeleton } from "../../ui/skeleton" -import { - integrationEmpty, - integrationEmptyCard, - integrationEmptyFeatures, - integrationEmptyFooter, - integrationEmptyHint, - integrationEmptyMedia, - integrationIconPlate, -} from "../infra-integrations/kit/integration-empty" -import { HAZEL_ACCENT } from "./catalog" -import { Message } from "./message" -import type { Model } from "./model" -import { disconnectDialog } from "./view-disconnect" - -// `HazelIntegrationCard`. - -type H = HtmlBuilder - -const BRAND = { icon: "HazelIcon", accent: HAZEL_ACCENT } as const - -const notConnected = (h: H, model: Model): Html => - integrationEmpty(h, [ - integrationEmptyFeatures(h, [ - { - label: "Alert delivery", - title: "Alerts post to Hazel", - description: "Fired alerts land straight in the channel you pick.", - }, - { - label: "Channel routing", - title: "Workspace per destination", - description: "Each destination picks the workspace and channel that gets notified.", - }, - { - label: "Escalations", - title: "A step in your policies", - description: "Use Hazel as a delivery step inside escalation chains.", - }, - ]), - integrationEmptyCard(h, [ - integrationEmptyMedia(h, BRAND), - integrationEmptyHint(h, ["Alert destinations will appear here after connecting your workspace."]), - button( - h, - { - isDisabled: model.connect.isStarting || model.disconnect.isPending, - isLoading: model.connect.isStarting, - attributes: [h.OnClick(Message.ClickedConnect())], - }, - [icon(h, "HazelIcon"), "Connect Hazel"], - ), - integrationEmptyFooter(h, ["You'll authorize Maple in your Hazel workspace."]), - ]), - ]) - -const connected = ( - h: H, - model: Model, - status: { - readonly externalUserEmail: string | null - readonly externalUserId: string | null - readonly scope: string | null - }, -): Html => { - const busy = model.connect.isStarting || model.disconnect.isPending - return item(h, { variant: "card", className: "items-start gap-4 p-4" }, [ - itemMedia(h, {}, [integrationIconPlate(h, BRAND)]), - itemContent( - h, - [ - h.div( - [], - [ - h.div( - [h.Class("flex items-center gap-2")], - [ - h.h3([h.Class("text-sm font-semibold")], ["Hazel"]), - badge(h, { variant: "ok" }, ["Connected"]), - ], - ), - h.p( - [h.Class("mt-1 text-xs text-muted-foreground")], - [ - "Forward Maple alerts into a Hazel workspace via OAuth. Once connected, create a Hazel destination to pick which workspace receives notifications.", - ], - ), - ], - ), - h.div( - [ - h.Class( - "flex flex-col gap-1 rounded-md bg-surface-sunken px-3 py-2 text-2xs text-muted-foreground", - ), - ], - [ - ...(status.externalUserEmail !== null && status.externalUserEmail !== "" - ? [ - h.div( - [h.Class("[overflow-wrap:anywhere]")], - [ - h.span([h.Class("text-foreground")], [status.externalUserEmail]), - " authorized this connection.", - ], - ), - ] - : status.externalUserId !== null && status.externalUserId !== "" - ? [h.div([], [`External account: ${status.externalUserId}`])] - : []), - ...(status.scope !== null && status.scope !== "" - ? [h.div([], [`Scopes: ${status.scope}`])] - : []), - ], - ), - h.div( - [h.Class("flex flex-wrap gap-2")], - [ - button( - h, - { - size: "sm", - variant: "outline", - isDisabled: busy, - isLoading: model.connect.isStarting, - attributes: [h.OnClick(Message.ClickedConnect())], - }, - ["Reconnect"], - ), - button( - h, - { - size: "sm", - variant: "outline", - isDisabled: busy, - isLoading: model.disconnect.isPending, - attributes: [h.OnClick(Message.ClickedDisconnect())], - }, - ["Disconnect"], - ), - ], - ), - disconnectDialog(h, model, "hazel"), - ], - "gap-2", - ), - ]) -} - -export const hazelCard = (h: H, model: Model): Html => - AsyncData.match(model.statuses.hazel.data, { - onIdle: () => skeleton(h, "h-32 w-full rounded-md"), - onLoading: () => skeleton(h, "h-32 w-full rounded-md"), - onFailure: (failure) => - errorState(h, { - failure, - title: "Couldn't load the Hazel integration", - onRetry: Message.ClickedRetryStatus({ name: "hazel" }), - }), - onStale: ({ data }) => (data.connected ? connected(h, model, data) : notConnected(h, model)), - onRefreshing: (data) => (data.connected ? connected(h, model, data) : notConnected(h, model)), - onSuccess: (data) => (data.connected ? connected(h, model, data) : notConnected(h, model)), - }) diff --git a/apps/web/src/page/integrations/view.ts b/apps/web/src/page/integrations/view.ts index 1aec5f3fc4..735f70d27d 100644 --- a/apps/web/src/page/integrations/view.ts +++ b/apps/web/src/page/integrations/view.ts @@ -17,7 +17,6 @@ import { chatCard } from "./view-chat-card" import { cloudflareCard, cloudflareHeaderActions } from "./view-cloudflare" import { gcpCard, gcpHeaderStatus } from "./view-gcp" import { githubCard } from "./view-github" -import { hazelCard } from "./view-hazel" import { linearCard } from "./view-linear" import { planetscaleCard } from "./view-planetscale" import { railwayCard } from "./view-railway" @@ -61,8 +60,6 @@ export const cardView = ( const connector = chatConnectorOf(integration) if (connector !== null) return [chatCard(h, model, connector, timeZone)] switch (integration) { - case "hazel": - return [hazelCard(h, model)] case "linear": return [linearCard(h, model)] case "github": diff --git a/apps/web/src/page/onboarding/quick-start-drawer.ts b/apps/web/src/page/onboarding/quick-start-drawer.ts index c24c66a411..727ab24758 100644 --- a/apps/web/src/page/onboarding/quick-start-drawer.ts +++ b/apps/web/src/page/onboarding/quick-start-drawer.ts @@ -132,7 +132,6 @@ const PLATFORM_OF_FLOW = { planetscale: "planetscale", supabase: "supabase", github: null, - hazel: null, linear: null, chat_install: null, chat_identity: null, diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index 912359d87f..ffa728331a 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -53,11 +53,16 @@ export const routeRedirect = (route: AppRoute): Option.Option => : AppRoute.Integrations({ search: {} }), false, ), - /** Old links and OAuth returns to a data integration's page open its step under Sources. */ + /** + * Old links and OAuth returns to a data integration's page open its step under Sources. + * Hazel's app still links to the id its connector replaced. + */ Integrations: ({ search }) => search.integration !== undefined && DATA_INTEGRATION_IDS.has(search.integration) ? redirect(AppRoute.SourcesNew({ search: { integration: search.integration } }), true) - : Option.none(), + : search.integration === "hazel" + ? redirect(AppRoute.Integrations({ search: { integration: "chat-hazel" } }), true) + : Option.none(), Developer: ({ search }) => redirect( AppRoute.Settings({ diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 5b7f8dbc37..6b1bb7a31b 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -64,6 +64,13 @@ describe("routes", () => { ) }) + test("the old Hazel integration link opens the Hazel chat connector", () => { + expect(routeRedirect(parse("/integrations?integration=hazel"))).toEqual( + Option.some({ url: "/integrations?integration=chat-hazel", isReplace: true }), + ) + expect(routeRedirect(parse("/integrations?integration=chat-hazel"))).toEqual(Option.none()) + }) + test("an org outside the statusPages rollout is sent home from every status page route", () => { for (const path of ["/status-pages", "/status-pages/new", "/status-pages/stp_abc"]) { expect(flaggedRouteRedirect(parse(path), { statusPages: false })).toEqual( diff --git a/apps/web/src/ui/icons.generated.ts b/apps/web/src/ui/icons.generated.ts index 23e6d5f215..464c8826a6 100644 --- a/apps/web/src/ui/icons.generated.ts +++ b/apps/web/src/ui/icons.generated.ts @@ -11249,32 +11249,6 @@ export const ICON_TREES = { ], ], ], - HazelIcon: [ - "svg", - [ - ["xmlns", "http://www.w3.org/2000/svg"], - ["viewBox", "0 0 266 219"], - ["width", "7777"], - ["height", "7777"], - ["class", "__icon_class__"], - ["fill", "currentColor"], - ["aria-hidden", "true"], - ], - [ - [ - "path", - [ - ["fill-rule", "evenodd"], - ["clip-rule", "evenodd"], - [ - "d", - "M155 124.5C167.16 122 187.95 109.3 194.35 96.5H194.35C186.18 82.16 159.85 55 119.85 61C117.85 55.83 111.35 43.4 101.35 35C89.35 43 36.85 167.5 93.35 219C65.35 210.16 7.65 178.6 0.85 123C-7.64 53.5 48.85 8.5 98.85 8.5C113.44 8.5 132.42 10.75 151.99 13.08C199.45 18.73 250.35 24.78 250.35 0C270.18 29 289 137.5 172.5 183.5C172.84 182.02 173.22 180.49 173.61 178.92C177.93 161.62 183.42 139.63 155 124.5ZM146 88.5C146 93.19 142.19 97 137.5 97C132.80 97 129 93.19 129 88.5C129 83.80 132.80 80 137.5 80C142.19 80 146 83.80 146 88.5ZM120.78 136.70C92.00 138.35 87.77 156.55 89.25 165.44C89.25 169.30 95.96 172.51 102.08 175.45C107.69 178.13 112.82 180.59 111.89 183.10C109.95 188.35 100.57 188.57 96.12 188.03C99.90 198.70 111.00 219.39 125.23 216.77C139.46 214.14 150.02 196.51 153.53 188.03C149.49 188.85 137.76 186.38 137.76 181.46C137.76 179.71 140.55 178.54 144.14 177.01C150.71 174.23 160 170.31 160 159.70C159.03 139.99 136.95 135.33 126.04 135.47C128.30 127.59 134.80 124.25 137.76 123.56C136.79 118.97 132.78 117.27 130.89 117C123.45 122.58 121.05 132.46 120.78 136.70Z", - ], - ], - [], - ], - ], - ], LinearIcon: [ "svg", [ diff --git a/docs/api-v2.md b/docs/api-v2.md index 295467542d..8443550ea0 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -27,7 +27,7 @@ PlanetScale was the first promotion under that rule, and it shows what "a public Google Cloud started on v2 for the same reason: there is no OAuth, so the whole setup is scriptable. A caller creates a connector for a project, a folder or an organization with a scoped key, chooses whether it forwards logs and whether Maple may read metrics and resources, and runs the returned `gcloud` script. The script tells Maple what it applied, which `status` returns as `applied_logs_enabled` and `applied_metrics_enabled`, so a caller can see within seconds that the run finished. `setup_scripts` takes `application_logs`, the runtimes (`cloud_run`, `cloud_functions`, `app_engine`, `gke`) whose stdout and stderr the log sink forwards; platform, request and audit logs are forwarded either way. Omitted, an existing sink keeps its filter and a new one forwards all but `gke`, because instrumented workloads already send theirs over OTLP; given, the script sets the filter to exactly that list. `status` is ungated; creating, updating and deleting connectors and `setup_scripts` (it carries the push secret, and is a POST so a read-scoped key cannot fetch it) require org-admin. What a connector discovers is not public yet: the resource inventory (`/internal/integrations/gcp/resources`) and the per-service metric tables (`/internal/query-engine/gcp-infra-*`) serve only the Infrastructure page while their contracts settle. -Cloudflare, GitHub, Hazel, and Railway live on the internal `integrations` group (`/internal/integrations`) with no v2 counterpart. They predate v2 and nothing public depends on them. Per the rule above, each gets promoted individually if something does. Scope families are derived mechanically from the first path segment under `/v2`, so every provider mounted at `/v2/integrations/` shares the `integrations:read` / `integrations:write` family. +Cloudflare, GitHub, and Railway live on the internal `integrations` group (`/internal/integrations`) with no v2 counterpart. They predate v2 and nothing public depends on them. Per the rule above, each gets promoted individually if something does. Scope families are derived mechanically from the first path segment under `/v2`, so every provider mounted at `/v2/integrations/` shares the `integrations:read` / `integrations:write` family. Two things never move with a provider, no matter how much of it is promoted: the OAuth **callback** and any webhook **receiver**. Both are raw `HttpRouter` routes under `/api/…` serving a browser redirect or a provider POST, and a receiver URL is already registered in the provider's settings. So `POST /v2/integrations/planetscale/connect` still mints a `/api/integrations/planetscale/callback` URL, and `webhook_config` still reports a `/api/…` receiver. That is the intended end state. diff --git a/docs/eu-region-plan.md b/docs/eu-region-plan.md index fd2984e0b4..3f3cf0037f 100644 --- a/docs/eu-region-plan.md +++ b/docs/eu-region-plan.md @@ -43,7 +43,7 @@ there is no per-org routing anywhere, because each instance knows exactly one re | Repository sandbox (`apps/sandbox`) | prd Worker, US | per instance, EU Worker | | Landing, billing | shared | shared, no customer data | | GitHub App, chat connector apps | US apps | EU apps of their own, see Phase 0 step 8 | -| OAuth clients (Cloudflare, PlanetScale, Hazel) | US redirect URIs | same clients, EU redirect URIs added | +| OAuth clients (Cloudflare, PlanetScale) | US redirect URIs | same clients, EU redirect URIs added | | Scraper (Fargate) | US service | EU service of its own, in the EU ingest VPC | The landing site stays one site. Billing metadata is not customer telemetry. The GitHub App was @@ -100,7 +100,6 @@ across regions because there is no routing. allowlist). The US client is reusable; add the EU URL to its allowlist: - Cloudflare OAuth: `https://api.eu.maple.dev/api/integrations/cloudflare/callback` - PlanetScale OAuth: `https://api.eu.maple.dev/api/integrations/planetscale/callback` - - Hazel OAuth: `https://api.eu.maple.dev/api/integrations/hazel/callback` - **One URL per app** (registered on the app, not sent per request). These need an EU app: - **GitHub App.** Register "Maple EU" per `docs/github-app-setup.md` with webhook `https://api.eu.maple.dev/api/integrations/github/webhook` and callback diff --git a/docs/http-api-migration.md b/docs/http-api-migration.md index 2f3afc729d..32b20e2ceb 100644 --- a/docs/http-api-migration.md +++ b/docs/http-api-migration.md @@ -10,16 +10,16 @@ The governing rule is consumer intent, not transport convenience: ## Where each v1 group went -| v1 group | Destination | -| --- | --- | -| `apiKeys`, `ingestKeys`, `ingestAttributeMappings`, `recommendationIssues`, `scrapeTargets`, `investigations`, `anomalies`, `dashboards`, PlanetScale | `/v2`; v1 deleted 2026-08-14 | -| `observability`, `onboarding`, `warehouse` | Deleted outright (no callers) | -| `queryEngine`, `billing`, `demo`, `digest`, `aiTriage`, `chat` (apply) | `/internal`, August 2026 | -| `sessionReplays` | `/v2/session_replays`; v1 deleted 2026-10-08 after 30 quiet days. Facets and trace summaries are `/internal/session-replays`. | -| `errors` | `/internal/errors`, 2026-10-08. Ten operations without a caller were deleted. Reads are `/v2/error_issues`. | -| `integrations` (Hazel, Cloudflare, Railway, GitHub, VCS lookups), `codeReview` | `/internal/integrations`, `/internal/code-review`, 2026-10-08. `githubListPrReviews` was deleted (no caller). | -| `orgClickHouseSettings`, `organizations`, `organizationCreation`, `organizationRegion` | `/internal/org-clickhouse-settings`, `/internal/organizations`, 2026-10-08 | -| `auth`, `authPublic`, `billingPublic`, `emailPublic` | Stay on `MapleApi` at their `/api` URLs (version-neutral, see above) | +| v1 group | Destination | +| ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `apiKeys`, `ingestKeys`, `ingestAttributeMappings`, `recommendationIssues`, `scrapeTargets`, `investigations`, `anomalies`, `dashboards`, PlanetScale | `/v2`; v1 deleted 2026-08-14 | +| `observability`, `onboarding`, `warehouse` | Deleted outright (no callers) | +| `queryEngine`, `billing`, `demo`, `digest`, `aiTriage`, `chat` (apply) | `/internal`, August 2026 | +| `sessionReplays` | `/v2/session_replays`; v1 deleted 2026-10-08 after 30 quiet days. Facets and trace summaries are `/internal/session-replays`. | +| `errors` | `/internal/errors`, 2026-10-08. Ten operations without a caller were deleted. Reads are `/v2/error_issues`. | +| `integrations` (Cloudflare, Railway, GitHub, VCS lookups), `codeReview` | `/internal/integrations`, `/internal/code-review`, 2026-10-08. `githubListPrReviews` was deleted (no caller). | +| `orgClickHouseSettings`, `organizations`, `organizationCreation`, `organizationRegion` | `/internal/org-clickhouse-settings`, `/internal/organizations`, 2026-10-08 | +| `auth`, `authPublic`, `billingPublic`, `emailPublic` | Stay on `MapleApi` at their `/api` URLs (version-neutral, see above) | Moving a group to `/internal` swaps `Authorization` for `SessionAuthorization`, which refuses API-key-shaped bearers. Production traffic for the 2026-10-08 moves was dashboard traffic, and the break for any API-key caller was accepted. One documented manual flow is affected: `GET /internal/org-clickhouse-settings/collector-config` now needs a dashboard session (see `self-hosted-clickhouse.md`). diff --git a/docs/signal-to-event-projection.md b/docs/signal-to-event-projection.md index d1d0977215..893edec320 100644 --- a/docs/signal-to-event-projection.md +++ b/docs/signal-to-event-projection.md @@ -1060,7 +1060,7 @@ conflict. ## Compatibility and delivery notes The Local adapter projects OTLP logs only (`otel.log`). Traces and metrics go -through ordinary warehouse ingestion. Alert webhook and Hazel payloads carry an +through ordinary warehouse ingestion. Alert webhook payloads carry an additive `event` CloudEvent envelope (including `tenantid`, typically about 1 KB). Alert event identities are deterministic for a scheduled tick. Retries reuse the retained payload and identity. diff --git a/packages/alchemy-maple/README.md b/packages/alchemy-maple/README.md index 706c7249b7..fb8c0fe60f 100644 --- a/packages/alchemy-maple/README.md +++ b/packages/alchemy-maple/README.md @@ -99,13 +99,13 @@ seam when constructing the API client without the Alchemy provider collection. ## Resources -| Resource | Semantics | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Maple.Dashboard` | Full CRUD over typed native v2 dashboard data (`snake_case`, see `/v2/docs`). Updates PATCH in place. Set `dashboardId` to adopt an existing dashboard, and `refresh_interval_seconds` to manage its refresh cadence. | -| `Maple.AlertDestination` | Full CRUD for declaratively provisionable types (PagerDuty, webhook, Discord, Telegram, email). Channel secrets are write-only — accept `Redacted` values. Chat-connector and Hazel destinations use integrations linked in Maple. Changing `type` replaces the destination. | -| `Maple.AlertRule` | Full CRUD. `destination_ids` accepts outputs from `Maple.AlertDestination`. Lost state is recovered only when the rule's ownership tag matches the stack, stage, and logical resource. Other rules require explicit adoption. | -| `Maple.ApiKey` | Create / roll / revoke (the API has no key update). Changing props replaces the key; bumping the `rotate` prop rolls it in place (same name/scopes, new secret). `secret` is captured once and preserved in Alchemy state — it can never be re-read from the API. | -| `Maple.IngestKeys` | Read-only per-org singleton. Surfaces `publicKey` / `privateKey` as `Redacted` outputs; delete only stops tracking it. | +| Resource | Semantics | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Maple.Dashboard` | Full CRUD over typed native v2 dashboard data (`snake_case`, see `/v2/docs`). Updates PATCH in place. Set `dashboardId` to adopt an existing dashboard, and `refresh_interval_seconds` to manage its refresh cadence. | +| `Maple.AlertDestination` | Full CRUD for declaratively provisionable types (PagerDuty, webhook, Discord, Telegram, email). Channel secrets are write-only and accept `Redacted` values. Chat-connector destinations use workspaces linked in Maple. Changing `type` replaces the destination. | +| `Maple.AlertRule` | Full CRUD. `destination_ids` accepts outputs from `Maple.AlertDestination`. Lost state is recovered only when the rule's ownership tag matches the stack, stage, and logical resource. Other rules require explicit adoption. | +| `Maple.ApiKey` | Create / roll / revoke (the API has no key update). Changing props replaces the key; bumping the `rotate` prop rolls it in place (same name/scopes, new secret). `secret` is captured once and preserved in Alchemy state; it can never be re-read from the API. | +| `Maple.IngestKeys` | Read-only per-org singleton. Surfaces `publicKey` / `privateKey` as `Redacted` outputs; delete only stops tracking it. | ## Typed dashboard data diff --git a/packages/alchemy-maple/src/AlertDestination.ts b/packages/alchemy-maple/src/AlertDestination.ts index 12b7a35ed3..d770a5821b 100644 --- a/packages/alchemy-maple/src/AlertDestination.ts +++ b/packages/alchemy-maple/src/AlertDestination.ts @@ -57,8 +57,8 @@ export type AlertDestination = Resource< /** * A notification channel (PagerDuty, webhook, Discord, Telegram, Google Chat, workspace-member * email, or a Linear team) that `Maple.AlertRule`s deliver to. Linear needs the - * workspace connected in Maple first. Chat-connector and Hazel destinations use - * integrations linked in Maple and are managed there. + * workspace connected in Maple first. Chat-connector destinations use workspaces + * linked in Maple and are managed there. * * @example * ```typescript diff --git a/packages/backend/src/platform/Env.ts b/packages/backend/src/platform/Env.ts index 9809231614..5a9b696721 100644 --- a/packages/backend/src/platform/Env.ts +++ b/packages/backend/src/platform/Env.ts @@ -108,11 +108,6 @@ export interface EnvConfig { readonly SD_INTERNAL_TOKEN: Option.Option> readonly INTERNAL_SERVICE_TOKEN: Option.Option> readonly EMAIL_FROM: string - readonly HAZEL_API_BASE_URL: string - readonly HAZEL_OAUTH_DISCOVERY_URL: string - readonly HAZEL_OAUTH_CLIENT_ID: Option.Option - readonly HAZEL_OAUTH_CLIENT_SECRET: Option.Option> - readonly HAZEL_OAUTH_SCOPES: string readonly LINEAR_OAUTH_CLIENT_ID: Option.Option readonly LINEAR_OAUTH_CLIENT_SECRET: Option.Option> /** @@ -267,17 +262,6 @@ const envConfig = Config.all({ SD_INTERNAL_TOKEN: optionalRedacted("SD_INTERNAL_TOKEN"), INTERNAL_SERVICE_TOKEN: optionalRedacted("INTERNAL_SERVICE_TOKEN"), EMAIL_FROM: stringWithDefault("EMAIL_FROM", "Maple "), - HAZEL_API_BASE_URL: stringWithDefault("HAZEL_API_BASE_URL", "https://api.hazel.sh"), - HAZEL_OAUTH_DISCOVERY_URL: stringWithDefault( - "HAZEL_OAUTH_DISCOVERY_URL", - "https://clerk.hazel.sh/.well-known/openid-configuration", - ), - HAZEL_OAUTH_CLIENT_ID: optionalString("HAZEL_OAUTH_CLIENT_ID"), - HAZEL_OAUTH_CLIENT_SECRET: optionalRedacted("HAZEL_OAUTH_CLIENT_SECRET"), - HAZEL_OAUTH_SCOPES: stringWithDefault( - "HAZEL_OAUTH_SCOPES", - "openid email profile organizations:read channels:read channel-webhooks:write", - ), LINEAR_OAUTH_CLIENT_ID: optionalString("LINEAR_OAUTH_CLIENT_ID"), LINEAR_OAUTH_CLIENT_SECRET: optionalRedacted("LINEAR_OAUTH_CLIENT_SECRET"), CHAT_CONNECTOR_CONFIG: chatConnectorConfig, diff --git a/packages/backend/src/services/alerts/AlertDeliveryDispatch.providers.test.ts b/packages/backend/src/services/alerts/AlertDeliveryDispatch.providers.test.ts index bfe84a818d..8b36549e03 100644 --- a/packages/backend/src/services/alerts/AlertDeliveryDispatch.providers.test.ts +++ b/packages/backend/src/services/alerts/AlertDeliveryDispatch.providers.test.ts @@ -26,7 +26,7 @@ const dispatchDelivery = ( * Characterization tests: they pin what each provider ACTUALLY sends today, * before the delivery layer is restructured onto a transport registry. * - * `discord`, `pagerduty`, `webhook` and `hazel-oauth` had no direct dispatch + * `discord`, `pagerduty` and `webhook` had no direct dispatch * coverage at all, so a refactor had nothing to refactor against. These are deliberately literal * (exact URL, exact headers, exact parsed body) rather than * behaviour-describing: their job is to make any change to the wire show up as @@ -291,7 +291,7 @@ describe("dispatchDelivery: discord", () => { // provider reference, so its delivery rows had nothing to correlate // against. Discord's plain webhook POST returns an empty body, so there // is no message id to quote — it now reports the dedupe key, the same - // correlation handle pagerduty/webhook/hazel use. + // correlation handle pagerduty/webhook use. assert.deepStrictEqual(result, { providerMessage: "Delivered to Discord", providerReference: "org_1:rule_1:checkout", @@ -310,6 +310,32 @@ describe("dispatchDelivery: discord", () => { ) }) +describe("dispatchDelivery: google-chat", () => { + const context = contextFor({ + type: "google-chat", + webhookUrl: "https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t", + }) + + it.effect("names a revoked webhook on 401 and 403", () => + Effect.gen(function* () { + for (const status of [401, 403]) { + const { fetchFn } = recorder(() => new Response("", { status })) + const error = yield* Effect.flip(dispatch(context, fetchFn)) + assert.strictEqual(error.destinationType, "google-chat") + assert.include(error.message, "it was revoked") + } + }), + ) + + it.effect("asks for a new webhook on 404", () => + Effect.gen(function* () { + const { fetchFn } = recorder(() => new Response("", { status: 404 })) + const error = yield* Effect.flip(dispatch(context, fetchFn)) + assert.include(error.message, "create a new one in the space") + }), + ) +}) + describe("dispatchDelivery: telegram", () => { const botToken = "123456789:tok3n-in-the-path" const context = contextFor({ type: "telegram", botToken, chatId: "-1001234567890" }) @@ -380,82 +406,3 @@ describe("dispatchDelivery: telegram", () => { }), ) }) - -describe("dispatchDelivery: hazel-oauth", () => { - const config = { - type: "hazel-oauth" as const, - hazelOrganizationId: "org_hz", - hazelOrganizationName: "Acme", - hazelChannelId: "chan_hz", - hazelChannelName: "incidents", - webhookId: "wh_1", - webhookUrl: "https://hazel.test/api/webhooks/wh_1/tok3n-in-the-path", - webhookToken: "tok3n-in-the-path", - } - const context = contextFor(config) - - it.effect("appends /maple to the stored webhook url", () => - Effect.gen(function* () { - const { calls, fetchFn } = recorder(ok) - const result = yield* dispatch(context, fetchFn) - - assert.strictEqual(calls[0]!.url, `${config.webhookUrl}/maple`) - assert.strictEqual(calls[0]!.headers.get("x-maple-event-type"), "trigger") - assert.strictEqual(calls[0]!.headers.get("x-maple-delivery-key"), "org_1:dest_1:delivery") - assert.deepStrictEqual(result, { - providerMessage: "Delivered to Hazel #incidents", - providerReference: "org_1:rule_1:checkout", - responseCode: 200, - }) - }), - ) - - it.effect("tolerates a trailing slash on the stored url", () => - Effect.gen(function* () { - const { calls, fetchFn } = recorder(ok) - yield* dispatch(contextFor({ ...config, webhookUrl: `${config.webhookUrl}/` }), fetchFn) - - assert.strictEqual(calls[0]!.url, `${config.webhookUrl}/maple`) - }), - ) - - /** - * NORMALIZED. Hazel used to build its own near-copy of the wire payload, and - * it had drifted: `thresholdUpper` was dropped entirely and `incidentStatus` - * was recomputed from `eventType` rather than carried. It now ships the - * canonical payload, exactly like the webhook destination, so there is only - * one wire contract to keep correct. - */ - it.effect("ships the canonical wire payload rather than a hand-built copy", () => - Effect.gen(function* () { - const { calls, fetchFn } = recorder(ok) - const payloadJson = '{"canonical":"payload","incidentStatus":"acknowledged"}' - yield* dispatch( - { ...context, incidentStatus: "acknowledged", thresholdUpper: 0.5 }, - fetchFn, - payloadJson, - ) - - assert.strictEqual(calls[0]!.body, payloadJson) - }), - ) - - it.effect("maps 401/403 to an actionable reconfigure message", () => - Effect.gen(function* () { - for (const status of [401, 403]) { - const { fetchFn } = recorder(() => new Response("", { status })) - const error = yield* Effect.flip(dispatch(context, fetchFn)) - assert.strictEqual(error.destinationType, "hazel-oauth") - assert.include(error.message, "reconfigure the channel") - } - }), - ) - - it.effect("maps 404 to a pick-a-different-channel message", () => - Effect.gen(function* () { - const { fetchFn } = recorder(() => new Response("", { status: 404 })) - const error = yield* Effect.flip(dispatch(context, fetchFn)) - assert.include(error.message, "no longer exists") - }), - ) -}) diff --git a/packages/backend/src/services/alerts/AlertDestinationDelivery.ts b/packages/backend/src/services/alerts/AlertDestinationDelivery.ts index 77f2ae9182..8714b746e0 100644 --- a/packages/backend/src/services/alerts/AlertDestinationDelivery.ts +++ b/packages/backend/src/services/alerts/AlertDestinationDelivery.ts @@ -18,12 +18,7 @@ import { buildAlertRuleUrl, type DispatchContext as DeliveryDispatchContext } fr import { dispatchDelivery as dispatchDeliveryImpl } from "./delivery/dispatch" import type { DispatchDestination, DispatchResult } from "./delivery/context" import type { LinearDeliveryPort } from "./delivery/Transport" -import { - hydrateDestinationRow, - type DestinationSecretConfig, - type EnrichedDestinationSecretConfig, - type StoredDestinationConfig, -} from "./AlertDestinationHydration" +import { hydrateDestinationRow, type StoredDestinationConfig } from "./AlertDestinationHydration" /** The destination columns delivery reads; any stored row shape satisfies it. */ type DeliverableDestination = DispatchDestination & StoredDestinationConfig @@ -98,14 +93,6 @@ export const makeAlertDestinationDelivery = (options: { return { row, publicConfig, secretConfig } as const }) - const enrichSecretForDispatch = ( - _row: DeliverableDestination, - secretConfig: DestinationSecretConfig, - ): Effect.Effect => - // Hazel-OAuth webhooks embed their delivery token in the URL path, so - // there's nothing to enrich at dispatch time. - Effect.succeed(secretConfig) - /** The incident's own page when there is one, else the service (or the alerts list). */ const composeLinkUrl = (serviceLinkName: string | null, incidentId: string | null = null) => incidentId !== null @@ -234,11 +221,10 @@ export const makeAlertDestinationDelivery = (options: { context: Omit, ) { const hydrated = yield* hydrateDestination(destinationRow) - const enrichedSecret = yield* enrichSecretForDispatch(hydrated.row, hydrated.secretConfig) const fullContext: AlertDispatchContext = { destination: hydrated.row, publicConfig: hydrated.publicConfig, - secretConfig: enrichedSecret, + secretConfig: hydrated.secretConfig, ...context, } const payload = yield* buildPayload(fullContext, destinationRow.orgId) @@ -247,7 +233,6 @@ export const makeAlertDestinationDelivery = (options: { return { hydrateDestination, - enrichSecretForDispatch, composeLinkUrl, dispatchDelivery, buildPayload, diff --git a/packages/backend/src/services/alerts/AlertDestinationHydration.ts b/packages/backend/src/services/alerts/AlertDestinationHydration.ts index b50b839362..e6f6801f86 100644 --- a/packages/backend/src/services/alerts/AlertDestinationHydration.ts +++ b/packages/backend/src/services/alerts/AlertDestinationHydration.ts @@ -12,11 +12,6 @@ import { decryptAes256Gcm } from "@maple/backend/platform/Crypto" export const DestinationPublicConfigSchema = Schema.Struct({ summary: Schema.String, channelLabel: Schema.NullOr(Schema.String), - hazelOrganizationId: Schema.optionalKey(Schema.String), - hazelOrganizationName: Schema.optionalKey(Schema.String), - hazelOrganizationLogoUrl: Schema.optionalKey(Schema.NullOr(Schema.String)), - hazelChannelId: Schema.optionalKey(Schema.String), - hazelChannelName: Schema.optionalKey(Schema.String), memberUserIds: Schema.optionalKey(Schema.Array(Schema.String)), /** `chat` only: which connector draws the row, and the workspace an edit lists channels from. */ chatConnector: Schema.optionalKey(ChatConnectorId), @@ -35,16 +30,6 @@ const DestinationSecretConfigSchema = Schema.Union([ url: Schema.String, signingSecret: Schema.NullOr(Schema.String), }), - Schema.Struct({ - type: Schema.Literal("hazel-oauth"), - hazelOrganizationId: Schema.String, - hazelOrganizationName: Schema.String, - hazelChannelId: Schema.String, - hazelChannelName: Schema.String, - webhookId: Schema.String, - webhookUrl: Schema.String, - webhookToken: Schema.String, - }), Schema.Struct({ type: Schema.Literal("discord"), webhookUrl: Schema.String, @@ -90,8 +75,6 @@ const DestinationSecretConfigSchema = Schema.Union([ export type DestinationPublicConfig = Schema.Schema.Type export type DestinationSecretConfig = Schema.Schema.Type -export type EnrichedDestinationSecretConfig = DestinationSecretConfig - export const SecretConfigFromJson = Schema.fromJsonString(DestinationSecretConfigSchema) /** The stored columns hydration reads, so callers holding a narrower row still fit. */ diff --git a/packages/backend/src/services/alerts/AlertDestinationsService.ts b/packages/backend/src/services/alerts/AlertDestinationsService.ts index 152c7efd65..e4019520b0 100644 --- a/packages/backend/src/services/alerts/AlertDestinationsService.ts +++ b/packages/backend/src/services/alerts/AlertDestinationsService.ts @@ -34,7 +34,6 @@ import { EmailService } from "@maple/backend/platform/EmailService" import { Env } from "@maple/backend/platform/Env" import { currentTxid, readTxid } from "@maple/backend/platform/electric-txid" import { validateExternalUrl } from "@maple/safe-fetch" -import { HazelOAuthService, type HazelOAuthServiceApi } from "@maple/backend/services/auth/HazelOAuthService" import { LinearOAuthService, type LinearOAuthServiceApi, @@ -111,7 +110,6 @@ const emailSecretConfig = (members: ReadonlyArray): DestinationSecret }) type AlertDestinationDependencyError = - | Effect.Error> | Effect.Error> | Effect.Error> @@ -193,15 +191,6 @@ const buildPublicConfig = ( Match.discriminatorsExhaustive("type")({ pagerduty: () => ({ summary: "PagerDuty Events API v2", channelLabel: null }), webhook: (r) => ({ summary: summarizeWebhookUrl(r.url), channelLabel: null }), - "hazel-oauth": (r) => ({ - summary: `${r.hazelOrganizationName} · #${r.hazelChannelName}`, - channelLabel: `#${r.hazelChannelName}`, - hazelOrganizationId: r.hazelOrganizationId, - hazelOrganizationName: r.hazelOrganizationName, - hazelOrganizationLogoUrl: r.hazelOrganizationLogoUrl ?? null, - hazelChannelId: r.hazelChannelId, - hazelChannelName: r.hazelChannelName, - }), discord: (r) => ({ summary: summarizeWebhookUrl(r.webhookUrl), channelLabel: null }), // The chat id, not the token: this config is Electric-synced to the // browser, so nothing secret may appear here. It rides in @@ -213,10 +202,7 @@ const buildPublicConfig = ( ) const buildSecretConfig = ( - request: Exclude< - AlertDestinationCreateRequest, - { readonly type: "hazel-oauth" | "email" | "chat" | "linear" } - >, + request: Exclude, ): DestinationSecretConfig => Match.value(request).pipe( Match.discriminatorsExhaustive("type")({ @@ -375,7 +361,6 @@ export class AlertDestinationsService extends Context.Service< const env = yield* Env const runtime = yield* AlertRuntime const httpClient = yield* HttpClient.HttpClient - const hazelOAuth = yield* HazelOAuthService const linearOAuth = yield* LinearOAuthService const email = yield* EmailService const orgMembers = yield* OrgMembersService @@ -585,26 +570,7 @@ export class AlertDestinationsService extends Context.Service< secretConfig = linearSecretConfig(team) } else { publicConfig = buildPublicConfig(request) - secretConfig = - request.type === "hazel-oauth" - ? yield* hazelOAuth - .createChannelWebhook(orgId, { - channelId: request.hazelChannelId.trim(), - name: request.name.trim(), - }) - .pipe( - Effect.map((webhook) => ({ - type: "hazel-oauth" as const, - hazelOrganizationId: request.hazelOrganizationId.trim(), - hazelOrganizationName: request.hazelOrganizationName.trim(), - hazelChannelId: request.hazelChannelId.trim(), - hazelChannelName: request.hazelChannelName.trim(), - webhookId: webhook.id, - webhookUrl: webhook.webhookUrl, - webhookToken: webhook.token, - })), - ) - : buildSecretConfig(request) + secretConfig = buildSecretConfig(request) } if (secretConfig.type === "pagerduty") yield* validatePagerDutyKey(secretConfig.integrationKey) if (secretConfig.type === "telegram") { @@ -712,61 +678,6 @@ export class AlertDestinationsService extends Context.Service< : normalizeOptionalString(r.signingSecret), } satisfies DestinationSecretConfig, }), - "hazel-oauth": (r) => - Effect.gen(function* () { - const previousSecret = - hydrated.secretConfig.type === "hazel-oauth" ? hydrated.secretConfig : null - const nextOrganizationId = - normalizeOptionalString(r.hazelOrganizationId) ?? - previousSecret?.hazelOrganizationId ?? - "" - const nextOrganizationName = - normalizeOptionalString(r.hazelOrganizationName) ?? - previousSecret?.hazelOrganizationName ?? - "" - const nextOrganizationLogoUrl = - r.hazelOrganizationLogoUrl === undefined - ? (hydrated.publicConfig.hazelOrganizationLogoUrl ?? null) - : r.hazelOrganizationLogoUrl - const nextChannelId = - normalizeOptionalString(r.hazelChannelId) ?? - previousSecret?.hazelChannelId ?? - "" - const nextChannelName = - normalizeOptionalString(r.hazelChannelName) ?? - previousSecret?.hazelChannelName ?? - "" - const nextName = normalizeOptionalString(r.name) ?? existing.name - const channelChanged = - previousSecret == null || previousSecret.hazelChannelId !== nextChannelId - const provisioned = channelChanged - ? yield* hazelOAuth.createChannelWebhook(orgId, { - channelId: nextChannelId, - name: nextName, - }) - : null - return { - nextPublicConfig: { - summary: `${nextOrganizationName} · #${nextChannelName}`, - channelLabel: `#${nextChannelName}`, - hazelOrganizationId: nextOrganizationId, - hazelOrganizationName: nextOrganizationName, - hazelOrganizationLogoUrl: nextOrganizationLogoUrl, - hazelChannelId: nextChannelId, - hazelChannelName: nextChannelName, - } satisfies DestinationPublicConfig, - nextSecretConfig: { - type: "hazel-oauth" as const, - hazelOrganizationId: nextOrganizationId, - hazelOrganizationName: nextOrganizationName, - hazelChannelId: nextChannelId, - hazelChannelName: nextChannelName, - webhookId: provisioned?.id ?? previousSecret!.webhookId, - webhookUrl: provisioned?.webhookUrl ?? previousSecret!.webhookUrl, - webhookToken: provisioned?.token ?? previousSecret!.webhookToken, - } satisfies DestinationSecretConfig, - } - }), discord: (r) => Effect.succeed({ nextPublicConfig: { @@ -1133,7 +1044,6 @@ export class AlertDestinationsService extends Context.Service< Layer.provide( Layer.mergeAll( EmailService.layer, - HazelOAuthService.layer, LinearOAuthService.layer, LinearIssueTracker.layer, OrgMembersService.layer, diff --git a/packages/backend/src/services/alerts/AlertsService.test.ts b/packages/backend/src/services/alerts/AlertsService.test.ts index 29db3ad594..0ee833cd8c 100644 --- a/packages/backend/src/services/alerts/AlertsService.test.ts +++ b/packages/backend/src/services/alerts/AlertsService.test.ts @@ -1936,7 +1936,7 @@ describe("AlertsService", () => { const fixedTime = 1_710_000_200_000 const testDb = createTestDb(trackedDbs) let requestCount = 0 - // An empty-bodied 400 — the exact shape prod sees from Hazel. + // An empty-bodied 400, the shape a rejecting webhook returns. const rejectingFetch = (async () => { requestCount += 1 return new Response("", { status: 400, headers: { "content-type": "application/json" } }) diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 5f36c9dd6e..1a880b650e 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -636,7 +636,6 @@ export class AlertsService extends Context.Service => - // Hazel-OAuth webhooks now embed their delivery token in the URL path, - // so no enrichment is required at dispatch time. - Effect.succeed(secretConfig) - const dispatchOne = Effect.fn("NotificationDispatcher.dispatchOne")(function* ( row: AlertDestinationRow, request: NotificationRequest, @@ -157,11 +145,10 @@ const make: Effect.Effect< onSecretConfigInvalid: () => new NotificationDispatchError({ message: "Stored destination secret is invalid" }), }) - const enrichedSecret = yield* enrichSecretConfig(row, hydrated.secretConfig) const context: DispatchContext = { destination: row, publicConfig: hydrated.publicConfig, - secretConfig: enrichedSecret, + secretConfig: hydrated.secretConfig, deliveryKey: request.deliveryKey, ruleId: request.ruleId, ruleName: request.ruleName, diff --git a/packages/backend/src/services/alerts/alerts-service-test-support.ts b/packages/backend/src/services/alerts/alerts-service-test-support.ts index 86d876592a..f9e010d72d 100644 --- a/packages/backend/src/services/alerts/alerts-service-test-support.ts +++ b/packages/backend/src/services/alerts/alerts-service-test-support.ts @@ -27,7 +27,6 @@ import { EdgeCacheService } from "@maple/cache" import { baselineWarehouseCapabilities } from "@maple/query-engine" import { CacheBackendLive } from "@maple/backend/platform/CacheBackendLive" import { Env } from "@maple/backend/platform/Env" -import { HazelOAuthService } from "@maple/backend/services/auth/HazelOAuthService" import { LinearOAuthService } from "@maple/backend/services/auth/LinearOAuthService" import { LinearIssueTracker } from "./LinearIssueTracker" import { EmailService } from "@maple/backend/platform/EmailService" @@ -251,7 +250,6 @@ export const makeLayer = ( Layer.provide(configLive), ) const runtimeLive = Layer.succeed(AlertRuntime, { ...defaultTestRuntime, ...runtimeOverrides }) - const hazelOAuthLive = HazelOAuthService.layer.pipe(Layer.provide(Layer.mergeAll(envLive, databaseLive))) const linearOAuthLive = LinearOAuthService.layer.pipe( Layer.provide(Layer.mergeAll(envLive, databaseLive)), ) @@ -272,7 +270,6 @@ export const makeLayer = ( envLive, databaseLive, runtimeLive, - hazelOAuthLive, linearOAuthLive, linearIssuesLive, emailLive, @@ -302,7 +299,6 @@ export const makeLayer = ( Layer.provide(queryEngineLive), Layer.provide(warehouseLive), Layer.provide(runtimeLive), - Layer.provide(hazelOAuthLive), Layer.provide(linearIssuesLive), Layer.provide(emailLive), Layer.provide(orgMembersLive), diff --git a/packages/backend/src/services/alerts/delivery/Transport.ts b/packages/backend/src/services/alerts/delivery/Transport.ts index ece8351332..094bf92071 100644 --- a/packages/backend/src/services/alerts/delivery/Transport.ts +++ b/packages/backend/src/services/alerts/delivery/Transport.ts @@ -6,7 +6,7 @@ * time the fetch, check `!response.ok`, read the body, hand-concatenate * `" delivery failed with : "`. That sentence was * written out five separate times, and no arm had a span, so the outbound calls - * to PagerDuty/Discord/Hazel were invisible. + * to PagerDuty/Discord were invisible. * * Here each provider declares only what is genuinely provider-specific, and * `runHttpTransport` owns everything shared: the spans, the timeout, the SSRF @@ -67,7 +67,7 @@ export interface HttpRequestSpec { */ readonly guarded: boolean /** - * `true` when the URL path itself carries a credential — Discord and Hazel + * `true` when the URL path itself carries a credential: Discord * webhook URLs embed their delivery token in the path. The runner then * annotates `server.address` only, never `url.path`. * @@ -101,7 +101,7 @@ export interface HttpTransport { readonly followUps?: (input: RenderInput) => ReadonlyArray /** * PURE. Give a non-2xx a better message than the generic one. Return null to - * fall through to the runner's default. Only `hazel-oauth` implements this. + * fall through to the runner's default. Only `google-chat` implements this. */ readonly describeStatus?: (status: number) => string | null /** diff --git a/packages/backend/src/services/alerts/delivery/context.ts b/packages/backend/src/services/alerts/delivery/context.ts index f46d603456..a56f10468c 100644 --- a/packages/backend/src/services/alerts/delivery/context.ts +++ b/packages/backend/src/services/alerts/delivery/context.ts @@ -9,7 +9,7 @@ */ import type { AlertComparator, AlertEventType, AlertSeverity, AlertSignalType } from "@maple/domain/http" import type { AlertDestinationRow } from "@maple/db/tables" -import type { EnrichedDestinationSecretConfig } from "../AlertDestinationHydration" +import type { DestinationSecretConfig } from "../AlertDestinationHydration" import type { SignalDisplay } from "../alert-signal-display" import type { NotificationTemplateConfig } from "../alert-templating/renderer" @@ -25,7 +25,7 @@ export interface DispatchContext { readonly deliveryKey: string readonly destination: DispatchDestination readonly publicConfig: DestinationPublicConfig - readonly secretConfig: EnrichedDestinationSecretConfig + readonly secretConfig: DestinationSecretConfig readonly ruleId: string readonly ruleName: string readonly groupKey: string | null diff --git a/packages/backend/src/services/alerts/delivery/delivery-spans.test.ts b/packages/backend/src/services/alerts/delivery/delivery-spans.test.ts index dc61c1b004..5743b9872d 100644 --- a/packages/backend/src/services/alerts/delivery/delivery-spans.test.ts +++ b/packages/backend/src/services/alerts/delivery/delivery-spans.test.ts @@ -25,14 +25,14 @@ const dispatchDelivery = ( /** * The outbound provider call must be a **Client-kind span carrying - * `peer.service`** — that pair is what draws PagerDuty/Discord/Hazel/Telegram as + * `peer.service`**: that pair is what draws PagerDuty/Discord/Telegram as * nodes on the service map and makes their latency and status attributable. * Before the transport registry existed, `dispatchDelivery` had no span at all * and no provider arm had one, so every provider dependency was invisible. * These assertions are the regression guard for that (same reasoning as * `GithubAppClient.span.test.ts`). * - * They also guard the secret-leak rule: Discord and Hazel webhook URLs embed + * They also guard the secret-leak rule: Discord and Telegram embed * their delivery token in the PATH, so those transports must annotate * `server.address` only — never `url.path`, and never `url.full`. */ @@ -148,23 +148,6 @@ describe("AlertDelivery.http span", () => { // Discord answers a plain webhook POST with 204 and no body. respond: () => new Response(null, { status: 204 }), }, - { - name: "hazel-oauth", - config: { - type: "hazel-oauth", - hazelOrganizationId: "o", - hazelOrganizationName: "Acme", - hazelChannelId: "c", - hazelChannelName: "incidents", - webhookId: "wh_1", - webhookUrl: "https://hazel.test/api/webhooks/wh_1/s3cr3t-token", - webhookToken: "s3cr3t-token", - } as const, - peerService: "hazel", - host: "hazel.test", - expectPath: null, - respond: () => new Response("", { status: 200 }), - }, { name: "telegram", config: { type: "telegram", botToken: "123456789:s3cr3t-token", chatId: "-100123" } as const, diff --git a/packages/backend/src/services/alerts/delivery/dispatch.ts b/packages/backend/src/services/alerts/delivery/dispatch.ts index 28b844b60c..04094a1150 100644 --- a/packages/backend/src/services/alerts/delivery/dispatch.ts +++ b/packages/backend/src/services/alerts/delivery/dispatch.ts @@ -15,7 +15,6 @@ import { discordTransport } from "./transports/discord" import { chatTransport } from "./transports/chat" import { emailTransport } from "./transports/email" import { googleChatTransport } from "./transports/google-chat" -import { hazelTransport } from "./transports/hazel" import { linearTransport } from "./transports/linear" import { pagerDutyTransport } from "./transports/pagerduty" import { telegramTransport } from "./transports/telegram" @@ -48,7 +47,6 @@ export const dispatchDelivery = ( Match.discriminatorsExhaustive("type")({ pagerduty: (config) => runHttpTransport(pagerDutyTransport, input(config), runtime), webhook: (config) => runHttpTransport(webhookTransport, input(config), runtime), - "hazel-oauth": (config) => runHttpTransport(hazelTransport, input(config), runtime), discord: (config) => runHttpTransport(discordTransport, input(config), runtime), telegram: (config) => runHttpTransport(telegramTransport, input(config), runtime), "google-chat": (config) => runHttpTransport(googleChatTransport, input(config), runtime), diff --git a/packages/backend/src/services/alerts/delivery/runTransport.ts b/packages/backend/src/services/alerts/delivery/runTransport.ts index 59b3d7062c..fd6a2fef26 100644 --- a/packages/backend/src/services/alerts/delivery/runTransport.ts +++ b/packages/backend/src/services/alerts/delivery/runTransport.ts @@ -120,7 +120,7 @@ const sendHttp = Effect.fn("AlertDelivery.http", { kind: "client" })(function* ( "http.request.method": "POST", ...(Option.isSome(parsed) ? { "server.address": parsed.value.host } : undefined), // Never `url.full`, and `url.path` only when the path carries no secret: - // Discord and Hazel webhook URLs embed their delivery token in the path. + // Discord webhook URLs embed their delivery token in the path. ...(Option.isSome(parsed) && !spec.sensitivePath ? { "url.path": parsed.value.pathname } : undefined), }) @@ -139,7 +139,7 @@ const sendHttp = Effect.fn("AlertDelivery.http", { kind: "client" })(function* ( // cancelled when `runHttpTransport`'s scope closes. const scoped = HttpClient.withScope(client) const response = yield* (spec.guarded ? guard(scoped) : scoped).execute(request).pipe( - // The client's own span records `url.full`, and Discord, Hazel and + // The client's own span records `url.full`, and Discord and // Telegram carry their delivery token in the URL path. This span is the // client span. Effect.provideService(HttpClient.TracerDisabledWhen, constTrue), diff --git a/packages/backend/src/services/alerts/delivery/transports/hazel.ts b/packages/backend/src/services/alerts/delivery/transports/hazel.ts deleted file mode 100644 index e2da0da077..0000000000 --- a/packages/backend/src/services/alerts/delivery/transports/hazel.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { HttpTransport, RenderInput, SecretConfigOf } from "../Transport" - -type Config = SecretConfigOf<"hazel-oauth"> - -/** - * Hazel exposes per-integration sibling endpoints under the same - * `:webhookId/:token` prefix. The stored `webhookUrl` is the base; `/maple` - * selects the `executeMaple` handler — without it the payload routes to the - * Discord-style `execute` endpoint and is rejected. - */ -export const hazelTransport: HttpTransport = { - kind: "http", - type: "hazel-oauth", - peerService: "hazel", - providerLabel: "Hazel", - render: (input: RenderInput) => ({ - url: `${input.config.webhookUrl.replace(/\/$/, "")}/maple`, - headers: { - "content-type": "application/json", - "x-maple-event-type": input.context.eventType, - "x-maple-delivery-key": input.context.deliveryKey, - }, - // User-configured host, and the delivery token is a path segment. - guarded: true, - sensitivePath: true, - // Hazel used to build its own near-copy of the wire payload, which had - // drifted (it dropped `thresholdUpper` and recomputed `incidentStatus` - // from `eventType` instead of carrying it). It now ships the canonical - // payload, same as the webhook destination. - body: input.payloadJson, - }), - describeStatus: (status) => { - if (status === 401 || status === 403) { - return "Hazel rejected the webhook token — reconfigure the channel" - } - if (status === 404) return "Hazel webhook no longer exists — pick a different channel" - return null - }, - ack: (input) => ({ - providerMessage: `Delivered to Hazel #${input.config.hazelChannelName}`, - providerReference: input.context.dedupeKey, - }), -} diff --git a/packages/backend/src/services/alerts/delivery/transports/render.test.ts b/packages/backend/src/services/alerts/delivery/transports/render.test.ts index bd946f517a..e2bb69e09e 100644 --- a/packages/backend/src/services/alerts/delivery/transports/render.test.ts +++ b/packages/backend/src/services/alerts/delivery/transports/render.test.ts @@ -7,7 +7,6 @@ import type { DispatchContext } from "../context" import type { HttpRequestSpec, RenderInput } from "../Transport" import { discordTransport } from "./discord" import { googleChatTransport } from "./google-chat" -import { hazelTransport } from "./hazel" import { pagerDutyTransport } from "./pagerduty" import { telegramTransport } from "./telegram" import { webhookTransport } from "./webhook" @@ -107,21 +106,6 @@ describe("transport render: guard flags", () => { inputFor({ type: "discord", webhookUrl: "https://discord.com/api/w/1/tok" }), ), ], - [ - "hazel-oauth", - hazelTransport.render( - inputFor({ - type: "hazel-oauth", - hazelOrganizationId: "o", - hazelOrganizationName: "Acme", - hazelChannelId: "c", - hazelChannelName: "ops", - webhookId: "w", - webhookUrl: "https://hazel.test/api/webhooks/w/tok", - webhookToken: "tok", - }), - ), - ], [ "telegram", telegramTransport.render( @@ -144,7 +128,7 @@ describe("transport render: guard flags", () => { // pagerduty + telegram post to compile-time vendor constants: there // is no attacker-controlled URL to validate, so the guard would only cost a // redirect walk. - assert.deepStrictEqual(guarded, ["webhook", "discord", "hazel-oauth", "google-chat"]) + assert.deepStrictEqual(guarded, ["webhook", "discord", "google-chat"]) }) it("marks exactly the providers whose token rides in the URL path", () => { @@ -152,7 +136,7 @@ describe("transport render: guard flags", () => { // telegram is the first provider where this disagrees with `guarded`: a // fixed vendor host, but the bot token rides in the path. google-chat carries its // credential in the query string, which no span records. - assert.deepStrictEqual(sensitive, ["discord", "hazel-oauth", "telegram"]) + assert.deepStrictEqual(sensitive, ["discord", "telegram"]) }) it("always produces a parseable JSON body", () => { @@ -193,32 +177,14 @@ describe("transport render: templates", () => { assert.strictEqual(body.routing_key, "k") }) - it("webhook and hazel ignore templates — they ship the canonical payload", () => { - for (const spec of [ - webhookTransport.render( - inputFor( - { type: "webhook", url: "https://hooks.test/x", signingSecret: null }, - { templated: TEMPLATED }, - ), - ), - hazelTransport.render( - inputFor( - { - type: "hazel-oauth", - hazelOrganizationId: "o", - hazelOrganizationName: "Acme", - hazelChannelId: "c", - hazelChannelName: "ops", - webhookId: "w", - webhookUrl: "https://hazel.test/api/webhooks/w/tok", - webhookToken: "tok", - }, - { templated: TEMPLATED }, - ), + it("webhook ignores templates: it ships the canonical payload", () => { + const spec = webhookTransport.render( + inputFor( + { type: "webhook", url: "https://hooks.test/x", signingSecret: null }, + { templated: TEMPLATED }, ), - ]) { - assert.strictEqual(spec.body, '{"canonical":"payload"}') - } + ) + assert.strictEqual(spec.body, '{"canonical":"payload"}') }) }) diff --git a/packages/backend/src/services/auth/HazelOAuthService.ts b/packages/backend/src/services/auth/HazelOAuthService.ts deleted file mode 100644 index 3f2919b733..0000000000 --- a/packages/backend/src/services/auth/HazelOAuthService.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { randomBytes } from "node:crypto" -import { - IntegrationsNotConnectedError, - IntegrationsPersistenceError, - IntegrationsRevokedError, - IntegrationsUpstreamError, - IntegrationsValidationError, - type HazelChannelSummary, - type HazelOrganizationSummary, - OrgId, - type UserId, -} from "@maple/domain/http" -import * as PG from "@maple-dev/effect-orm/postgres" -import { OAuthAuthStates } from "@maple/db/tables" -import { Clock, Context, Effect, Layer, Option, Redacted, Ref, Schema } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http" -import { Env, type EnvConfig } from "@maple/backend/platform/Env" -import { Database } from "@maple/backend/platform/DatabaseLive" -import { makeOAuthConnectionHelpers, OAUTH_STATE_TTL_MS, toUpstreamError } from "./oauth/connection-helpers" -import type { OAuthFlowInitiator } from "./oauth/oauth-state" - -const HAZEL_PROVIDER = "hazel" - -const decodeOrgId = Schema.decodeUnknownSync(OrgId) - -const UserInfoSchema = Schema.Struct({ - sub: Schema.String, - email: Schema.optionalKey(Schema.String), - email_verified: Schema.optionalKey(Schema.Boolean), - name: Schema.optionalKey(Schema.String), -}) - -const HazelOrganizationsResponseSchema = Schema.Struct({ - data: Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - slug: Schema.NullOr(Schema.String), - logoUrl: Schema.NullOr(Schema.String), - }), - ), -}) - -const HazelChannelsResponseSchema = Schema.Struct({ - data: Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - type: Schema.Literals(["public", "private"]), - organizationId: Schema.String, - }), - ), -}) - -const HazelChannelWebhookResponseSchema = Schema.Struct({ - id: Schema.String, - channelId: Schema.String, - organizationId: Schema.String, - name: Schema.String, - webhookUrl: Schema.String, - token: Schema.String, -}) - -const DiscoveryDocumentSchema = Schema.Struct({ - authorization_endpoint: Schema.String, - token_endpoint: Schema.String, - userinfo_endpoint: Schema.optionalKey(Schema.String), -}) - -const decodeUserInfo = Schema.decodeUnknownEffect(UserInfoSchema) -const decodeOrganizationsResponse = Schema.decodeUnknownEffect(HazelOrganizationsResponseSchema) -const decodeChannelsResponse = Schema.decodeUnknownEffect(HazelChannelsResponseSchema) -const decodeChannelWebhookResponse = Schema.decodeUnknownEffect(HazelChannelWebhookResponseSchema) -const decodeDiscoveryDocument = Schema.decodeUnknownEffect(DiscoveryDocumentSchema) - -interface ResolvedHazelOAuthEnv { - readonly clientId: string - readonly clientSecret: Redacted.Redacted - readonly discoveryUrl: string - readonly scopes: string - readonly apiBaseUrl: string -} - -interface ResolvedHazelOAuthConfig extends ResolvedHazelOAuthEnv { - readonly authorizeUrl: string - readonly tokenUrl: string - readonly userInfoUrl: string -} - -const resolveEnv = Effect.fn("HazelOAuthService.resolveEnv")(function* (env: EnvConfig) { - const requireSome = ( - opt: Option.Option, - message: string, - ): Effect.Effect => - Option.match(opt, { - onNone: () => Effect.fail(new IntegrationsValidationError({ message })), - onSome: (value) => Effect.succeed(value), - }) - - const clientId = yield* requireSome( - env.HAZEL_OAUTH_CLIENT_ID, - "HAZEL_OAUTH_CLIENT_ID is required to use the Hazel integration", - ) - const clientSecretRedacted = yield* requireSome( - env.HAZEL_OAUTH_CLIENT_SECRET, - "HAZEL_OAUTH_CLIENT_SECRET is required to use the Hazel integration", - ) - - return { - clientId, - clientSecret: clientSecretRedacted, - discoveryUrl: env.HAZEL_OAUTH_DISCOVERY_URL, - scopes: env.HAZEL_OAUTH_SCOPES, - apiBaseUrl: env.HAZEL_API_BASE_URL.replace(/\/$/, ""), - } satisfies ResolvedHazelOAuthEnv -}) - -interface HazelOAuthAccessToken { - readonly accessToken: string - readonly externalUserId: string -} - -export interface HazelOAuthServiceApi { - readonly startConnect: ( - orgId: OrgId, - userId: UserId, - options: { - readonly callbackUrl: string - readonly returnTo?: string - }, - ) => Effect.Effect< - { readonly redirectUrl: string; readonly state: string }, - IntegrationsValidationError | IntegrationsUpstreamError | IntegrationsPersistenceError - > - readonly completeConnect: ( - code: string, - state: string, - initiator: OAuthFlowInitiator, - ) => Effect.Effect< - { readonly orgId: OrgId; readonly returnTo: string | null }, - IntegrationsValidationError | IntegrationsUpstreamError | IntegrationsPersistenceError - > - readonly getStatus: (orgId: OrgId) => Effect.Effect< - | { - readonly connected: false - } - | { - readonly connected: true - readonly externalUserId: string - readonly externalUserEmail: string | null - readonly connectedByUserId: string - readonly scope: string - }, - IntegrationsPersistenceError - > - readonly getValidAccessToken: ( - orgId: OrgId, - ) => Effect.Effect< - HazelOAuthAccessToken, - | IntegrationsNotConnectedError - | IntegrationsRevokedError - | IntegrationsUpstreamError - | IntegrationsPersistenceError - | IntegrationsValidationError - > - readonly listOrganizations: ( - orgId: OrgId, - ) => Effect.Effect< - ReadonlyArray, - | IntegrationsNotConnectedError - | IntegrationsRevokedError - | IntegrationsUpstreamError - | IntegrationsPersistenceError - | IntegrationsValidationError - > - readonly listChannels: ( - orgId: OrgId, - hazelOrganizationId: string, - ) => Effect.Effect< - ReadonlyArray, - | IntegrationsNotConnectedError - | IntegrationsRevokedError - | IntegrationsUpstreamError - | IntegrationsPersistenceError - | IntegrationsValidationError - > - readonly createChannelWebhook: ( - orgId: OrgId, - options: { - readonly channelId: string - readonly name: string - readonly description?: string - }, - ) => Effect.Effect< - { - readonly id: string - readonly channelId: string - readonly organizationId: string - readonly name: string - readonly webhookUrl: string - readonly token: string - }, - | IntegrationsNotConnectedError - | IntegrationsRevokedError - | IntegrationsUpstreamError - | IntegrationsPersistenceError - | IntegrationsValidationError - > - readonly disconnect: ( - orgId: OrgId, - ) => Effect.Effect<{ readonly disconnected: boolean }, IntegrationsPersistenceError> -} - -export class HazelOAuthService extends Context.Service()( - "@maple/backend/services/HazelOAuthService", - { - make: Effect.gen(function* () { - const database = yield* Database - const env = yield* Env - const httpClient = yield* HttpClient.HttpClient - const oauth = yield* makeOAuthConnectionHelpers({ - provider: HAZEL_PROVIDER, - providerLabel: "Hazel", - database, - env, - }) - - const fetchDiscoveryDocument = Effect.fn("HazelOAuthService.fetchDiscoveryDocument")(function* ( - discoveryUrl: string, - ) { - const response = yield* httpClient - .get(discoveryUrl, { headers: { accept: "application/json" } }) - .pipe( - Effect.mapError((cause) => - toUpstreamError(`OIDC discovery fetch failed: ${cause.message}`), - ), - ) - if (response.status < 200 || response.status >= 300) { - return yield* Effect.fail( - toUpstreamError(`OIDC discovery returned ${response.status}`, response.status), - ) - } - const json = yield* response.json.pipe( - Effect.mapError((cause) => - toUpstreamError("OIDC discovery returned a non-JSON response", undefined, cause), - ), - ) - return yield* decodeDiscoveryDocument(json).pipe( - Effect.mapError((cause) => - toUpstreamError("OIDC discovery returned an unexpected payload", undefined, cause), - ), - ) - }) - - const cachedDiscovery = yield* Ref.make<{ - url: string - doc: Schema.Schema.Type - } | null>(null) - - const resolveConfig: Effect.Effect< - ResolvedHazelOAuthConfig, - IntegrationsValidationError | IntegrationsUpstreamError - > = Effect.gen(function* () { - const base = yield* resolveEnv(env) - const cached = yield* Ref.get(cachedDiscovery) - if (cached && cached.url === base.discoveryUrl) { - return { - ...base, - authorizeUrl: cached.doc.authorization_endpoint, - tokenUrl: cached.doc.token_endpoint, - userInfoUrl: - cached.doc.userinfo_endpoint ?? - cached.doc.authorization_endpoint.replace( - /\/oauth\/authorize$/, - "/oauth/userinfo", - ), - } - } - const doc = yield* fetchDiscoveryDocument(base.discoveryUrl) - yield* Ref.set(cachedDiscovery, { url: base.discoveryUrl, doc }) - return { - ...base, - authorizeUrl: doc.authorization_endpoint, - tokenUrl: doc.token_endpoint, - userInfoUrl: - doc.userinfo_endpoint ?? - doc.authorization_endpoint.replace(/\/oauth\/authorize$/, "/oauth/userinfo"), - } - }) - - const startConnect = Effect.fn("HazelOAuthService.startConnect")(function* ( - orgId: OrgId, - userId: UserId, - options: { readonly callbackUrl: string; readonly returnTo?: string }, - ) { - const config = yield* resolveConfig - const state = randomBytes(24).toString("base64url") - const currentTime = yield* Clock.currentTimeMillis - const callbackUrl = options.callbackUrl - - yield* oauth.purgeExpiredStates(currentTime) - yield* oauth.dbExecute((db) => - db.run( - PG.insertInto(OAuthAuthStates).values({ - state, - orgId, - provider: HAZEL_PROVIDER, - initiatedByUserId: userId, - redirectUri: callbackUrl, - returnTo: options.returnTo ?? null, - createdAt: currentTime, - expiresAt: currentTime + OAUTH_STATE_TTL_MS, - }), - ), - ) - - const params = new URLSearchParams({ - client_id: config.clientId, - redirect_uri: callbackUrl, - response_type: "code", - scope: config.scopes, - state, - }) - return { - redirectUrl: `${config.authorizeUrl}?${params.toString()}`, - state, - } - }) - - const fetchUserInfo = Effect.fn("HazelOAuthService.fetchUserInfo")(function* ( - config: ResolvedHazelOAuthConfig, - accessToken: string, - ) { - const response = yield* httpClient - .get(config.userInfoUrl, { - headers: { - authorization: `Bearer ${accessToken}`, - accept: "application/json", - }, - }) - .pipe( - Effect.mapError((cause) => - toUpstreamError(`Userinfo fetch failed: ${cause.message}`), - ), - ) - if (response.status < 200 || response.status >= 300) { - return yield* Effect.fail( - toUpstreamError(`Userinfo fetch failed with ${response.status}`, response.status), - ) - } - const json = yield* response.json.pipe( - Effect.mapError((cause) => - toUpstreamError("Userinfo returned a non-JSON response", undefined, cause), - ), - ) - return yield* decodeUserInfo(json).pipe( - Effect.mapError((cause) => - toUpstreamError("Userinfo returned an unexpected payload", undefined, cause), - ), - ) - }) - - const completeConnect = Effect.fn("HazelOAuthService.completeConnect")(function* ( - code: string, - state: string, - initiator: OAuthFlowInitiator, - ) { - const config = yield* resolveConfig - const stateRow = yield* oauth.consumeStateRow(state, initiator) - - const tokenResponse = yield* oauth.exchangeAuthorizationCode( - config, - code, - stateRow.redirectUri, - ) - const userInfo = yield* fetchUserInfo(config, tokenResponse.access_token) - - const accessEnc = yield* oauth.encryptValue(tokenResponse.access_token) - const refreshEnc = tokenResponse.refresh_token - ? yield* oauth.encryptValue(tokenResponse.refresh_token) - : null - const currentTime = yield* Clock.currentTimeMillis - const expiresAt = - tokenResponse.expires_in != null ? currentTime + tokenResponse.expires_in * 1000 : null - const orgId = decodeOrgId(stateRow.orgId) - - yield* oauth.upsertConnection(orgId, currentTime, { - externalUserId: userInfo.sub, - externalUserEmail: userInfo.email ?? null, - connectedByUserId: stateRow.initiatedByUserId, - scope: tokenResponse.scope ?? config.scopes, - accessTokenCiphertext: accessEnc.ciphertext, - accessTokenIv: accessEnc.iv, - accessTokenTag: accessEnc.tag, - refreshTokenCiphertext: refreshEnc?.ciphertext ?? null, - refreshTokenIv: refreshEnc?.iv ?? null, - refreshTokenTag: refreshEnc?.tag ?? null, - expiresAt, - }) - - return { orgId, returnTo: stateRow.returnTo ?? null } - }) - - const getValidAccessToken = Effect.fn("HazelOAuthService.getValidAccessToken")(function* ( - orgId: OrgId, - ) { - const config = yield* resolveConfig - const { accessToken, row } = yield* oauth.getValidConnectionToken(config, orgId) - return { - accessToken, - externalUserId: row.externalUserId, - } satisfies HazelOAuthAccessToken - }) - - const getStatus = Effect.fn("HazelOAuthService.getStatus")(function* (orgId: OrgId) { - const row = yield* oauth.loadConnection(orgId) - if (!row) { - return { connected: false } as const - } - return { - connected: true, - externalUserId: row.externalUserId, - externalUserEmail: row.externalUserEmail, - connectedByUserId: row.connectedByUserId, - scope: row.scope, - } as const - }) - - const listOrganizations = Effect.fn("HazelOAuthService.listOrganizations")(function* ( - orgId: OrgId, - ) { - const config = yield* resolveConfig - const { accessToken } = yield* getValidAccessToken(orgId) - const response = yield* httpClient - .get(`${config.apiBaseUrl}/api/v1/organizations`, { - headers: { - authorization: `Bearer ${accessToken}`, - accept: "application/json", - }, - }) - .pipe( - Effect.mapError((cause) => - toUpstreamError(`Hazel organizations request failed: ${cause.message}`), - ), - ) - if (response.status === 401) { - return yield* Effect.fail( - new IntegrationsRevokedError({ - message: "Hazel rejected the access token — reconnect required", - }), - ) - } - if (response.status < 200 || response.status >= 300) { - return yield* Effect.fail( - toUpstreamError(`Hazel organizations returned ${response.status}`, response.status), - ) - } - const json = yield* response.json.pipe( - Effect.mapError((cause) => - toUpstreamError("Hazel organizations returned a non-JSON response", undefined, cause), - ), - ) - const decoded = yield* decodeOrganizationsResponse(json).pipe( - Effect.mapError((cause) => - toUpstreamError( - "Hazel organizations returned an unexpected payload", - undefined, - cause, - ), - ), - ) - return decoded.data.map((o) => ({ - id: o.id, - name: o.name, - slug: o.slug, - logoUrl: o.logoUrl, - })) - }) - - const listChannels = Effect.fn("HazelOAuthService.listChannels")(function* ( - orgId: OrgId, - hazelOrganizationId: string, - ) { - const config = yield* resolveConfig - const { accessToken } = yield* getValidAccessToken(orgId) - const encodedOrgId = encodeURIComponent(hazelOrganizationId) - const response = yield* httpClient - .get(`${config.apiBaseUrl}/api/v1/organizations/${encodedOrgId}/channels`, { - headers: { - authorization: `Bearer ${accessToken}`, - accept: "application/json", - }, - }) - .pipe( - Effect.mapError((cause) => - toUpstreamError(`Hazel channels request failed: ${cause.message}`), - ), - ) - if (response.status === 401) { - return yield* Effect.fail( - new IntegrationsRevokedError({ - message: "Hazel rejected the access token — reconnect required", - }), - ) - } - if (response.status === 404) { - return yield* Effect.fail( - new IntegrationsValidationError({ - message: "Hazel organization not found or you are not a member", - }), - ) - } - if (response.status < 200 || response.status >= 300) { - return yield* Effect.fail( - toUpstreamError(`Hazel channels returned ${response.status}`, response.status), - ) - } - const json = yield* response.json.pipe( - Effect.mapError((cause) => - toUpstreamError("Hazel channels returned a non-JSON response", undefined, cause), - ), - ) - const decoded = yield* decodeChannelsResponse(json).pipe( - Effect.mapError((cause) => - toUpstreamError("Hazel channels returned an unexpected payload", undefined, cause), - ), - ) - return decoded.data.map((c) => ({ - id: c.id, - name: c.name, - type: c.type, - organizationId: c.organizationId, - })) - }) - - const createChannelWebhook = Effect.fn("HazelOAuthService.createChannelWebhook")(function* ( - orgId: OrgId, - options: { - readonly channelId: string - readonly name: string - readonly description?: string - }, - ) { - const config = yield* resolveConfig - const { accessToken } = yield* getValidAccessToken(orgId) - const body: Record = { - channelId: options.channelId, - name: options.name, - integrationProvider: "maple", - } satisfies Record - if (options.description) body.description = options.description - const request = HttpClientRequest.post(`${config.apiBaseUrl}/api/v1/channel-webhooks`, { - headers: { - authorization: `Bearer ${accessToken}`, - accept: "application/json", - }, - }).pipe(HttpClientRequest.bodyJsonUnsafe(body)) - const response = yield* httpClient - .execute(request) - .pipe( - Effect.mapError((cause) => - toUpstreamError(`Hazel webhook provisioning failed: ${cause.message}`), - ), - ) - if (response.status === 401) { - return yield* Effect.fail( - new IntegrationsRevokedError({ - message: "Hazel rejected the access token — reconnect required", - }), - ) - } - if (response.status === 404) { - return yield* Effect.fail( - new IntegrationsValidationError({ - message: "Hazel channel not found — pick a different channel and try again", - }), - ) - } - if (response.status < 200 || response.status >= 300) { - const text = yield* response.text.pipe( - Effect.mapError(() => - toUpstreamError( - `Hazel webhook provisioning returned ${response.status}`, - response.status, - ), - ), - ) - return yield* Effect.fail( - toUpstreamError( - `Hazel webhook provisioning failed: ${text || response.status}`, - response.status, - ), - ) - } - const json = yield* response.json.pipe( - Effect.mapError((cause) => - toUpstreamError( - "Hazel webhook provisioning returned a non-JSON response", - undefined, - cause, - ), - ), - ) - return yield* decodeChannelWebhookResponse(json).pipe( - Effect.mapError((cause) => - toUpstreamError( - "Hazel webhook provisioning returned an unexpected payload", - undefined, - cause, - ), - ), - ) - }) - - const disconnect = Effect.fn("HazelOAuthService.disconnect")(function* (orgId: OrgId) { - return yield* oauth.deleteConnection(orgId) - }) - - return { - startConnect, - completeConnect, - getStatus, - getValidAccessToken, - listOrganizations, - listChannels, - createChannelWebhook, - disconnect, - } satisfies HazelOAuthServiceApi - }), - }, -) { - static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) -} diff --git a/packages/backend/src/services/auth/oauth/connection-helpers.ts b/packages/backend/src/services/auth/oauth/connection-helpers.ts index 7894b3cff8..0e5fc4f408 100644 --- a/packages/backend/src/services/auth/oauth/connection-helpers.ts +++ b/packages/backend/src/services/auth/oauth/connection-helpers.ts @@ -1,5 +1,5 @@ // Shared OAuth-connection machinery for provider integrations (Cloudflare, -// Hazel, ...). Both providers persist into the same `oauth_connections` / +// Linear, ...). Providers persist into the same `oauth_connections` / // `oauth_auth_states` tables, so the state-row lifecycle, encrypted token // persistence, token-endpoint HTTP calls, and refresh semantics live here — // parameterized by provider id and display label. Provider-specific flow @@ -131,7 +131,7 @@ const clientCredentials = ( export interface MakeOAuthConnectionHelpersOptions { /** Row value in `oauth_connections.provider` / `oauth_auth_states.provider`. */ readonly provider: string - /** Display name used in error messages ("Cloudflare", "Hazel"). */ + /** Display name used in error messages ("Cloudflare", "Linear"). */ readonly providerLabel: string readonly database: DatabaseApi readonly env: EnvConfig diff --git a/packages/chat-platform/src/vendor-isolation.test.ts b/packages/chat-platform/src/vendor-isolation.test.ts index 38c6f9e626..445c847ebc 100644 --- a/packages/chat-platform/src/vendor-isolation.test.ts +++ b/packages/chat-platform/src/vendor-isolation.test.ts @@ -55,13 +55,6 @@ const GUARDED_FILES = [ const VENDORS = ["discord", "hazel", "slack", "teams", "telegram", "whatsapp"] -/** - * Vendor names a guarded file may hold for an integration that is not a chat connector. The - * catalog lists Hazel's alert-webhook integration, which predates (and is separate from) the Hazel - * chat connector. - */ -const OTHER_INTEGRATIONS = new Map([["apps/web/src/page/integrations/catalog.ts", ["hazel"]]]) - const SELF = fileURLToPath(import.meta.url) const sourcesUnder = (dir: string): Array => @@ -143,9 +136,7 @@ describe("vendor isolation", () => { } it("keeps the files this feature owns elsewhere free of vendor names", () => { - const offenders = GUARDED_FILES.flatMap((path) => - offendingLines(join(repoRoot, path), OTHER_INTEGRATIONS.get(path) ?? [], never), - ) + const offenders = GUARDED_FILES.flatMap((path) => offendingLines(join(repoRoot, path), [], never)) expect(offenders).toEqual([]) }) diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index 8630e707dc..45a8c08e07 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -8,8 +8,6 @@ import { ChatConnectorId, ChatWorkspaceId, ErrorIssueId, - HazelChannelId, - HazelOrganizationId, IsoDateTimeString, PostgresTransactionId, RoleName, @@ -21,7 +19,6 @@ import { HttpTaggedError } from "./error-policy" export const AlertDestinationType = Schema.Literals([ "pagerduty", "webhook", - "hazel-oauth", "discord", "telegram", "google-chat", @@ -299,19 +296,6 @@ export class WebhookAlertDestinationConfig extends Schema.Class( - "HazelOAuthAlertDestinationConfig", -)({ - type: Schema.Literal("hazel-oauth"), - name: ChannelLabel, - hazelOrganizationId: HazelOrganizationId, - hazelOrganizationName: NonEmptyString, - hazelOrganizationLogoUrl: Schema.optionalKey(Schema.NullOr(NonEmptyString)), - hazelChannelId: HazelChannelId, - hazelChannelName: NonEmptyString, - enabled: Schema.optionalKey(Schema.Boolean), -}) {} - export class DiscordAlertDestinationConfig extends Schema.Class( "DiscordAlertDestinationConfig", )({ @@ -417,7 +401,6 @@ export class EmailAlertDestinationConfig extends Schema.Class( - "UpdateHazelOAuthAlertDestinationConfig", -)({ - name: OptionalNonEmptyString, - hazelOrganizationId: Schema.optionalKey(HazelOrganizationId), - hazelOrganizationName: Schema.optionalKey(Schema.String), - hazelOrganizationLogoUrl: Schema.optionalKey(Schema.NullOr(Schema.String)), - hazelChannelId: Schema.optionalKey(HazelChannelId), - hazelChannelName: Schema.optionalKey(Schema.String), - enabled: Schema.optionalKey(Schema.Boolean), -}) {} - export class UpdateDiscordAlertDestinationConfig extends Schema.Class( "UpdateDiscordAlertDestinationConfig", )({ @@ -515,10 +486,6 @@ export const AlertDestinationUpdateRequest = Schema.Union([ type: Schema.Literal("webhook"), ...UpdateWebhookAlertDestinationConfig.fields, }), - Schema.Struct({ - type: Schema.Literal("hazel-oauth"), - ...UpdateHazelOAuthAlertDestinationConfig.fields, - }), Schema.Struct({ type: Schema.Literal("discord"), ...UpdateDiscordAlertDestinationConfig.fields, diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index 4ad65e4734..4297465a19 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -2,7 +2,6 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { AwsAccountId, - ExternalUserId, GcpProjectId, GcpResourceNumber, GcpScopeType, @@ -65,67 +64,6 @@ export const validateIntegrationReturnPath = (raw: string | null | undefined): s ? raw : null -export class HazelIntegrationStatus extends Schema.Class("HazelIntegrationStatus")({ - connected: Schema.Boolean, - externalUserId: Schema.NullOr(ExternalUserId), - externalUserEmail: Schema.NullOr(Schema.String), - connectedByUserId: Schema.NullOr(UserId), - scope: Schema.NullOr(Schema.String), -}) {} - -export class HazelOrganizationSummary extends Schema.Class( - "HazelOrganizationSummary", -)({ - id: Schema.String, - name: Schema.String, - slug: Schema.NullOr(Schema.String), - logoUrl: Schema.NullOr(Schema.String), -}) {} - -export class HazelOrganizationsListResponse extends Schema.Class( - "HazelOrganizationsListResponse", -)({ - organizations: Schema.Array(HazelOrganizationSummary), -}) {} - -export const HazelChannelType = Schema.Literals(["public", "private"]).annotate({ - identifier: "@maple/HazelChannelType", - title: "Hazel Channel Type", -}) -export type HazelChannelType = Schema.Schema.Type - -export class HazelChannelSummary extends Schema.Class("HazelChannelSummary")({ - id: Schema.String, - name: Schema.String, - type: HazelChannelType, - organizationId: Schema.String, -}) {} - -export class HazelChannelsListResponse extends Schema.Class( - "HazelChannelsListResponse", -)({ - channels: Schema.Array(HazelChannelSummary), -}) {} - -export class HazelStartConnectRequest extends Schema.Class( - "HazelStartConnectRequest", -)({ - returnTo: Schema.optionalKey(IntegrationReturnPath), -}) {} - -export class HazelStartConnectResponse extends Schema.Class( - "HazelStartConnectResponse", -)({ - redirectUrl: Schema.String, - state: Schema.String, -}) {} - -export class HazelDisconnectResponse extends Schema.Class("HazelDisconnectResponse")( - { - disconnected: Schema.Boolean, - }, -) {} - export class LinearIntegrationStatus extends Schema.Class("LinearIntegrationStatus")( { connected: Schema.Boolean, @@ -1464,7 +1402,7 @@ export type IntegrationHttpError = | IntegrationsPersistenceError /** - * Dashboard control surface for the Hazel, Linear, Cloudflare, Railway and GitHub + * Dashboard control surface for the Linear, Cloudflare, Railway and GitHub * integrations, plus VCS lookups. Internal transport: session-only, absent from * the public docs. PlanetScale and chat connectors are public v2 resources. * @@ -1472,57 +1410,6 @@ export type IntegrationHttpError = * not part of this group: providers store those URLs on their side. */ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") - .add( - HttpApiEndpoint.get("hazelStatus", "/hazel/status", { - success: HazelIntegrationStatus, - error: IntegrationsPersistenceError, - }), - ) - .add( - HttpApiEndpoint.post("hazelStart", "/hazel/start", { - payload: HazelStartConnectRequest, - success: HazelStartConnectResponse, - error: [ - IntegrationsForbiddenError, - IntegrationsValidationError, - IntegrationsUpstreamError, - IntegrationsPersistenceError, - ], - }), - ) - .add( - HttpApiEndpoint.get("hazelOrganizations", "/hazel/organizations", { - success: HazelOrganizationsListResponse, - error: [ - IntegrationsValidationError, - IntegrationsNotConnectedError, - IntegrationsRevokedError, - IntegrationsUpstreamError, - IntegrationsPersistenceError, - ], - }), - ) - .add( - HttpApiEndpoint.get("hazelChannels", "/hazel/organizations/:organizationId/channels", { - params: { - organizationId: Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed()), - }, - success: HazelChannelsListResponse, - error: [ - IntegrationsValidationError, - IntegrationsNotConnectedError, - IntegrationsRevokedError, - IntegrationsUpstreamError, - IntegrationsPersistenceError, - ], - }), - ) - .add( - HttpApiEndpoint.delete("hazelDisconnect", "/hazel", { - success: HazelDisconnectResponse, - error: [IntegrationsForbiddenError, IntegrationsPersistenceError], - }), - ) .add( HttpApiEndpoint.get("linearStatus", "/linear/status", { success: LinearIntegrationStatus, diff --git a/packages/domain/src/http/oauth-return.ts b/packages/domain/src/http/oauth-return.ts index a3475dab9c..2ade8b2bba 100644 --- a/packages/domain/src/http/oauth-return.ts +++ b/packages/domain/src/http/oauth-return.ts @@ -10,7 +10,6 @@ import { ChatConnectorId } from "../primitives" */ export const OAuthReturnFlow = Schema.Literals([ "cloudflare", - "hazel", "linear", "github", "planetscale", diff --git a/packages/domain/src/http/v2/alert-destinations.ts b/packages/domain/src/http/v2/alert-destinations.ts index 290de2baa7..86a852201b 100644 --- a/packages/domain/src/http/v2/alert-destinations.ts +++ b/packages/domain/src/http/v2/alert-destinations.ts @@ -1,12 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" import { Schema } from "effect" -import { - ChatConnectorId, - HazelChannelId, - HazelOrganizationId, - PostgresTransactionId, - UserId, -} from "../../primitives" +import { ChatConnectorId, PostgresTransactionId, UserId } from "../../primitives" import { AlertDeliveryAuthError, AlertDeliveryError, @@ -86,7 +80,7 @@ export const V2AlertDestination = Schema.Struct({ }), type: AlertDestinationType.annotate({ description: - "The delivery channel: `pagerduty`, `webhook`, `hazel-oauth`, `discord`, `telegram`, `google-chat`, `email`, `chat` (a channel in a chat workspace linked through a chat connector), or `linear` (a team in the connected Linear workspace). Immutable after creation.", + "The delivery channel: `pagerduty`, `webhook`, `discord`, `telegram`, `google-chat`, `email`, `chat` (a channel in a chat workspace linked through a chat connector), or `linear` (a team in the connected Linear workspace). Immutable after creation.", examples: ["pagerduty"], }), enabled: Schema.Boolean.annotate({ @@ -137,7 +131,7 @@ export const V2AlertDestination = Schema.Struct({ identifier: "AlertDestination", title: "Alert Destination", description: - "A notification channel that alert rules deliver to (PagerDuty, generic webhook, Hazel OAuth, Discord, Telegram, Google Chat, workspace-member email, a channel in a linked chat workspace, or a Linear team). Channel secrets are write-only: responses carry a redacted `summary` instead.", + "A notification channel that alert rules deliver to (PagerDuty, generic webhook, Discord, Telegram, Google Chat, workspace-member email, a channel in a linked chat workspace, or a Linear team). Channel secrets are write-only: responses carry a redacted `summary` instead.", examples: [wireExample(alertDestinationExample)], }) export type V2AlertDestination = Schema.Schema.Type @@ -189,29 +183,6 @@ const V2WebhookDestinationCreateParams = Schema.Struct({ enabled: enabledField, }).annotate({ identifier: "AlertDestinationCreateWebhook", title: "Webhook destination" }) -const V2HazelOAuthDestinationCreateParams = Schema.Struct({ - type: Schema.Literal("hazel-oauth"), - name: nameField, - hazel_organization_id: HazelOrganizationId.annotate({ - description: "The connected Hazel organization ID.", - }), - hazel_organization_name: NonEmptyString.annotate({ - description: "Display name of the connected Hazel organization.", - }), - hazel_organization_logo_url: Schema.optionalKey( - Schema.NullOr(NonEmptyString).annotate({ - description: "Optional logo URL for the connected Hazel organization.", - }), - ), - hazel_channel_id: HazelChannelId.annotate({ - description: "The Hazel channel that receives notifications.", - }), - hazel_channel_name: NonEmptyString.annotate({ - description: "Display name of the Hazel channel.", - }), - enabled: enabledField, -}).annotate({ identifier: "AlertDestinationCreateHazelOauth", title: "Hazel (OAuth) destination" }) - const V2DiscordDestinationCreateParams = Schema.Struct({ type: Schema.Literal("discord"), name: nameField, @@ -283,7 +254,6 @@ const V2LinearDestinationCreateParams = Schema.Struct({ export const V2AlertDestinationCreateParams = Schema.Union([ V2PagerDutyDestinationCreateParams, V2WebhookDestinationCreateParams, - V2HazelOAuthDestinationCreateParams, V2DiscordDestinationCreateParams, V2TelegramDestinationCreateParams, V2GoogleChatDestinationCreateParams, @@ -324,19 +294,6 @@ export const V2AlertDestinationUpdateParams = Schema.Union([ signing_secret: Schema.optionalKey(Schema.String), enabled: Schema.optionalKey(Schema.Boolean), }).annotate({ identifier: "AlertDestinationUpdateWebhook", title: "Webhook destination update" }), - Schema.Struct({ - type: Schema.Literal("hazel-oauth"), - name: optionalNameField, - hazel_organization_id: Schema.optionalKey(HazelOrganizationId), - hazel_organization_name: Schema.optionalKey(Schema.String), - hazel_organization_logo_url: Schema.optionalKey(Schema.NullOr(Schema.String)), - hazel_channel_id: Schema.optionalKey(HazelChannelId), - hazel_channel_name: Schema.optionalKey(Schema.String), - enabled: Schema.optionalKey(Schema.Boolean), - }).annotate({ - identifier: "AlertDestinationUpdateHazelOauth", - title: "Hazel (OAuth) destination update", - }), Schema.Struct({ type: Schema.Literal("discord"), name: optionalNameField, @@ -450,7 +407,8 @@ const alertDeliveryErrors = publicErrors( AlertDeliveryTargetMissingError, AlertDeliveryRejectedError, ) -const hazelWebhookProvisionErrors = publicErrors( +/** Linear destinations read the linked integration on create and update. */ +const integrationLookupErrors = publicErrors( IntegrationsNotConnectedError, IntegrationsRevokedError, IntegrationsUpstreamError, @@ -555,7 +513,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina alertValidation, alertPersistence, destinationEncryption, - ...hazelWebhookProvisionErrors, + ...integrationLookupErrors, ...emailRecipientErrors, ], }).annotateMerge( @@ -607,7 +565,7 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina alertNotFound, destinationEncryption, ...destinationReadErrors, - ...hazelWebhookProvisionErrors, + ...integrationLookupErrors, ...emailRecipientErrors, ], }).annotateMerge( @@ -665,6 +623,6 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina OpenApi.annotations({ title: "Alert Destinations", description: - "Notification channels for alert rules — PagerDuty, generic webhooks, Hazel OAuth, Discord, Telegram, Google Chat, workspace-member email, and channels in linked chat workspaces. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", + "Notification channels for alert rules: PagerDuty, generic webhooks, Discord, Telegram, Google Chat, workspace-member email, and channels in linked chat workspaces. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", }), ) {} diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 03e32f0572..61ef77e4b0 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -804,7 +804,7 @@ describe("MapleApiV2 OpenAPI", () => { } }) - it("preserves Hazel provisioning failures on alert destination mutations", () => { + it("preserves integration lookup failures on alert destination mutations", () => { for (const [method, path] of [ ["post", "/v2/alerts/destinations"], ["patch", "/v2/alerts/destinations/{id}"], diff --git a/packages/domain/src/integration-inventory.ts b/packages/domain/src/integration-inventory.ts index cc229b2229..6f81175c21 100644 --- a/packages/domain/src/integration-inventory.ts +++ b/packages/domain/src/integration-inventory.ts @@ -514,9 +514,8 @@ const prometheus = (inputs: IntegrationInventoryInputs, now: number): Integratio } const chat = (inputs: IntegrationInventoryInputs, now: number): IntegrationEntry | null => { - const hazel = oauthFor(inputs, "hazel") const linear = oauthFor(inputs, "linear") - if (!hazel && !linear && inputs.chatWorkspaces.length === 0) return null + if (!linear && inputs.chatWorkspaces.length === 0) return null const workspaces = inputs.chatWorkspaces.map((w): IntegrationResource => ({ kind: "workspace", name: w.name, @@ -525,18 +524,6 @@ const chat = (inputs: IntegrationInventoryInputs, now: number): IntegrationEntry lastError: null, note: w.connector, })) - const hazelResource: ReadonlyArray = hazel - ? [ - { - kind: "workspace", - name: hazel.externalAccountName ?? hazel.externalUserEmail ?? "Hazel", - status: hazel.revokedAt === null ? "healthy" : "disconnected", - lastSuccessAt: null, - lastError: null, - note: "hazel", - }, - ] - : [] const linearResource: ReadonlyArray = linear ? [ { @@ -551,13 +538,12 @@ const chat = (inputs: IntegrationInventoryInputs, now: number): IntegrationEntry : [] const connectedAt = [ ...inputs.chatWorkspaces.map((w) => w.createdAt), - ...(hazel ? [hazel.createdAt] : []), ...(linear ? [linear.createdAt] : []), ] return entry("chat", now, { account: null, connectedAt: Math.min(...connectedAt), - resources: [...workspaces, ...hazelResource, ...linearResource], + resources: [...workspaces, ...linearResource], }) } diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 67e65a0ea2..e03ddb1d5a 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -579,12 +579,6 @@ export type HttpMethod = Schema.Schema.Type export const ExternalUserId = MapleId("@maple/ExternalUserId", "External User ID") export type ExternalUserId = Schema.Schema.Type -export const HazelOrganizationId = MapleId("@maple/HazelOrganizationId", "Hazel Organization ID") -export type HazelOrganizationId = Schema.Schema.Type - -export const HazelChannelId = MapleId("@maple/HazelChannelId", "Hazel Channel ID") -export type HazelChannelId = Schema.Schema.Type - export const WidgetId = MapleId("@maple/WidgetId", "Widget ID") export type WidgetId = Schema.Schema.Type diff --git a/packages/web-data/src/alerts/form-utils.ts b/packages/web-data/src/alerts/form-utils.ts index 5183f252b3..e1ba6dabe1 100644 --- a/packages/web-data/src/alerts/form-utils.ts +++ b/packages/web-data/src/alerts/form-utils.ts @@ -11,8 +11,6 @@ import { AlertQueryTiming, AlertRulePreviewResponse, AlertRulePreviewSeries, - HazelChannelId, - HazelOrganizationId, IsoDateTimeString, UserId, type AlertComparator, @@ -50,8 +48,6 @@ import { getBrowserTimeZone } from "../lib/timezone" import { formatDateInTimezone, formatTimeInTimezone, formatTimestampInTimezone } from "../lib/timezone-format" import type { Tone } from "@maple/ui/lib/tone" -const asHazelOrganizationId = Schema.decodeUnknownSync(HazelOrganizationId) -const asHazelChannelId = Schema.decodeUnknownSync(HazelChannelId) const asUserId = Schema.decodeUnknownSync(UserId) // v2 timestamps arrive as plain ISO strings; the v1 domain documents brand them. @@ -617,11 +613,6 @@ export type DestinationFormState = { /** Telegram bot token from @BotFather, and the chat it posts to. */ telegramBotToken: string telegramChatId: string - hazelOrganizationId: string - hazelOrganizationName: string - hazelOrganizationLogoUrl: string | null - hazelChannelId: string - hazelChannelName: string /** Selected workspace-member recipients (email type only). */ memberUserIds: string[] /** @@ -652,11 +643,6 @@ export function defaultDestinationForm(type: AlertDestinationType = "discord"): signingSecret: "", telegramBotToken: "", telegramChatId: "", - hazelOrganizationId: "", - hazelOrganizationName: "", - hazelOrganizationLogoUrl: null, - hazelChannelId: "", - hazelChannelName: "", memberUserIds: [], chatWorkspaceId: null, chatConnector: "", @@ -695,8 +681,6 @@ export function suggestedDestinationName(form: DestinationFormState): string { switch (form.type) { case "chat": return form.chatChannelName.length > 0 ? `#${form.chatChannelName}` : "" - case "hazel-oauth": - return form.hazelChannelName.length > 0 ? `Hazel #${form.hazelChannelName}` : "Hazel" case "linear": return form.linearTeamName.length > 0 ? `Linear ${form.linearTeamName}` : "Linear" case "webhook": @@ -727,11 +711,6 @@ export function destinationToFormState(destination: AlertDestinationDocument): D // comes back as `channelLabel` so an edit doesn't demand retyping it. telegramBotToken: "", telegramChatId: destination.type === "telegram" ? (destination.channelLabel ?? "") : "", - hazelOrganizationId: "", - hazelOrganizationName: "", - hazelOrganizationLogoUrl: null, - hazelChannelId: "", - hazelChannelName: "", memberUserIds: destination.memberUserIds != null ? [...destination.memberUserIds] : [], // The stored channel's id isn't returned, its `#name` is — kept so the current // channel stays visible on edit (an empty id keeps the stored one). @@ -765,21 +744,6 @@ export function buildDestinationCreateParamsV2(form: DestinationFormState): V2Al ...(signingSecret ? { signing_secret: signingSecret } : undefined), } } - case "hazel-oauth": { - const logoUrl = form.hazelOrganizationLogoUrl - return { - type: "hazel-oauth", - name: form.name.trim(), - enabled: form.enabled, - hazel_organization_id: asHazelOrganizationId(form.hazelOrganizationId.trim()), - hazel_organization_name: form.hazelOrganizationName.trim(), - ...(logoUrl !== null && logoUrl.trim().length > 0 - ? { hazel_organization_logo_url: logoUrl.trim() } - : undefined), - hazel_channel_id: asHazelChannelId(form.hazelChannelId.trim()), - hazel_channel_name: form.hazelChannelName.trim(), - } - } case "discord": return { type: "discord", @@ -858,28 +822,6 @@ export function buildDestinationUpdateParamsV2(form: DestinationFormState): V2Al ...(signingSecret ? { signing_secret: signingSecret } : undefined), } } - case "hazel-oauth": { - const organizationId = form.hazelOrganizationId.trim() - const organizationName = form.hazelOrganizationName.trim() - const channelId = form.hazelChannelId.trim() - const channelName = form.hazelChannelName.trim() - return { - type: "hazel-oauth", - enabled: form.enabled, - ...(name ? { name } : undefined), - ...(organizationId - ? { hazel_organization_id: asHazelOrganizationId(organizationId) } - : undefined), - ...(organizationName ? { hazel_organization_name: organizationName } : undefined), - ...(form.hazelOrganizationLogoUrl === null - ? { hazel_organization_logo_url: null } - : form.hazelOrganizationLogoUrl.trim() - ? { hazel_organization_logo_url: form.hazelOrganizationLogoUrl.trim() } - : {}), - ...(channelId ? { hazel_channel_id: asHazelChannelId(channelId) } : undefined), - ...(channelName ? { hazel_channel_name: channelName } : undefined), - } - } case "discord": { const webhookUrl = form.webhookUrl.trim() return { diff --git a/packages/web-data/src/collections/alerts.ts b/packages/web-data/src/collections/alerts.ts index af80d2107d..c5e14409d0 100644 --- a/packages/web-data/src/collections/alerts.ts +++ b/packages/web-data/src/collections/alerts.ts @@ -301,7 +301,7 @@ export const rowToAlertIncidentDocument = (row: AlertIncidentRow): AlertIncident * column-restricted server-side (the encrypted `secret_*` columns are dropped — * see the proxy whitelist), so this struct intentionally lists only the synced * columns. `config_json` carries ONLY public config (summary / channel label / - * hazel metadata); the webhook secrets live in the excluded encrypted columns. + * chat workspace); the webhook secrets live in the excluded encrypted columns. */ export const AlertDestinationRowSchema = Schema.Struct({ id: Schema.String, @@ -321,8 +321,7 @@ export type AlertDestinationRow = typeof AlertDestinationRowSchema.Type /** * The public projection of a destination's `config_json` the browser renders — - * mirrors the server's `DestinationPublicConfigSchema` (summary + channelLabel; - * the hazel-* fields are excess and ignored here). The server stringifies the + * mirrors the server's `DestinationPublicConfigSchema`. The server stringifies the * jsonb before decoding; Electric's default parser already gives us the parsed * object, so we decode it directly. */