Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { HttpApiBuilder } from "effect/unstable/httpapi"
import {
CurrentTenant,
MapleApi,
MapleInternalApi,
RawSqlExecuteResponse,
type RawSqlValidationError,
SpanHierarchyResponse,
Expand All @@ -15,8 +15,6 @@ import {
ServiceHealthSnapshotResponse,
ServiceHealthBaselineResponse,
ServiceApdexResponse,
ServiceDependenciesResponse,
ServiceDbEdgesResponse,
PlanetScaleInfraTimeseriesResponse,
ServiceCloudflareStatsResponse,
ServicePlanetScaleStatsResponse,
Expand All @@ -33,7 +31,6 @@ import {
ServiceDetailOverviewResponse,
ServiceDependenciesBundleResponse,
ServiceMapBundleResponse,
ServicePlatformsResponse,
ServiceWorkloadsResponse,
ServiceUsageResponse,
ServiceOperationsResponse,
Expand Down Expand Up @@ -271,7 +268,7 @@ const toServiceWorkloadRow = (row: CH.ServiceWorkloadsOutput) => ({
row.avgMemoryLimitUtilization == null ? null : Number(row.avgMemoryLimitUtilization),
})

export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine", (handlers) =>
export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "queryEngine", (handlers) =>
Effect.gen(function* () {
const queryEngine = yield* QueryEngineService
const warehouse = yield* WarehouseQueryService
Expand All @@ -283,12 +280,6 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine",
>(warehouse)

return handlers
.handle("execute", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
return yield* queryEngine.execute(tenant, payload)
}),
)
.handle("executeBatch", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
Expand Down Expand Up @@ -503,20 +494,6 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine",
})
}),
)
.handle("serviceDependencies", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rows = yield* runQuery(Queries.serviceDependencies, tenant, payload)
return new ServiceDependenciesResponse({ data: rows.map((row) => ({ ...row })) })
}),
)
.handle("serviceDbEdges", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rows = yield* runQuery(Queries.serviceDbEdges, tenant, payload)
return new ServiceDbEdgesResponse({ data: rows.map((row) => ({ ...row })) })
}),
)
.handle("serviceCloudflareStats", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
Expand Down Expand Up @@ -1073,13 +1050,6 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine",
})
}),
)
.handle("servicePlatforms", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rows = yield* runQuery(Queries.servicePlatforms, tenant, payload)
return new ServicePlatformsResponse({ data: rows.map(toServicePlatformRow) })
}),
)
.handle("serviceWorkloads", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/routes/query-engine-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Clock, Duration, Effect } from "effect"
import { QueryEngineTimeoutError, type SelfDescribingHttpError } from "@maple/domain/http"

/**
* Fan-out for `POST /api/query-engine/execute-batch`.
* Fan-out for `POST /internal/query-engine/execute-batch`.
*
* Extracted from the route handler so the two things that actually carry risk —
* the shared deadline and per-item failure isolation — are testable without an
Expand Down
26 changes: 22 additions & 4 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MapleApi } from "@maple/domain/http"
import { MapleApi, MapleInternalApi } from "@maple/domain/http"
import { MapleApiV2 } from "@maple/domain/http/v2"
import { Layer } from "effect"
import { Headers, HttpMiddleware, HttpRouter, HttpServerResponse } from "effect/unstable/http"
Expand All @@ -25,7 +25,7 @@ import { HttpOrgClickHouseSettingsLive } from "@/routes/v1/org-clickhouse-settin
import { HttpOrganizationsLive } from "@/routes/v1/organizations.http"
import { PlanetScaleWebhookRouter } from "@/routes/v1/planetscale-webhook.http"
import { PrometheusScrapeProxyRouter } from "@/routes/v1/prometheus-scrape-proxy.http"
import { HttpQueryEngineLive } from "@/routes/v1/query-engine.http"
import { HttpQueryEngineLive } from "@/routes/internal/query-engine.http"
import { ScraperInternalRouter } from "@/routes/v1/scraper-internal.http"
import { HttpSessionReplaysLive } from "@/routes/v1/session-replay.http"
import { SlackCallbackRouter, SlackInternalRouter } from "@/routes/v1/slack-integration.http"
Expand Down Expand Up @@ -58,6 +58,7 @@ import {
} from "@/routes/v2/telemetry.http"
import { ApiAuthorizationLayer } from "@/services/auth/ApiAuthorizationLayer"
import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer"
import { SessionAuthorizationLayer } from "@/services/auth/SessionAuthorizationLayer"
import { ApiV2RateLimiter } from "@/services/auth/ApiV2RateLimiter"
import { ApiKeysService } from "@/services/org/ApiKeysService"

Expand Down Expand Up @@ -91,7 +92,19 @@ const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe(
Layer.provide(HttpOnboardingLive),
Layer.provide(HttpOrgClickHouseSettingsLive),
Layer.provide(HttpOrganizationsLive),
Layer.provide(Layer.mergeAll(HttpQueryEngineLive, HttpSessionReplaysLive, HttpWarehouseLive)),
Layer.provide(Layer.mergeAll(HttpSessionReplaysLive, HttpWarehouseLive)),
Layer.provide(V1ErrorBoundaryLive),
)

/**
* The dashboard's private transport, served under `/internal/*`.
*
* Session-only: `SessionAuthorizationLayer` refuses API-key-shaped bearers, so
* nothing here is reachable as public API. It is also absent from `/docs`,
* which is generated from `MapleApi`.
*/
const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe(
Layer.provide(HttpQueryEngineLive),
Layer.provide(V1ErrorBoundaryLive),
)

Expand Down Expand Up @@ -128,6 +141,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe(

export const AllRoutes = Layer.mergeAll(
ApiRoutes,
ApiInternalRoutes,
ApiV2Routes,
ChatSessionsRouter,
IntegrationsCallbackRouter,
Expand All @@ -144,7 +158,11 @@ export const AllRoutes = Layer.mergeAll(
DocsV2Route,
).pipe(Layer.provideMerge(HttpRouter.cors(API_CORS_OPTIONS)))

export const ApiAuthLive = Layer.mergeAll(ApiAuthorizationLayer, ApiAuthorizationV2Layer).pipe(
export const ApiAuthLive = Layer.mergeAll(
ApiAuthorizationLayer,
ApiAuthorizationV2Layer,
SessionAuthorizationLayer,
).pipe(
Layer.provideMerge(ApiV2RateLimiter.layer),
Layer.provideMerge(ApiKeysService.layer),
Layer.provideMerge(Env.layer),
Expand Down
58 changes: 58 additions & 0 deletions apps/api/src/services/auth/SessionAuthorizationLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { HttpServerRequest } from "effect/unstable/http"
import { API_KEY_PREFIX } from "@maple/db"
import { CurrentTenant } from "@maple/domain/http"
import { Effect, Layer } from "effect"
import { makeResolveTenant } from "./AuthService"
import { annotateAuthSpan } from "@/services/auth/auth-span"
import { Env } from "@/platform/Env"

const getBearerToken = (headers: Record<string, string | undefined>): string | undefined => {
const header = headers["authorization"] ?? headers["Authorization"]
if (!header) return undefined
const [scheme, token] = header.split(" ")
if (!scheme || !token || scheme.toLowerCase() !== "bearer") return undefined
return token
}

/**
* Authorization for the internal API: Clerk sessions only.
*
* The public `ApiAuthorizationLayer` tries an API key first and falls back to a
* session. This one inverts the posture — an API-key-shaped token is refused
* outright, because these endpoints are dashboard transport whose request and
* response shapes change with the UI.
*
* The refusal is a prefix test, not a lookup: `API_KEY_PREFIX` identifies the
* credential without consulting Postgres, so a rejected key costs no database
* dial on what is the busiest path in the API. Session tokens never match the
* prefix, so they reach `resolveTenant` exactly as before.
*/
export const SessionAuthorizationLayer = Layer.effect(
CurrentTenant.SessionAuthorization,
Effect.gen(function* () {
const env = yield* Env
const resolveTenant = makeResolveTenant(env)

return CurrentTenant.SessionAuthorization.of({
bearer: (httpEffect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest

const token = getBearerToken(request.headers)
if (token?.startsWith(API_KEY_PREFIX)) {
return yield* new CurrentTenant.ApiKeyNotAcceptedError({
message: "API keys cannot call the internal API; use the /v2 API instead",
})
}

const tenant = yield* resolveTenant(request.headers)
yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId })
return yield* Effect.provideService(
httpEffect,
CurrentTenant.Context,
new CurrentTenant.TenantSchema(tenant),
)
}),
})
}),
)
19 changes: 10 additions & 9 deletions apps/web/src/api/warehouse/cloudflare-infra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
CloudflareTopTrafficRequest,
} from "@maple/domain/http"
import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client"
import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils"

const ZONE_SERVICE_PREFIX = "cloudflare/"
Expand Down Expand Up @@ -151,7 +152,7 @@ export const getCloudflareZones = Effect.fn("QueryEngine.getCloudflareZones")(fu
const input = yield* decodeInput(TimeRangeInputSchema, data, "getCloudflareZones")
const result = yield* runWarehouseQuery("cloudflareInfraZones", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.cloudflareInfraZones({
payload: new CloudflareInfraZonesRequest({
startTime: input.startTime,
Expand Down Expand Up @@ -197,7 +198,7 @@ export const getCloudflareZoneTimeseries = Effect.fn("QueryEngine.getCloudflareZ
const input = yield* decodeInput(TimeseriesInputSchema, data, "getCloudflareZoneTimeseries")
const result = yield* runWarehouseQuery("cloudflareInfraZoneTimeseries", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.cloudflareInfraZoneTimeseries({
payload: new CloudflareInfraZoneTimeseriesRequest({
startTime: input.startTime,
Expand Down Expand Up @@ -268,7 +269,7 @@ export const getCloudflareZoneDetail = Effect.fn("QueryEngine.getCloudflareZoneD
const input = yield* decodeInput(ZoneDetailInputSchema, data, "getCloudflareZoneDetail")
const result = yield* runWarehouseQuery("cloudflareInfraZoneDetail", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.cloudflareInfraZoneDetail({
payload: new CloudflareInfraZoneDetailRequest({
serviceName: input.serviceName,
Expand Down Expand Up @@ -320,7 +321,7 @@ export const getCloudflareWorkers = Effect.fn("QueryEngine.getCloudflareWorkers"
const input = yield* decodeInput(TimeRangeInputSchema, data, "getCloudflareWorkers")
const result = yield* runWarehouseQuery("cloudflareInfraWorkers", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.cloudflareInfraWorkers({
payload: new CloudflareInfraWorkersRequest({
startTime: input.startTime,
Expand Down Expand Up @@ -375,7 +376,7 @@ export const getCloudflareZoneSecurity = Effect.fn("QueryEngine.getCloudflareZon
const input = yield* decodeInput(ZoneDetailInputSchema, data, "getCloudflareZoneSecurity")
const result = yield* runWarehouseQuery("cloudflareInfraZoneSecurity", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.cloudflareInfraZoneSecurity({
payload: new CloudflareInfraZoneSecurityRequest({
serviceName: input.serviceName,
Expand Down Expand Up @@ -429,7 +430,7 @@ export const getCloudflareZoneDns = Effect.fn("QueryEngine.getCloudflareZoneDns"
const input = yield* decodeInput(ZoneDetailInputSchema, data, "getCloudflareZoneDns")
const result = yield* runWarehouseQuery("cloudflareInfraZoneDns", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.cloudflareInfraZoneDns({
payload: new CloudflareInfraZoneDnsRequest({
serviceName: input.serviceName,
Expand Down Expand Up @@ -487,7 +488,7 @@ export const getCloudflarePlatformResources = Effect.fn("QueryEngine.getCloudfla
const input = yield* decodeInput(TimeRangeInputSchema, data, "getCloudflarePlatformResources")
const result = yield* runWarehouseQuery("cloudflareInfraPlatformResources", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.cloudflareInfraPlatformResources({
payload: new CloudflareInfraPlatformResourcesRequest({
startTime: input.startTime,
Expand Down Expand Up @@ -599,7 +600,7 @@ export const getCloudflareZoneBreakdown = Effect.fn("QueryEngine.getCloudflareZo
const input = yield* decodeInput(BreakdownInputSchema, data, "getCloudflareZoneBreakdown")
const result = yield* runWarehouseQuery("cloudflareInfraZoneBreakdown", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.cloudflareInfraZoneBreakdown({
payload: new CloudflareInfraZoneBreakdownRequest({
serviceName: input.serviceName,
Expand Down Expand Up @@ -661,7 +662,7 @@ export const getCloudflareZoneFacets = Effect.fn("QueryEngine.getCloudflareZoneF
const input = yield* decodeInput(ZoneFacetsInputSchema, data, "getCloudflareZoneFacets")
return yield* runWarehouseQuery("cloudflareInfraZoneFacets", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.cloudflareInfraZoneFacets({
payload: new CloudflareInfraZoneFacetsRequest({
serviceName: input.serviceName,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/api/warehouse/custom-charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
invalidWarehouseInput,
runWarehouseQuery,
} from "@/api/warehouse/effect-utils"
import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client"
import type { ServiceDetailTimeSeriesPoint, ServiceTimeSeriesPoint } from "@/api/warehouse/services"
import { QUERY_BUILDER_DATA_SOURCES, QUERY_BUILDER_METRIC_TYPES } from "@maple/query-model"
const dateTimeString = WarehouseDateTimeString
Expand Down Expand Up @@ -868,7 +868,7 @@ const getServiceDetailOverviewEffect = Effect.fn("QueryEngine.getServiceDetailOv

const result = yield* runWarehouseQuery("serviceDetailOverview", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.serviceDetailOverview({
payload: new ServiceDetailOverviewRequest({
serviceName: input.serviceName,
Expand Down
23 changes: 18 additions & 5 deletions apps/web/src/api/warehouse/effect-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ import {
type DurationStats,
type AttributeValueItem,
} from "@maple/query-engine"
import { Effect, Schema } from "effect"
import { Effect, Layer, Schema } from "effect"
import { PublicHttpErrorBodySchema, type AnyPublicHttpErrorBody } from "@maple/domain/http"
import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client"
import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client"
import { mapleApiClientLayer, mapleApiV2ClientLayer, mapleRuntime } from "@/lib/registry"
import {
mapleApiClientLayer,
mapleApiV2ClientLayer,
mapleInternalClientLayer,
mapleRuntime,
} from "@/lib/registry"
import { makeClientErrorBody } from "@/lib/error-messages"
import { makeExecuteBatcher } from "./execute-batcher"

Expand Down Expand Up @@ -148,15 +154,22 @@ export function decodeInput<S extends Schema.Top & { readonly DecodingServices:
)
}

/**
* Accepts either v1 client because the warehouse adapters straddle two APIs:
* query-engine moved to the private `/internal` transport, while the session
* replay and integrations groups it shares this helper with are still on
* `/api`. Both layers are provided, so a caller depends only on the one it
* actually uses.
*/
export function runWarehouseQuery<A, E>(
operation: string,
execute: () => Effect.Effect<A, E, MapleApiAtomClient>,
execute: () => Effect.Effect<A, E, MapleApiAtomClient | MapleInternalAtomClient>,
): Effect.Effect<A, WarehouseApiError | BackendError> {
return Effect.suspend(execute).pipe(
Effect.withSpan(operation),
// Warehouse adapters are imperative server-function entrypoints and own this runtime layer.
// oxlint-disable-next-line effecttsgo/strict-effect-provide
Effect.provide(mapleApiClientLayer),
Effect.provide(Layer.mergeAll(mapleApiClientLayer, mapleInternalClientLayer)),
Effect.mapError((cause) => normalizeWarehouseError(operation, cause)),
)
}
Expand Down Expand Up @@ -198,7 +211,7 @@ export function invalidWarehouseInput(
const executeBatcher = makeExecuteBatcher((requests) =>
mapleRuntime.runPromise(
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
const response = yield* client.queryEngine.executeBatch({
payload: new QueryEngineExecuteBatchRequest({ requests }),
})
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/api/warehouse/error-rates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Clock, Effect, Schema } from "effect"
import { ErrorRateByServiceRequest } from "@maple/domain/http"
import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client"
import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils"

import { formatWarehouseDateTime } from "@maple/query-engine"
Expand Down Expand Up @@ -28,7 +28,7 @@ export const getErrorRateByService = Effect.fn("QueryEngine.getErrorRateByServic
const fallback = defaultTimeRange(yield* Clock.currentTimeMillis)
const result = yield* runWarehouseQuery("errorRateByService", () =>
Effect.gen(function* () {
const client = yield* MapleApiAtomClient
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.errorRateByService({
payload: new ErrorRateByServiceRequest({
startTime: input.startTime ?? fallback.startTime,
Expand Down
Loading
Loading