From f924d9edcdbbc29bc352629e29e990cfd5922177 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 21 Oct 2026 14:44:50 +0200 Subject: [PATCH 01/13] feat(integrations): connect an AWS account as a source An AWS connection is a pull source backed by an IAM role in the customer's account. Maple mints an external id, the customer deploys a generated CloudFormation stack (CloudShell script, console template or Terraform), and Maple assumes the role from its own identity to confirm the setup. No AWS secret is stored. - aws_connections table (source_id required, cascades with its source), AwsConnectionService and /v2/integrations/aws (status, create, update, setup, check, delete). - The role trusts Maple's account root, conditioned on the exact principal ARN and the connection's external id. Its name is derived from the connection id, so a caller can never point Maple at another role. - Each future module (metrics, inventory, security, cost) is its own policy on the same role; the check reads which are attached and the setup version tag, so a new module is a stack update, not a reconnect. - Hourly alerting lane re-checks connections: paused sources are skipped in SQL, the batch is claimed in one statement, two checks run at a time. A deleted stack reports the source disconnected; AWS outages only record the error. - Connect card under Sources: account form, setup panel that re-checks every 10s while open, connection health, disconnect with a cleanup script kept until dismissed. - Docs page integrations/aws. --- .env.example | 7 + apps/alerting/src/schedule.ts | 2 +- apps/alerting/src/scheduled.test.ts | 3 +- apps/alerting/src/scheduled.ts | 19 + apps/alerting/src/worker.ts | 4 + apps/api/src/resources/env.ts | 4 + .../routes/v2/integrations-aws.http.test.ts | 234 + .../src/routes/v2/integrations-aws.http.ts | 130 + apps/api/src/routes/v2/sources.http.ts | 7 +- apps/api/src/routes/v2/v2-test-support.ts | 3 + apps/api/src/runtime/http-graph.ts | 2 + apps/api/src/runtime/service-graph.ts | 2 + .../src/content/docs/integrations/aws.md | 57 + apps/web/src/lib/docs.ts | 1 + apps/web/src/page/integrations/aws-schema.ts | 14 + .../src/page/integrations/aws.scene.test.ts | 215 + apps/web/src/page/integrations/aws.ts | 177 + apps/web/src/page/integrations/catalog.ts | 13 + apps/web/src/page/integrations/command.ts | 13 + apps/web/src/page/integrations/derive.ts | 40 + .../src/page/integrations/hub.scene.test.ts | 1 + apps/web/src/page/integrations/message.ts | 39 +- apps/web/src/page/integrations/model.ts | 4 + .../web/src/page/integrations/subscription.ts | 20 + apps/web/src/page/integrations/update-aws.ts | 290 + .../src/page/integrations/update-status.ts | 9 + apps/web/src/page/integrations/update.ts | 43 +- apps/web/src/page/integrations/view-aws.ts | 518 + apps/web/src/page/integrations/view.ts | 3 + apps/web/src/page/settings/kit/code-block.ts | 4 +- .../page/sources/detail/detail.scene.test.ts | 139 +- apps/web/src/page/sources/detail/update.ts | 44 +- apps/web/src/page/sources/kit/platforms.ts | 1 + apps/web/src/page/sources/kit/shared.ts | 24 +- apps/web/src/route/data-integrations.ts | 2 +- apps/web/src/shell/palette/palette.test.ts | 2 +- bun.lock | 1 + packages/backend/package.json | 1 + packages/backend/src/platform/Env.ts | 11 + .../integrations/AwsConnectionService.test.ts | 291 + .../integrations/AwsConnectionService.ts | 616 + .../src/services/integrations/aws/api-impl.ts | 167 + .../src/services/integrations/aws/api.ts | 13 + .../src/services/integrations/aws/errors.ts | 17 + .../src/services/integrations/aws/fake-aws.ts | 214 + .../src/services/integrations/aws/setup.ts | 188 + .../integrations/gcp/setup-scripts.ts | 33 +- .../src/services/integrations/paste-script.ts | 34 + .../src/services/org/OrganizationService.ts | 3 + .../src/services/org/integration-sources.ts | 3 + .../migration.sql | 28 + .../snapshot.json | 13896 ++++++++++++++++ packages/db/src/tables/aws-connections.ts | 50 + packages/db/src/tables/index.ts | 3 + packages/domain/package.json | 1 + packages/domain/src/audit-actions.ts | 9 + packages/domain/src/aws.ts | 215 + packages/domain/src/http/integrations.ts | 35 + packages/domain/src/http/v2/api.ts | 2 + packages/domain/src/http/v2/index.ts | 1 + .../domain/src/http/v2/integrations-aws.ts | 338 + packages/domain/src/http/v2/openapi.test.ts | 6 + packages/domain/src/http/v2/public-id.ts | 1 + packages/domain/src/source-health.ts | 2 + packages/primitives/src/index.ts | 19 + 65 files changed, 18232 insertions(+), 56 deletions(-) create mode 100644 apps/api/src/routes/v2/integrations-aws.http.test.ts create mode 100644 apps/api/src/routes/v2/integrations-aws.http.ts create mode 100644 apps/landing/src/content/docs/integrations/aws.md create mode 100644 apps/web/src/page/integrations/aws-schema.ts create mode 100644 apps/web/src/page/integrations/aws.scene.test.ts create mode 100644 apps/web/src/page/integrations/aws.ts create mode 100644 apps/web/src/page/integrations/update-aws.ts create mode 100644 apps/web/src/page/integrations/view-aws.ts create mode 100644 packages/backend/src/services/integrations/AwsConnectionService.test.ts create mode 100644 packages/backend/src/services/integrations/AwsConnectionService.ts create mode 100644 packages/backend/src/services/integrations/aws/api-impl.ts create mode 100644 packages/backend/src/services/integrations/aws/api.ts create mode 100644 packages/backend/src/services/integrations/aws/errors.ts create mode 100644 packages/backend/src/services/integrations/aws/fake-aws.ts create mode 100644 packages/backend/src/services/integrations/aws/setup.ts create mode 100644 packages/backend/src/services/integrations/paste-script.ts create mode 100644 packages/db/drizzle/20261021114524_aws_connections/migration.sql create mode 100644 packages/db/drizzle/20261021114524_aws_connections/snapshot.json create mode 100644 packages/db/src/tables/aws-connections.ts create mode 100644 packages/domain/src/aws.ts create mode 100644 packages/domain/src/http/v2/integrations-aws.ts diff --git a/.env.example b/.env.example index cb59b27aba..11149792c3 100644 --- a/.env.example +++ b/.env.example @@ -198,6 +198,13 @@ INGEST_REQUIRE_TLS=false # the IAM Service Account Credentials API enabled. # MAPLE_GCP_SERVICE_ACCOUNT_KEY= +# AWS integration (an administrator deploys a generated CloudFormation stack from CloudShell). +# Maple's own IAM user, whose only permission is sts:AssumeRole on arn:aws:iam::*:role/MapleIntegration-*. +# Customer roles trust it with their connection's external id. All three unset: AWS is unavailable. +# MAPLE_AWS_PRINCIPAL_ARN=arn:aws:iam:::user/maple-integrations +# MAPLE_AWS_ACCESS_KEY_ID= +# MAPLE_AWS_SECRET_ACCESS_KEY= + # OpenTelemetry (API self-observability) — read by @maple-dev/effect-sdk # MAPLE_ENVIRONMENT=local # "local" = no export; any other value enables OTLP # MAPLE_ENDPOINT=http://127.0.0.1:3474 # Ingest gateway endpoint (enriches with org_id) diff --git a/apps/alerting/src/schedule.ts b/apps/alerting/src/schedule.ts index 86beac3d84..c12432de95 100644 --- a/apps/alerting/src/schedule.ts +++ b/apps/alerting/src/schedule.ts @@ -34,7 +34,7 @@ export const ALERTING_SCHEDULE = { deadline: "270 seconds", }, "*/15 * * * *": { lanes: ["digest"], deadline: "10 minutes" }, - "0 * * * *": { lanes: ["service_map_rollup"], deadline: "15 minutes" }, + "0 * * * *": { lanes: ["service_map_rollup", "aws_connections"], deadline: "15 minutes" }, } as const satisfies Record export type AlertingCron = keyof typeof ALERTING_SCHEDULE diff --git a/apps/alerting/src/scheduled.test.ts b/apps/alerting/src/scheduled.test.ts index 5470badc60..57555c5913 100644 --- a/apps/alerting/src/scheduled.test.ts +++ b/apps/alerting/src/scheduled.test.ts @@ -18,7 +18,7 @@ const cronCases = [ ], ], ["*/15 * * * *", ["digest"]], - ["0 * * * *", ["serviceMapRollup"]], + ["0 * * * *", ["serviceMapRollup", "awsConnections"]], ["* * * * *", ["alert", "error", "escalation", "fixVerification"]], ] as const @@ -31,6 +31,7 @@ const recordingTicks = ( return { alert: tick("alert"), anomaly: tick("anomaly"), + awsConnections: tick("awsConnections"), cloudflareAnalytics: tick("cloudflareAnalytics"), digest: tick("digest"), error: tick("error"), diff --git a/apps/alerting/src/scheduled.ts b/apps/alerting/src/scheduled.ts index c53b122cd6..217ba33997 100644 --- a/apps/alerting/src/scheduled.ts +++ b/apps/alerting/src/scheduled.ts @@ -22,6 +22,7 @@ import { PullRequestLookupLive } from "@maple/backend/services/errors/pull-reque import { PlanetScaleService } from "@maple/backend/services/integrations/PlanetScaleService" import { PrReviewPostMergeService } from "@maple/backend/services/pr-review/PrReviewPostMergeService" import { RailwayMetricsService } from "@maple/backend/services/integrations/RailwayMetricsService" +import { AwsConnectionService } from "@maple/backend/services/integrations/AwsConnectionService" import { ServiceMapRollupService } from "@maple/backend/services/dashboards/ServiceMapRollupService" import { layerMapleDbConnection } from "@maple/backend/platform/pg-connection-source" import { summarizeCause } from "@maple/backend/platform/describe-cause" @@ -53,6 +54,7 @@ export const layerTick = ( GcpMetricsService.layer, PlanetScaleService.layer, RailwayMetricsService.layer, + AwsConnectionService.layer, DigestService.layer, WebAnalyticsDigestService.layer, ErrorsService.layer, @@ -285,6 +287,19 @@ const planetScaleTick = makeTick( : undefined, ) +const awsConnectionsTick = makeTick( + AwsConnectionService.use((aws) => aws.checkDue()), + "aws_connections", + (result) => + result.due > 0 + ? { + due: result.due, + checked: result.checked, + failures: result.failures, + } + : undefined, +) + const railwayMetricsTick = makeTick( RailwayMetricsService.use((railway) => railway.pollAllOrgs()), "railway_metrics", @@ -317,6 +332,7 @@ const prReviewPostMergeTick = makeTick( export interface ScheduledTickPrograms { readonly alert: Effect.Effect readonly anomaly: Effect.Effect + readonly awsConnections: Effect.Effect readonly cloudflareAnalytics: Effect.Effect readonly digest: Effect.Effect readonly error: Effect.Effect @@ -346,6 +362,7 @@ const laneProgram = (lane: TickLane, ticks: ScheduledTickPrograms): Effect Match.when("pr_review_post_merge", () => ticks.prReviewPostMerge), Match.when("digest", () => ticks.digest), Match.when("service_map_rollup", () => ticks.serviceMapRollup), + Match.when("aws_connections", () => ticks.awsConnections), Match.exhaustive, ) @@ -384,6 +401,7 @@ export const selectLaneProgram = ( type ScheduledServices = | AlertsService | AnomalyDetectionService + | AwsConnectionService | CloudflareAnalyticsService | DigestService | WebAnalyticsDigestService @@ -399,6 +417,7 @@ type ScheduledServices = export const scheduledTicks: ScheduledTickPrograms = { alert: alertTick, anomaly: anomalyTick, + awsConnections: awsConnectionsTick, cloudflareAnalytics: cloudflareAnalyticsTick, digest: digestTick, error: errorTick, diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index 047687bdb9..b6baf71f15 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -70,6 +70,10 @@ const configuredEnv = (stage: MapleStage, region: MapleRegion, domains: MapleDom planetScaleOAuthEnv, // Sign-in for the Google Cloud metrics poller; unset leaves its tick a no-op. optionalSecret("MAPLE_GCP_SERVICE_ACCOUNT_KEY"), + // Maple's AWS identity for the hourly connection check; unset leaves its tick a no-op. + optionalPlain("MAPLE_AWS_PRINCIPAL_ARN"), + optionalPlain("MAPLE_AWS_ACCESS_KEY_ID"), + optionalSecret("MAPLE_AWS_SECRET_ACCESS_KEY"), // Ticks post pull-request replies through the GitHub App; without it each one fails. githubAppSourceEnv, // Outbound config for `chat` destinations' connectors. diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index 0fff70d331..3b27051a3d 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -82,4 +82,8 @@ export const apiConfiguredEnv = (stage: MapleStage, region: MapleRegion, domains cloudflareOAuthEnv, planetScaleOAuthEnv, optionalPlain("MAPLE_GCP_SERVICE_ACCOUNT_EMAIL"), + // Maple's AWS identity: assumes customer roles. Unset leaves AWS unavailable. + optionalPlain("MAPLE_AWS_PRINCIPAL_ARN"), + optionalPlain("MAPLE_AWS_ACCESS_KEY_ID"), + optionalSecret("MAPLE_AWS_SECRET_ACCESS_KEY"), ) diff --git a/apps/api/src/routes/v2/integrations-aws.http.test.ts b/apps/api/src/routes/v2/integrations-aws.http.test.ts new file mode 100644 index 0000000000..e8f1ce4b51 --- /dev/null +++ b/apps/api/src/routes/v2/integrations-aws.http.test.ts @@ -0,0 +1,234 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from "effect" +import { HttpRouter } from "effect/http" +import { HttpApiBuilder } from "effect/http-api" +import { OrgId, UserId } from "@maple/domain/http" +import { MapleApiV2, encodePublicId } from "@maple/domain/http/v2" +import { Env } from "@maple/backend/platform/Env" +import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" +import { ApiAuthorizationV2Live } from "@maple/backend/services/auth/ApiAuthorizationV2Live" +import { AuthService } from "@maple/backend/services/auth/AuthService" +import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" +import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" +import { SharedDashboardService } from "@maple/backend/services/dashboards/SharedDashboardService" +import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService" +import { V2TransportErrorBoundaryLive } from "./error-envelope" +import { + AlertsServiceStubLayer, + AllV2GroupLayersLive, + ApiV2RateLimiterAllowAllLayer, + ConfigResourceServiceStubsLayer, + PlanetScaleServiceStubsLayer, + TelemetryServiceStubsLayer, +} from "./v2-test-support" + +/** + * `/v2/integrations/aws` over an embedded PGlite with the real connection service: the wire shape a + * script parses, the one-account rule, the admin gate and the setup a CloudShell paste runs. The + * check against AWS itself is covered by `AwsConnectionService.test.ts`. + */ + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const ORG = Schema.decodeUnknownSync(OrgId)("org_aws_e2e") +const USER = Schema.decodeUnknownSync(UserId)("user_aws_e2e") +const BASE = "/v2/integrations/aws" + +const testConfig = (awsAvailable: boolean) => + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + PORT: "3478", + MCP_PORT: "3479", + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + MAPLE_INGEST_PUBLIC_URL: "https://ingest.test", + INTERNAL_SERVICE_TOKEN: "test-internal-token", + ...(awsAvailable + ? { + MAPLE_AWS_PRINCIPAL_ARN: "arn:aws:iam::999999999999:user/maple-integrations", + MAPLE_AWS_ACCESS_KEY_ID: "AKIAMAPLETEST0000001", + MAPLE_AWS_SECRET_ACCESS_KEY: "maple-test-secret", + } + : undefined), + }), + ) + +const makeHarness = (options: { readonly awsAvailable: boolean } = { awsAvailable: true }) => { + const testDb = createTestDb(createdDbs) + const envLive = Env.layer.pipe(Layer.provide(testConfig(options.awsAvailable))) + const servicesLive = Layer.mergeAll( + ApiKeysService.layer, + AuthService.layer, + DashboardPersistenceService.layer, + SharedDashboardService.layer, + ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) + + const routes = HttpApiBuilder.layer(MapleApiV2).pipe( + Layer.provide(AllV2GroupLayersLive), + Layer.provide(V2TransportErrorBoundaryLive), + Layer.provide(AlertsServiceStubLayer), + Layer.provide(ConfigResourceServiceStubsLayer), + Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(TelemetryServiceStubsLayer), + Layer.provideMerge(ApiAuthorizationV2Live), + Layer.provideMerge(AuditLogService.layerMemory), + Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), + Layer.provideMerge(servicesLive), + ) + const { handler, dispose: disposeHandler } = HttpRouter.toWebHandler(routes, { disableLogger: true }) + const runtime = ManagedRuntime.make(servicesLive) + + /** Root-role key (API keys resolve as `root` unless their metadata pins roles). */ + const adminKey = (scopes?: ReadonlyArray) => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + return yield* service.create(ORG, USER, { name: "aws-admin", scopes }) + }), + ) + + const memberKey = () => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + return yield* service.create(ORG, USER, { + name: "aws-member", + metadataJson: { source: "maple_cli", roles: ["org:member"], deviceName: "laptop" }, + }) + }), + ) + + const request = async (method: string, path: string, token: string, body?: unknown) => { + const response = await handler( + new Request(`http://maple.test${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body !== undefined ? { "content-type": "application/json" } : undefined), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + Context.empty() as never, + ) + const text = await response.text() + return { status: response.status, body: text.length === 0 ? null : JSON.parse(text) } + } + + return { + request, + adminKey, + memberKey, + testDb, + dispose: async () => { + await disposeHandler() + await runtime.dispose() + }, + } +} + +const CONNECTIONS = `${BASE}/connections` + +describe("v2 aws integration over HTTP", () => { + it("creates a connection, renders its setup for admins, lists it for any member, deletes it", async () => { + const harness = makeHarness() + const admin = await harness.adminKey() + const member = await harness.memberKey() + + const created = await harness.request("POST", CONNECTIONS, admin.secret, { + account_id: "123456789012", + }) + expect(created.status).toBe(200) + expect(created.body).toEqual({ + id: expect.stringMatching(/^awsc_/), + object: "aws_connection", + account_id: "123456789012", + role_arn: expect.stringMatching( + /^arn:aws:iam::123456789012:role\/MapleIntegration-[0-9a-f]{24}$/, + ), + external_id: expect.stringMatching(/^[0-9a-f]{32}$/), + modules: [], + granted_modules: null, + template_version: null, + setup_outdated: false, + verified_at: null, + last_checked_at: null, + last_check_error: null, + created_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T.*Z$/), + }) + const id: string = created.body.id + + const duplicate = await harness.request("POST", CONNECTIONS, admin.secret, { + account_id: "123456789012", + }) + expect(duplicate.status).toBe(409) + + const setup = await harness.request("GET", `${CONNECTIONS}/${id}/setup`, admin.secret) + expect(setup.status).toBe(200) + expect(setup.body.object).toBe("aws_connection.setup") + expect(setup.body.setup_script).toContain(`--stack-name ${setup.body.stack_name}`) + expect(setup.body.setup_script).toContain('if [ "$account" != "123456789012" ]') + // The template names the role the connection reports and requires its external id. + const template = JSON.parse(setup.body.cloudformation_template) + const role = template.Resources.MapleRole.Properties + expect(created.body.role_arn.endsWith(`/${role.RoleName}`)).toBe(true) + expect(role.AssumeRolePolicyDocument.Statement[0].Condition.StringEquals["sts:ExternalId"]).toBe( + created.body.external_id, + ) + expect(setup.body.terraform).toContain(`name = "${role.RoleName}"`) + + // Members read the status; only admins render setups or change anything. + const status = await harness.request("GET", BASE, member.secret) + expect(status.status).toBe(200) + expect(status.body.available).toBe(true) + expect(status.body.connections.map((connection: { id: string }) => connection.id)).toEqual([id]) + expect(status.body.modules.map((module: { module: string }) => module.module)).toEqual([ + "observability", + "inventory", + "security", + "cost", + ]) + expect((await harness.request("GET", `${CONNECTIONS}/${id}/setup`, member.secret)).status).toBe(403) + expect((await harness.request("DELETE", `${CONNECTIONS}/${id}`, member.secret)).status).toBe(403) + + const deleted = await harness.request("DELETE", `${CONNECTIONS}/${id}`, admin.secret) + expect(deleted.status).toBe(200) + expect(deleted.body).toMatchObject({ id, object: "aws_connection", deleted: true }) + expect(deleted.body.cleanup_script).toContain( + `delete-stack --region us-east-1 --stack-name ${setup.body.stack_name}`, + ) + expect((await harness.request("GET", BASE, admin.secret)).body.connections).toEqual([]) + await harness.dispose() + }) + + it("rejects a malformed account id and modules Maple does not read yet", async () => { + const harness = makeHarness() + const admin = await harness.adminKey() + expect( + (await harness.request("POST", CONNECTIONS, admin.secret, { account_id: "12345" })).status, + ).toBe(400) + const planned = await harness.request("POST", CONNECTIONS, admin.secret, { + account_id: "123456789012", + modules: ["cost"], + }) + expect(planned.status).toBe(400) + await harness.dispose() + }) + + it("reports AWS unavailable on a deployment without Maple's AWS identity", async () => { + const harness = makeHarness({ awsAvailable: false }) + const admin = await harness.adminKey() + expect((await harness.request("GET", BASE, admin.secret)).body.available).toBe(false) + const created = await harness.request("POST", CONNECTIONS, admin.secret, { + account_id: "123456789012", + }) + expect(created.status).toBe(409) + expect(created.body.error.code).toBe("aws_integration_unavailable") + await harness.dispose() + }) +}) diff --git a/apps/api/src/routes/v2/integrations-aws.http.ts b/apps/api/src/routes/v2/integrations-aws.http.ts new file mode 100644 index 0000000000..2428d54236 --- /dev/null +++ b/apps/api/src/routes/v2/integrations-aws.http.ts @@ -0,0 +1,130 @@ +import { HttpApiBuilder } from "effect/http-api" +import { CurrentTenant } from "@maple/domain/http" +import type { V2AwsConnection } from "@maple/domain/http/v2" +import { + MapleApiV2, + isoTimestamp, + isoTimestampOrNull, + V2InsufficientPermissions, +} from "@maple/domain/http/v2" +import { Effect } from "effect" +import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" +import { requireAdmin } from "@maple/backend/services/auth/auth" +import { + AwsConnectionService, + type AwsConnection, +} from "@maple/backend/services/integrations/AwsConnectionService" + +// Reading the status is open to every member. Everything else is admin-only: a connection decides +// what Maple may read in the org's AWS account. +const adminOnly = () => V2InsufficientPermissions.make("Only org admins can manage AWS connections") + +const toV2Connection = (connection: AwsConnection): V2AwsConnection => ({ + id: connection.id, + object: "aws_connection", + account_id: connection.accountId, + role_arn: connection.roleArn, + external_id: connection.externalId, + modules: connection.modules, + granted_modules: connection.grantedModules, + template_version: connection.templateVersion, + setup_outdated: connection.setupOutdated, + verified_at: isoTimestampOrNull(connection.verifiedAt), + last_checked_at: isoTimestampOrNull(connection.lastCheckedAt), + last_check_error: connection.lastCheckError, + created_at: isoTimestamp(connection.createdAt), +}) + +const auditMetadata = (connection: AwsConnection) => ({ + account_id: connection.accountId, + modules: connection.modules, +}) + +export const HttpV2AwsIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "awsIntegration", (handlers) => + Effect.gen(function* () { + const aws = yield* AwsConnectionService + + return handlers + .handle("status", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const status = yield* aws.status(tenant.orgId) + return { + object: "aws_integration" as const, + available: status.available, + modules: status.modules, + connections: status.connections.map(toV2Connection), + } + }), + ) + .handle("createConnection", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + const connection = yield* aws.create(tenant.orgId, tenant.userId, { + accountId: payload.account_id, + modules: payload.modules ?? [], + }) + yield* recordHttpAudit("aws_connection.created", { + resourceId: connection.id, + metadata: auditMetadata(connection), + }) + return toV2Connection(connection) + }), + ) + .handle("updateConnection", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + const connection = yield* aws.update(tenant.orgId, params.id, { + modules: payload.modules, + }) + yield* recordHttpAudit("aws_connection.updated", { + resourceId: connection.id, + metadata: auditMetadata(connection), + }) + return toV2Connection(connection) + }), + ) + .handle("setup", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + const setup = yield* aws.setup(tenant.orgId, params.id) + return { + object: "aws_connection.setup" as const, + stack_name: setup.stackName, + stack_region: setup.stackRegion, + setup_script: setup.setupScript, + cleanup_script: setup.cleanupScript, + cloudformation_template: setup.cloudformationTemplate, + terraform: setup.terraform, + } + }), + ) + .handle("checkConnection", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + return toV2Connection(yield* aws.check(tenant.orgId, params.id)) + }), + ) + .handle("deleteConnection", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly) + const deleted = yield* aws.delete(tenant.orgId, params.id) + yield* recordHttpAudit("aws_connection.deleted", { + resourceId: params.id, + metadata: auditMetadata(deleted.connection), + }) + return { + id: params.id, + object: "aws_connection" as const, + deleted: true as const, + cleanup_script: deleted.cleanupScript, + } + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/sources.http.ts b/apps/api/src/routes/v2/sources.http.ts index a728309d1d..8a6311137c 100644 --- a/apps/api/src/routes/v2/sources.http.ts +++ b/apps/api/src/routes/v2/sources.http.ts @@ -2,6 +2,7 @@ import { HttpApiBuilder } from "effect/http-api" import type { Source } from "@maple/domain/http" import { CreateSourceRequest, CurrentTenant, UpdateSourceRequest } from "@maple/domain/http" import { + AwsConnectionPublicId, GcpConnectorPublicId, MapleApiV2, paginateArray, @@ -12,7 +13,7 @@ import { V2InsufficientPermissions, } from "@maple/domain/http/v2" import type { V2Source, V2SourceUpdateParams } from "@maple/domain/http/v2" -import { GcpConnectorId, ScrapeTargetId } from "@maple/domain/primitives" +import { AwsConnectionId, GcpConnectorId, ScrapeTargetId } from "@maple/domain/primitives" import { Effect, Option, Schema } from "effect" import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" import { SourceUsageService } from "@maple/backend/services/org/SourceUsageService" @@ -32,6 +33,8 @@ const decodeScrapeTargetId = Schema.decodeUnknownOption(ScrapeTargetId) const decodeGcpConnectorId = Schema.decodeUnknownOption(GcpConnectorId) const encodeScrapeTargetId = Schema.encodeSync(ScrapeTargetPublicId) const encodeGcpConnectorId = Schema.encodeSync(GcpConnectorPublicId) +const decodeAwsConnectionId = Schema.decodeUnknownOption(AwsConnectionId) +const encodeAwsConnectionId = Schema.encodeSync(AwsConnectionPublicId) /** The id a client can look the integration record up by: its public form where the API has one. */ export const integrationIdOf = (source: Source): string | null => { @@ -41,6 +44,8 @@ export const integrationIdOf = (source: Source): string | null => { return Option.match(decodeScrapeTargetId(raw), { onNone: () => raw, onSome: encodeScrapeTargetId }) if (source.platform === "gcp") return Option.match(decodeGcpConnectorId(raw), { onNone: () => raw, onSome: encodeGcpConnectorId }) + if (source.platform === "aws") + return Option.match(decodeAwsConnectionId(raw), { onNone: () => raw, onSome: encodeAwsConnectionId }) return raw } diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index f75eb00e72..a18e90090e 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -47,8 +47,10 @@ import { HttpV2DashboardsLive } from "./dashboards.http" import { HttpV2IngestKeysLive } from "./ingest-keys.http" import { HttpV2SourcesLive } from "./sources.http" import { HttpV2ChatIntegrationsLive } from "./integrations-chat.http" +import { HttpV2AwsIntegrationsLive } from "./integrations-aws.http" import { HttpV2GcpIntegrationsLive } from "./integrations-gcp.http" import { HttpV2PlanetScaleIntegrationsLive } from "./integrations.http" +import { AwsConnectionService } from "@maple/backend/services/integrations/AwsConnectionService" import { GcpConnectorService } from "@maple/backend/services/integrations/GcpConnectorService" import { HttpV2ErrorIssuesLive } from "./error-issues.http" import { HttpV2AnomaliesLive } from "./anomalies.http" @@ -170,6 +172,7 @@ const v2GroupLayersExceptOnboardingChecklist = (chatWorkspace: Layer.Layer`. It takes about a minute. +5. Maple checks the connection every 10 seconds while the setup is open, and shows **Connected** once it can assume the role. + +Running the script again is always safe: it updates the stack in place, and replaces a stack whose first deploy failed. The stack always lives in `us-east-1`, whatever region CloudShell opens in. IAM roles are global, so this changes nothing about what Maple can read. + +### In the console + +Choose **Console** in the setup and save the template. Then in CloudFormation, in the `us-east-1` region, choose **Create stack → With new resources**, upload the template, give the stack the name Maple shows, and acknowledge that it creates IAM resources. The cleanup script looks for the stack in `us-east-1`. + +### With Terraform + +Choose **Terraform** in the setup and copy the file into your repository. It needs only the `aws` provider, configured for the account you connect. Apply it, then select **Check connection**. + +## What the stack creates + +| Resource | Purpose | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| IAM role `MapleIntegration-` | Assumed by Maple. Its trust policy names Maple's identity and requires your external ID. | +| Inline policy `maple-self-read` | Lets the role read its own tags and attached policies, and nothing else. This is how Maple checks it. | +| Managed policy `MapleIntegration--…` | One per module you turn on. None until modules are available. | + +The role is tagged with the setup version. When a later version of the setup changes the role, the connection shows **Update the stack**. Deploy it again from the setup to apply the change. + +## Troubleshooting + +| What you see | What to do | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| **Maple cannot assume the role yet** | The stack isn't deployed, or it was deployed to another account. Run the script in the account the setup names. | +| **Maple can no longer assume the role** | The stack was deleted or its trust policy was edited. Run the script again. | +| **The role cannot read its own policies** | The `maple-self-read` policy was removed or changed. Run the script again to repair it. | +| **AWS rejected Maple's own credentials** | A problem on Maple's side. Nothing in your account needs to change. | + +## Disconnect + +Select **Disconnect** on the connection. Maple stops assuming the role and removes the source; data already collected stays. Maple has no write access to your account, so the role exists until you delete its stack. Maple shows a cleanup script for CloudShell that deletes it, or delete the stack in the CloudFormation console. diff --git a/apps/web/src/lib/docs.ts b/apps/web/src/lib/docs.ts index a2a07bc991..068a8cea1b 100644 --- a/apps/web/src/lib/docs.ts +++ b/apps/web/src/lib/docs.ts @@ -35,6 +35,7 @@ export const DOCS = { kubernetes: "/docs/infrastructure/kubernetes", cloudflare: "/docs/integrations/cloudflare", gcp: "/docs/integrations/gcp", + aws: "/docs/integrations/aws", gcpOpenTelemetry: "/docs/integrations/gcp-opentelemetry", gcpManualSetup: "/docs/integrations/gcp-manual-setup", gcpInfrastructureAsCode: "/docs/integrations/gcp-infrastructure-as-code", diff --git a/apps/web/src/page/integrations/aws-schema.ts b/apps/web/src/page/integrations/aws-schema.ts new file mode 100644 index 0000000000..c094be777b --- /dev/null +++ b/apps/web/src/page/integrations/aws-schema.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect" + +// `AwsIntegrationCard`'s schemas that its Messages carry, apart from `./aws`, which imports them. + +export const AwsSetupMethod = Schema.Literals(["cloudshell", "console", "terraform"]) +export type AwsSetupMethod = typeof AwsSetupMethod.Type + +/** A stack a disconnect left in AWS, and the script that deletes it. Kept until dismissed. */ +export const AwsRemovedStack = Schema.Struct({ + connectionId: Schema.String, + accountId: Schema.String, + cleanupScript: Schema.String, +}) +export type AwsRemovedStack = typeof AwsRemovedStack.Type diff --git a/apps/web/src/page/integrations/aws.scene.test.ts b/apps/web/src/page/integrations/aws.scene.test.ts new file mode 100644 index 0000000000..ba819b0bb9 --- /dev/null +++ b/apps/web/src/page/integrations/aws.scene.test.ts @@ -0,0 +1,215 @@ +import { OrgId } from "@maple/domain" +import { Timestamp, type V2AwsConnection, type V2AwsIntegration } from "@maple/domain/http/v2" +import { AwsAccountId, AwsConnectionId } from "@maple/domain/primitives" +import { Result, Schema } from "effect" +import { AsyncData } from "foldkit" +import { describe, expect, test } from "vitest" +import * as Field from "../../ui/field" +import { PageOutMessage, type PageContext } from "../contract" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { statusKey } from "./update-status" + +const orgId = Schema.decodeSync(OrgId)("org_acme") +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + (commands ?? []).map(({ name }) => name) + +const connectionId = Schema.decodeSync(AwsConnectionId)("018f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8") + +const connection = (overrides: Partial = {}): V2AwsConnection => ({ + id: connectionId, + object: "aws_connection", + account_id: Schema.decodeSync(AwsAccountId)("123456789012"), + role_arn: "arn:aws:iam::123456789012:role/MapleIntegration-018f2b3c4d5e4f708192a3b4", + external_id: "9f1c4e2a7b3d4c6e8a0b1c2d3e4f5a6b", + modules: [], + granted_modules: null, + template_version: null, + setup_outdated: false, + verified_at: null, + last_checked_at: null, + last_check_error: null, + created_at: Schema.decodeSync(Timestamp)("2026-10-21T12:00:00.000Z"), + ...overrides, +}) + +const entered = (): Model => init({ search: { integration: "aws" } }, context).model + +const withStatus = (model: Model, connections: ReadonlyArray): Model => { + const status: V2AwsIntegration = { object: "aws_integration", available: true, modules: [], connections } + return pageUpdate( + model, + Message.CompletedLoadAws({ key: statusKey(context, "aws"), result: Result.succeed(status) }), + ).model +} + +const typed = (value: string): Model => + pageUpdate(entered(), Message.GotAwsAccountIdMessage({ message: Field.Message.ChangedValue({ value }) })) + .model + +describe("aws card", () => { + test("an account ID pasted with dashes is sent once; one too short never is", () => { + const short = pageUpdate(typed("12345678901"), Message.SubmittedAwsAccount()) + expect(names(short.commands)).toEqual([]) + expect(short.model.awsCard.createError).toBe("An AWS account ID is 12 digits.") + + const sent = pageUpdate(typed(" 1234-5678-9012 "), Message.SubmittedAwsAccount()) + expect(names(sent.commands)).toEqual(["CreateAwsConnection"]) + expect(names(pageUpdate(sent.model, Message.SubmittedAwsAccount()).commands)).toEqual([]) + }) + + test("a new connection opens its setup and re-reads the status", () => { + const sent = pageUpdate(typed("123456789012"), Message.SubmittedAwsAccount()).model + const created = pageUpdate(sent, Message.SucceededAwsCreate({ connection: connection() })) + expect(created.model.awsCard.setupId).toBe(connectionId) + expect(created.model.awsCard.accountId.value).toBe("") + expect(names(created.commands)).toEqual(["LoadAwsSetup", "LoadIntegrationsAwsStatus"]) + }) + + test("while waiting, a tick checks quietly; the first success closes the setup with a toast", () => { + const waiting = pageUpdate( + withStatus(entered(), [connection()]), + Message.ClickedAwsSetup({ id: connectionId }), + ).model + const ticked = pageUpdate(waiting, Message.TickedAwsPending()) + expect(names(ticked.commands)).toEqual(["CheckAwsConnection"]) + // A check in flight is not sent twice. + expect(names(pageUpdate(ticked.model, Message.TickedAwsPending()).commands)).toEqual([]) + + const stillWaiting = pageUpdate( + ticked.model, + Message.CompletedAwsCheck({ + id: connectionId, + quiet: true, + connection: connection(), + failure: null, + }), + ) + expect(stillWaiting.outMessage).toBeUndefined() + expect(stillWaiting.model.awsCard.setupId).toBe(connectionId) + + const verified = connection({ + verified_at: Schema.decodeSync(Timestamp)("2026-10-21T12:05:00.000Z"), + template_version: 1, + granted_modules: [], + }) + const done = pageUpdate( + ticked.model, + Message.CompletedAwsCheck({ id: connectionId, quiet: true, connection: verified, failure: null }), + ) + expect(done.model.awsCard.setupId).toBeNull() + // The check is newer than the page clock: it is read again so "checked" is not in the future. + expect(names(done.commands)).toContain("ReadIntegrationsClock") + expect(done.outMessage).toEqual( + PageOutMessage.RequestedToast({ + type: "success", + title: "AWS connected", + description: "Maple can read account 123456789012.", + }), + ) + }) + + test("opening another connection's setup never shows the first one's script meanwhile", () => { + const other = Schema.decodeSync(AwsConnectionId)("028f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8") + const model = withStatus(entered(), [connection(), connection({ id: other })]) + const first = pageUpdate(model, Message.ClickedAwsSetup({ id: connectionId })) + const [load] = first.commands ?? [] + const loaded = pageUpdate( + first.model, + Message.CompletedLoadAwsSetup({ + key: first.model.awsCard.setup.key ?? "", + result: Result.succeed({ + object: "aws_connection.setup", + stack_name: "maple-integration-first", + stack_region: "us-east-1", + setup_script: "first", + cleanup_script: "first", + cloudformation_template: "{}", + terraform: "", + }), + }), + ).model + expect(load?.name).toBe("LoadAwsSetup") + const switched = pageUpdate(loaded, Message.ClickedAwsSetup({ id: other })).model + expect(switched.awsCard.setupId).toBe(other) + expect(AsyncData.getData(switched.awsCard.setup.data)._tag).toBe("None") + }) + + test("a click during a background check gets that check's result as a toast", () => { + const waiting = pageUpdate( + withStatus(entered(), [connection()]), + Message.ClickedAwsSetup({ id: connectionId }), + ).model + const polling = pageUpdate(waiting, Message.TickedAwsPending()).model + const clicked = pageUpdate(polling, Message.ClickedAwsCheck({ id: connectionId })) + // The check in flight answers for both: no second call to AWS. + expect(names(clicked.commands)).toEqual([]) + const failing = connection({ last_check_error: "Maple cannot assume the role yet." }) + const done = pageUpdate( + clicked.model, + Message.CompletedAwsCheck({ id: connectionId, quiet: true, connection: failing, failure: null }), + ) + expect(done.outMessage).toEqual( + PageOutMessage.RequestedToast({ + type: "error", + title: "The check failed", + description: "Maple cannot assume the role yet.", + }), + ) + }) + + test("a status read that finds the open setup verified closes it", () => { + const waiting = pageUpdate( + withStatus(entered(), [connection()]), + Message.ClickedAwsSetup({ id: connectionId }), + ).model + const verified = connection({ verified_at: Schema.decodeSync(Timestamp)("2026-10-21T12:05:00.000Z") }) + const status: V2AwsIntegration = { + object: "aws_integration", + available: true, + modules: [], + connections: [verified], + } + const arrived = pageUpdate( + waiting, + Message.CompletedLoadAws({ key: statusKey(context, "aws"), result: Result.succeed(status) }), + ) + expect(arrived.model.awsCard.setupId).toBeNull() + expect(arrived.outMessage?._tag).toBe("RequestedToast") + }) + + test("a disconnect keeps the stack's cleanup script until it is dismissed", () => { + const confirming = pageUpdate( + withStatus(entered(), [connection()]), + Message.ClickedAwsDisconnect({ id: connectionId }), + ).model + const sent = pageUpdate(confirming, Message.ConfirmedAwsDisconnect()) + expect(names(sent.commands)).toEqual(["DeleteAwsConnection"]) + const removed = { + connectionId, + accountId: "123456789012", + cleanupScript: "aws cloudformation delete-stack", + } + const done = pageUpdate( + sent.model, + Message.CompletedAwsDisconnect({ id: connectionId, removed, error: null }), + ) + expect(done.model.awsCard.removed).toEqual([removed]) + // The role outlives the connection, so the script outlives the page. + expect(names(done.commands)).toContain("WriteAwsRemovedStacks") + expect(names(done.commands)).toContain("LoadIntegrationsAwsStatus") + + const dismissed = pageUpdate(done.model, Message.DismissedAwsRemoved({ connectionId })) + expect(dismissed.model.awsCard.removed).toEqual([]) + expect(names(dismissed.commands)).toEqual(["WriteAwsRemovedStacks"]) + }) + + test("entering the card reads the stacks this browser kept for the org", () => { + expect(names(init({ search: { integration: "aws" } }, context).commands)).toContain( + "ReadAwsRemovedStacks", + ) + }) +}) diff --git a/apps/web/src/page/integrations/aws.ts b/apps/web/src/page/integrations/aws.ts new file mode 100644 index 0000000000..a99384d0be --- /dev/null +++ b/apps/web/src/page/integrations/aws.ts @@ -0,0 +1,177 @@ +import { + V2AwsConnection, + V2AwsCreateConnectionRequest, + type V2AwsIntegration, + V2AwsSetup, +} from "@maple/domain/http/v2" +import { AwsConnectionId } from "@maple/domain/primitives" +import { readLocalStorage, writeLocalStorage } from "@maple/ui/lib/local-storage" +import { displayError, isUnexpectedError } from "@maple/web-data/lib/error-messages" +import { Effect, Option, Schema } from "effect" +import { AsyncData, Command } from "foldkit" +import { KeyedSchema } from "../../data/keyed" +import { LoadFailure, toLoadFailure } from "../../data/load-failure" +import { MapleApiV2Client } from "../../platform/api-clients" +import * as CopyButton from "../../ui/copy-button" +import * as Dialog from "../../ui/dialog" +import * as Field from "../../ui/field" +import { AwsRemovedStack, AwsSetupMethod } from "./aws-schema" +import { Message } from "./message" + +// `AwsIntegrationCard`: state and writes. + +export { AwsRemovedStack, AwsSetupMethod } from "./aws-schema" + +export const AwsCardModel = Schema.Struct({ + accountId: Field.Model, + isCreating: Schema.Boolean, + createError: Schema.NullOr(Schema.String), + /** The add form while connections exist; with none it always shows. */ + isAdding: Schema.Boolean, + /** The connection whose setup is open, and its setup read. */ + setupId: Schema.NullOr(AwsConnectionId), + setup: KeyedSchema(V2AwsSetup, LoadFailure), + method: AwsSetupMethod, + /** Checks in flight, by connection; a quiet one raises no toast unless the user asks meanwhile. */ + checking: Schema.Array(Schema.Struct({ id: AwsConnectionId, quiet: Schema.Boolean })), + copies: Schema.Record(Schema.String, CopyButton.Model), + disconnectId: Schema.NullOr(AwsConnectionId), + disconnectDialog: Dialog.Model, + isDisconnecting: Schema.Boolean, + removed: Schema.Array(AwsRemovedStack), +}) +export type AwsCardModel = typeof AwsCardModel.Type + +export const initAwsCard = (): AwsCardModel => ({ + accountId: Field.init(), + isCreating: false, + createError: null, + isAdding: false, + setupId: null, + setup: { key: null, data: AsyncData.Idle() }, + method: "cloudshell", + checking: [], + copies: {}, + disconnectId: null, + disconnectDialog: Dialog.init("aws-disconnect", "alert-dialog"), + isDisconnecting: false, + removed: [], +}) + +export const decodeCreateRequest = Schema.decodeUnknownOption(V2AwsCreateConnectionRequest) + +/** Account ids are often pasted with the console's dashes: `1234-5678-9012`. */ +export const normalizeAccountId = (value: string) => value.replaceAll(/[\s-]/gu, "") + +/** Connections whose stack Maple has not assumed yet: the card shows their setup and keeps checking. */ +export const pendingConnections = (status: V2AwsIntegration): ReadonlyArray => + status.connections.filter((connection) => connection.verified_at === null) + +const messageOf = (error: unknown, fallback: string) => { + const presentation = displayError(error) + return isUnexpectedError(presentation) ? fallback : presentation.message +} + +export const CreateAwsConnection = Command.define("CreateAwsConnection", { + args: { request: V2AwsCreateConnectionRequest }, + messages: [Message.SucceededAwsCreate, Message.FailedAwsCreate], + execute: ({ request }) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const connection = yield* client.awsIntegration.createConnection({ payload: request }) + return Message.SucceededAwsCreate({ connection }) + }).pipe( + Effect.catch((error) => + Effect.succeed( + Message.FailedAwsCreate({ error: messageOf(error, "Failed to connect AWS.") }), + ), + ), + ), +}) + +export const LoadAwsSetup = Command.define("LoadAwsSetup", { + args: { key: Schema.String, id: AwsConnectionId }, + messages: [Message.CompletedLoadAwsSetup], + execute: ({ key, id }) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.awsIntegration.setup({ params: { id } }) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.result, + Effect.map((result) => Message.CompletedLoadAwsSetup({ key, result })), + ), +}) + +/** `quiet`: the background re-check while a setup is open, which raises no toast. */ +export const CheckAwsConnection = Command.define("CheckAwsConnection", { + args: { id: AwsConnectionId, quiet: Schema.Boolean }, + messages: [Message.CompletedAwsCheck], + execute: ({ id, quiet }) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.awsIntegration.checkConnection({ params: { id } }) + }).pipe( + Effect.match({ + onFailure: (error) => + Message.CompletedAwsCheck({ id, quiet, connection: null, failure: toLoadFailure(error) }), + onSuccess: (connection) => + Message.CompletedAwsCheck({ id, quiet, connection, failure: null }), + }), + ), +}) + +export const DeleteAwsConnection = Command.define("DeleteAwsConnection", { + args: { id: AwsConnectionId, accountId: Schema.String }, + messages: [Message.CompletedAwsDisconnect], + execute: ({ id, accountId }) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.awsIntegration.deleteConnection({ params: { id } }) + }).pipe( + Effect.match({ + onFailure: (error) => + Message.CompletedAwsDisconnect({ + id, + removed: null, + error: messageOf(error, "Failed to disconnect AWS."), + }), + onSuccess: (response) => + Message.CompletedAwsDisconnect({ + id, + removed: { connectionId: id, accountId, cleanupScript: response.cleanup_script }, + error: null, + }), + }), + ), +}) + +// The org's removed stacks survive a reload: the role exists until someone runs the cleanup. +const storageKey = (orgId: string) => `maple.aws.removed-stacks.${orgId}` +const decodeRemoved = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(AwsRemovedStack))) +const encodeRemoved = Schema.encodeSync(Schema.fromJsonString(Schema.Array(AwsRemovedStack))) + +export const ReadAwsRemoved = Command.define("ReadAwsRemovedStacks", { + args: { orgId: Schema.String }, + messages: [Message.CompletedReadAwsRemoved], + execute: ({ orgId }) => + Effect.sync(() => + Message.CompletedReadAwsRemoved({ + removed: Option.getOrElse( + Option.flatMap(readLocalStorage(storageKey(orgId)), decodeRemoved), + () => [], + ), + }), + ), +}) + +export const WriteAwsRemoved = Command.define("WriteAwsRemovedStacks", { + args: { orgId: Schema.String, removed: Schema.Array(AwsRemovedStack) }, + messages: [Message.CompletedWriteAwsRemoved], + execute: ({ orgId, removed }) => + Effect.sync(() => { + // Best effort: private mode and blocked site data throw here. + writeLocalStorage(storageKey(orgId), encodeRemoved(removed)) + return Message.CompletedWriteAwsRemoved() + }), +}) diff --git a/apps/web/src/page/integrations/catalog.ts b/apps/web/src/page/integrations/catalog.ts index 37d44a0c62..8f4dce5dfe 100644 --- a/apps/web/src/page/integrations/catalog.ts +++ b/apps/web/src/page/integrations/catalog.ts @@ -13,6 +13,7 @@ export type IntegrationId = | "prometheus" | "planetscale" | "gcp" + | "aws" | "railway" | "warpstream" | "hazel" @@ -24,6 +25,7 @@ export const HAZEL_ACCENT = "#F46F0F" export const CLOUDFLARE_ACCENT = "#F38020" export const RAILWAY_ACCENT = "#0B0D0E" export const GCP_ACCENT = "#4285F4" +export const AWS_ACCENT = "#FF9900" /** A generated icon, or a chat connector's manifest mark (rendered from its path data). */ export type EntryIcon = @@ -116,6 +118,17 @@ export const CATALOG: ReadonlyArray = [ accent: GCP_ACCENT, docsUrl: docsUrl("gcp"), }, + { + id: "aws", + category: "infrastructure", + isNew: true, + name: "AWS", + description: "Connect an AWS account through a read-only role you deploy with CloudFormation.", + icon: named("AwsIcon"), + accent: AWS_ACCENT, + iconClassName: "text-foreground", + docsUrl: docsUrl("aws"), + }, { id: "railway", category: "infrastructure", diff --git a/apps/web/src/page/integrations/command.ts b/apps/web/src/page/integrations/command.ts index e65ff3a4db..c270421818 100644 --- a/apps/web/src/page/integrations/command.ts +++ b/apps/web/src/page/integrations/command.ts @@ -88,6 +88,19 @@ export const LoadGcp = Command.define("LoadIntegrationsGcpStatus", { ), }) +export const LoadAws = Command.define("LoadIntegrationsAwsStatus", { + args: Args, + messages: [Message.CompletedLoadAws], + execute: ({ key }) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.awsIntegration.status() + }).pipe( + settle, + Effect.map((result) => Message.CompletedLoadAws({ key, result })), + ), +}) + /** `retainedQueryV2("planetscaleIntegration", "databases")`: the poller's inventory. */ export const LoadPlanetscaleDatabases = Command.define("LoadIntegrationsPlanetscaleDatabases", { args: Args, diff --git a/apps/web/src/page/integrations/derive.ts b/apps/web/src/page/integrations/derive.ts index 055a192322..4e5eb54400 100644 --- a/apps/web/src/page/integrations/derive.ts +++ b/apps/web/src/page/integrations/derive.ts @@ -146,6 +146,17 @@ export const cardStatusOf = (statuses: Statuses, id: IntegrationId): CardStatus : { label: countLabel(status.connectors.length, "connection"), variant: "ok" }, unavailableStatus, ) + case "aws": + return fold( + statuses.aws, + (status): CardStatus => + status.connections.length === 0 + ? NOT_CONNECTED + : status.connections.some((connection) => connection.verified_at === null) + ? { label: "Finish setup", variant: "warn" } + : { label: countLabel(status.connections.length, "account"), variant: "ok" }, + unavailableStatus, + ) case "railway": return fold( statuses.railway, @@ -239,6 +250,33 @@ const gcpOverview = (statuses: Statuses, nowMs: number) => unavailableOverview, ) +const awsOverview = (statuses: Statuses, nowMs: number) => + fold( + statuses.aws, + (status): IntegrationOverview => { + if (status.connections.length === 0) return CONNECT + const failing = status.connections.filter( + (connection) => connection.verified_at !== null && connection.last_check_error !== null, + ).length + const pending = status.connections.some((connection) => connection.verified_at === null) + const issue = + failing > 0 ? `${countLabel(failing, "account")} failing` : pending ? "Finish setup" : null + return { + kind: "connected", + ...attentionOr(issue), + context: status.connections.length === 1 ? (status.connections[0]?.account_id ?? null) : null, + stat: countLabel(status.connections.length, "account"), + lastSyncLabel: syncedLabel( + maxMs(status.connections.map((connection) => isoMs(connection.last_checked_at))), + nowMs, + "checked", + ), + issue, + } + }, + unavailableOverview, + ) + const railwayOverview = (statuses: Statuses, nowMs: number) => fold( statuses.railway, @@ -452,6 +490,8 @@ export const overviewOf = (statuses: Statuses, nowMs: number, id: IntegrationId) switch (id) { case "gcp": return gcpOverview(statuses, nowMs) + case "aws": + return awsOverview(statuses, nowMs) case "railway": return railwayOverview(statuses, nowMs) case "cloudflare": diff --git a/apps/web/src/page/integrations/hub.scene.test.ts b/apps/web/src/page/integrations/hub.scene.test.ts index 92a7db390e..e1147dda58 100644 --- a/apps/web/src/page/integrations/hub.scene.test.ts +++ b/apps/web/src/page/integrations/hub.scene.test.ts @@ -54,6 +54,7 @@ describe("integrations hub", () => { "LoadIntegrationsPlanetscaleStatus", "LoadIntegrationsPlanetscaleDatabases", "LoadIntegrationsGcpStatus", + "LoadIntegrationsAwsStatus", "LoadIntegrationsHazelStatus", "LoadIntegrationsGithubStatus", "LoadIntegrationsChatConnectors", diff --git a/apps/web/src/page/integrations/message.ts b/apps/web/src/page/integrations/message.ts index ca89e780c3..4c608f80d4 100644 --- a/apps/web/src/page/integrations/message.ts +++ b/apps/web/src/page/integrations/message.ts @@ -10,6 +10,9 @@ import { VcsRepositoryId, } from "@maple/domain/http" import { + V2AwsConnection, + V2AwsIntegration, + V2AwsSetup, V2ChatConnectorList, V2GcpConnector, V2GcpIntegration, @@ -21,7 +24,7 @@ import { V2ScrapeTarget, V2ScrapeTargetCheck, } from "@maple/domain/http/v2" -import { ChatWorkspaceId, GcpConnectorId, GcpLogRuntime } from "@maple/domain/primitives" +import { AwsConnectionId, ChatWorkspaceId, GcpConnectorId, GcpLogRuntime } from "@maple/domain/primitives" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import { LoadFailure } from "../../data/load-failure" @@ -38,6 +41,7 @@ import { ConnectProvider } from "./connect" import { FleetRead } from "../infra-integrations/gcp/data" import { Capability, GcpFlags, SetupMethod } from "./gcp-configure-schema" import { ConfigureTarget, LogVolume, RemovedConnector } from "./gcp-schema" +import { AwsRemovedStack, AwsSetupMethod } from "./aws-schema" export const StatusName = Schema.Literals([ "cloudflare", @@ -45,6 +49,7 @@ export const StatusName = Schema.Literals([ "planetscale", "planetscaleDatabases", "gcp", + "aws", "hazel", "github", "chat", @@ -64,6 +69,7 @@ export const Message = defineMessageUnion({ CompletedLoadPlanetscale: result(V2PlanetScaleIntegration), CompletedLoadPlanetscaleDatabases: result(V2PlanetScaleDatabaseList), CompletedLoadGcp: result(V2GcpIntegration), + CompletedLoadAws: result(V2AwsIntegration), CompletedLoadHazel: result(HazelIntegrationStatus), CompletedLoadGithub: result(GithubIntegrationStatus), CompletedLoadChat: result(V2ChatConnectorList), @@ -179,6 +185,37 @@ export const Message = defineMessageUnion({ ClickedRailwayRotateCancel: {}, /** Every 15 s while environments wait for their first sync. */ TickedRailwaySettle: {}, + GotAwsAccountIdMessage: { message: Field.Message }, + SubmittedAwsAccount: {}, + SucceededAwsCreate: { connection: V2AwsConnection }, + FailedAwsCreate: { error: Schema.String }, + ClickedAwsAdd: {}, + ClickedAwsAddCancel: {}, + ClickedAwsSetup: { id: AwsConnectionId }, + ClickedAwsSetupClose: {}, + CompletedLoadAwsSetup: result(V2AwsSetup), + ClickedAwsSetupMethod: { method: AwsSetupMethod }, + ClickedAwsCheck: { id: AwsConnectionId }, + /** The background re-check of connections still waiting for their stack. */ + TickedAwsPending: {}, + CompletedAwsCheck: { + id: AwsConnectionId, + quiet: Schema.Boolean, + connection: Schema.NullOr(V2AwsConnection), + failure: Schema.NullOr(LoadFailure), + }, + GotAwsCopyMessage: { id: Schema.String, message: CopyButton.Message }, + ClickedAwsDisconnect: { id: AwsConnectionId }, + GotAwsDisconnectDialogMessage: { message: Dialog.Message }, + ConfirmedAwsDisconnect: {}, + CompletedAwsDisconnect: { + id: AwsConnectionId, + removed: Schema.NullOr(AwsRemovedStack), + error: Schema.NullOr(Schema.String), + }, + DismissedAwsRemoved: { connectionId: Schema.String }, + CompletedReadAwsRemoved: { removed: Schema.Array(AwsRemovedStack) }, + CompletedWriteAwsRemoved: {}, CompletedFetchOrganizations: result(V2PlanetScaleOrganizationList), CompletedFetchWebhookConfig: result(V2PlanetScaleWebhookConfig), ClickedChangeOrganization: {}, diff --git a/apps/web/src/page/integrations/model.ts b/apps/web/src/page/integrations/model.ts index 1c4494c9ea..0051e8df75 100644 --- a/apps/web/src/page/integrations/model.ts +++ b/apps/web/src/page/integrations/model.ts @@ -6,6 +6,7 @@ import { RailwayIntegrationStatus, } from "@maple/domain/http" import { + V2AwsIntegration, V2ChatConnectorList, V2GcpIntegration, V2PlanetScaleDatabaseList, @@ -24,6 +25,7 @@ import { PrometheusCardModel } from "./prometheus" import { RailwayCardModel } from "./railway" import { PlanetscaleCardModel } from "./planetscale" import { GcpCardModel } from "./gcp" +import { AwsCardModel } from "./aws" import { ConnectModel } from "./connect" // `/integrations`: the hub and every integration's page. @@ -37,6 +39,7 @@ export const Statuses = Schema.Struct({ planetscale: keyed(V2PlanetScaleIntegration), planetscaleDatabases: keyed(V2PlanetScaleDatabaseList), gcp: keyed(V2GcpIntegration), + aws: keyed(V2AwsIntegration), hazel: keyed(HazelIntegrationStatus), github: keyed(GithubIntegrationStatus), chat: keyed(V2ChatConnectorList), @@ -64,5 +67,6 @@ export const Model = Schema.Struct({ railwayCard: RailwayCardModel, planetscaleCard: PlanetscaleCardModel, gcpCard: GcpCardModel, + awsCard: AwsCardModel, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/integrations/subscription.ts b/apps/web/src/page/integrations/subscription.ts index 3705170102..e2ce4d3e79 100644 --- a/apps/web/src/page/integrations/subscription.ts +++ b/apps/web/src/page/integrations/subscription.ts @@ -147,6 +147,18 @@ const isRailwaySettling = (model: Model): boolean => { return status !== null && status.connected && !status.authFailed && unsyncedEnvironments(status) > 0 } +/** A setup is open for a connection whose stack Maple has not assumed yet. */ +const isAwsPending = (model: Model): boolean => { + if (integrationOfSearch(model.search.integration) !== "aws") return false + const status = Option.getOrNull(AsyncData.getData(model.statuses.aws.data)) + const setupId = model.awsCard.setupId + return ( + status !== null && + setupId !== null && + status.connections.some((connection) => connection.id === setupId && connection.verified_at === null) + ) +} + const isAwaitingFirstScrape = (model: Model): boolean => { if (integrationOfSearch(model.search.integration) !== "planetscale") return false const status = Option.getOrNull(AsyncData.getData(model.statuses.planetscale.data)) @@ -320,6 +332,14 @@ export const subscriptions = Subscription.make, Mes isSettling ? every(15_000, () => Message.TickedRailwaySettle()) : Stream.empty, }, ), + awsPending: entry( + { isPending: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ isPending: isAwsPending(model) }), + dependenciesToStream: ({ isPending }) => + isPending ? every(10_000, () => Message.TickedAwsPending()) : Stream.empty, + }, + ), /** `useIntervalRefresh(refreshStatus, { intervalMs: 5_000, enabled: awaitingFirstScrape })`. */ planetscaleWatch: entry( { isWatching: Schema.Boolean }, diff --git a/apps/web/src/page/integrations/update-aws.ts b/apps/web/src/page/integrations/update-aws.ts new file mode 100644 index 0000000000..bf27017654 --- /dev/null +++ b/apps/web/src/page/integrations/update-aws.ts @@ -0,0 +1,290 @@ +import type { V2AwsConnection, V2AwsSetup } from "@maple/domain/http/v2" +import type { AwsConnectionId } from "@maple/domain/primitives" +import { Option, type Result } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "../../data/keyed" +import type { LoadFailure } from "../../data/load-failure" +import { requestKey } from "../../data/request-key" +import * as CopyButton from "../../ui/copy-button" +import * as Dialog from "../../ui/dialog" +import * as Field from "../../ui/field" +import { type PageContext, PageOutMessage, type PageReturn } from "../contract" +import { + type AwsCardModel, + type AwsRemovedStack, + type AwsSetupMethod, + CheckAwsConnection, + CreateAwsConnection, + DeleteAwsConnection, + decodeCreateRequest, + LoadAwsSetup, + normalizeAccountId, + pendingConnections, + ReadAwsRemoved, + WriteAwsRemoved, +} from "./aws" +import { ReadClock } from "./command" +import { Message } from "./message" +import type { Model } from "./model" +import { chain, requestStatus } from "./update-status" + +// `AwsIntegrationCard`'s state changes. + +type Return = PageReturn + +const withCard = (model: Model, card: Partial): Model => ({ + ...model, + awsCard: { ...model.awsCard, ...card }, +}) + +const toast = (model: Model, type: "success" | "error", title: string, description = ""): Return => ({ + model, + outMessage: PageOutMessage.RequestedToast({ type, title, description }), +}) + +export const connectionsOf = (model: Model): ReadonlyArray => + Option.match(AsyncData.getData(model.statuses.aws.data), { + onNone: () => [], + onSome: (status) => status.connections, + }) + +export const gotAccountId = (model: Model, message: Field.Message): Return => { + const step = Field.update(model.awsCard.accountId, message) + return { + model: withCard(model, { accountId: step.model, createError: null }), + commands: FoldkitCommand.mapMessages(step.commands ?? [], (inner) => + Message.GotAwsAccountIdMessage({ message: inner }), + ), + } +} + +export const submittedAccount = (model: Model): Return => { + const card = model.awsCard + if (card.isCreating) return { model } + return Option.match(decodeCreateRequest({ account_id: normalizeAccountId(card.accountId.value) }), { + onNone: () => ({ model: withCard(model, { createError: "An AWS account ID is 12 digits." }) }), + onSome: (request) => ({ + model: withCard(model, { isCreating: true, createError: null }), + commands: [CreateAwsConnection({ request })], + }), + }) +} + +/** The new connection's setup opens right away: deploying the stack is the next step. */ +export const succeededCreate = (model: Model, context: PageContext, connection: V2AwsConnection): Return => + chain(withCard(model, { accountId: Field.init(), isCreating: false, isAdding: false }), [ + (next) => openSetup(next, context, connection.id), + (next) => requestStatus(next, context, "aws", "Supersede"), + ]) + +export const failedCreate = (model: Model, error: string): Return => ({ + model: withCard(model, { isCreating: false, createError: error }), +}) + +export const setAdding = (model: Model, isAdding: boolean): Return => ({ + model: withCard(model, { isAdding, createError: null }), +}) + +const setupKey = (context: PageContext, id: AwsConnectionId) => + requestKey(context.orgId, "awsIntegration.setup", { id }) + +export const openSetup = (model: Model, context: PageContext, id: AwsConnectionId): Return => { + // Another connection's setup is never shown while this one loads: its stack is for another account. + const opened = withCard( + model, + model.awsCard.setupId === id ? { setupId: id } : { setupId: id, setup: Keyed.idle() }, + ) + return Option.match(Keyed.requestFor(opened.awsCard.setup, setupKey(context, id), "Load"), { + onNone: () => ({ model: opened }), + onSome: (requested) => ({ + model: withCard(opened, { setup: requested }), + commands: [LoadAwsSetup({ key: requested.key, id })], + }), + }) +} + +export const closeSetup = (model: Model): Return => ({ model: withCard(model, { setupId: null }) }) + +export const completedLoadSetup = ( + model: Model, + key: string, + result: Result.Result, +): Return => ({ model: withCard(model, { setup: Keyed.accept(model.awsCard.setup, key, result) }) }) + +export const setMethod = (model: Model, method: AwsSetupMethod): Return => ({ + model: withCard(model, { method }), +}) + +/** A click during a background check makes that check the user's: its result is toasted. */ +const startCheck = (model: Model, id: AwsConnectionId, quiet: boolean): Return => { + const checking = model.awsCard.checking + if (!checking.some((entry) => entry.id === id)) { + return { + model: withCard(model, { checking: [...checking, { id, quiet }] }), + commands: [CheckAwsConnection({ id, quiet })], + } + } + return quiet + ? { model } + : { + model: withCard(model, { + checking: checking.map((entry) => (entry.id === id ? { id, quiet: false } : entry)), + }), + } +} + +export const clickedCheck = (model: Model, id: AwsConnectionId): Return => startCheck(model, id, false) + +/** Every connection still waiting for its stack is checked again, without a toast. */ +export const tickedPending = (model: Model): Return => + chain( + model, + Option.match(AsyncData.getData(model.statuses.aws.data), { + onNone: () => [], + onSome: (status) => + pendingConnections(status).map( + (connection) => (next: Model) => startCheck(next, connection.id, true), + ), + }), + ) + +export const completedCheck = ( + model: Model, + context: PageContext, + id: AwsConnectionId, + sentQuiet: boolean, + connection: V2AwsConnection | null, + failure: LoadFailure | null, +): Return => { + const quiet = model.awsCard.checking.find((entry) => entry.id === id)?.quiet ?? sentQuiet + const settled = withCard(model, { checking: model.awsCard.checking.filter((entry) => entry.id !== id) }) + if (failure !== null) { + return quiet + ? { model: settled } + : toast(settled, "error", "Failed to check the AWS connection", failure.message) + } + const before = connectionsOf(model).find((entry) => entry.id === id) + const justVerified = before?.verified_at === null && connection?.verified_at != null + return chain(settled, [ + // A check lands after the page read its clock: "checked" would read as in the future. + (next) => ({ model: next, commands: [ReadClock()] }), + (next) => requestStatus(next, context, "aws", "Supersede"), + (next) => + justVerified + ? chain(withCard(next, { setupId: null }), [ + (after) => + toast( + after, + "success", + "AWS connected", + `Maple can read account ${connection.account_id}.`, + ), + ]) + : quiet || connection === null + ? { model: next } + : toast( + next, + connection.last_check_error === null ? "success" : "error", + connection.last_check_error === null ? "Connection works" : "The check failed", + connection.last_check_error ?? "", + ), + ]) +} + +export const gotCopy = (model: Model, id: string, message: CopyButton.Message): Return => { + const step = CopyButton.update(model.awsCard.copies[id] ?? CopyButton.init(), message) + return { + model: withCard(model, { copies: { ...model.awsCard.copies, [id]: step.model } }), + commands: FoldkitCommand.mapMessages(step.commands ?? [], (inner) => + Message.GotAwsCopyMessage({ id, message: inner }), + ), + } +} + +const dialogStep = (model: Model, step: ReturnType): Return => ({ + model: withCard(model, { disconnectDialog: step.model }), + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotAwsDisconnectDialogMessage({ message }), + ), +}) + +export const clickedDisconnect = (model: Model, id: AwsConnectionId): Return => + dialogStep(withCard(model, { disconnectId: id }), Dialog.open(model.awsCard.disconnectDialog)) + +export const gotDisconnectDialog = (model: Model, message: Dialog.Message): Return => + dialogStep(model, Dialog.update(model.awsCard.disconnectDialog, message)) + +export const confirmedDisconnect = (model: Model): Return => { + const id = model.awsCard.disconnectId + const connection = connectionsOf(model).find((entry) => entry.id === id) + if (id === null || connection === undefined || model.awsCard.isDisconnecting) return { model } + return { + model: withCard(model, { isDisconnecting: true }), + commands: [DeleteAwsConnection({ id, accountId: connection.account_id })], + } +} + +/** The stack stays in AWS until an administrator deletes it: its cleanup script takes its place. */ +export const completedDisconnect = ( + model: Model, + context: PageContext, + removed: AwsRemovedStack | null, + error: string | null, +): Return => { + const settled = withCard(model, { isDisconnecting: false }) + if (removed === null) return toast(settled, "error", "Failed to disconnect AWS", error ?? "") + const wasOpen = settled.awsCard.setupId === settled.awsCard.disconnectId + const stacks = [ + removed, + ...settled.awsCard.removed.filter((stack) => stack.connectionId !== removed.connectionId), + ] + return chain( + // `disconnectId` stays: the closing dialog still names the account. + withCard(settled, { removed: stacks, setupId: wasOpen ? null : settled.awsCard.setupId }), + [ + (next) => dialogStep(next, Dialog.close(next.awsCard.disconnectDialog)), + (next) => ({ + model: next, + commands: [WriteAwsRemoved({ orgId: context.orgId, removed: stacks })], + }), + (next) => + toast(next, "success", "AWS disconnected", "Delete the stack in AWS to remove the role."), + (next) => requestStatus(next, context, "aws", "Supersede"), + ], + ) +} + +export const dismissedRemoved = (model: Model, context: PageContext, connectionId: string): Return => { + const removed = model.awsCard.removed.filter((stack) => stack.connectionId !== connectionId) + return { + model: withCard(model, { removed }), + commands: [WriteAwsRemoved({ orgId: context.orgId, removed })], + } +} + +/** The removed stacks this browser kept for the org, read when the card mounts. */ +export const enterAws = (model: Model, context: PageContext): Return => ({ + model, + commands: [ReadAwsRemoved({ orgId: context.orgId })], +}) + +export const completedReadRemoved = (model: Model, removed: ReadonlyArray): Return => ({ + model: withCard(model, { removed }), +}) + +/** + * A status read can verify the open setup before any check of this page does (the hourly check, + * another tab): the setup closes then too. + */ +export const statusArrived = (model: Model, before: ReadonlyArray): Return => { + const setupId = model.awsCard.setupId + const was = before.find((entry) => entry.id === setupId) + const now = connectionsOf(model).find((entry) => entry.id === setupId) + if (was?.verified_at !== null || now === undefined || now.verified_at === null) return { model } + return toast( + withCard(model, { setupId: null }), + "success", + "AWS connected", + `Maple can read account ${now.account_id}.`, + ) +} diff --git a/apps/web/src/page/integrations/update-status.ts b/apps/web/src/page/integrations/update-status.ts index 2fb2973e98..c86f548c4c 100644 --- a/apps/web/src/page/integrations/update-status.ts +++ b/apps/web/src/page/integrations/update-status.ts @@ -5,6 +5,7 @@ import { requestKey } from "../../data/request-key" import type { Resources } from "../../platform/resources" import type { PageContext, PageReturn } from "../contract" import { + LoadAws, LoadChat, LoadCloudflare, LoadGcp, @@ -29,6 +30,7 @@ const OPERATIONS = { planetscale: "planetscaleIntegration.status", planetscaleDatabases: "planetscaleIntegration.databases", gcp: "gcpIntegration.status", + aws: "awsIntegration.status", hazel: "integrations.hazelStatus", github: "integrations.githubStatus", chat: "chatIntegration.connectors", @@ -100,6 +102,12 @@ export const requestStatus = ( (all, gcp) => ({ ...all, gcp }), (slotKey) => LoadGcp({ key: slotKey, refresh }), ) + case "aws": + return go( + s.aws, + (all, aws) => ({ ...all, aws }), + (slotKey) => LoadAws({ key: slotKey }), + ) case "hazel": return go( s.hazel, @@ -147,6 +155,7 @@ export const STATUS_NAMES: ReadonlyArray = [ "planetscale", "planetscaleDatabases", "gcp", + "aws", "hazel", "github", "chat", diff --git a/apps/web/src/page/integrations/update.ts b/apps/web/src/page/integrations/update.ts index 2bc8cdd4ea..a7efe87888 100644 --- a/apps/web/src/page/integrations/update.ts +++ b/apps/web/src/page/integrations/update.ts @@ -19,6 +19,8 @@ import { initPrometheusCard } from "./prometheus" import { initRailwayCard } from "./railway" import { initPlanetscaleCard } from "./planetscale" import { initGcpCard } from "./gcp" +import { initAwsCard } from "./aws" +import * as Aws from "./update-aws" import * as Gcp from "./update-gcp" import * as Configure from "./update-gcp-configure" import * as Planetscale from "./update-planetscale" @@ -84,6 +86,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): planetscale: Keyed.idle(), planetscaleDatabases: Keyed.idle(), gcp: Keyed.idle(), + aws: Keyed.idle(), hazel: Keyed.idle(), github: Keyed.idle(), chat: Keyed.idle(), @@ -99,6 +102,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): prometheusCard: initPrometheusCard(), planetscaleCard: initPlanetscaleCard(), gcpCard: initGcpCard(), + awsCard: initAwsCard(), railwayCard: initRailwayCard(), } const loaded = chain(model, [ @@ -127,6 +131,7 @@ export const routeChanged = ( const enterIntegration = (model: Model, context: PageContext): Return => { const integration = integrationOfSearch(model.search.integration) if (integration === "cloudflare") return CloudflareCard.enterCloudflare(model, context) + if (integration === "aws") return Aws.enterAws(model, context) if (integration === "gcp") return chain(model, [(next) => Gcp.enterGcp(next, context), (next) => Gcp.requestBand(next, context)]) return { model } @@ -179,6 +184,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re s.gcp.key === key && Result.isSuccess(result) ? Configure.statusArrived(read).model : read return gcpBand(arrived, context) }, + CompletedLoadAws: ({ key, result }) => + Aws.statusArrived( + accepted(model, { ...s, aws: Keyed.accept(s.aws, key, result) }).model, + Aws.connectionsOf(model), + ), CompletedLoadHazel: ({ key, result }) => accepted(model, { ...s, hazel: Keyed.accept(s.hazel, key, result) }), CompletedLoadGithub: ({ key, result }) => @@ -268,6 +278,29 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedRailwayRotate: () => Railway.setRotating(model, true), ClickedRailwayRotateCancel: () => Railway.setRotating(model, false), TickedRailwaySettle: () => requestStatus(model, context, "railway", "Refresh"), + GotAwsAccountIdMessage: ({ message: child }) => Aws.gotAccountId(model, child), + SubmittedAwsAccount: () => Aws.submittedAccount(model), + SucceededAwsCreate: ({ connection }) => Aws.succeededCreate(model, context, connection), + FailedAwsCreate: ({ error }) => Aws.failedCreate(model, error), + ClickedAwsAdd: () => Aws.setAdding(model, true), + ClickedAwsAddCancel: () => Aws.setAdding(model, false), + ClickedAwsSetup: ({ id }) => Aws.openSetup(model, context, id), + ClickedAwsSetupClose: () => Aws.closeSetup(model), + CompletedLoadAwsSetup: ({ key, result }) => Aws.completedLoadSetup(model, key, result), + ClickedAwsSetupMethod: ({ method }) => Aws.setMethod(model, method), + ClickedAwsCheck: ({ id }) => Aws.clickedCheck(model, id), + TickedAwsPending: () => Aws.tickedPending(model), + CompletedAwsCheck: ({ id, quiet, connection, failure }) => + Aws.completedCheck(model, context, id, quiet, connection, failure), + GotAwsCopyMessage: ({ id, message: child }) => Aws.gotCopy(model, id, child), + ClickedAwsDisconnect: ({ id }) => Aws.clickedDisconnect(model, id), + GotAwsDisconnectDialogMessage: ({ message: child }) => Aws.gotDisconnectDialog(model, child), + ConfirmedAwsDisconnect: () => Aws.confirmedDisconnect(model), + CompletedAwsDisconnect: ({ removed, error }) => + Aws.completedDisconnect(model, context, removed, error), + DismissedAwsRemoved: ({ connectionId }) => Aws.dismissedRemoved(model, context, connectionId), + CompletedReadAwsRemoved: ({ removed }) => Aws.completedReadRemoved(model, removed), + CompletedWriteAwsRemoved: () => ({ model }), CompletedFetchOrganizations: ({ key, result }) => Planetscale.updateCard(model, { organizations: Keyed.accept(model.planetscaleCard.organizations, key, result), @@ -324,9 +357,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re Gcp.updateGcpCard(model, { inventory: Keyed.accept(model.gcpCard.inventory, key, result) }), GotWorkloadFilterMessage: ({ message: child }) => Gcp.gotWorkloadFilter(model, child), GotGcpMenuMessage: ({ id, message: child }) => - Gcp.gotMenu(model, context, id, child, (next, target) => - Configure.openConfigure(next, target), - ), + Gcp.gotMenu(model, context, id, child, (next, target) => Configure.openConfigure(next, target)), CompletedGcpScripts: ({ key, result }) => Gcp.updateGcpCard(model, { scripts: Keyed.accept(model.gcpCard.scripts, key, result) }), ClickedGcpScriptToggle: () => @@ -370,12 +401,14 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedGcpMethod: ({ method }) => Configure.clickedMethod(model, method), GotGcpFlavourMessage: ({ message: child }) => Configure.gotFlavour(model, child), ClickedGcpMethodContinue: () => Configure.clickedMethodContinue(model, context), - ClickedGcpManualStep: ({ title, firstTitle }) => Configure.clickedManualStep(model, title, firstTitle), + ClickedGcpManualStep: ({ title, firstTitle }) => + Configure.clickedManualStep(model, title, firstTitle), ClickedGcpCopyPrompt: ({ prompt }) => Configure.clickedCopyPrompt(model, prompt), CompletedGcpPromptCopy: ({ isCopied }) => Configure.completedPromptCopy(model, isCopied), ClickedGcpPromptBack: () => Configure.clickedPromptBack(model), ClickedGcpConfirmRemoval: () => Configure.clickedConfirmRemoval(model), - CompletedGcpConfirmRemoval: ({ failure }) => Configure.completedConfirmRemoval(model, context, failure), + CompletedGcpConfirmRemoval: ({ failure }) => + Configure.completedConfirmRemoval(model, context, failure), TickedGcpRefresh: () => { const refreshed = requestStatus(model, context, "gcp", "Refresh") return { ...refreshed, commands: [...(refreshed.commands ?? []), ReadClock()] } diff --git a/apps/web/src/page/integrations/view-aws.ts b/apps/web/src/page/integrations/view-aws.ts new file mode 100644 index 0000000000..83a37b9506 --- /dev/null +++ b/apps/web/src/page/integrations/view-aws.ts @@ -0,0 +1,518 @@ +import type { V2AwsConnection, V2AwsIntegration, V2AwsSetup } from "@maple/domain/http/v2" +import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "../../ui/button" +import { confirmDialog } from "../../ui/confirm-dialog" +import * as CopyButton from "../../ui/copy-button" +import { badge } from "../../ui/display" +import { errorState } from "../../ui/error-state" +import * as Field from "../../ui/field" +import { icon } from "../../ui/icon" +import { input } from "../../ui/input" +import { item, itemContent, itemMedia } from "../../ui/item" +import { skeleton } from "../../ui/skeleton" +import { relativeTime } from "../kit/relative-time" +import { + integrationEmpty, + integrationEmptyCard, + integrationEmptyFeatures, + integrationEmptyFooter, + integrationEmptyHint, + integrationEmptyMedia, + integrationIconPlate, +} from "../infra-integrations/kit/integration-empty" +import { type AwsSetupMethod, decodeCreateRequest, normalizeAccountId } from "./aws" +import { codeBlock } from "../settings/kit/code-block" +import { segmentedSelect } from "../../ui/segmented-select" +import { externalLink } from "./view-gcp-configure-parts" +import { AWS_ACCENT } from "./catalog" +import { Message } from "./message" +import type { Model } from "./model" + +// `AwsIntegrationCard`. + +type H = HtmlBuilder + +const BRAND = { icon: "AwsIcon", accent: AWS_ACCENT, iconClassName: "text-foreground" } as const +// The stack's region (`stack_region`, always us-east-1): the console opens where the scripts deploy. +const CLOUDSHELL_URL = "https://us-east-1.console.aws.amazon.com/cloudshell/home?region=us-east-1" +const CREATE_STACK_URL = + "https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/create" +const SMALL = "text-xs text-muted-foreground" + +const FEATURES = [ + { + label: "Access", + title: "One read-only role", + description: "A CloudFormation stack creates it. Maple reads metadata, never your data.", + }, + { + label: "Security", + title: "No stored secret", + description: "The role trusts Maple with an external ID only your organization holds.", + }, + { + label: "Modules", + title: "One role for every module", + description: "Metrics, inventory, security and cost each add their own policy to the same role.", + }, +] + +const ACCOUNT_IDS: Field.FieldIds = { + control: "aws-account-id", + label: "aws-account-id-label", + error: "aws-account-id-error", +} + +const accountForm = (h: H, model: Model, canCancel: boolean): Html => { + const card = model.awsCard + const typed = normalizeAccountId(card.accountId.value) + const isValid = Option.isSome(decodeCreateRequest({ account_id: typed })) + const state = Field.stateOf(card.accountId, card.createError !== null) + return h.form( + [h.Class("flex w-full flex-col gap-2 text-left"), h.OnSubmit(Message.SubmittedAwsAccount())], + [ + Field.field(h, { className: "items-stretch gap-2", state }, [ + Field.fieldLabel(h, { ids: ACCOUNT_IDS, state }, "AWS account ID"), + h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [ + input(h, { + id: ACCOUNT_IDS.control, + className: "min-w-48 flex-1", + controlClassName: "font-mono tabular-nums", + fieldState: state, + attributes: [ + h.Autocomplete("off"), + h.Placeholder("123456789012"), + h.InputMode("numeric"), + ...Field.controlAttributes(h, card.accountId, (message) => + Message.GotAwsAccountIdMessage({ message }), + ), + h.AriaLabelledBy(ACCOUNT_IDS.label), + ...(card.createError === null ? [] : [h.AriaInvalid(true)]), + ], + }), + ...(canCancel + ? [ + button( + h, + { + variant: "outline", + isDisabled: card.isCreating, + attributes: [h.OnClick(Message.ClickedAwsAddCancel())], + }, + ["Cancel"], + ), + ] + : []), + button(h, { type: "submit", isDisabled: !isValid, isLoading: card.isCreating }, [ + icon(h, "AwsIcon"), + "Connect AWS", + ]), + ], + ), + ...(card.createError === null + ? [] + : [ + Field.fieldError( + h, + { ids: ACCOUNT_IDS, state, attributes: [h.Role("alert")] }, + card.createError, + ), + ]), + ]), + ], + ) +} + +const connectCard = (h: H, model: Model, status: V2AwsIntegration): Html => + integrationEmpty(h, [ + integrationEmptyFeatures(h, FEATURES), + integrationEmptyCard(h, [ + integrationEmptyMedia(h, BRAND), + integrationEmptyHint(h, [ + "Enter the account to connect. Maple then gives you a stack to deploy from AWS CloudShell, and confirms it by assuming the role.", + ]), + status.available + ? accountForm(h, model, false) + : h.p( + [h.Class("text-sm text-muted-foreground"), h.Role("status")], + ["AWS can't be connected on this Maple deployment yet."], + ), + integrationEmptyFooter(h, [ + "You need permission to create IAM roles in the account. Each account connects separately.", + ]), + ]), + ]) + +type Health = Readonly<{ label: string; variant: "ok" | "warn" | "crit" | "muted" }> + +const healthOf = (connection: V2AwsConnection): Health => { + if (connection.verified_at === null) return { label: "Waiting for setup", variant: "muted" } + if (connection.last_check_error !== null) return { label: "Needs attention", variant: "crit" } + if (connection.setup_outdated) return { label: "Update the stack", variant: "warn" } + return { label: "Connected", variant: "ok" } +} + +const roleName = (connection: V2AwsConnection) => connection.role_arn.split("/").at(-1) ?? connection.role_arn + +const connectionRow = (h: H, model: Model, connection: V2AwsConnection, timeZone: string): Html => { + const health = healthOf(connection) + const isChecking = model.awsCard.checking.some((entry) => entry.id === connection.id) + const isOpen = model.awsCard.setupId === connection.id + return item(h, { variant: "card", className: "items-start gap-4 p-4" }, [ + itemMedia(h, {}, [integrationIconPlate(h, BRAND)]), + itemContent( + h, + [ + h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [ + h.h3( + [h.Class("font-mono text-sm font-semibold tabular-nums")], + [connection.account_id], + ), + badge(h, { variant: health.variant }, [health.label]), + ], + ), + h.p( + [h.Class(cn(SMALL, "[overflow-wrap:anywhere]"))], + [ + roleName(connection), + ...(connection.last_checked_at === null + ? [] + : [ + " · ", + relativeTime(h, { + value: connection.last_checked_at, + nowMs: model.nowMs, + timeZone, + prefix: "checked", + }), + ]), + ], + ), + ...(connection.last_check_error === null + ? [] + : [ + h.p( + [ + h.Class( + cn( + "text-xs [overflow-wrap:anywhere]", + connection.verified_at === null + ? "text-muted-foreground" + : "text-severity-error", + ), + ), + h.Role(connection.verified_at === null ? "status" : "alert"), + ], + [connection.last_check_error], + ), + ]), + h.div( + [h.Class("flex flex-wrap gap-2")], + [ + button( + h, + { + size: "sm", + variant: isOpen ? "secondary" : "outline", + attributes: [ + h.OnClick( + isOpen + ? Message.ClickedAwsSetupClose() + : Message.ClickedAwsSetup({ id: connection.id }), + ), + h.AriaExpanded(isOpen), + ], + }, + [connection.verified_at === null ? "Set up" : "Redeploy stack"], + ), + button( + h, + { + size: "sm", + variant: "outline", + isLoading: isChecking, + attributes: [h.OnClick(Message.ClickedAwsCheck({ id: connection.id }))], + }, + ["Check connection"], + ), + button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedAwsDisconnect({ id: connection.id }))], + }, + ["Disconnect"], + ), + ], + ), + ...(isOpen ? [setupPanel(h, model, connection)] : []), + ], + "gap-2", + ), + ]) +} + +/** A generated file to copy, its name in the eyebrow; a long one scrolls under its copy button. */ +const fileBlock = (h: H, model: Model, id: string, name: string, text: string): Html => + codeBlock(h, { + code: text.trimEnd(), + language: name, + bodyClassName: "max-h-72 overflow-y-auto", + copy: model.awsCard.copies[id] ?? CopyButton.init(), + toCopyMessage: (message) => Message.GotAwsCopyMessage({ id, message }), + }) + +const METHOD_OPTIONS: ReadonlyArray> = [ + { value: "cloudshell", label: "CloudShell" }, + { value: "console", label: "Console" }, + { value: "terraform", label: "Terraform" }, +] + +const methodBody = ( + h: H, + model: Model, + connection: V2AwsConnection, + setup: V2AwsSetup, +): ReadonlyArray => { + switch (model.awsCard.method) { + case "cloudshell": + return [ + h.p( + [h.Class(SMALL)], + [ + "Open ", + externalLink(h, CLOUDSHELL_URL, "AWS CloudShell"), + ` signed in to account ${connection.account_id}, paste this, and press Enter. It deploys the stack ${setup.stack_name}.`, + ], + ), + fileBlock(h, model, "aws-setup-script", "setup.sh", setup.setup_script), + ] + case "console": + return [ + h.p( + [h.Class(SMALL)], + [ + "Save this template, then ", + externalLink(h, CREATE_STACK_URL, "create a stack"), + ` with it in account ${connection.account_id}, region ${setup.stack_region}. Name it ${setup.stack_name} and acknowledge that it creates IAM resources.`, + ], + ), + fileBlock( + h, + model, + "aws-template", + `${setup.stack_name}.json`, + setup.cloudformation_template, + ), + ] + case "terraform": + return [ + h.p( + [h.Class(SMALL)], + [ + `Apply this with the aws provider, using credentials for account ${connection.account_id}.`, + ], + ), + fileBlock(h, model, "aws-terraform", "maple-aws.tf", setup.terraform), + ] + } +} + +const setupPanel = (h: H, model: Model, connection: V2AwsConnection): Html => { + const waiting = connection.verified_at === null + return h.div( + [h.Class("mt-2 flex w-full flex-col gap-3 rounded-lg border bg-muted/20 p-3")], + [ + h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [ + h.h4( + [h.Class("text-sm font-medium")], + [waiting ? "Deploy the stack" : "Deploy the stack again"], + ), + h.span( + [h.Class("ml-auto")], + [ + segmentedSelect(h, { + options: METHOD_OPTIONS, + value: model.awsCard.method, + onChange: (method) => Message.ClickedAwsSetupMethod({ method }), + size: "sm", + ariaLabel: "Setup method", + }), + ], + ), + ], + ), + ...AsyncData.match(model.awsCard.setup.data, { + onIdle: () => [skeleton(h, "h-40 w-full rounded-lg")], + onLoading: () => [skeleton(h, "h-40 w-full rounded-lg")], + onFailure: (failure) => [ + errorState(h, { + failure, + title: "Failed to load the setup", + onRetry: Message.ClickedAwsSetup({ id: connection.id }), + }), + ], + onStale: ({ data }) => methodBody(h, model, connection, data), + onRefreshing: (data) => methodBody(h, model, connection, data), + onSuccess: (data) => methodBody(h, model, connection, data), + }), + h.p( + [h.Class(SMALL)], + [ + waiting + ? "Maple checks the connection every 10 seconds while this is open." + : "After changing what Maple reads, deploy the stack again and check the connection.", + ], + ), + ], + ) +} + +/** Each stack a disconnect left in AWS, with the script that deletes it, until dismissed. */ +const removedPanel = (h: H, model: Model, focusId: string | null): ReadonlyArray => + model.awsCard.removed + .filter((stack) => focusId === null || stack.connectionId === focusId) + .map((stack) => + h.div( + [h.Class("flex flex-col gap-3 rounded-lg border p-4")], + [ + h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [ + h.h4( + [h.Class("text-sm font-medium")], + [`Delete the stack in ${stack.accountId}`], + ), + h.span( + [h.Class("ml-auto")], + [ + button( + h, + { + size: "sm", + variant: "ghost", + attributes: [ + h.OnClick( + Message.DismissedAwsRemoved({ + connectionId: stack.connectionId, + }), + ), + ], + }, + ["Dismiss"], + ), + ], + ), + ], + ), + h.p( + [h.Class(SMALL)], + [ + "Maple no longer assumes the role, but it exists until you delete its stack. Paste this into ", + externalLink(h, CLOUDSHELL_URL, "AWS CloudShell"), + ".", + ], + ), + fileBlock( + h, + model, + `aws-cleanup-${stack.connectionId}`, + "cleanup.sh", + stack.cleanupScript, + ), + ], + ), + ) + +const disconnectConfirm = (h: H, model: Model, status: V2AwsIntegration): Html => { + const connection = status.connections.find((entry) => entry.id === model.awsCard.disconnectId) + return confirmDialog(h, { + slotId: "aws-disconnect", + model: model.awsCard.disconnectDialog, + toParentMessage: (message) => Message.GotAwsDisconnectDialogMessage({ message }), + title: `Disconnect AWS account ${connection?.account_id ?? ""}`, + description: + "Maple stops assuming the role and removes this source. Data already collected stays. Afterwards, delete the stack in AWS with the script Maple shows.", + confirmLabel: "Disconnect", + isPending: model.awsCard.isDisconnecting, + onConfirm: Message.ConfirmedAwsDisconnect(), + }) +} + +/** The connection a source page focuses on (`focusId`), or every connection. */ +const connectedView = ( + h: H, + model: Model, + status: V2AwsIntegration, + timeZone: string, + focusId: string | null, +): Html => { + const shown = status.connections.filter((connection) => focusId === null || connection.id === focusId) + return h.div( + [h.Class("flex flex-col gap-3")], + [ + ...removedPanel(h, model, focusId), + ...shown.map((connection) => connectionRow(h, model, connection, timeZone)), + ...(focusId !== null || !status.available + ? [] + : model.awsCard.isAdding + ? [h.div([h.Class("rounded-lg border p-4")], [accountForm(h, model, true)])] + : [ + h.div( + [], + [ + button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedAwsAdd())], + }, + [icon(h, "PlusIcon", { size: 14 }), "Connect another account"], + ), + ], + ), + ]), + disconnectConfirm(h, model, status), + ], + ) +} + +const byStatus = ( + h: H, + model: Model, + status: V2AwsIntegration, + timeZone: string, + focusId: string | null, +): Html => + status.connections.length === 0 + ? h.div( + [h.Class("flex flex-col gap-3")], + [...removedPanel(h, model, focusId), connectCard(h, model, status)], + ) + : connectedView(h, model, status, timeZone, focusId) + +export const awsCard = (h: H, model: Model, timeZone: string, focusId: string | null): Html => + AsyncData.match(model.statuses.aws.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: "Failed to load the AWS integration", + onRetry: Message.ClickedRetryStatus({ name: "aws" }), + }), + onStale: ({ data }) => byStatus(h, model, data, timeZone, focusId), + onRefreshing: (data) => byStatus(h, model, data, timeZone, focusId), + onSuccess: (data) => byStatus(h, model, data, timeZone, focusId), + }) diff --git a/apps/web/src/page/integrations/view.ts b/apps/web/src/page/integrations/view.ts index 9e0ba5e3db..fe6f6c12fc 100644 --- a/apps/web/src/page/integrations/view.ts +++ b/apps/web/src/page/integrations/view.ts @@ -20,6 +20,7 @@ import { githubCard } from "./view-github" import { hazelCard } from "./view-hazel" import { planetscaleCard } from "./view-planetscale" import { railwayCard } from "./view-railway" +import { awsCard } from "./view-aws" import { scrapeIntro, scrapeTargetsSection } from "./view-prometheus" import { integrationHeader } from "./view-header" import { integrationCatalog, integrationsSummary } from "./view-hub" @@ -68,6 +69,8 @@ export const cardView = ( return [planetscaleCard(h, model, timeZone)] case "gcp": return [gcpCard(h, model, timeZone, focusId)] + case "aws": + return [awsCard(h, model, timeZone, focusId)] case "prometheus": case "warpstream": return focusId === null diff --git a/apps/web/src/page/settings/kit/code-block.ts b/apps/web/src/page/settings/kit/code-block.ts index a95b30e59a..404bd40fc7 100644 --- a/apps/web/src/page/settings/kit/code-block.ts +++ b/apps/web/src/page/settings/kit/code-block.ts @@ -10,6 +10,8 @@ export type CodeBlockOptions = Readonly<{ code: string language?: string className?: string + /** The scrolling body's classes: a height cap for a long file keeps the copy button in view. */ + bodyClassName?: string copy: CopyButton.Model toCopyMessage: (message: CopyButton.Message) => M /** Pre-highlighted HTML for a caller that refines the tokens. */ @@ -34,7 +36,7 @@ export const codeBlock = (h: HtmlBuilder, options: CodeBlockOptions): H ], ), h.div( - [h.Class("overflow-x-auto bg-background/50 p-3")], + [h.Class(cn("overflow-x-auto bg-background/50 p-3", options.bodyClassName))], [ h.pre( [h.Class("text-xs leading-relaxed")], diff --git a/apps/web/src/page/sources/detail/detail.scene.test.ts b/apps/web/src/page/sources/detail/detail.scene.test.ts index 9864602c1f..e23d164460 100644 --- a/apps/web/src/page/sources/detail/detail.scene.test.ts +++ b/apps/web/src/page/sources/detail/detail.scene.test.ts @@ -1,5 +1,11 @@ import { OrgId } from "@maple/domain" -import { encodePublicId, PublicIdPrefixes, V2Source, V2SourceUsage } from "@maple/domain/http/v2" +import { + encodePublicId, + PublicIdPrefixes, + V2AwsConnection, + V2Source, + V2SourceUsage, +} from "@maple/domain/http/v2" import { Option, Result, Schema } from "effect" import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" @@ -7,12 +13,13 @@ import { ResolveThemeColors, ThemeMessage } from "../../../charts/theme-colors" import { toLoadFailure } from "../../../data/load-failure" import { AppRoute } from "../../../route" import { type PageContext, PageOutMessage } from "../../contract" -import { ScrapeTargetId } from "@maple/domain/primitives" +import { AwsConnectionId, ScrapeTargetId } from "@maple/domain/primitives" import { Message as IntegrationMessage } from "../../integrations/message" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, sourceOf, update } from "./update" import { breadcrumbsOf, view as pageView } from "./view" +import { focusIdOf } from "../kit/shared" const context: PageContext = { orgId: Schema.decodeSync(OrgId)("org_parity"), @@ -171,7 +178,10 @@ describe("source detail", () => { ) expect(names(resumed.commands)).toEqual(["FetchSourceDetailList"]) const final = [ - Message.CompletedFetchList({ key: resumed.model.list.key ?? "", result: Result.succeed([source()]) }), + Message.CompletedFetchList({ + key: resumed.model.list.key ?? "", + result: Result.succeed([source()]), + }), Message.CompletedFetchList({ key: paused.list.key ?? "", result: Result.succeed([source({ status: "paused" })]), @@ -405,3 +415,126 @@ describe("integration sources", () => { expect(loaded(source()).connection).toBeNull() }) }) + +const awsConnection = (id: AwsConnectionId): V2AwsConnection => + Schema.decodeUnknownSync(V2AwsConnection)({ + id: encodePublicId(PublicIdPrefixes.awsConnection, id), + object: "aws_connection", + account_id: "123456789012", + role_arn: "arn:aws:iam::123456789012:role/MapleIntegration-018f2b3c4d5e4f708192a3b4", + external_id: "9f1c4e2a7b3d4c6e8a0b1c2d3e4f5a6b", + modules: [], + granted_modules: null, + template_version: null, + setup_outdated: false, + verified_at: null, + last_checked_at: "2026-10-01T11:59:00.000Z", + last_check_error: "Maple cannot assume the role yet.", + created_at: "2026-10-01T11:00:00.000Z", + }) + +describe("integration focus", () => { + test("an AWS source's card shows only the connection it stands for", () => { + const connection = "018f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8" + const aws = source({ + platform: "aws", + kind: "integration", + key_prefix: null, + integration_id: encodePublicId(PublicIdPrefixes.awsConnection, connection), + }) + expect(focusIdOf(aws)).toBe(connection) + }) + + test("an AWS check in the Connection tab re-reads the source, so the header shows its health", () => { + const connection = Schema.decodeSync(AwsConnectionId)("018f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8") + const model = loaded( + source({ + platform: "aws", + kind: "integration", + key_prefix: null, + integration_id: encodePublicId(PublicIdPrefixes.awsConnection, connection), + }), + "connection", + ) + const checked = pageUpdate( + model, + Message.GotConnectionMessage({ + message: IntegrationMessage.CompletedAwsCheck({ + id: connection, + quiet: false, + connection: awsConnection(connection), + failure: null, + }), + }), + ) + expect(names(checked.commands)).toContain("FetchSourceDetailList") + expect(names(checked.commands)).toContain("ReadSourceClock") + // A background poll that still finds no stack changes nothing the header shows. + const polled = pageUpdate( + model, + Message.GotConnectionMessage({ + message: IntegrationMessage.CompletedAwsCheck({ + id: connection, + quiet: true, + connection: awsConnection(connection), + failure: null, + }), + }), + ) + expect(names(polled.commands)).not.toContain("FetchSourceDetailList") + // A click while that poll was in flight made it the user's: its answer re-reads the header. + const clicked = pageUpdate( + model, + Message.GotConnectionMessage({ message: IntegrationMessage.ClickedAwsCheck({ id: connection }) }), + ).model + const answered = pageUpdate( + clicked, + Message.GotConnectionMessage({ + message: IntegrationMessage.CompletedAwsCheck({ + id: connection, + quiet: true, + connection: awsConnection(connection), + failure: null, + }), + }), + ) + expect(names(answered.commands)).toContain("FetchSourceDetailList") + }) + + test("disconnecting its own AWS connection leaves for the AWS page, where the cleanup waits", () => { + const connection = Schema.decodeSync(AwsConnectionId)("018f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8") + const model = loaded( + source({ + platform: "aws", + kind: "integration", + key_prefix: null, + integration_id: encodePublicId(PublicIdPrefixes.awsConnection, connection), + }), + "connection", + ) + const removed = { connectionId: connection, accountId: "123456789012", cleanupScript: "cleanup" } + const done = pageUpdate( + model, + Message.GotConnectionMessage({ + message: IntegrationMessage.CompletedAwsDisconnect({ id: connection, removed, error: null }), + }), + ) + expect(done.outMessage?._tag === "RequestedNavigation" && done.outMessage.url).toBe( + "/sources/new?integration=aws", + ) + expect(names(done.commands)).toContain("WriteAwsRemovedStacks") + // Another account's disconnect leaves this source where it is. + const other = Schema.decodeSync(AwsConnectionId)("028f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8") + const elsewhere = pageUpdate( + model, + Message.GotConnectionMessage({ + message: IntegrationMessage.CompletedAwsDisconnect({ + id: other, + removed: { ...removed, connectionId: other }, + error: null, + }), + }), + ) + expect(elsewhere.outMessage?._tag).not.toBe("RequestedNavigation") + }) +}) diff --git a/apps/web/src/page/sources/detail/update.ts b/apps/web/src/page/sources/detail/update.ts index e9510b3b0c..405beffe2e 100644 --- a/apps/web/src/page/sources/detail/update.ts +++ b/apps/web/src/page/sources/detail/update.ts @@ -14,7 +14,14 @@ import { copyToast } from "../../kit/clipboard" import { sourcesListKey, sourceUsageKey } from "../kit/data" import * as Rules from "../kit/rules" import * as Setup from "../kit/setup" -import { effectiveTab, fromPublicSourceId, hasToken, integrationPlatformOf, scrapeTargetIdOf } from "../kit/shared" +import { + effectiveTab, + focusIdOf, + fromPublicSourceId, + hasToken, + integrationPlatformOf, + scrapeTargetIdOf, +} from "../kit/shared" import { initEmbedded } from "../../integrations/embed" import * as Integrations from "../../integrations/update" import { Message as IntegrationMessage } from "../../integrations/message" @@ -255,14 +262,39 @@ const save = (model: Model): Return => { /** * A write in the Connection tab changes this source too: deleting its own target leaves for the - * list (the source goes with it); a toggle, edit or disconnect re-reads it so the header follows. + * list (the source goes with it); a toggle, edit, check or disconnect re-reads it so the header + * follows. */ const afterConnectionWrite = (model: Model, child: IntegrationMessage, step: Return, context: PageContext): Return => { + // Disconnecting its own AWS connection deletes this source; the AWS page shows the stack's cleanup. + if (child._tag === "CompletedAwsDisconnect" && child.removed !== null) { + const own = Option.match(sourceOf(model), { onNone: () => null, onSome: focusIdOf }) + if (own !== child.id) return step + return { + ...step, + outMessage: PageOutMessage.RequestedNavigation({ + url: hrefOf(AppRoute.SourcesNew({ search: { integration: "aws" } })), + isReplace: true, + toast: { + type: "success", + title: "AWS disconnected", + description: "Delete the stack in AWS to remove the role.", + }, + }), + } + } + // An AWS check rewrites the source's health; a background one only matters once it verifies. A + // click during a background check makes it the user's, which only the card's model knows. + const isAwsCheck = + child._tag === "CompletedAwsCheck" && + (child.connection?.verified_at != null || + !(model.connection?.awsCard.checking.find((entry) => entry.id === child.id)?.quiet ?? child.quiet)) const isWrite = child._tag === "CompletedTargetDelete" || child._tag === "CompletedToggle" || child._tag === "CompletedSave" || - child._tag === "CompletedDisconnect" + child._tag === "CompletedDisconnect" || + isAwsCheck if (!isWrite || child.failure !== null) return step const own = Option.match(sourceOf(model), { onNone: () => null, onSome: scrapeTargetIdOf }) if (child._tag === "CompletedTargetDelete" && own !== null && child.id === own) { @@ -277,7 +309,11 @@ const afterConnectionWrite = (model: Model, child: IntegrationMessage, step: Ret }) return { ...step, outMessage: leave } } - return andThen(step, (next) => loadList(next, context, "Supersede")) + // The re-read can be newer than the page clock: read it again so "ago" stays in the past. + return andThen(step, (next) => { + const reloaded = loadList(next, context, "Supersede") + return { ...reloaded, commands: [ReadSourceClock(), ...(reloaded.commands ?? [])] } + }) } export const update = (model: Model, message: Message, context: PageContext): Return => diff --git a/apps/web/src/page/sources/kit/platforms.ts b/apps/web/src/page/sources/kit/platforms.ts index 0023f0d1fa..67f3f88b2a 100644 --- a/apps/web/src/page/sources/kit/platforms.ts +++ b/apps/web/src/page/sources/kit/platforms.ts @@ -42,6 +42,7 @@ export const PLATFORMS = { icon: "CloudflareBrandIcon", }, gcp: { label: "Google Cloud", description: "Logs, metrics and resources", icon: "GoogleCloudIcon" }, + aws: { label: "AWS", description: "A read-only role in an account", icon: "AwsIcon" }, railway: { label: "Railway", description: "Service metrics", icon: "RailwayIcon" }, planetscale: { label: "PlanetScale", description: "Database metrics", icon: "PlanetScaleIcon" }, github: { label: "GitHub", description: "Repositories, CI and deploys", icon: "GithubIcon" }, diff --git a/apps/web/src/page/sources/kit/shared.ts b/apps/web/src/page/sources/kit/shared.ts index f4052e2b7c..a19b31d2d1 100644 --- a/apps/web/src/page/sources/kit/shared.ts +++ b/apps/web/src/page/sources/kit/shared.ts @@ -1,4 +1,10 @@ -import { GcpConnectorPublicId, ScrapeTargetPublicId, SourcePublicId, type V2Source } from "@maple/domain/http/v2" +import { + AwsConnectionPublicId, + GcpConnectorPublicId, + ScrapeTargetPublicId, + SourcePublicId, + type V2Source, +} from "@maple/domain/http/v2" import { isPushSourcePlatform, type PullSourcePlatform, @@ -89,6 +95,7 @@ const VIA = { railway: "API token", planetscale: "OAuth", github: "GitHub App", + aws: "IAM role", } as const satisfies Record /** How the source reaches Maple: its token prefix, the org keys, or the integration's credential. */ @@ -108,9 +115,12 @@ export const scrapeTargetIdOf = (source: V2Source): ScrapeTargetId | null => : Option.getOrNull(decodeScrapeTargetId(source.integration_id)) const decodeGcpConnectorId = Schema.decodeUnknownOption(GcpConnectorPublicId) - -/** The record an integration source's card focuses on: its scrape target or Google Cloud connector. */ -export const focusIdOf = (source: V2Source): string | null => - source.platform === "gcp" && source.integration_id !== null - ? Option.getOrNull(decodeGcpConnectorId(source.integration_id)) - : scrapeTargetIdOf(source) +const decodeAwsConnectionId = Schema.decodeUnknownOption(AwsConnectionPublicId) + +/** The record an integration source's card focuses on: its scrape target, connector or connection. */ +export const focusIdOf = (source: V2Source): string | null => { + if (source.integration_id === null) return scrapeTargetIdOf(source) + if (source.platform === "gcp") return Option.getOrNull(decodeGcpConnectorId(source.integration_id)) + if (source.platform === "aws") return Option.getOrNull(decodeAwsConnectionId(source.integration_id)) + return scrapeTargetIdOf(source) +} diff --git a/apps/web/src/route/data-integrations.ts b/apps/web/src/route/data-integrations.ts index 07f9005e21..e73552d98f 100644 --- a/apps/web/src/route/data-integrations.ts +++ b/apps/web/src/route/data-integrations.ts @@ -1,3 +1,3 @@ // The integrations that bring data in. They live under Sources; chat and alert connectors stay in // Settings. One list for the redirect (`route/`) and the Sources pages (`page/integrations/embed.ts`). -export const DATA_INTEGRATIONS = ["cloudflare", "prometheus", "planetscale", "gcp", "railway", "warpstream", "github"] as const +export const DATA_INTEGRATIONS = ["cloudflare", "prometheus", "planetscale", "gcp", "aws", "railway", "warpstream", "github"] as const diff --git a/apps/web/src/shell/palette/palette.test.ts b/apps/web/src/shell/palette/palette.test.ts index cb85e5a8a9..ff83eb0fce 100644 --- a/apps/web/src/shell/palette/palette.test.ts +++ b/apps/web/src/shell/palette/palette.test.ts @@ -66,7 +66,7 @@ describe("command palette", () => { serviceNames.slice(0, 5), ) expect(browsed.filter((entry) => entry.group === "Integrations").map((entry) => entry.title)).toEqual( - ["Connect Google Cloud", "Connect Railway"], + ["Connect Google Cloud", "Connect AWS", "Connect Railway"], ) }) diff --git a/bun.lock b/bun.lock index 371ccb9427..3e4f89f69d 100644 --- a/bun.lock +++ b/bun.lock @@ -570,6 +570,7 @@ "name": "@maple/backend", "dependencies": { "@clerk/backend": "^3.23.0", + "@distilled.cloud/aws": "1.0.0-rc.15", "@distilled.cloud/cloudflare": "1.0.0-rc.15", "@distilled.cloud/core": "1.0.0-rc.15", "@distilled.cloud/planetscale": "1.0.0-rc.15", diff --git a/packages/backend/package.json b/packages/backend/package.json index ac291a2119..be314c32f2 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@clerk/backend": "^3.23.0", + "@distilled.cloud/aws": "1.0.0-rc.15", "@distilled.cloud/cloudflare": "1.0.0-rc.15", "@distilled.cloud/core": "1.0.0-rc.15", "@distilled.cloud/planetscale": "1.0.0-rc.15", diff --git a/packages/backend/src/platform/Env.ts b/packages/backend/src/platform/Env.ts index c9f7ba5cb8..7e16e3bd29 100644 --- a/packages/backend/src/platform/Env.ts +++ b/packages/backend/src/platform/Env.ts @@ -156,6 +156,14 @@ export interface EnvConfig { * poller signs in with it. Unset: nothing is polled. */ readonly MAPLE_GCP_SERVICE_ACCOUNT_KEY: Option.Option> + /** + * Maple's own AWS identity (an IAM user ARN in Maple's account). Customer roles trust it, with + * their connection's external id. Unset with its keys: AWS cannot be connected. + */ + readonly MAPLE_AWS_PRINCIPAL_ARN: Option.Option + /** An access key of that IAM user. It can only `sts:AssumeRole` into Maple integration roles. */ + readonly MAPLE_AWS_ACCESS_KEY_ID: Option.Option + readonly MAPLE_AWS_SECRET_ACCESS_KEY: Option.Option> readonly CLOUDFLARE_OAUTH_CLIENT_ID: Option.Option readonly CLOUDFLARE_OAUTH_CLIENT_SECRET: Option.Option> readonly CLOUDFLARE_OAUTH_SCOPES: string @@ -266,6 +274,9 @@ const envConfig = Config.all({ GITHUB_API_BASE_URL: stringWithDefault("GITHUB_API_BASE_URL", "https://api.github.com"), MAPLE_GCP_SERVICE_ACCOUNT_EMAIL: optionalString("MAPLE_GCP_SERVICE_ACCOUNT_EMAIL"), MAPLE_GCP_SERVICE_ACCOUNT_KEY: optionalRedacted("MAPLE_GCP_SERVICE_ACCOUNT_KEY"), + MAPLE_AWS_PRINCIPAL_ARN: optionalString("MAPLE_AWS_PRINCIPAL_ARN"), + MAPLE_AWS_ACCESS_KEY_ID: optionalString("MAPLE_AWS_ACCESS_KEY_ID"), + MAPLE_AWS_SECRET_ACCESS_KEY: optionalRedacted("MAPLE_AWS_SECRET_ACCESS_KEY"), CLOUDFLARE_OAUTH_CLIENT_ID: optionalString("CLOUDFLARE_OAUTH_CLIENT_ID"), CLOUDFLARE_OAUTH_CLIENT_SECRET: optionalRedacted("CLOUDFLARE_OAUTH_CLIENT_SECRET"), // Cloudflare OAuth scope ids are DOT-delimited (mirroring API-token permission names; diff --git a/packages/backend/src/services/integrations/AwsConnectionService.test.ts b/packages/backend/src/services/integrations/AwsConnectionService.test.ts new file mode 100644 index 0000000000..fd0e506408 --- /dev/null +++ b/packages/backend/src/services/integrations/AwsConnectionService.test.ts @@ -0,0 +1,291 @@ +import { afterEach, assert, describe, it } from "@effect/vitest" +import { ConfigProvider, Effect, Layer, Schema } from "effect" +import { TestClock } from "effect/testing" +import { HttpClient } from "effect/http" +import { AwsAccountId, OrgId, UserId } from "@maple/domain/primitives" +import { Env } from "@maple/backend/platform/Env" +import { + cleanupTestDbs, + createTestDb, + executeSql, + queryFirstRow, + type TestDb, +} from "@maple/backend/platform/test-pglite" +import { AwsConnectionService } from "./AwsConnectionService" +import { MAPLE_ACCESS_KEY_ID, MAPLE_PRINCIPAL_ARN, makeFakeAws, type FakeAws } from "./aws/fake-aws" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const baseConfig = { + PORT: "3472", + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + MAPLE_INGEST_PUBLIC_URL: "https://ingest.test/", +} + +const mapleAws = { + MAPLE_AWS_PRINCIPAL_ARN: MAPLE_PRINCIPAL_ARN, + MAPLE_AWS_ACCESS_KEY_ID: MAPLE_ACCESS_KEY_ID, + MAPLE_AWS_SECRET_ACCESS_KEY: "maple-test-secret", +} + +const makeLayer = (testDb: TestDb, aws: FakeAws, config: Record = mapleAws) => + Layer.effect(AwsConnectionService, AwsConnectionService.make).pipe( + Layer.provide(testDb.layer), + Layer.provide(Env.layer), + Layer.provide(Layer.succeed(HttpClient.HttpClient, aws.client)), + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ ...baseConfig, ...config }))), + ) + +const orgId = Schema.decodeUnknownSync(OrgId)("org_aws") +const otherOrgId = Schema.decodeUnknownSync(OrgId)("org_other") +const userId = Schema.decodeUnknownSync(UserId)("user_1") +const account = Schema.decodeUnknownSync(AwsAccountId)("123456789012") + +interface SourceRow { + readonly platform: string + readonly kind: string + readonly last_poll_ok_at: string | null + readonly last_poll_error: string | null + readonly disconnected_at: string | null +} + +const sourceOf = (testDb: TestDb, connectionId: string) => + Effect.promise(() => + queryFirstRow( + testDb, + `SELECT s.platform, s.kind, s.last_poll_ok_at, s.last_poll_error, s.disconnected_at + FROM sources s JOIN aws_connections c ON c.source_id = s.id WHERE c.id = $1`, + [connectionId], + ), + ) + +/** Checks run seconds apart: the service answers a check within 5s of the last with that one. */ +const later = (effect: Effect.Effect) => + TestClock.adjust("10 seconds").pipe(Effect.andThen(effect)) + +const countSources = (testDb: TestDb) => + Effect.promise(() => + queryFirstRow<{ count: number }>( + testDb, + "SELECT count(*)::int AS count FROM sources WHERE platform = 'aws'", + ), + ).pipe(Effect.map((row) => row?.count)) + +describe("AwsConnectionService", () => { + it.effect("connects an account once the stack it renders is deployed", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const created = yield* service.create(orgId, userId, { accountId: account, modules: [] }) + assert.match(created.externalId, /^[0-9a-f]{32}$/) + assert.match(created.roleArn, /^arn:aws:iam::123456789012:role\/MapleIntegration-[0-9a-f]{24}$/) + assert.isNull(created.verifiedAt) + + // Before the stack exists the check explains what to do, and the source stays pending. + const early = yield* later(service.check(orgId, created.id)) + assert.isNull(early.verifiedAt) + assert.include(early.lastCheckError ?? "", "Run the setup script") + const pending = yield* sourceOf(testDb, created.id) + assert.strictEqual(pending?.platform, "aws") + assert.isNull(pending?.last_poll_ok_at) + assert.isNull(pending?.last_poll_error) + + const setup = yield* service.setup(orgId, created.id) + assert.include(setup.setupScript, setup.stackName) + aws.deploy(account, setup.cloudformationTemplate) + + const checked = yield* later(service.check(orgId, created.id)) + assert.isNotNull(checked.verifiedAt) + assert.isNull(checked.lastCheckError) + assert.strictEqual(checked.templateVersion, 1) + assert.deepStrictEqual(checked.grantedModules, []) + assert.isFalse(checked.setupOutdated) + const healthy = yield* sourceOf(testDb, created.id) + assert.isNotNull(healthy?.last_poll_ok_at) + assert.deepStrictEqual(aws.calls.slice(-3).sort(), [ + "AssumeRole", + "GetRole", + "ListAttachedRolePolicies", + ]) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + + it.effect("never lets one connection's stack verify another's", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const mine = yield* service.create(orgId, userId, { accountId: account, modules: [] }) + const theirs = yield* service.create(otherOrgId, userId, { accountId: account, modules: [] }) + aws.deploy(account, (yield* service.setup(orgId, mine.id)).cloudformationTemplate) + const checked = yield* later(service.check(otherOrgId, theirs.id)) + assert.isNull(checked.verifiedAt) + assert.notStrictEqual(mine.externalId, theirs.externalId) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + + it.effect("reports a deleted stack as a disconnected source", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const created = yield* service.create(orgId, userId, { accountId: account, modules: [] }) + aws.deploy(account, (yield* service.setup(orgId, created.id)).cloudformationTemplate) + yield* later(service.check(orgId, created.id)) + aws.deleteStacks(account) + const checked = yield* later(service.check(orgId, created.id)) + assert.include(checked.lastCheckError ?? "", "can no longer assume the role") + const source = yield* sourceOf(testDb, created.id) + assert.isNotNull(source?.disconnected_at) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + + it.effect("blames Maple, not the customer, when AWS rejects Maple's own key", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws("AKIASOMEOTHERKEY0001") + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const created = yield* service.create(orgId, userId, { accountId: account, modules: [] }) + const checked = yield* later(service.check(orgId, created.id)) + assert.include(checked.lastCheckError ?? "", "Maple's own credentials") + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + + it.effect("refuses a second connection for the same account and leaves no source behind", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + yield* service.create(orgId, userId, { accountId: account, modules: [] }) + const error = yield* Effect.flip( + service.create(orgId, userId, { accountId: account, modules: [] }), + ) + assert.strictEqual(error._tag, "@maple/http/errors/AwsAccountAlreadyConnectedError") + assert.strictEqual(yield* countSources(testDb), 1) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + + it.effect("refuses modules Maple does not read yet", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const error = yield* Effect.flip( + service.create(orgId, userId, { accountId: account, modules: ["security"] }), + ) + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsValidationError") + assert.strictEqual(yield* countSources(testDb), 0) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + + it.effect("is unavailable without Maple's AWS identity", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + assert.isFalse((yield* service.status(orgId)).available) + const error = yield* Effect.flip( + service.create(orgId, userId, { accountId: account, modules: [] }), + ) + assert.strictEqual(error._tag, "@maple/http/errors/AwsIntegrationUnavailableError") + assert.deepStrictEqual(yield* service.checkDue(), { due: 0, checked: 0, failures: 0 }) + }).pipe(Effect.provide(makeLayer(testDb, aws, { MAPLE_AWS_PRINCIPAL_ARN: MAPLE_PRINCIPAL_ARN }))) + }) + + it.effect("sweeps each connection once an hour and skips paused sources", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const active = yield* service.create(orgId, userId, { accountId: account, modules: [] }) + const paused = yield* service.create(otherOrgId, userId, { accountId: account, modules: [] }) + aws.deploy(account, (yield* service.setup(orgId, active.id)).cloudformationTemplate) + yield* Effect.promise(() => + executeSql( + testDb, + "UPDATE sources SET paused_at = now() WHERE id = (SELECT source_id FROM aws_connections WHERE id = $1)", + [paused.id], + ), + ) + + const first = yield* service.checkDue() + assert.deepStrictEqual(first, { due: 1, checked: 1, failures: 0 }) + const [checked] = (yield* service.status(orgId)).connections + assert.isNotNull(checked?.verifiedAt) + + // Checked moments ago: nothing is due, and the paused one is never claimed. + const second = yield* service.checkDue() + assert.deepStrictEqual(second, { due: 0, checked: 0, failures: 0 }) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + + it.effect("deletes the connection with its source and hands back the stack's cleanup", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const created = yield* service.create(orgId, userId, { accountId: account, modules: [] }) + const { stackName } = yield* service.setup(orgId, created.id) + const deleted = yield* service.delete(orgId, created.id) + assert.include(deleted.cleanupScript, `delete-stack --region us-east-1 --stack-name ${stackName}`) + assert.strictEqual(yield* countSources(testDb), 0) + const error = yield* Effect.flip(service.check(orgId, created.id)) + assert.strictEqual(error._tag, "@maple/http/errors/IntegrationsNotFoundError") + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + + it.effect("two overlapping sweeps check each connection once", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const created = yield* service.create(orgId, userId, { accountId: account, modules: [] }) + aws.deploy(account, (yield* service.setup(orgId, created.id)).cloudformationTemplate) + const [a, b] = yield* Effect.all([service.checkDue(), service.checkDue()], { concurrency: 2 }) + assert.strictEqual((a?.checked ?? 0) + (b?.checked ?? 0), 1) + assert.strictEqual(aws.calls.filter((call) => call === "AssumeRole").length, 1) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + + it.effect("an AWS outage after setup is recorded without failing the source", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const created = yield* service.create(orgId, userId, { accountId: account, modules: [] }) + aws.deploy(account, (yield* service.setup(orgId, created.id)).cloudformationTemplate) + yield* later(service.check(orgId, created.id)) + aws.failWith = { status: 400, code: "InvalidParameterValue" } + const checked = yield* later(service.check(orgId, created.id)) + assert.include(checked.lastCheckError ?? "", "AssumeRole") + const source = yield* sourceOf(testDb, created.id) + assert.isNull(source?.last_poll_error) + assert.isNull(source?.disconnected_at) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + + it.effect("a check within seconds of a good one answers with it; a pending one always asks", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const created = yield* service.create(orgId, userId, { accountId: account, modules: [] }) + yield* later(service.check(orgId, created.id)) + // Still pending: the stack may have been deployed a second ago. + aws.deploy(account, (yield* service.setup(orgId, created.id)).cloudformationTemplate) + const verified = yield* service.check(orgId, created.id) + assert.isNotNull(verified.verifiedAt) + const calls = aws.calls.length + yield* service.check(orgId, created.id) + assert.strictEqual(aws.calls.length, calls) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) +}) diff --git a/packages/backend/src/services/integrations/AwsConnectionService.ts b/packages/backend/src/services/integrations/AwsConnectionService.ts new file mode 100644 index 0000000000..d11f6e2876 --- /dev/null +++ b/packages/backend/src/services/integrations/AwsConnectionService.ts @@ -0,0 +1,616 @@ +import { randomBytes, randomUUID } from "node:crypto" +import * as PG from "@maple-dev/effect-orm/postgres" +import { AwsConnections, type AwsConnectionRow, Sources } from "@maple/db/tables" +import { + AWS_MODULE_SPECS, + AWS_ROLE_TAGS, + AWS_TEMPLATE_VERSION, + awsConnectionResourceNames, + awsModuleOfPolicyName, + awsRoleArn, + awsSetupPlan, + isAwsModuleAvailable, + parseAwsMaplePrincipal, +} from "@maple/domain/aws" +import { + AwsAccountAlreadyConnectedError, + AwsIntegrationUnavailableError, + IntegrationsNotFoundError, + IntegrationsPersistenceError, + IntegrationsValidationError, +} from "@maple/domain/http" +import { + AWS_MODULES, + type AwsAccountId, + AwsConnectionId, + type AwsModule, + type OrgId, + type UserId, +} from "@maple/domain/primitives" +import { Cause, Clock, Context, Effect, Layer, Option } from "effect" +import { FetchHttpClient, HttpClient } from "effect/http" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute, makePersistenceErrorMapper } from "@maple/backend/platform/db-execute" +import { Env } from "@maple/backend/platform/Env" +import { + createIntegrationSource, + deleteSourcesOf, + integrationSourceName, + type PollOutcome, + reportPoll, +} from "@maple/backend/services/org/integration-sources" +import { type AwsApiError, type AwsKey, checkRole } from "./aws/api" +import { renderAwsCleanupScript, renderAwsSetup } from "./aws/setup" + +export interface AwsConnection { + readonly id: AwsConnectionId + readonly accountId: AwsAccountId + readonly roleArn: string + readonly externalId: string + readonly modules: ReadonlyArray + /** What the latest check found on the role. Null until a check could assume it. */ + readonly grantedModules: ReadonlyArray | null + readonly templateVersion: number | null + /** The deployed setup differs from what the connection asks for. */ + readonly setupOutdated: boolean + readonly verifiedAt: number | null + readonly lastCheckedAt: number | null + readonly lastCheckError: string | null + readonly createdAt: number +} + +export interface AwsModuleInfo { + readonly module: AwsModule + readonly title: string + readonly summary: string + readonly available: boolean +} + +export interface AwsSetup { + readonly stackName: string + readonly stackRegion: string + readonly setupScript: string + readonly cleanupScript: string + readonly cloudformationTemplate: string + readonly terraform: string +} + +export interface AwsCheckSweep { + readonly due: number + readonly checked: number + readonly failures: number +} + +type ModulesError = IntegrationsValidationError + +export interface AwsConnectionServiceApi { + readonly status: (orgId: OrgId) => Effect.Effect< + { + /** False when this deployment has no AWS identity to assume roles with. */ + readonly available: boolean + readonly modules: ReadonlyArray + readonly connections: ReadonlyArray + }, + IntegrationsPersistenceError + > + readonly create: ( + orgId: OrgId, + userId: UserId, + input: { readonly accountId: AwsAccountId; readonly modules: ReadonlyArray }, + ) => Effect.Effect< + AwsConnection, + | AwsAccountAlreadyConnectedError + | AwsIntegrationUnavailableError + | ModulesError + | IntegrationsPersistenceError + > + /** AWS only follows once the stack is deployed again. */ + readonly update: ( + orgId: OrgId, + connectionId: AwsConnectionId, + patch: { readonly modules?: ReadonlyArray | undefined }, + ) => Effect.Effect + readonly setup: ( + orgId: OrgId, + connectionId: AwsConnectionId, + ) => Effect.Effect< + AwsSetup, + IntegrationsNotFoundError | AwsIntegrationUnavailableError | IntegrationsPersistenceError + > + /** Assumes the role now. A failed check is recorded on the connection, not failed with. */ + readonly check: ( + orgId: OrgId, + connectionId: AwsConnectionId, + ) => Effect.Effect< + AwsConnection, + IntegrationsNotFoundError | AwsIntegrationUnavailableError | IntegrationsPersistenceError + > + /** The hourly sweep: every connection not checked within the hour, except paused ones. */ + readonly checkDue: () => Effect.Effect + /** Deleting the row is the disconnect: Maple stops assuming the role. */ + readonly delete: ( + orgId: OrgId, + connectionId: AwsConnectionId, + ) => Effect.Effect< + { readonly connection: AwsConnection; readonly cleanupScript: string }, + IntegrationsNotFoundError | IntegrationsPersistenceError + > +} + +const toPersistenceError = makePersistenceErrorMapper( + IntegrationsPersistenceError, + "AWS connection database error", +) + +const notFound = () => new IntegrationsNotFoundError({ message: "No such AWS connection." }) + +const unavailable = () => + new AwsIntegrationUnavailableError({ message: "AWS cannot be connected on this Maple deployment." }) + +/** A sweep checks a connection once its last check is this old. */ +const CHECK_INTERVAL_MS = 55 * 60_000 +/** A claimed batch holds its rows for the lane's whole deadline; a stalled sweep's rest is retried. */ +const CHECK_LEASE_MS = 15 * 60_000 +/** A manual check this soon after a good one answers with it. */ +const MANUAL_CHECK_INTERVAL_MS = 5_000 +/** Connections one sweep claims, sized so a slow AWS still finishes inside the lane's deadline. */ +const SWEEP_LIMIT = 100 +/** + * A Worker allows six simultaneous connections, shared by the lane's Postgres pool and the AWS + * fetches; each check holds one of each at most. + */ +const SWEEP_CONCURRENCY = 2 + +const sameModules = (a: ReadonlyArray, b: ReadonlyArray) => + a.length === b.length && a.every((module) => b.includes(module)) + +/** Modules in their canonical order, without repeats. */ +const normalizeModules = (modules: ReadonlyArray) => + AWS_MODULES.filter((module) => modules.includes(module)) + +const toConnection = (row: AwsConnectionRow): AwsConnection => { + const names = awsConnectionResourceNames(row.id) + return { + id: row.id, + accountId: row.accountId, + roleArn: awsRoleArn(row.accountId, names.roleName), + externalId: row.externalId, + modules: row.modules, + grantedModules: row.grantedModules, + templateVersion: row.templateVersion, + setupOutdated: + row.grantedModules !== null && + (row.templateVersion !== AWS_TEMPLATE_VERSION || !sameModules(row.modules, row.grantedModules)), + verifiedAt: row.verifiedAt, + lastCheckedAt: row.lastCheckedAt, + lastCheckError: row.lastCheckError, + createdAt: row.createdAt, + } +} + +const MODULE_INFO: ReadonlyArray = AWS_MODULES.map((module) => ({ + module, + title: AWS_MODULE_SPECS[module].title, + summary: AWS_MODULE_SPECS[module].summary, + available: isAwsModuleAvailable(module), +})) + +const validateModules = ( + modules: ReadonlyArray, +): Effect.Effect, ModulesError> => { + const planned = modules.filter((module) => !isAwsModuleAvailable(module)) + return planned.length === 0 + ? Effect.succeed(normalizeModules(modules)) + : Effect.fail( + new IntegrationsValidationError({ + message: `Maple does not read ${planned.join(", ")} from AWS yet.`, + }), + ) +} + +interface CheckResult { + readonly changes: Partial + /** Null: nothing to report on the source, the setup is still being done. */ + readonly outcome: PollOutcome | null +} + +/** What one check means for the row and its source. */ +export const interpretCheck = ( + row: AwsConnectionRow, + result: + | { + readonly _tag: "Ok" + readonly tags: ReadonlyArray<{ readonly key: string; readonly value: string }> + readonly attachedPolicyNames: ReadonlyArray + } + | { readonly _tag: "Failed"; readonly error: AwsApiError }, + now: number, +): CheckResult => { + const roleName = awsConnectionResourceNames(row.id).roleName + if (result._tag === "Ok") { + const version = Number(result.tags.find((tag) => tag.key === AWS_ROLE_TAGS.templateVersion)?.value) + const granted = normalizeModules( + result.attachedPolicyNames.flatMap((name) => { + const module = awsModuleOfPolicyName(roleName, name) + return module === undefined ? [] : [module] + }), + ) + const missing = row.modules.filter((module) => !granted.includes(module)) + const error = + missing.length === 0 + ? null + : `The role does not grant ${missing.join(", ")} yet: run the setup script again.` + return { + changes: { + verifiedAt: row.verifiedAt ?? now, + lastCheckedAt: now, + lastCheckError: error, + grantedModules: granted, + templateVersion: Number.isInteger(version) ? version : null, + }, + outcome: error === null ? { _tag: "Ok" } : { _tag: "Failed", error }, + } + } + const { error } = result + const base = { lastCheckedAt: now } + switch (error.kind) { + case "assume_denied": { + // Before the first success the setup is still being done: the source stays pending. + if (row.verifiedAt === null) { + return { + changes: { + ...base, + lastCheckError: + "Maple cannot assume the role yet. Run the setup script in AWS CloudShell, signed in to this account.", + }, + outcome: null, + } + } + const reason = + "Maple can no longer assume the role: its stack was deleted or its trust policy changed. Run the setup script again." + return { changes: { ...base, lastCheckError: reason }, outcome: { _tag: "Disconnected", reason } } + } + case "read_denied": { + const message = + "Maple assumed the role but it cannot read its own policies. Run the setup script again to repair it." + return { + changes: { ...base, lastCheckError: message }, + outcome: row.verifiedAt === null ? null : { _tag: "Failed", error: message }, + } + } + case "maple_credentials": { + const message = + "AWS rejected Maple's own credentials. This is on Maple's side; nothing to change in AWS." + return { + changes: { ...base, lastCheckError: message }, + outcome: row.verifiedAt === null ? null : { _tag: "Failed", error: message }, + } + } + // AWS itself, not the setup: the source keeps its last report, and goes stale if it lasts. + case "throttled": + case "upstream": + return { changes: { ...base, lastCheckError: error.message }, outcome: null } + } +} + +export class AwsConnectionService extends Context.Service()( + "@maple/backend/services/AwsConnectionService", + { + make: Effect.gen(function* () { + const database = yield* Database + const env = yield* Env + const httpClient = yield* HttpClient.HttpClient + const dbExecute = makeDbExecute(database, "AwsConnectionService", toPersistenceError) + + // All three or nothing: a principal without a key could hand out setups nothing checks. + const maple = Option.all({ + principalArn: env.MAPLE_AWS_PRINCIPAL_ARN, + accessKeyId: env.MAPLE_AWS_ACCESS_KEY_ID, + secretAccessKey: env.MAPLE_AWS_SECRET_ACCESS_KEY, + }).pipe( + Option.flatMap(({ principalArn, accessKeyId, secretAccessKey }) => + Option.map( + Option.fromUndefinedOr(parseAwsMaplePrincipal(principalArn.trim())), + (principal) => ({ + principal, + key: { accessKeyId: accessKeyId.trim(), secretAccessKey } satisfies AwsKey, + }), + ), + ), + Option.getOrUndefined, + ) + if (maple === undefined && Option.isSome(env.MAPLE_AWS_PRINCIPAL_ARN)) { + yield* Effect.logWarning( + "AWS integration disabled: MAPLE_AWS_PRINCIPAL_ARN is not an IAM user or role ARN, or its access key is unset", + ) + } + + const selectRow = (orgId: OrgId, connectionId: AwsConnectionId) => + dbExecute((db) => + db.run( + PG.from(AwsConnections) + .select() + .where(($) => [$.orgId.eq(orgId), $.id.eq(connectionId)]) + .limit(1), + ), + ).pipe( + Effect.flatMap(([row]) => + row === undefined ? Effect.fail(notFound()) : Effect.succeed(row), + ), + ) + + const status = Effect.fn("AwsConnectionService.status")(function* (orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + const rows = yield* dbExecute((db) => + db.run( + PG.from(AwsConnections) + .select() + .where(($) => [$.orgId.eq(orgId)]) + .orderBy(["createdAt", "asc"], ["id", "asc"]), + ), + ) + return { + available: maple !== undefined, + modules: MODULE_INFO, + connections: rows.map(toConnection), + } + }) + + const create = Effect.fn("AwsConnectionService.create")(function* ( + orgId: OrgId, + userId: UserId, + input: { readonly accountId: AwsAccountId; readonly modules: ReadonlyArray }, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.aws.account_id": input.accountId }) + if (maple === undefined) return yield* unavailable() + const modules = yield* validateModules(input.modules) + const id = AwsConnectionId.make(randomUUID()) + const now = yield* Clock.currentTimeMillis + // The connection and the source it sends as land together. + const [row] = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + const sourceId = yield* createIntegrationSource( + db, + orgId, + { platform: "aws", name: integrationSourceName.aws(input) }, + now, + ) + const inserted = yield* db.run( + PG.insertInto(AwsConnections) + .values({ + id, + orgId, + sourceId, + accountId: input.accountId, + externalId: randomBytes(16).toString("hex"), + modules, + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ target: ["orgId", "accountId"] }) + .returning(), + ) + // An account that is already connected keeps no source of its own. + if (inserted.length === 0) yield* deleteSourcesOf(db, orgId, [{ sourceId }]) + return inserted + }), + ), + ) + if (row === undefined) { + return yield* new AwsAccountAlreadyConnectedError({ + accountId: input.accountId, + message: `The AWS account ${input.accountId} is already connected.`, + }) + } + return toConnection(row) + }) + + const update = Effect.fn("AwsConnectionService.update")(function* ( + orgId: OrgId, + connectionId: AwsConnectionId, + patch: { readonly modules?: ReadonlyArray | undefined }, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.aws.connection_id": connectionId }) + const modules = + patch.modules === undefined ? undefined : yield* validateModules(patch.modules) + if (modules === undefined) return toConnection(yield* selectRow(orgId, connectionId)) + const updatedAt = yield* Clock.currentTimeMillis + const [row] = yield* dbExecute((db) => + db.run( + PG.update(AwsConnections) + .set({ modules, updatedAt }) + .where(($) => [$.orgId.eq(orgId), $.id.eq(connectionId)]) + .returning(), + ), + ) + if (row === undefined) return yield* notFound() + return toConnection(row) + }) + + const setup = Effect.fn("AwsConnectionService.setup")(function* ( + orgId: OrgId, + connectionId: AwsConnectionId, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.aws.connection_id": connectionId }) + if (maple === undefined) return yield* unavailable() + const row = yield* selectRow(orgId, connectionId) + const plan = awsSetupPlan({ + connectionId: row.id, + accountId: row.accountId, + externalId: row.externalId, + maplePrincipal: maple.principal, + modules: row.modules, + }) + return yield* renderAwsSetup(row.id, row.accountId, plan) + }) + + /** Assumes the row's role and writes what it found, on the row and on its source. */ + const runCheck = (key: AwsKey, row: AwsConnectionRow) => + Effect.gen(function* () { + const names = awsConnectionResourceNames(row.id) + const result = yield* checkRole(httpClient, key, { + roleArn: awsRoleArn(row.accountId, names.roleName), + roleName: names.roleName, + externalId: row.externalId, + sessionName: names.sessionName, + }).pipe( + Effect.map((report) => ({ _tag: "Ok" as const, ...report })), + Effect.catchTag("@maple/backend/integrations/AwsApiError", (error) => + Effect.succeed({ _tag: "Failed" as const, error }), + ), + ) + const now = yield* Clock.currentTimeMillis + const { changes, outcome } = interpretCheck(row, result, now) + const [updated] = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + const rows = yield* db.run( + PG.update(AwsConnections) + .set({ ...changes, updatedAt: now }) + .where(($) => [$.id.eq(row.id)]) + .returning(), + ) + if (outcome !== null) yield* reportPoll(db, row.sourceId, outcome, now) + return rows + }), + ), + ) + yield* Effect.annotateCurrentSpan({ + "maple.aws.check_outcome": result._tag === "Ok" ? "ok" : result.error.kind, + }) + // Deleted while AWS answered: there is nothing left to report on. + if (updated === undefined) return yield* notFound() + return { row: updated, ok: result._tag === "Ok" } + }) + + const check = Effect.fn("AwsConnectionService.check")(function* ( + orgId: OrgId, + connectionId: AwsConnectionId, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.aws.connection_id": connectionId }) + if (maple === undefined) return yield* unavailable() + const row = yield* selectRow(orgId, connectionId) + // Every check spends Maple's one key, whose STS limits all tenants share: a click within + // seconds of a good check answers with it. A pending one always asks: the stack may be new. + const now = yield* Clock.currentTimeMillis + const recent = row.lastCheckedAt !== null && now - row.lastCheckedAt < MANUAL_CHECK_INTERVAL_MS + if (recent && row.verifiedAt !== null && row.lastCheckError === null) { + return toConnection(row) + } + return toConnection((yield* runCheck(maple.key, row)).row) + }) + + const checkDue = Effect.fn("AwsConnectionService.checkDue")(function* () { + if (maple === undefined) return { due: 0, checked: 0, failures: 0 } + const now = yield* Clock.currentTimeMillis + // A paused source is left out here, so it neither reads AWS nor holds a place in the limit. + const due = yield* dbExecute((db) => + db.run( + PG.from(AwsConnections) + .innerJoin(Sources, "s", (connection, source) => + connection.sourceId.eq(source.id), + ) + .select(($) => ({ id: $.id, lastCheckedAt: $.lastCheckedAt })) + .where(($) => [ + PG.or($.lastCheckedAt.isNull(), $.lastCheckedAt.lt(now - CHECK_INTERVAL_MS)), + PG.or($.checkLeaseUntil.isNull(), $.checkLeaseUntil.lt(now)), + PG.or( + $.s.pausedAt.isNull(), + PG.and($.s.pausedUntil.isNotNull(), $.s.pausedUntil.lte(now)), + ), + ]) + .orderBy(["lastCheckedAt", "asc"], ["id", "asc"]) + .limit(SWEEP_LIMIT), + ), + ) + // One conditional write claims the batch: rows a concurrent sweep holds are left out. + const claimed = + due.length === 0 + ? [] + : yield* dbExecute((db) => + db.run( + PG.update(AwsConnections) + .set({ checkLeaseUntil: now + CHECK_LEASE_MS }) + .where(($) => [ + PG.inList( + $.id, + due.map((row) => row.id), + ), + PG.or($.checkLeaseUntil.isNull(), $.checkLeaseUntil.lt(now)), + ]) + .returning(), + ), + ) + const results = yield* Effect.forEach( + claimed, + (row) => + runCheck(maple.key, row).pipe( + Effect.map(({ ok }) => ok), + Effect.withSpan("AwsConnectionService.checkDue.connection", { + attributes: { orgId: row.orgId, "maple.aws.connection_id": row.id }, + }), + // One connection's failure, expected or not, is that connection's alone. + Effect.catchCause((cause) => + Effect.logWarning("AWS connection check failed").pipe( + Effect.annotateLogs({ + "maple.aws.connection_id": row.id, + cause: Cause.pretty(cause), + }), + Effect.as(false), + ), + ), + ), + { concurrency: SWEEP_CONCURRENCY }, + ) + return { + due: due.length, + checked: results.length, + failures: results.filter((ok) => !ok).length, + } + }) + + const deleteConnection = Effect.fn("AwsConnectionService.delete")(function* ( + orgId: OrgId, + connectionId: AwsConnectionId, + ) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.aws.connection_id": connectionId }) + const current = yield* selectRow(orgId, connectionId) + // Rendered first: once the row is gone, a failed render would lose the only cleanup. + const cleanupScript = yield* renderAwsCleanupScript({ + accountId: current.accountId, + stackName: awsConnectionResourceNames(current.id).stackName, + }) + // Its source goes with it. + const [row] = yield* dbExecute((db) => + db.transaction( + Effect.gen(function* () { + const deleted = yield* db.run( + PG.deleteFrom(AwsConnections) + .where(($) => [$.orgId.eq(orgId), $.id.eq(connectionId)]) + .returning(), + ) + yield* deleteSourcesOf(db, orgId, deleted, "aws") + return deleted + }), + ), + ) + if (row === undefined) return yield* notFound() + return { connection: toConnection(row), cleanupScript } + }) + + return { + status, + create, + update, + setup, + check, + checkDue, + delete: deleteConnection, + } satisfies AwsConnectionServiceApi + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/packages/backend/src/services/integrations/aws/api-impl.ts b/packages/backend/src/services/integrations/aws/api-impl.ts new file mode 100644 index 0000000000..a19d867427 --- /dev/null +++ b/packages/backend/src/services/integrations/aws/api-impl.ts @@ -0,0 +1,167 @@ +/** + * AWS access through `@distilled.cloud/aws`: Maple's own key assumes a customer role, and the role + * reads itself back. Every SDK failure is folded into one {@link AwsApiError} whose `kind` says what + * the connection check reports. + */ +import * as Credentials from "@distilled.cloud/aws/Credentials" +import { UnknownAwsError } from "@distilled.cloud/aws/Errors" +import * as IAM from "@distilled.cloud/aws/iam" +import * as STS from "@distilled.cloud/aws/sts" +import { Duration, Effect, Redacted, Stream } from "effect" +import { HttpClient } from "effect/http" +import { AwsApiError } from "./errors" + +const REQUEST_TIMEOUT = Duration.seconds(20) +// STS and IAM answer in us-east-1 for every commercial account. +const REGION = "us-east-1" +// The shortest session AWS allows: a check needs seconds, and nothing keeps the credentials. +const SESSION_SECONDS = 900 + +export interface AwsKey { + readonly accessKeyId: string + readonly secretAccessKey: Redacted.Redacted +} + +export interface AwsRoleTarget { + readonly roleArn: string + readonly roleName: string + readonly externalId: string + readonly sessionName: string +} + +export interface AwsRoleReport { + /** The account of the session AWS handed out. */ + readonly accountId: string + readonly tags: ReadonlyArray<{ readonly key: string; readonly value: string }> + readonly attachedPolicyNames: ReadonlyArray +} + +// Codes AWS answers a rejected caller key with, as opposed to a role that refuses it. +const MAPLE_KEY_CODES = new Set([ + "InvalidClientTokenId", + "SignatureDoesNotMatch", + "UnrecognizedClientException", + "IncompleteSignature", +]) +const THROTTLE_CODES = new Set(["Throttling", "ThrottlingException", "RequestLimitExceeded"]) + +const codeOf = (error: { readonly _tag: string }) => + error instanceof UnknownAwsError ? error.errorTag : error._tag + +const messageOf = (error: unknown) => + typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : undefined + +const toApiError = + (denied: "assume_denied" | "read_denied", what: string) => + (error: { readonly _tag: string }): AwsApiError => { + const code = codeOf(error) + const detail = messageOf(error)?.slice(0, 300) + const message = detail === undefined || detail === "" ? `${what}: ${code}` : `${what}: ${detail}` + if (MAPLE_KEY_CODES.has(code)) return new AwsApiError({ message, kind: "maple_credentials", code }) + if (THROTTLE_CODES.has(code)) return new AwsApiError({ message, kind: "throttled", code }) + if (code === "AccessDenied" || code === "AccessDeniedException" || code === "NoSuchEntityException") { + return new AwsApiError({ message, kind: denied, code }) + } + return new AwsApiError({ message, kind: "upstream", code }) + } + +/** What an operation signs with: fixed credentials, in the region STS and IAM answer in. */ +const signWith = (credentials: { + readonly accessKeyId: Redacted.Redacted + readonly secretAccessKey: Redacted.Redacted + readonly sessionToken?: Redacted.Redacted +}): Credentials.Credentials["Service"] => + Effect.succeed({ + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + region: REGION, + }) + +const timedOut = (what: string) => + new AwsApiError({ message: `${what}: AWS did not answer in time`, kind: "upstream" }) + +/** + * Assumes `target` with Maple's key, then reads the role's tags and attached policies with the + * session. Each read needs only the setup's self-read policy. + */ +export const checkRole = Effect.fn("AwsApi.checkRole")(function* ( + httpClient: HttpClient.HttpClient, + key: AwsKey, + target: AwsRoleTarget, +) { + yield* Effect.annotateCurrentSpan({ "maple.aws.role_arn": target.roleArn }) + const assumed = yield* STS.assumeRole({ + RoleArn: target.roleArn, + RoleSessionName: target.sessionName, + ExternalId: target.externalId, + DurationSeconds: SESSION_SECONDS, + }).pipe( + Effect.provideService( + Credentials.Credentials, + signWith({ accessKeyId: Redacted.make(key.accessKeyId), secretAccessKey: key.secretAccessKey }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.timeoutOrElse({ + duration: REQUEST_TIMEOUT, + orElse: () => Effect.fail(timedOut("AssumeRole")), + }), + Effect.mapError((error) => + error instanceof AwsApiError ? error : toApiError("assume_denied", "AssumeRole")(error), + ), + ) + const credentials = assumed.Credentials + const sessionArn = assumed.AssumedRoleUser?.Arn + if (credentials === undefined || sessionArn === undefined) { + return yield* new AwsApiError({ message: "AssumeRole returned no credentials", kind: "upstream" }) + } + const session = signWith({ + accessKeyId: Redacted.make(credentials.AccessKeyId), + secretAccessKey: Redacted.isRedacted(credentials.SecretAccessKey) + ? credentials.SecretAccessKey + : Redacted.make(credentials.SecretAccessKey), + sessionToken: Redacted.make(credentials.SessionToken), + }) + const asRole = ( + what: string, + effect: Effect.Effect, + ) => + effect.pipe( + Effect.timeoutOrElse({ duration: REQUEST_TIMEOUT, orElse: () => Effect.fail(timedOut(what)) }), + Effect.mapError((error) => + error instanceof AwsApiError ? error : toApiError("read_denied", what)(error), + ), + ) + const [role, attached] = yield* Effect.all( + [ + asRole( + "GetRole", + IAM.getRole({ RoleName: target.roleName }).pipe( + Effect.provideService(Credentials.Credentials, session), + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + ), + asRole( + "ListAttachedRolePolicies", + IAM.listAttachedRolePolicies + .items({ RoleName: target.roleName }) + .pipe( + Stream.runCollect, + Effect.provideService(Credentials.Credentials, session), + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + ), + ], + // One at a time: a sweep runs several checks, and each fetch holds one of the Worker's connections. + { concurrency: 1 }, + ) + return { + accountId: /^arn:aws:sts::([0-9]{12}):/u.exec(sessionArn)?.[1] ?? "", + tags: (role.Role.Tags ?? []).map((tag) => ({ key: tag.Key, value: tag.Value })), + attachedPolicyNames: attached.flatMap((policy) => + policy.PolicyName === undefined ? [] : [policy.PolicyName], + ), + } satisfies AwsRoleReport +}) diff --git a/packages/backend/src/services/integrations/aws/api.ts b/packages/backend/src/services/integrations/aws/api.ts new file mode 100644 index 0000000000..70d6ca4dc8 --- /dev/null +++ b/packages/backend/src/services/integrations/aws/api.ts @@ -0,0 +1,13 @@ +/** + * Lazy facade over {@link Impl}, which owns the `@distilled.cloud/aws` SDK. Only the AWS connection + * check touches it, so it loads on first call instead of with every request graph. + */ +import { Effect } from "effect" +import type * as Impl from "./api-impl" + +export { AwsApiError } from "./errors" +export type { AwsKey, AwsRoleReport, AwsRoleTarget } from "./api-impl" + +const impl = Effect.promise(() => import("./api-impl")) + +export const checkRole: typeof Impl.checkRole = (...args) => Effect.flatMap(impl, (m) => m.checkRole(...args)) diff --git a/packages/backend/src/services/integrations/aws/errors.ts b/packages/backend/src/services/integrations/aws/errors.ts new file mode 100644 index 0000000000..0f1390f409 --- /dev/null +++ b/packages/backend/src/services/integrations/aws/errors.ts @@ -0,0 +1,17 @@ +import { Schema } from "effect" + +/** + * One failed AWS call, folded to what the connection check does about it. `assume_denied`: the role + * does not exist or does not trust Maple with this external id. `read_denied`: Maple assumed the + * role but it cannot read itself (an edited or partial setup). `maple_credentials`: AWS rejected + * Maple's own key, a deployment problem rather than the customer's. + */ +export class AwsApiError extends Schema.TaggedError()( + "@maple/backend/integrations/AwsApiError", + { + message: Schema.String, + kind: Schema.Literals(["assume_denied", "read_denied", "maple_credentials", "throttled", "upstream"]), + /** The AWS error code, when AWS sent one. */ + code: Schema.optionalKey(Schema.String), + }, +) {} diff --git a/packages/backend/src/services/integrations/aws/fake-aws.ts b/packages/backend/src/services/integrations/aws/fake-aws.ts new file mode 100644 index 0000000000..4d84ba591d --- /dev/null +++ b/packages/backend/src/services/integrations/aws/fake-aws.ts @@ -0,0 +1,214 @@ +/** + * A small AWS for tests: STS AssumeRole and the two IAM reads the connection check makes, answered + * from roles "deployed" by interpreting a rendered CloudFormation template. Trust is enforced the + * way AWS does it (principal, external id, signing key), so a test proves that the template Maple + * renders is the one its check accepts. + */ +import { Effect, Schema } from "effect" +import { HttpClient, HttpClientResponse } from "effect/http" + +export const MAPLE_ACCESS_KEY_ID = "AKIAMAPLETEST0000001" +export const MAPLE_PRINCIPAL_ARN = "arn:aws:iam::999999999999:user/maple-integrations" + +interface FakeRole { + readonly accountId: string + readonly roleName: string + readonly trustedPrincipalArn: string + readonly externalId: string + readonly tags: ReadonlyArray<{ readonly Key: string; readonly Value: string }> + readonly attachedPolicyNames: ReadonlyArray + /** Whether the role may read itself (the template's self-read policy). */ + readonly canReadSelf: boolean +} + +const Template = Schema.Struct({ + Resources: Schema.Record( + Schema.String, + Schema.Struct({ Type: Schema.String, Properties: Schema.Record(Schema.String, Schema.Unknown) }), + ), +}) + +const TrustDocument = Schema.Struct({ + Statement: Schema.Array( + Schema.Struct({ + Condition: Schema.Struct({ + StringEquals: Schema.Struct({ "sts:ExternalId": Schema.String }), + ArnEquals: Schema.Struct({ "aws:PrincipalArn": Schema.String }), + }), + }), + ), +}) + +const RoleProperties = Schema.Struct({ + RoleName: Schema.String, + AssumeRolePolicyDocument: TrustDocument, + ManagedPolicyArns: Schema.Array(Schema.Union([Schema.String, Schema.Struct({ Ref: Schema.String })])), + Policies: Schema.Array(Schema.Struct({ PolicyName: Schema.String })), + Tags: Schema.Array(Schema.Struct({ Key: Schema.String, Value: Schema.String })), +}) + +const PolicyProperties = Schema.Struct({ ManagedPolicyName: Schema.String }) + +/** The role a template creates, as `aws cloudformation deploy` would in `accountId`. */ +export const deployTemplate = (accountId: string, templateJson: string): FakeRole => { + const template = Schema.decodeUnknownSync(Template)(JSON.parse(templateJson)) + const resources = template.Resources + const role = Object.values(resources).find((resource) => resource.Type === "AWS::IAM::Role") + if (role === undefined) throw new Error("template has no role") + const properties = Schema.decodeUnknownSync(RoleProperties)(role.Properties) + const [statement] = properties.AssumeRolePolicyDocument.Statement + if (statement === undefined) throw new Error("role has no trust statement") + const attachedPolicyNames = properties.ManagedPolicyArns.map((arn) => { + if (typeof arn === "string") return arn.split("/").at(-1) ?? arn + const policy = resources[arn.Ref] + if (policy === undefined) throw new Error(`no resource ${arn.Ref}`) + return Schema.decodeUnknownSync(PolicyProperties)(policy.Properties).ManagedPolicyName + }) + return { + accountId, + roleName: properties.RoleName, + trustedPrincipalArn: statement.Condition.ArnEquals["aws:PrincipalArn"], + externalId: statement.Condition.StringEquals["sts:ExternalId"], + tags: properties.Tags, + attachedPolicyNames, + canReadSelf: properties.Policies.some((policy) => policy.PolicyName === "maple-self-read"), + } +} + +const xml = (status: number, body: string) => + new Response(body, { status, headers: { "content-type": "text/xml" } }) + +const stsError = (status: number, code: string, message: string) => + xml( + status, + `Sender${code}${message}req`, + ) + +const iamError = (status: number, code: string, message: string) => + xml( + status, + `Sender${code}${message}req`, + ) + +const decoder = new TextDecoder() + +/** The signing key id of a SigV4 request: `Credential=//...`. */ +const signingKey = (authorization: string | undefined) => + /Credential=([^/]+)\//u.exec(authorization ?? "")?.[1] + +export interface FakeAws { + readonly client: HttpClient.HttpClient + /** Every call, as ``. */ + readonly calls: Array + readonly deploy: (accountId: string, templateJson: string) => void + /** Deletes every role of an account, as deleting its stack would. */ + readonly deleteStacks: (accountId: string) => void + /** Answers every call with this error instead, as AWS does when it throttles or fails. */ + failWith: { readonly status: number; readonly code: string } | undefined +} + +/** `mapleKeyId` signs as `maplePrincipalArn`: the only identity a deployed role may trust. */ +export const makeFakeAws = ( + mapleKeyId: string = MAPLE_ACCESS_KEY_ID, + maplePrincipalArn: string = MAPLE_PRINCIPAL_ARN, +): FakeAws => { + const roles = new Map() + // Session key id → the role it is a session of. + const sessions = new Map() + const calls: Array = [] + const state: FakeAws = { + calls, + failWith: undefined, + deploy: (accountId, templateJson) => { + const role = deployTemplate(accountId, templateJson) + roles.set(`arn:aws:iam::${accountId}:role/${role.roleName}`, role) + }, + deleteStacks: (accountId) => { + for (const [arn, role] of roles) if (role.accountId === accountId) roles.delete(arn) + }, + client: HttpClient.make((request) => + Effect.sync(() => { + const body = request.body._tag === "Uint8Array" ? decoder.decode(request.body.body) : "" + const form = new URLSearchParams(body) + const action = form.get("Action") ?? "" + calls.push(action) + const respond = (response: Response) => HttpClientResponse.fromWeb(request, response) + if (state.failWith !== undefined) { + return respond(stsError(state.failWith.status, state.failWith.code, "fake failure")) + } + const key = signingKey(request.headers["authorization"]) + if (action === "AssumeRole") { + if (key !== mapleKeyId) { + return respond( + stsError(403, "InvalidClientTokenId", "The security token is invalid."), + ) + } + const roleArn = form.get("RoleArn") ?? "" + const role = roles.get(roleArn) + if ( + role === undefined || + role.trustedPrincipalArn !== maplePrincipalArn || + role.externalId !== form.get("ExternalId") + ) { + return respond( + stsError( + 403, + "AccessDenied", + `User is not authorized to perform: sts:AssumeRole on resource: ${roleArn}`, + ), + ) + } + const sessionKey = `ASIA${String(sessions.size).padStart(16, "0")}` + sessions.set(sessionKey, role) + const session = form.get("RoleSessionName") ?? "" + return respond( + xml( + 200, + `${sessionKey}secrettoken2030-01-01T00:00:00ZAROAFAKE:${session}arn:aws:sts::${role.accountId}:assumed-role/${role.roleName}/${session}req`, + ), + ) + } + const role = key === undefined ? undefined : sessions.get(key) + const stillDeployed = + role !== undefined && roles.has(`arn:aws:iam::${role.accountId}:role/${role.roleName}`) + if ( + role === undefined || + !stillDeployed || + request.headers["x-amz-security-token"] !== "token" + ) { + return respond(iamError(403, "InvalidClientTokenId", "The security token is invalid.")) + } + if (!role.canReadSelf || form.get("RoleName") !== role.roleName) { + return respond(iamError(403, "AccessDenied", `not authorized to perform: iam:${action}`)) + } + if (action === "GetRole") { + const tags = role.tags + .map((tag) => `${tag.Key}${tag.Value}`) + .join("") + return respond( + xml( + 200, + `/${role.roleName}AROAFAKEarn:aws:iam::${role.accountId}:role/${role.roleName}2026-10-21T12:00:00Z${tags}req`, + ), + ) + } + if (action === "ListAttachedRolePolicies") { + const policies = role.attachedPolicyNames + .map( + (name) => + `${name}arn:aws:iam::${role.accountId}:policy/${name}`, + ) + .join("") + return respond( + xml( + 200, + `${policies}falsereq`, + ), + ) + } + return respond(iamError(400, "InvalidAction", `unknown action ${action}`)) + }), + ), + } + return state +} diff --git a/packages/backend/src/services/integrations/aws/setup.ts b/packages/backend/src/services/integrations/aws/setup.ts new file mode 100644 index 0000000000..85e787f489 --- /dev/null +++ b/packages/backend/src/services/integrations/aws/setup.ts @@ -0,0 +1,188 @@ +import { AWS_TEMPLATE_VERSION, type AwsSetupPlan } from "@maple/domain/aws" +import type { AwsAccountId, AwsConnectionId } from "@maple/domain/primitives" +import { Effect } from "effect" +import { forPaste } from "../paste-script" + +const DOCS_URL = "https://maple.dev/docs/integrations/aws" +/** + * Stacks live in a region while IAM does not: one fixed region lets the cleanup find the stack + * whatever region CloudShell opened in, and a re-run update it instead of colliding on the role. + */ +export const AWS_STACK_REGION = "us-east-1" + +/** CloudFormation logical id of a module's policy: `ObservabilityPolicy`. */ +const policyLogicalId = (module: string) => `${module[0]?.toUpperCase() ?? ""}${module.slice(1)}Policy` + +/** The stack as a CloudFormation template (JSON, which CloudFormation reads as it reads YAML). */ +export const renderAwsCloudFormation = (connectionId: AwsConnectionId, plan: AwsSetupPlan) => { + const policies = Object.fromEntries( + plan.modules.map((module) => [ + policyLogicalId(module.module), + { + Type: "AWS::IAM::ManagedPolicy", + Properties: { + ManagedPolicyName: module.policyName, + Description: `Maple ${module.module} access`, + PolicyDocument: module.policy, + }, + }, + ]), + ) + const template = { + AWSTemplateFormatVersion: "2010-09-09", + Description: `Maple integration ${connectionId} (setup v${AWS_TEMPLATE_VERSION}): a read-only role Maple assumes. ${DOCS_URL}`, + Resources: { + ...policies, + MapleRole: { + Type: "AWS::IAM::Role", + Properties: { + RoleName: plan.roleName, + Description: "Assumed by Maple to read this account", + AssumeRolePolicyDocument: plan.trustPolicy, + MaxSessionDuration: 3600, + ManagedPolicyArns: [ + ...new Set(plan.modules.flatMap((module) => module.managedPolicies)), + ...plan.modules.map((module) => ({ Ref: policyLogicalId(module.module) })), + ], + Policies: [{ PolicyName: "maple-self-read", PolicyDocument: plan.selfReadPolicy }], + Tags: plan.tags, + }, + }, + }, + Outputs: { + RoleArn: { Description: "The role Maple assumes", Value: { "Fn::GetAtt": ["MapleRole", "Arn"] } }, + }, + } + return `${JSON.stringify(template, null, 2)}\n` +} + +export interface AwsScriptTarget { + readonly accountId: AwsAccountId + readonly stackName: string +} + +// Every value interpolated below is pattern-checked (account id) or derived from a UUID (stack +// name), and the template is JSON, so nothing needs shell quoting inside the quoted here-document. +const accountGuard = ( + accountId: AwsAccountId, +) => `account="$(aws sts get-caller-identity --query Account --output text)" +if [ "$account" != "${accountId}" ]; then + echo "This shell is signed in to AWS account $account, not ${accountId}." >&2 + echo "Open CloudShell from the AWS console of account ${accountId} and paste this again." >&2 + exit 1 +fi +` + +/** + * The script an administrator pastes into AWS CloudShell. It deploys the stack, which converges: a + * re-run after a module change updates the role and its policies in place. + */ +export const renderAwsSetupScript = (target: AwsScriptTarget, template: string) => + forPaste( + "AWS", + "MAPLE_SETUP_SCRIPT", + `set -euo pipefail +${accountGuard(target.accountId)}template="$(mktemp)" +cat > "$template" <<'MAPLE_TEMPLATE' +${template}MAPLE_TEMPLATE +# A first deploy that failed leaves a stack that can only be deleted, not updated. +status="$(aws cloudformation describe-stacks --region ${AWS_STACK_REGION} --stack-name ${target.stackName} --query 'Stacks[0].StackStatus' --output text 2>/dev/null || true)" +if [ "$status" = "ROLLBACK_COMPLETE" ]; then + echo "Removing the stack a failed deploy left behind..." + aws cloudformation delete-stack --region ${AWS_STACK_REGION} --stack-name ${target.stackName} + aws cloudformation wait stack-delete-complete --region ${AWS_STACK_REGION} --stack-name ${target.stackName} +fi +echo "Deploying ${target.stackName} to account ${target.accountId}..." +aws cloudformation deploy \\ + --region ${AWS_STACK_REGION} \\ + --stack-name ${target.stackName} \\ + --template-file "$template" \\ + --capabilities CAPABILITY_NAMED_IAM \\ + --no-fail-on-empty-changeset +rm -f "$template" +echo "Done. Go back to Maple and select Check connection. Details: ${DOCS_URL}" +`, + ) + +export const renderAwsCleanupScript = (target: AwsScriptTarget) => + forPaste( + "AWS", + "MAPLE_CLEANUP_SCRIPT", + `set -euo pipefail +${accountGuard(target.accountId)}echo "Deleting ${target.stackName} from account ${target.accountId}..." +aws cloudformation delete-stack --region ${AWS_STACK_REGION} --stack-name ${target.stackName} +aws cloudformation wait stack-delete-complete --region ${AWS_STACK_REGION} --stack-name ${target.stackName} +echo "Done. The role Maple used is gone." +`, + ) + +const hcl = (value: unknown) => JSON.stringify(value) + +/** The same setup as a self-contained Terraform file for the `aws` provider. */ +export const renderAwsTerraform = (connectionId: AwsConnectionId, plan: AwsSetupPlan) => { + const modulePolicies = plan.modules + .map( + (module) => ` +resource "aws_iam_policy" "maple_${module.module}" { + name = ${hcl(module.policyName)} + policy = jsonencode(${hcl(module.policy)}) +} + +resource "aws_iam_role_policy_attachment" "maple_${module.module}" { + role = aws_iam_role.maple.name + policy_arn = aws_iam_policy.maple_${module.module}.arn +} +`, + ) + .join("") + const managed = [...new Set(plan.modules.flatMap((module) => module.managedPolicies))] + .map( + (arn, index) => ` +resource "aws_iam_role_policy_attachment" "maple_managed_${index}" { + role = aws_iam_role.maple.name + policy_arn = ${hcl(arn)} +} +`, + ) + .join("") + return `# Maple integration ${connectionId} (setup v${AWS_TEMPLATE_VERSION}): a read-only role Maple assumes. +# Apply it with credentials for the account in the role ARN below. +# Details: ${DOCS_URL} + +resource "aws_iam_role" "maple" { + name = ${hcl(plan.roleName)} + description = "Assumed by Maple to read this account" + max_session_duration = 3600 + assume_role_policy = jsonencode(${hcl(plan.trustPolicy)}) + tags = ${hcl(Object.fromEntries(plan.tags.map((tag) => [tag.Key, tag.Value])))} +} + +resource "aws_iam_role_policy" "maple_self_read" { + name = "maple-self-read" + role = aws_iam_role.maple.id + policy = jsonencode(${hcl(plan.selfReadPolicy)}) +} +${modulePolicies}${managed} +output "maple_role_arn" { + value = aws_iam_role.maple.arn +} +` +} + +/** Every rendering of one connection's setup. */ +export const renderAwsSetup = Effect.fn("renderAwsSetup")(function* ( + connectionId: AwsConnectionId, + accountId: AwsAccountId, + plan: AwsSetupPlan, +) { + const target = { accountId, stackName: plan.stackName } + const template = renderAwsCloudFormation(connectionId, plan) + return { + stackName: plan.stackName, + stackRegion: AWS_STACK_REGION, + setupScript: yield* renderAwsSetupScript(target, template), + cleanupScript: yield* renderAwsCleanupScript(target), + cloudformationTemplate: template, + terraform: renderAwsTerraform(connectionId, plan), + } +}) diff --git a/packages/backend/src/services/integrations/gcp/setup-scripts.ts b/packages/backend/src/services/integrations/gcp/setup-scripts.ts index 47ef1fb71b..66cfb224f8 100644 --- a/packages/backend/src/services/integrations/gcp/setup-scripts.ts +++ b/packages/backend/src/services/integrations/gcp/setup-scripts.ts @@ -5,7 +5,6 @@ import { GCP_READER_SCOPE_ROLES, gcpConnectorResourceNames, } from "@maple/domain/gcp" -import { IntegrationsPersistenceError } from "@maple/domain/http" import { type GcpConnectorId, GcpLogRuntime, @@ -13,7 +12,7 @@ import { type GcpResourceNumber, type GcpScopeType, } from "@maple/domain/primitives" -import { Effect } from "effect" +import { forPaste } from "../paste-script" /** * Single-quote a value for bash. Every value a script uses goes through here; the scope named in @@ -701,34 +700,6 @@ else fi ${notifies ? `notify "Maple notified" '"metrics":false'\n` : ""}` -/** - * The text that is copied: the script as a here-document for a child bash. Pasted at a prompt, - * nothing runs before the closing brace is read, a failing step ends the child and not the login - * shell, and the terminal stays the script's stdin. The first line keeps the paste, secret and - * all, out of the history file of zsh 5.3+ (which writes it after the command ran) and drops it - * from the history of bash 5; its leading space does the same under `HISTCONTROL=ignorespace` - * and `HIST_IGNORE_SPACE`. bash 3.2 keeps only the first line, which has no secret. Nothing - * outside the here-document may be a comment: an interactive zsh runs `#` as a command. - */ -// A terminal, not the bytes, decides where a pasted line ends: a CR or a TAB (completion) in a -// value could end the here-document early and hand the rest to the login shell. -const PASTE_SAFE = /^[\x20-\x7e\n]*$/u - -const forPaste = (marker: string, body: string): Effect.Effect => - !PASTE_SAFE.test(body) || body.split("\n").includes(marker) - ? Effect.fail( - new IntegrationsPersistenceError({ - message: "A Google Cloud script value would end the script's here-document early", - }), - ) - : Effect.succeed( - ` { [ -z "\${ZSH_VERSION-}" ] || case "\${HISTORY_IGNORE-}" in *"*${marker}*"*) ;; *) HISTORY_IGNORE="(\${HISTORY_IGNORE:+$HISTORY_IGNORE|}*${marker}*)" ;; esac; [ -z "\${BASH_VERSION-}" ] || case "$(history 1)" in *${marker}*) history -d -1 ;; esac 2>/dev/null -bash /dev/fd/3 3<<'${marker}' -${body}${marker} -} -`, - ) - export interface GcpSetupScriptInput extends GcpScriptTarget { /** The ingest gateway's receiver URL for this connector, including its secret. */ readonly pushEndpoint: string @@ -762,6 +733,7 @@ export const renderGcpSetupScript = (input: GcpSetupScriptInput) => { const metricsState = metrics === "lost" ? "not available on this Maple deployment, left as is" : state(metrics === "on") return forPaste( + "Google Cloud", "MAPLE_SETUP_SCRIPT", `#!/usr/bin/env bash # Maple setup for Google Cloud ${scopeType} ${input.scopeId} @@ -881,6 +853,7 @@ export interface GcpCleanupScriptInput extends GcpScriptTarget { /** Removes what the setup script created. Without a push endpoint it carries no secret. */ export const renderGcpCleanupScript = (input: GcpCleanupScriptInput) => forPaste( + "Google Cloud", "MAPLE_CLEANUP_SCRIPT", `#!/usr/bin/env bash # Maple cleanup for Google Cloud ${input.scopeType} ${input.scopeId} diff --git a/packages/backend/src/services/integrations/paste-script.ts b/packages/backend/src/services/integrations/paste-script.ts new file mode 100644 index 0000000000..438c4e2db8 --- /dev/null +++ b/packages/backend/src/services/integrations/paste-script.ts @@ -0,0 +1,34 @@ +import { IntegrationsPersistenceError } from "@maple/domain/http" +import { Effect } from "effect" + +/** + * The text that is copied: the script as a here-document for a child bash. Pasted at a prompt, + * nothing runs before the closing brace is read, a failing step ends the child and not the login + * shell, and the terminal stays the script's stdin. The first line keeps the paste, secret and + * all, out of the history file of zsh 5.3+ (which writes it after the command ran) and drops it + * from the history of bash 5; its leading space does the same under `HISTCONTROL=ignorespace` + * and `HIST_IGNORE_SPACE`. bash 3.2 keeps only the first line, which has no secret. Nothing + * outside the here-document may be a comment: an interactive zsh runs `#` as a command. + */ +// A terminal, not the bytes, decides where a pasted line ends: a CR or a TAB (completion) in a +// value could end the here-document early and hand the rest to the login shell. +const PASTE_SAFE = /^[\x20-\x7e\n]*$/u + +export const forPaste = ( + provider: string, + marker: string, + body: string, +): Effect.Effect => + !PASTE_SAFE.test(body) || body.split("\n").includes(marker) + ? Effect.fail( + new IntegrationsPersistenceError({ + message: `A ${provider} script value would end the script's here-document early`, + }), + ) + : Effect.succeed( + ` { [ -z "\${ZSH_VERSION-}" ] || case "\${HISTORY_IGNORE-}" in *"*${marker}*"*) ;; *) HISTORY_IGNORE="(\${HISTORY_IGNORE:+$HISTORY_IGNORE|}*${marker}*)" ;; esac; [ -z "\${BASH_VERSION-}" ] || case "$(history 1)" in *${marker}*) history -d -1 ;; esac 2>/dev/null +bash /dev/fd/3 3<<'${marker}' +${body}${marker} +} +`, + ) diff --git a/packages/backend/src/services/org/OrganizationService.ts b/packages/backend/src/services/org/OrganizationService.ts index 924b8545fc..3d9e7e0cd3 100644 --- a/packages/backend/src/services/org/OrganizationService.ts +++ b/packages/backend/src/services/org/OrganizationService.ts @@ -33,6 +33,7 @@ import { ChatWorkspaces, CliDeviceAuthorizations, CloudflareLogpushConnectors, + AwsConnections, GcpConnectors, GcpResources, Dashboards, @@ -132,6 +133,8 @@ const ORG_SCOPED_TABLES: ReadonlyArray = [ GcpConnectors, // Also removed with its connector, by the foreign key. GcpResources, + // Holds the external id: a surviving row would keep Maple assuming the customer's role. + AwsConnections, ErrorIssueEvents, ErrorIssueStates, ErrorIncidents, diff --git a/packages/backend/src/services/org/integration-sources.ts b/packages/backend/src/services/org/integration-sources.ts index 072c160930..fac949d381 100644 --- a/packages/backend/src/services/org/integration-sources.ts +++ b/packages/backend/src/services/org/integration-sources.ts @@ -1,5 +1,6 @@ import type { MapleDb } from "@maple/db/client" import { + AwsConnections, GcpConnectors, OAuthConnections, PlanetscaleConnections, @@ -38,6 +39,7 @@ export const integrationSourceName = { cloudflare: (connection: { readonly externalAccountName: string | null }) => connection.externalAccountName ?? "Cloudflare", github: (installation: { readonly accountLogin: string }) => `GitHub ${installation.accountLogin}`, + aws: (connection: { readonly accountId: string }) => `AWS ${connection.accountId}`, } /** A new integration source. Create it in the transaction that writes the row pointing at it. */ @@ -242,6 +244,7 @@ export const readSourceOwners = Effect.fnUntraced(function* (db: MapleDb, orgId: .where(($) => [$.orgId.eq(orgId), $.sourceId.isNotNull()]), ), db.run(PG.from(VcsInstallations).select("id", "sourceId").where(($) => [$.orgId.eq(orgId)])), + db.run(PG.from(AwsConnections).select("id", "sourceId").where(($) => [$.orgId.eq(orgId)])), ], { concurrency: "unbounded" }, ) diff --git a/packages/db/drizzle/20261021114524_aws_connections/migration.sql b/packages/db/drizzle/20261021114524_aws_connections/migration.sql new file mode 100644 index 0000000000..bb2bd4c786 --- /dev/null +++ b/packages/db/drizzle/20261021114524_aws_connections/migration.sql @@ -0,0 +1,28 @@ +-- metadata +CREATE TABLE IF NOT EXISTS "aws_connections" ( + "id" text NOT NULL, + "org_id" text NOT NULL, + "source_id" text NOT NULL, + "account_id" text NOT NULL, + "external_id" text NOT NULL, + "modules" jsonb NOT NULL, + "granted_modules" jsonb, + "template_version" integer, + "verified_at" timestamp with time zone, + "last_checked_at" timestamp with time zone, + "last_check_error" text, + "check_lease_until" timestamp with time zone, + "created_by" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "aws_connections_pkey" PRIMARY KEY ("id") +); +--> statement-breakpoint +-- metadata +CREATE UNIQUE INDEX IF NOT EXISTS "aws_connections_org_account_idx" ON "aws_connections" USING btree ("org_id", "account_id"); +--> statement-breakpoint +-- metadata +CREATE INDEX IF NOT EXISTS "aws_connections_source_idx" ON "aws_connections" USING btree ("source_id"); +--> statement-breakpoint +-- metadata +ALTER TABLE "aws_connections" ADD CONSTRAINT "aws_connections_source_id_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "sources" ("id") ON DELETE CASCADE; diff --git a/packages/db/drizzle/20261021114524_aws_connections/snapshot.json b/packages/db/drizzle/20261021114524_aws_connections/snapshot.json new file mode 100644 index 0000000000..7204a2b29f --- /dev/null +++ b/packages/db/drizzle/20261021114524_aws_connections/snapshot.json @@ -0,0 +1,13896 @@ +{ + "version": "1", + "dialect": "postgres", + "id": "b7603ffee18918ab087bba81d530d36b45332c807e98bd3255a5b055a810d556", + "prevIds": [ + "efb70afeb7e67aa497bb17dec1287aa4b1cd37c26b1173d575712e2adeb27787" + ], + "entities": [ + { + "kind": "table", + "name": "actors", + "primaryKey": { + "name": "actors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "agent_feedback", + "primaryKey": { + "name": "agent_feedback_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "ai_triage_settings", + "primaryKey": { + "name": "ai_triage_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "alert_delivery_events", + "primaryKey": { + "name": "alert_delivery_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_destinations", + "primaryKey": { + "name": "alert_destinations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_incidents", + "primaryKey": { + "name": "alert_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "alert_rule_claims", + "primaryKey": { + "name": "alert_rule_claims_pkey", + "columns": [ + "rule_id" + ] + } + }, + { + "kind": "table", + "name": "alert_rule_states", + "primaryKey": { + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "columns": [ + "org_id", + "rule_id", + "group_key" + ] + } + }, + { + "kind": "table", + "name": "alert_rules", + "primaryKey": { + "name": "alert_rules_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_settings", + "primaryKey": { + "name": "anomaly_detector_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "anomaly_detector_states", + "primaryKey": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + { + "kind": "table", + "name": "anomaly_incidents", + "primaryKey": { + "name": "anomaly_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "api_keys", + "primaryKey": { + "name": "api_keys_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "aws_connections", + "primaryKey": { + "name": "aws_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cancellation_reviews", + "primaryKey": { + "name": "cancellation_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_identities", + "primaryKey": { + "name": "chat_identities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "chat_workspaces", + "primaryKey": { + "name": "chat_workspaces_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cli_device_authorizations", + "primaryKey": { + "name": "cli_device_authorizations_pkey", + "columns": [ + "device_code_hash" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_analytics_state", + "primaryKey": { + "name": "cloudflare_analytics_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_hyperdrive_configs", + "primaryKey": { + "name": "cloudflare_hyperdrive_configs_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "cloudflare_logpush_connectors", + "primaryKey": { + "name": "cloudflare_logpush_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_shares", + "primaryKey": { + "name": "dashboard_shares_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboard_versions", + "primaryKey": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "dashboards", + "primaryKey": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + { + "kind": "table", + "name": "digest_subscriptions", + "primaryKey": { + "name": "digest_subscriptions_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_fingerprint_candidates", + "primaryKey": { + "name": "error_fingerprint_candidates_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + { + "kind": "table", + "name": "error_incidents", + "primaryKey": { + "name": "error_incidents_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_events", + "primaryKey": { + "name": "error_issue_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_pull_requests", + "primaryKey": { + "name": "error_issue_pull_requests_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_states", + "primaryKey": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + { + "kind": "table", + "name": "error_issue_verifications", + "primaryKey": { + "name": "error_issue_verifications_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_issues", + "primaryKey": { + "name": "error_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_deliveries", + "primaryKey": { + "name": "error_notification_deliveries_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "error_notification_policies", + "primaryKey": { + "name": "error_notification_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "error_tick_states", + "primaryKey": { + "name": "error_tick_states_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "gcp_connectors", + "primaryKey": { + "name": "gcp_connectors_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "gcp_resources", + "primaryKey": { + "name": "gcp_resources_pkey", + "columns": [ + "connector_id", + "name" + ] + } + }, + { + "kind": "table", + "name": "investigations", + "primaryKey": { + "name": "investigations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "issue_escalation_policies", + "primaryKey": { + "name": "issue_escalation_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "issue_escalations", + "primaryKey": { + "name": "issue_escalations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "live_activities", + "primaryKey": { + "name": "live_activities_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_authorizations", + "primaryKey": { + "name": "mcp_oauth_authorizations_pkey", + "columns": [ + "request_id_hash" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_clients", + "primaryKey": { + "name": "mcp_oauth_clients_pkey", + "columns": [ + "client_id" + ] + } + }, + { + "kind": "table", + "name": "mcp_oauth_refresh_tokens", + "primaryKey": { + "name": "mcp_oauth_refresh_tokens_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "mobile_devices", + "primaryKey": { + "name": "mobile_devices_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "oauth_auth_states", + "primaryKey": { + "name": "oauth_auth_states_pkey", + "columns": [ + "state" + ] + } + }, + { + "kind": "table", + "name": "oauth_connections", + "primaryKey": { + "name": "oauth_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_clickhouse_schema_apply_runs", + "primaryKey": { + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_clickhouse_settings", + "primaryKey": { + "name": "org_clickhouse_settings_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_attribute_mappings", + "primaryKey": { + "name": "org_ingest_attribute_mappings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_keys", + "primaryKey": { + "name": "org_ingest_keys_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_ingest_sampling_policies", + "primaryKey": { + "name": "org_ingest_sampling_policies_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_onboarding_state", + "primaryKey": { + "name": "org_onboarding_state_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_openrouter_keys", + "primaryKey": { + "name": "org_openrouter_keys_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "org_recommendation_issues", + "primaryKey": { + "name": "org_recommendation_issues_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "org_support_channels", + "primaryKey": { + "name": "org_support_channels_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_connections", + "primaryKey": { + "name": "planetscale_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_databases", + "primaryKey": { + "name": "planetscale_databases_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_events", + "primaryKey": { + "name": "planetscale_events_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_issue_receipts", + "primaryKey": { + "name": "planetscale_issue_receipts_org_id_event_id_pk", + "columns": [ + "org_id", + "event_id" + ] + } + }, + { + "kind": "table", + "name": "planetscale_poll_state", + "primaryKey": { + "name": "planetscale_poll_state_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_edits", + "primaryKey": { + "name": "pr_review_edits_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_finding_embeddings", + "primaryKey": { + "name": "pr_review_finding_embeddings_pkey", + "columns": [ + "finding_id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_findings", + "primaryKey": { + "name": "pr_review_findings_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_merge_steps", + "primaryKey": { + "name": "pr_review_merge_steps_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_replies", + "primaryKey": { + "name": "pr_review_replies_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "pr_review_settings", + "primaryKey": { + "name": "pr_review_settings_pkey", + "columns": [ + "org_id" + ] + } + }, + { + "kind": "table", + "name": "pr_reviews", + "primaryKey": { + "name": "pr_reviews_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "railway_connections", + "primaryKey": { + "name": "railway_connections_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "railway_environments", + "primaryKey": { + "name": "railway_environments_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "scrape_target_checks", + "primaryKey": { + "name": "scrape_target_checks_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "scrape_targets", + "primaryKey": { + "name": "scrape_targets_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "sources", + "primaryKey": { + "name": "sources_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_commits", + "primaryKey": { + "name": "vcs_commits_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_installations", + "primaryKey": { + "name": "vcs_installations_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_repositories", + "primaryKey": { + "name": "vcs_repositories_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "table", + "name": "vcs_repository_branches", + "primaryKey": { + "name": "vcs_repository_branches_pkey", + "columns": [ + "id" + ] + } + }, + { + "kind": "column", + "table": "actors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "user_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "agent_name", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "model", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "capabilities_json", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "created_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "actors", + "name": "last_active_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "kind", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "impact", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "summary", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "reason", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "details", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "related_to", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_type", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_name", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_model", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "agent_version", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "agent_feedback", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "max_runs_per_day", + "position": 2, + "type": "integer", + "notNull": true, + "default": "20", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "max_passes_per_day", + "position": 3, + "type": "integer", + "notNull": true, + "default": "90", + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "updated_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "ai_triage_settings", + "name": "updated_by", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "incident_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "rule_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "destination_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "delivery_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "event_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "attempt_number", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "scheduled_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claimed_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claim_expires_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "claimed_by", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "attempted_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "provider_message", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "provider_reference", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "response_code", + "position": 16, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "error_message", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "payload_json", + "position": 18, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_delivery_events", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "config_json", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "secret_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_tested_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_test_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "consecutive_failures", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "last_failure_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "disabled_reason", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_destinations", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "rule_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "incident_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "rule_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "group_key", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "signal_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "severity", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "peak_severity", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "comparator", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "threshold", + "position": 11, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "threshold_upper", + "position": 12, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "first_triggered_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_triggered_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "resolved_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_observed_value", + "position": 16, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_sample_count", + "position": 17, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_evaluated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "dedupe_key", + "position": 19, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_delivered_event_type", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "last_notified_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "hold_reason", + "position": 22, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "held_since", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "error_issue_id", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "created_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_incidents", + "name": "updated_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "rule_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_claims", + "name": "last_scheduled_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "rule_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "group_key", + "position": 2, + "type": "text", + "notNull": true, + "default": "'__total__'", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "consecutive_breaches", + "position": 3, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "consecutive_healthy", + "position": 4, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "history_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_status", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_value", + "position": 7, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_sample_count", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_evaluated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "last_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rule_states", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "notes", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "notification_template_json", + "position": 4, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "enabled", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "severity", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "service_names_json", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "exclude_service_names_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "environments_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "tags_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "signal_type", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "comparator", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold", + "position": 13, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "threshold_upper", + "position": 14, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "thresholds_json", + "position": 15, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "window_minutes", + "position": 16, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "minimum_sample_count", + "position": 17, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_breaches_required", + "position": 18, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "breaches_out_of", + "position": 19, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "consecutive_healthy_required", + "position": 20, + "type": "integer", + "notNull": true, + "default": "2", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "renotify_interval_minutes", + "position": 21, + "type": "integer", + "notNull": true, + "default": "30", + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "apdex_threshold_ms", + "position": 22, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_builder_draft_json", + "position": 23, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "raw_query_sql", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "group_by", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "destination_ids_json", + "position": 26, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "query_spec_json", + "position": 27, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "reducer", + "position": 28, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "sample_count_strategy", + "position": 29, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "no_data_behavior", + "position": 30, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "last_scheduled_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_at", + "position": 32, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_at", + "position": 33, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "created_by", + "position": 34, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "alert_rules", + "name": "updated_by", + "position": 35, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "sensitivity", + "position": 2, + "type": "text", + "notNull": true, + "default": "'normal'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "muted_signals_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "last_tick_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_settings", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "detector_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "signal_type", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "service_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "deployment_env", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "fingerprint_hash", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_breaches", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "consecutive_healthy", + "position": 7, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_status", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_value", + "position": 9, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "baseline_median", + "position": 10, + "type": "double precision", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_sample_count", + "position": 11, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_evaluated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "open_incident_id", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_resolved_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "last_incident_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_detector_states", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "detector_key", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "signal_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "deployment_env", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprint_hash", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "error_issue_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "status", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "opened_value", + "position": 10, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_median", + "position": 11, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "baseline_sigma", + "position": 12, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "threshold_value", + "position": 13, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_observed_value", + "position": 14, + "type": "double precision", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_sample_count", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "first_triggered_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_triggered_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolved_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "resolve_reason", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "triage_status", + "position": 20, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "dedupe_key", + "position": 21, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "fingerprints_json", + "position": 22, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "reopen_count", + "position": 23, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "last_reopened_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "created_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "anomaly_incidents", + "name": "updated_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "key_prefix", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "revoked_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "last_used_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "metadata_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "scopes", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "kind", + "position": 12, + "type": "text", + "notNull": true, + "default": "'standard'", + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "api_keys", + "name": "created_by_email", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "account_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "external_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "modules", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "granted_modules", + "position": 6, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "template_version", + "position": 7, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "verified_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "last_checked_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "last_check_error", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "check_lease_until", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "created_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "aws_connections", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "plan_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "subscription_started_at", + "position": 3, + "type": "bigint", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "canceled_at", + "position": 4, + "type": "bigint", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "rule_reason", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "posted_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cancellation_reviews", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "external_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "display_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_identities", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "connector", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "external_workspace_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "settings", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_ciphertext", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_iv", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "credentials_tag", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "chat_workspaces", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_code_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "user_code_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "device_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_org_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_roles", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_user_email", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "api_key_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_ciphertext", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_iv", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "token_tag", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "approved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "denied_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cli_device_authorizations", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "dataset", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_id", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "zone_name", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "watermark_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "backfill_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_json", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "settings_fetched_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "quantiles_available", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "discovered_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "live_scripts_json", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "grant_call_cap", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_success_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_error_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "lease_until", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "account_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "config_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_host", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_port", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_scheme", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_database", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "origin_user", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "deleted_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_hyperdrive_configs", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "zone_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "service_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "dataset", + "position": 6, + "type": "text", + "notNull": true, + "default": "'http_requests'", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_ciphertext", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_iv", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_tag", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_hash", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "enabled", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_received_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "last_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "secret_rotated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "created_by", + "position": 17, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_logpush_connectors", + "name": "updated_by", + "position": 18, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "widget_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "mode", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_hash", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_ciphertext", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_iv", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_tag", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "token_suffix", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "updated_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_shares", + "name": "revoked_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "dashboard_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "version_number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "snapshot_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "change_summary", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "source_version_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboard_versions", + "name": "created_by", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "payload_json", + "position": 3, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "created_by", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "dashboards", + "name": "version", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "email", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "opted_out_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "day_of_week", + "position": 6, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "timezone", + "position": 7, + "type": "text", + "notNull": true, + "default": "'UTC'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "send_hour", + "position": 8, + "type": "integer", + "notNull": true, + "default": "9", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "namespaces_json", + "position": 9, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "environments_json", + "position": 10, + "type": "text", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_sent_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "last_attempted_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_enabled", + "position": 13, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_opted_out_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_sent_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "web_analytics_last_attempted_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "created_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "digest_subscriptions", + "name": "updated_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "fingerprint_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "exception_message", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "error_label", + "position": 5, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "top_frame", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "service_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "first_seen_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "last_seen_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_fingerprint_candidates", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "status", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "reason", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "first_triggered_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "last_triggered_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "resolved_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_incidents", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "actor_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "type", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "from_state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "to_state", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "payload_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_events", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "repo_full_name", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "number", + "position": 6, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "url", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "title", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "author_login", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "state", + "position": 10, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merged_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "merge_commit_sha", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "link_source", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "linked_by_actor_id", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_pull_requests", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "issue_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_observed_occurrence_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "last_evaluated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "open_incident_id", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "pull_request_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "status", + "position": 4, + "type": "text", + "notNull": true, + "default": "'waiting'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "merged_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verify_after", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_versions_json", + "position": 7, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_occurrence_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "baseline_rate_per_hour", + "position": 9, + "type": "double precision", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "investigation_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "verdict_note", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "post_merge_occurrence_count", + "position": 13, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "attempt", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issue_verifications", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "kind", + "position": 2, + "type": "text", + "notNull": true, + "default": "'error'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "source_ref_json", + "position": 3, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_hash", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "fingerprint_version", + "position": 5, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "service_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_type", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "exception_message", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "error_label", + "position": 9, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "top_frame", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "workflow_state", + "position": 11, + "type": "text", + "notNull": true, + "default": "'triage'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "priority", + "position": 12, + "type": "integer", + "notNull": true, + "default": "3", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "severity_source", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "assigned_actor_id", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_holder_actor_id", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "lease_expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "claimed_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "notes", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "first_seen_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_seen_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "occurrence_count", + "position": 22, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_by_actor_id", + "position": 24, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_resolved_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "last_regressed_at", + "position": 26, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "regression_count", + "position": 27, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "seen_versions_json", + "position": 28, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "resolved_versions_json", + "position": 29, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "snooze_until", + "position": 30, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "archived_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "created_at", + "position": 32, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_issues", + "name": "updated_at", + "position": 33, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "destination_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "delivery_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "payload_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "status", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempt_count", + "position": 6, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "scheduled_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claim_expires_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "claimed_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "attempted_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "error_message", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_deliveries", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "destination_ids_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_first_seen", + "position": 3, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_regression", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_resolve", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_in_review", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_transition_done", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "notify_on_claim", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "min_occurrence_count", + "position": 9, + "type": "integer", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "severity", + "position": 10, + "type": "text", + "notNull": true, + "default": "'warning'", + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_notification_policies", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "processed_through", + "position": 1, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "bootstrap_completed", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_token", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "claim_expires_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "error_tick_states", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "scope_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "scope_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "project_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "logs_enabled", + "position": 6, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_ciphertext", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_iv", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_tag", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "secret_hash", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_received_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "applied_logs_enabled", + "position": 14, + "type": "boolean", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "applied_metrics_enabled", + "position": 15, + "type": "boolean", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "setup_reported_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_watermark_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_metrics_received_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_metrics_error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "metrics_lease_until", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "resources_synced_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "last_resources_error", + "position": 22, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "created_by", + "position": 23, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "created_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_connectors", + "name": "updated_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "connector_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "asset_type", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "project_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "location", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "display_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "state", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "labels", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "details", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "resource_created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "resource_updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "gcp_resources", + "name": "last_seen_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": "'investigating'", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "seeded_by", + "position": 3, + "type": "text", + "notNull": true, + "default": "'user'", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "subject_json", + "position": 4, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "snapshot_json", + "position": 5, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "incident_kind", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "incident_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "issue_id", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "report_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "progress_json", + "position": 10, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "severity", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "confidence", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "model", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "input_tokens", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "output_tokens", + "position": 15, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "started_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "autonomous_turns", + "position": 18, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "created_by", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "created_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "diagnosed_at", + "position": 21, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "investigations", + "name": "updated_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "enabled", + "position": 1, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "rules_json", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "updated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalation_policies", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "issue_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "severity", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "source", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "reason", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "run_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "investigation_id", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "payload_json", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "delivery_results_json", + "position": 9, + "type": "jsonb", + "notNull": true, + "default": "'[]'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "attempts", + "position": 11, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "dedupe_key", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "error", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "issue_escalations", + "name": "processed_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "device_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "incident_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "activity_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "push_token", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "ended_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "ended_reason", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "live_activities", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "request_id_hash", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "client_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "client_name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "redirect_uri", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "state", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "resource", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "scopes", + "position": 6, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "code_challenge", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "authorization_code_hash", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_org_id", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_user_id", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_roles", + "position": 11, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_user_email", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "approved_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "denied_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "used_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_authorizations", + "name": "expires_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_name", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "redirect_uris", + "position": 2, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "client_uri", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_clients", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "token_hash", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "family_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "client_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "resource", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "scopes", + "position": 5, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "org_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "user_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "roles", + "position": 8, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "user_email", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "access_key_id", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "replaced_by_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "revoked_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "expires_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mcp_oauth_refresh_tokens", + "name": "family_expires_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "user_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "platform", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "token", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "environment", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "bundle_id", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "app_version", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "live_activity_start_token", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "device_name", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "preferences", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "disabled_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "disabled_reason", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "last_pushed_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "last_seen_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "mobile_devices", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "state", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "initiated_by_user_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "redirect_uri", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "return_to", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "code_verifier", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_auth_states", + "name": "expires_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_user_email", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "external_account_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "granted_accounts_json", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "connected_by_user_id", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "scope", + "position": 9, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_ciphertext", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_iv", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "access_token_tag", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_ciphertext", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_iv", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "refresh_token_tag", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "expires_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "revoked_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "created_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "oauth_connections", + "name": "updated_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "workflow_instance_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "status", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "phase", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "current_migration", + "position": 4, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "steps_total", + "position": 5, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "steps_done", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "applied_versions", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "skipped", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "error_message", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "started_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "finished_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_schema_apply_runs", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_url", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_user", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_ciphertext", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_iv", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_password_tag", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "ch_database", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "sync_status", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "last_sync_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "last_sync_error", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "schema_version", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "created_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "updated_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "created_by", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_clickhouse_settings", + "name": "updated_by", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "source_context", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "source_key", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "target_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "operation", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "enabled", + "position": 7, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "created_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_attribute_mappings", + "name": "updated_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_key", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_key_hash", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_ciphertext", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_iv", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_tag", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_key_hash", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "public_rotated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "private_rotated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "created_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "created_by", + "position": 11, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_keys", + "name": "updated_by", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "trace_sample_ratio", + "position": 1, + "type": "double precision", + "notNull": true, + "default": "1", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "always_keep_error_spans", + "position": 2, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "always_keep_slow_spans_ms", + "position": 3, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "created_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_ingest_sampling_policies", + "name": "updated_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "user_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "email", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "role", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "demo_data_requested", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "onboarding_completed_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "checklist_dismissed_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "first_data_received_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "welcome_email_sent_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "connect_nudge_email_sent_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "stalled_email_sent_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "activation_email_sent_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "reward_claimed_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "reward_reserved_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_onboarding_state", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_ciphertext", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_iv", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_tag", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "key_hint", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "updated_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_openrouter_keys", + "name": "updated_by", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "number", + "position": 2, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "recommendation_key", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "source_key", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "canonical_key", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "status", + "position": 7, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "usage_count", + "position": 8, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "opened_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "updated_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": "now()", + "identity": null + }, + { + "kind": "column", + "table": "org_recommendation_issues", + "name": "resolved_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "slack_channel_id", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "slack_channel_name", + "position": 2, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "reservation_id", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "reserved_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "created_by_user_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "created_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "org_support_channels", + "name": "updated_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "ps_organization", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "connected_by_user_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "scrape_target_id", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_ciphertext", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_iv", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "webhook_secret_tag", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "detected_permissions_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "last_inventory_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "last_inventory_error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "created_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_connections", + "name": "updated_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "database_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "kind", + "position": 4, + "type": "text", + "notNull": true, + "default": "'mysql'", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "state", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "region", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "plan", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "branches_json", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "deleted_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_databases", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "database_id", + "position": 2, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "database_name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "branch_name", + "position": 4, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "category", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "event_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "state", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "external_id", + "position": 8, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "title", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "source", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "actor_login", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "url", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "payload_json", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "occurred_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_events", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "event_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_issue_receipts", + "name": "processed_at", + "position": 2, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "dataset", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "database_id", + "position": 3, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "enabled", + "position": 4, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "watermark_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_success_at", + "position": 6, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_error", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "last_error_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "lease_until", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "created_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "planetscale_poll_state", + "name": "updated_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "reply_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "seq", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "path", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "old_text", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "new_text", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_edits", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "finding_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "model", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "embedding", + "position": 4, + "type": "real[]", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_finding_embeddings", + "name": "created_at", + "position": 5, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "review_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "handle", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "path", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "line", + "position": 7, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "category", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "severity", + "position": 9, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "title", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "status", + "position": 11, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "comment_id", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "resolved_sha", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "reactions_up", + "position": 14, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "reactions_down", + "position": 15, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "created_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_findings", + "name": "updated_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "key", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "title", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "source", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "path", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'open'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "done_by", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "done_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "reminded_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "first_review_id", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "last_review_id", + "position": 14, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_merge_steps", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "comment_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "surface", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "thread_root_id", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "author_login", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "command", + "position": 8, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "head_sha", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "session_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "reply_url", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "commit_sha", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "error", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "created_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_replies", + "name": "updated_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "org_id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "model", + "position": 1, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "defaults", + "position": 2, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "updated_at", + "position": 3, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_review_settings", + "name": "updated_by", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "repository_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "number", + "position": 3, + "type": "integer", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "head_sha", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "base_sha", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "url", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "title", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "author_login", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "status", + "position": 9, + "type": "text", + "notNull": true, + "default": "'queued'", + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "skip_reason", + "position": 10, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "session_id", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "comment_attempt", + "position": 12, + "type": "integer", + "notNull": true, + "default": "0", + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "report_json", + "position": 13, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "score", + "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "check_run_url", + "position": 15, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "comment_url", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "review_url", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "publish_error", + "position": 18, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "model", + "position": 20, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "input_tokens", + "position": 21, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "output_tokens", + "position": 22, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "started_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "finished_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "merged_at", + "position": 25, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "telemetry_json", + "position": 26, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "merge_commit_sha", + "position": 27, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_status", + "position": 28, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_after", + "position": 29, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "post_merge_json", + "position": 30, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "created_at", + "position": 31, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "pr_reviews", + "name": "updated_at", + "position": 32, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_ciphertext", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_iv", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "token_tag", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "workspace_names", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "connected_by_user_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "auth_failed_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "discovered_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "lease_until", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_success_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_error", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "last_error_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_connections", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "connection_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "project_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "project_name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "environment_id", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "environment_name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "services_json", + "position": 7, + "type": "text", + "notNull": true, + "default": "'{}'", + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "enabled", + "position": 8, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "watermark_at", + "position": 9, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_success_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_error", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "last_error_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "railway_environments", + "name": "updated_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "id", + "position": 0, + "type": "integer", + "notNull": true, + "default": null, + "identity": "by default" + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "target_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "org_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "sub_target_key", + "position": 3, + "type": "text", + "notNull": true, + "default": "''", + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "checked_at", + "position": 4, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "error", + "position": 5, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "duration_ms", + "position": 6, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "samples_scraped", + "position": 7, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_target_checks", + "name": "samples_post_relabel", + "position": 8, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "name", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "service_name", + "position": 4, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "url", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "target_type", + "position": 6, + "type": "text", + "notNull": true, + "default": "'prometheus'", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "discovery_config_json", + "position": 7, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "scrape_interval_seconds", + "position": 8, + "type": "integer", + "notNull": true, + "default": "15", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "labels_json", + "position": 9, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_type", + "position": 10, + "type": "text", + "notNull": true, + "default": "'none'", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "managed_by", + "position": 11, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_ciphertext", + "position": 12, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_iv", + "position": 13, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "auth_credentials_tag", + "position": 14, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "enabled", + "position": 15, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "last_scrape_at", + "position": 16, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "last_scrape_error", + "position": 17, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "created_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "scrape_targets", + "name": "updated_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "name", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "description", + "position": 3, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "platform", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "kind", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "key_hash", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "key_prefix", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "allowed_signals", + "position": 8, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "rate_limit_rps", + "position": 9, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "resource_attributes", + "position": 10, + "type": "jsonb", + "notNull": true, + "default": "'[]'::jsonb", + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "expires_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "paused_at", + "position": 12, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "paused_until", + "position": 13, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_used_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_rejected_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_rejection", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_at", + "position": 17, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_ok_at", + "position": 18, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "last_poll_error", + "position": 19, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "disconnected_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "poll_resources", + "position": 21, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "rolled_at", + "position": 22, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "created_at", + "position": 23, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "updated_at", + "position": 24, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "created_by", + "position": 25, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "sources", + "name": "updated_by", + "position": 26, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "repository_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "sha", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "message", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_name", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_email", + "position": 7, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_login", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "author_avatar_url", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "authored_at", + "position": 10, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "committed_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "html_url", + "position": 12, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_commits", + "name": "created_at", + "position": 13, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "source_id", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "provider", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "external_installation_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_login", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_type", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "external_account_id", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "account_avatar_url", + "position": 8, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "repository_selection", + "position": 9, + "type": "text", + "notNull": true, + "default": "'all'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "status", + "position": 10, + "type": "text", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "suspended_at", + "position": 11, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "ci_traces_enabled", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "installed_by_user_id", + "position": 13, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "created_at", + "position": 14, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "updated_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "installation_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "external_repo_id", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "owner", + "position": 5, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "name", + "position": 6, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "full_name", + "position": 7, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "default_branch", + "position": 8, + "type": "text", + "notNull": true, + "default": "'main'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "tracked_branch", + "position": 9, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "html_url", + "position": 10, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "is_private", + "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "is_archived", + "position": 12, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "status", + "position": 13, + "type": "text", + "notNull": true, + "default": "'active'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "sync_status", + "position": 14, + "type": "text", + "notNull": true, + "default": "'pending'", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "last_synced_at", + "position": 15, + "type": "timestamp with time zone", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "last_sync_error", + "position": 16, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "pr_review_enabled", + "position": 17, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "pr_review_config", + "position": 18, + "type": "jsonb", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "created_at", + "position": 19, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repositories", + "name": "updated_at", + "position": 20, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "id", + "position": 0, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "org_id", + "position": 1, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "provider", + "position": 2, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "repository_id", + "position": 3, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "name", + "position": 4, + "type": "text", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "is_default", + "position": 5, + "type": "boolean", + "notNull": true, + "default": "false", + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "head_sha", + "position": 6, + "type": "text", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "created_at", + "position": 7, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "vcs_repository_branches", + "name": "updated_at", + "position": 8, + "type": "timestamp with time zone", + "notNull": true, + "default": null, + "identity": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_agent_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"agent_name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"" + ], + "where": null + }, + { + "kind": "index", + "table": "actors", + "name": "actors_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "agent_feedback", + "name": "agent_feedback_kind_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"kind\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "agent_feedback", + "name": "agent_feedback_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"claim_expires_at\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_delivery_attempt_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"attempt_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_delivery_events", + "name": "alert_delivery_events_org_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_destinations", + "name": "alert_destinations_org_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_incident_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"incident_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_open_group_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"rule_id\"", + "\"group_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_rule_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"rule_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_incidents", + "name": "alert_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rule_claims", + "name": "alert_rule_claims_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rule_states", + "name": "alert_rule_states_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "alert_rules", + "name": "alert_rules_org_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_evaluated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"last_evaluated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_detector_states", + "name": "anomaly_detector_states_open_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"open_incident_id\"" + ], + "where": "\"open_incident_id\" is not null" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_open_detector_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": "\"status\" = 'open'" + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_detector_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"detector_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"error_issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_status_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"", + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "anomaly_incidents", + "name": "anomaly_incidents_org_triggered_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "api_keys", + "name": "api_keys_org_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_org_account_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "aws_connections", + "name": "aws_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cancellation_reviews", + "name": "cancellation_reviews_subscription_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"plan_id\"", + "\"subscription_started_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"external_user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_connector_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"connector\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_identities", + "name": "chat_identities_org_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_connector_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"connector\"", + "\"external_workspace_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "chat_workspaces", + "name": "chat_workspaces_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cli_device_authorizations", + "name": "cli_device_authorizations_user_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"user_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_account_dataset_zone_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"account_id\"", + "\"dataset\"", + "\"zone_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_analytics_state", + "name": "cf_analytics_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_config_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"config_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_hyperdrive_configs", + "name": "cloudflare_hyperdrive_configs_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_live_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "coalesce(widget_id, '')" + ], + "where": "revoked_at is null" + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_shares", + "name": "dashboard_shares_token_hash_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboard_versions", + "name": "dashboard_versions_org_dashboard_version_unq", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dashboard_id\"", + "\"version_number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_name_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "index", + "table": "dashboards", + "name": "dashboards_org_updated_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"updated_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "digest_subscriptions", + "name": "digest_subscriptions_org_user_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_fingerprint_candidates", + "name": "error_fingerprint_candidates_last_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_incidents", + "name": "error_incidents_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"", + "\"last_triggered_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_actor_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"actor_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_events", + "name": "error_issue_events_type_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"type\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_issue_pr_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_pull_requests", + "name": "error_issue_pull_requests_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"repo_full_name\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"verify_after\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issue_verifications", + "name": "error_issue_verifications_open_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": "\"status\" in ('waiting', 'running')" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_lease_expiry_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"lease_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_archived_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"archived_at\"" + ], + "where": "\"archived_at\" is not null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_assignee_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"assigned_actor_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_fp_version_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"fingerprint_version\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_live_seen_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"last_seen_at\"", + "\"id\"" + ], + "where": "\"archived_at\" is null" + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_severity_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"severity\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_issues", + "name": "error_issues_org_workflow_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"workflow_state\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"scheduled_at\"", + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_key_destination_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"delivery_key\"", + "\"destination_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_notification_deliveries", + "name": "error_notification_deliveries_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "error_tick_states", + "name": "error_tick_states_claim_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"claim_expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_org_scope_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"scope_type\"", + "\"scope_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_secret_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"secret_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "gcp_connectors", + "name": "gcp_connectors_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_incident_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_kind\"", + "\"incident_id\"" + ], + "where": "\"incident_id\" is not null" + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "investigations", + "name": "investigations_org_status_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"status\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"dedupe_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"status\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "issue_escalations", + "name": "issue_escalations_org_issue_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"issue_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "live_activities", + "name": "live_activities_device_incident_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"device_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "live_activities", + "name": "live_activities_incident_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"incident_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_authorizations", + "name": "mcp_oauth_authorizations_code_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"authorization_code_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_authorizations", + "name": "mcp_oauth_authorizations_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_family_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"family_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mcp_oauth_refresh_tokens", + "name": "mcp_oauth_refresh_tokens_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"token_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_org_platform_token_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"platform\"", + "\"token\"" + ], + "where": null + }, + { + "kind": "index", + "table": "mobile_devices", + "name": "mobile_devices_user_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"user_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_auth_states", + "name": "oauth_auth_states_expires_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"expires_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_org_provider_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"" + ], + "where": null + }, + { + "kind": "index", + "table": "oauth_connections", + "name": "oauth_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_attribute_mappings", + "name": "org_ingest_attribute_mappings_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_private_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"private_key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_public_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"public_key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_ingest_keys", + "name": "org_ingest_keys_public_key_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"public_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_recommendation_issues", + "name": "org_recommendation_issues_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "org_recommendation_issues", + "name": "org_recommendation_issues_org_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"recommendation_key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_connections", + "name": "planetscale_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_connections", + "name": "planetscale_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_databases", + "name": "planetscale_databases_org_db_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_databases", + "name": "planetscale_databases_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_dedupe_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_name\"", + "\"event_type\"", + "\"external_id\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_org_db_time_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"database_name\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_events", + "name": "planetscale_events_org_time_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"occurred_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_issue_receipts", + "name": "planetscale_issue_receipts_processed_at_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"processed_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_poll_state", + "name": "planetscale_poll_state_org_dataset_db_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"dataset\"", + "\"database_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "planetscale_poll_state", + "name": "planetscale_poll_state_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_edits", + "name": "pr_review_edits_reply_seq_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"reply_id\"", + "\"seq\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_finding_embeddings", + "name": "pr_review_finding_embeddings_org_model_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"model\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_findings", + "name": "pr_review_findings_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_findings", + "name": "pr_review_findings_pr_handle_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"handle\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_merge_steps", + "name": "pr_review_merge_steps_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_merge_steps", + "name": "pr_review_merge_steps_pr_key_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"key\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_replies", + "name": "pr_review_replies_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_review_replies", + "name": "pr_review_replies_repo_comment_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"comment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_org_created_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"created_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_post_merge_due_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"post_merge_after\"" + ], + "where": "\"post_merge_status\" = 'waiting'" + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_repo_number_head_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"", + "\"head_sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "pr_reviews", + "name": "pr_reviews_repo_number_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"number\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_connections", + "name": "railway_connections_org_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_connections", + "name": "railway_connections_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_environments", + "name": "railway_environments_connection_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"connection_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "railway_environments", + "name": "railway_environments_org_env_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"environment_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_target_checks", + "name": "scrape_target_checks_target_checked_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"target_id\"", + "\"checked_at\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_org_enabled_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"enabled\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "scrape_targets", + "name": "scrape_targets_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "sources", + "name": "sources_key_hash_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"key_hash\"" + ], + "where": null + }, + { + "kind": "index", + "table": "sources", + "name": "sources_org_default_kind_unique", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": "\"kind\" = 'default'" + }, + { + "kind": "index", + "table": "sources", + "name": "sources_org_id_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_commits", + "name": "vcs_commits_org_sha_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_commits", + "name": "vcs_commits_repo_sha_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"sha\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_provider_external_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"provider\"", + "\"external_installation_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_installations", + "name": "vcs_installations_source_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"source_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_installation_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"installation_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repositories", + "name": "vcs_repositories_org_repo_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"org_id\"", + "\"provider\"", + "\"external_repo_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repository_branches", + "name": "vcs_repository_branches_org_idx", + "unique": false, + "method": "btree", + "columns": [ + "\"org_id\"" + ], + "where": null + }, + { + "kind": "index", + "table": "vcs_repository_branches", + "name": "vcs_repository_branches_repo_name_idx", + "unique": true, + "method": "btree", + "columns": [ + "\"repository_id\"", + "\"name\"" + ], + "where": null + }, + { + "kind": "foreign_key", + "table": "aws_connections", + "name": "aws_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "cloudflare_logpush_connectors", + "name": "cloudflare_logpush_connectors_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "SET NULL", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "dashboard_shares", + "name": "dashboard_shares_dashboard_fk", + "columns": [ + "org_id", + "dashboard_id" + ], + "foreignTable": "dashboards", + "foreignColumns": [ + "org_id", + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "gcp_connectors", + "name": "gcp_connectors_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "gcp_resources", + "name": "gcp_resources_connector_id_gcp_connectors_id_fk", + "columns": [ + "connector_id" + ], + "foreignTable": "gcp_connectors", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "oauth_connections", + "name": "oauth_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "planetscale_connections", + "name": "planetscale_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "railway_connections", + "name": "railway_connections_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "scrape_target_checks", + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "columns": [ + "target_id" + ], + "foreignTable": "scrape_targets", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "scrape_targets", + "name": "scrape_targets_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + }, + { + "kind": "foreign_key", + "table": "vcs_installations", + "name": "vcs_installations_source_id_sources_id_fk", + "columns": [ + "source_id" + ], + "foreignTable": "sources", + "foreignColumns": [ + "id" + ], + "onDelete": "CASCADE", + "onUpdate": "NO ACTION" + } + ] +} diff --git a/packages/db/src/tables/aws-connections.ts b/packages/db/src/tables/aws-connections.ts new file mode 100644 index 0000000000..5c3013772c --- /dev/null +++ b/packages/db/src/tables/aws-connections.ts @@ -0,0 +1,50 @@ +import * as PG from "@maple-dev/effect-orm/postgres" +import { AwsAccountId, AwsConnectionId, AwsModule, OrgId, SourceId } from "@maple/domain/primitives" +import { Schema } from "effect" +import { Sources } from "./sources" + +// One row per connected AWS account. Maple holds no secret for it: the customer's stack creates a +// role (named from `id`) that trusts Maple's own identity with `external_id`. `modules` is what +// the connection asks for; the check writes what the role was found to grant. +export const AwsConnections = PG.table("aws_connections", { + columns: { + id: PG.brand(PG.text, AwsConnectionId), + orgId: PG.column(PG.brand(PG.text, OrgId), { name: "org_id" }), + // The source this integration sends as. Deleting the source deletes the row. + sourceId: PG.column(PG.brand(PG.text, SourceId), { name: "source_id" }), + accountId: PG.column(PG.brand(PG.text, AwsAccountId), { name: "account_id" }), + externalId: PG.column(PG.text, { name: "external_id" }), + modules: PG.jsonb(Schema.Array(AwsModule)), + // What the latest check that assumed the role read off it. Null until one could. + grantedModules: PG.column(PG.nullable(PG.jsonb(Schema.Array(AwsModule))), { + name: "granted_modules", + }), + templateVersion: PG.column(PG.nullable(PG.int4), { name: "template_version" }), + // First check that assumed the role: the setup is deployed. A failure before it is the + // setup still being done, not a broken connection. + verifiedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "verified_at" }), + lastCheckedAt: PG.column(PG.nullable(PG.timestamptzMillis), { name: "last_checked_at" }), + lastCheckError: PG.column(PG.nullable(PG.text), { name: "last_check_error" }), + // A check claims the row by moving this past now; it also orders the hourly sweep. + checkLeaseUntil: PG.column(PG.nullable(PG.timestamptzMillis), { name: "check_lease_until" }), + createdBy: PG.column(PG.text, { name: "created_by" }), + createdAt: PG.column(PG.timestamptzMillis, { name: "created_at" }), + updatedAt: PG.column(PG.timestamptzMillis, { name: "updated_at" }), + }, + primaryKey: ["id"], + indexes: [ + PG.index("aws_connections_source_idx", ["sourceId"]), + PG.uniqueIndex("aws_connections_org_account_idx", ["orgId", "accountId"]), + ], + foreignKeys: [ + PG.foreignKey({ + columns: ["sourceId"], + references: Sources, + foreignColumns: ["id"], + onDelete: "cascade", + }), + ], + tenantColumn: "orgId", +}) + +export type AwsConnectionRow = PG.SelectRowOf diff --git a/packages/db/src/tables/index.ts b/packages/db/src/tables/index.ts index ac27666993..d7e6f53276 100644 --- a/packages/db/src/tables/index.ts +++ b/packages/db/src/tables/index.ts @@ -35,6 +35,7 @@ import { ErrorIssueVerifications, } from "./errors" import { IssueEscalationPolicies, IssueEscalations } from "./escalations" +import { AwsConnections } from "./aws-connections" import { GcpConnectors } from "./gcp-connectors" import { GcpResources } from "./gcp-resources" import { Investigations } from "./investigations" @@ -92,6 +93,7 @@ export * from "./dashboards" export * from "./digest" export * from "./errors" export * from "./escalations" +export * from "./aws-connections" export * from "./gcp-connectors" export * from "./gcp-resources" export * from "./investigations" @@ -155,6 +157,7 @@ export const allTables: ReadonlyArray> IssueEscalations, GcpConnectors, GcpResources, + AwsConnections, Investigations, LiveActivities, McpOAuthClients, diff --git a/packages/domain/package.json b/packages/domain/package.json index 681b1dd3bc..ee1d9d00d8 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -12,6 +12,7 @@ "./billing": "./src/billing.ts", "./billing-tax-ids": "./src/billing-tax-ids.ts", "./format": "./src/format.ts", + "./aws": "./src/aws.ts", "./gcp": "./src/gcp.ts", "./gcp-infra": "./src/gcp-infra.ts", "./gcp-metrics": "./src/gcp-metrics.ts", diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index 52e4893846..c576ff5fea 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -68,6 +68,11 @@ export const AuditResources = { prefix: PublicIdPrefixes.gcpConnector, verbs: ["created", "updated", "removal_confirmed", "deleted"], }, + /** One connected AWS account; the account and its modules are in `metadata`. */ + aws_connection: { + prefix: PublicIdPrefixes.awsConnection, + verbs: ["created", "updated", "deleted"], + }, /** Anything that sends data; a token source's secret is never in `changes`. */ source: { prefix: PublicIdPrefixes.source, @@ -156,6 +161,7 @@ export const AuditResourceLabels = { dashboard_share: "Dashboard share", error_issue: "Issue", gcp_connector: "Google Cloud connector", + aws_connection: "AWS connection", ingest_key: "Ingest key", mcp_connection: "MCP connection", mcp_tool: "MCP tool", @@ -230,6 +236,9 @@ export const AuditActionLabels = { "gcp_connector.updated": "Updated Google Cloud connector", "gcp_connector.removal_confirmed": "Confirmed Google Cloud resources removed", "gcp_connector.deleted": "Removed Google Cloud connector", + "aws_connection.created": "Connected AWS", + "aws_connection.updated": "Updated AWS connection", + "aws_connection.deleted": "Removed AWS connection", "ingest_key.rolled": "Rolled ingest key", "source.created": "Created source", "source.updated": "Updated source", diff --git a/packages/domain/src/aws.ts b/packages/domain/src/aws.ts new file mode 100644 index 0000000000..b8cf666366 --- /dev/null +++ b/packages/domain/src/aws.ts @@ -0,0 +1,215 @@ +import { Schema } from "effect" +import { type AwsAccountId, type AwsConnectionId, AwsModule } from "./primitives" + +const isAwsModule = Schema.is(AwsModule) + +/** + * Bumped whenever the setup's IAM shape changes. The role carries it as a tag, so a connection + * whose stack predates a change says "update the stack" instead of failing a read. + */ +export const AWS_TEMPLATE_VERSION = 1 + +/** Tag keys the stack puts on the role; the connection check reads them back. */ +export const AWS_ROLE_TAGS = { + templateVersion: "maple:template-version", + connectionId: "maple:connection-id", +} as const + +interface AwsModuleSpec { + readonly title: string + /** What the module lets Maple do, in one line for the connect card. */ + readonly summary: string + /** Whether Maple reads anything with it yet. A planned module is not offered. */ + readonly available: boolean + /** AWS managed policies attached when the module is on. Metadata only: never data reads. */ + readonly managedPolicies: ReadonlyArray + /** Actions of the module's own policy, beyond the managed ones. */ + readonly actions: ReadonlyArray +} + +/** + * What each module grants. The setup is generated from this table, and the connection check reads + * which module policies are attached, so a module and its permissions never drift apart. + */ +export const AWS_MODULE_SPECS = { + observability: { + title: "Metrics and logs", + summary: "CloudWatch metrics and log groups of your AWS resources.", + available: false, + managedPolicies: [], + actions: [ + "cloudwatch:GetMetricData", + "cloudwatch:ListMetrics", + "cloudwatch:DescribeAlarms", + "logs:DescribeLogGroups", + "tag:GetResources", + ], + }, + inventory: { + title: "Resource inventory", + summary: "What runs in the account and how it connects, matched to your services.", + available: false, + managedPolicies: ["arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"], + actions: ["resource-explorer-2:ListIndexes", "resource-explorer-2:Search", "tag:GetResources"], + }, + security: { + title: "Security scan", + summary: "Misconfigurations and findings, ranked by the traffic they are exposed to.", + available: false, + managedPolicies: ["arn:aws:iam::aws:policy/SecurityAudit"], + actions: [ + "securityhub:GetFindings", + "guardduty:ListDetectors", + "guardduty:ListFindings", + "guardduty:GetFindings", + "inspector2:ListFindings", + "access-analyzer:ListFindingsV2", + ], + }, + cost: { + title: "Cost", + summary: "Spend by service and resource, and what to cut.", + available: false, + managedPolicies: [], + actions: [ + "ce:GetCostAndUsage", + "ce:GetCostForecast", + "compute-optimizer:GetEnrollmentStatus", + "compute-optimizer:GetEC2InstanceRecommendations", + "bcm-data-exports:ListExports", + "bcm-data-exports:GetExport", + ], + }, +} as const satisfies Record + +export const isAwsModuleAvailable = (module: AwsModule) => AWS_MODULE_SPECS[module].available + +/** + * Names of what a connection's setup creates in the customer's account. Derived from the + * connection id alone, so a caller can never pick the role Maple assumes. + */ +export const awsConnectionResourceNames = (connectionId: AwsConnectionId) => { + const hex = connectionId.replaceAll("-", "").slice(0, 24) + const name = `MapleIntegration-${hex}` + return { + roleName: name, + stackName: `maple-integration-${hex}`, + /** The customer managed policy of one module. */ + modulePolicyName: (module: AwsModule) => `${name}-${module}`, + /** What Maple names its sessions on the role, as CloudTrail shows them. */ + sessionName: `maple-${hex}`, + } +} + +export const awsRoleArn = (accountId: AwsAccountId, roleName: string) => + `arn:aws:iam::${accountId}:role/${roleName}` + +/** The IAM identity Maple assumes customer roles as: an ARN in Maple's own account. */ +export interface AwsMaplePrincipal { + readonly arn: string + readonly accountId: string +} + +/** `arn:aws:iam::123456789012:user/maple` as a principal, or undefined for anything else. */ +export const parseAwsMaplePrincipal = (arn: string): AwsMaplePrincipal | undefined => { + const accountId = /^arn:aws:iam::([0-9]{12}):(user|role)\/[\w+=,.@/-]+$/u.exec(arn)?.[1] + return accountId === undefined ? undefined : { arn, accountId } +} + +/** Which module a customer managed policy name stands for, given the connection's role name. */ +export const awsModuleOfPolicyName = (roleName: string, policyName: string): AwsModule | undefined => { + const prefix = `${roleName}-` + if (!policyName.startsWith(prefix)) return undefined + const rest = policyName.slice(prefix.length) + return isAwsModule(rest) ? rest : undefined +} + +export interface AwsSetupPlanInput { + readonly connectionId: AwsConnectionId + readonly accountId: AwsAccountId + readonly externalId: string + readonly maplePrincipal: AwsMaplePrincipal + readonly modules: ReadonlyArray +} + +interface IamStatement { + readonly Effect: "Allow" + readonly Action: string | ReadonlyArray + readonly Resource?: string | ReadonlyArray + readonly Principal?: { readonly AWS: string } + readonly Condition?: Record> +} + +interface IamDocument { + readonly Version: "2012-10-17" + readonly Statement: ReadonlyArray +} + +export interface AwsSetupPlan { + readonly roleName: string + readonly stackName: string + readonly roleArn: string + readonly trustPolicy: IamDocument + /** Lets the role read its own tags and attached policies: how Maple checks the setup. */ + readonly selfReadPolicy: IamDocument + readonly modules: ReadonlyArray<{ + readonly module: AwsModule + readonly policyName: string + readonly managedPolicies: ReadonlyArray + readonly policy: IamDocument + }> + readonly tags: ReadonlyArray<{ readonly Key: string; readonly Value: string }> +} + +/** Everything the setup creates, as data. The CloudFormation and Terraform renderings read it. */ +export const awsSetupPlan = (input: AwsSetupPlanInput): AwsSetupPlan => { + const names = awsConnectionResourceNames(input.connectionId) + const roleArn = awsRoleArn(input.accountId, names.roleName) + return { + roleName: names.roleName, + stackName: names.stackName, + roleArn, + // Maple's account is the principal and the condition names the exact identity: an IAM user + // recreated under the same name still matches, which a principal ARN alone would not. + trustPolicy: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { AWS: `arn:aws:iam::${input.maplePrincipal.accountId}:root` }, + Action: "sts:AssumeRole", + Condition: { + StringEquals: { "sts:ExternalId": input.externalId }, + ArnEquals: { "aws:PrincipalArn": input.maplePrincipal.arn }, + }, + }, + ], + }, + selfReadPolicy: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["iam:GetRole", "iam:ListAttachedRolePolicies"], + Resource: roleArn, + }, + ], + }, + modules: input.modules.map((module) => { + const spec = AWS_MODULE_SPECS[module] + return { + module, + policyName: names.modulePolicyName(module), + managedPolicies: spec.managedPolicies, + policy: { + Version: "2012-10-17", + Statement: [{ Effect: "Allow", Action: [...spec.actions].sort(), Resource: "*" }], + }, + } + }), + tags: [ + { Key: AWS_ROLE_TAGS.templateVersion, Value: String(AWS_TEMPLATE_VERSION) }, + { Key: AWS_ROLE_TAGS.connectionId, Value: input.connectionId }, + ], + } +} diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index e976973421..627ce64f0a 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -1,6 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { + AwsAccountId, ExternalUserId, GcpProjectId, GcpResourceNumber, @@ -1141,6 +1142,40 @@ export class GcpMetricsUnavailableError extends HttpTaggedError()( + "@maple/http/errors/AwsAccountAlreadyConnectedError", + { + accountId: AwsAccountId, + message: Schema.String, + }, + { + status: 409, + code: "aws_account_already_connected", + title: "Already connected", + param: "account_id", + retry: "never", + recovery: "fix_request", + exposure: "public_message", + }, +) {} + +/** This Maple deployment has no AWS identity to assume customer roles with. */ +export class AwsIntegrationUnavailableError extends HttpTaggedError()( + "@maple/http/errors/AwsIntegrationUnavailableError", + { + message: Schema.String, + }, + { + status: 409, + code: "aws_integration_unavailable", + title: "AWS is not available", + retry: "never", + recovery: "none", + exposure: "public_message", + }, +) {} + export type IntegrationHttpError = | IntegrationsForbiddenError | IntegrationsConfigurationError diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index f9c1caf5ed..89bdf9c293 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -11,6 +11,7 @@ import { V2DashboardsApiGroup } from "./dashboards" import { V2IngestKeysApiGroup } from "./ingest-keys" import { V2SourcesApiGroup } from "./sources" import { V2ChatIntegrationsApiGroup } from "./integrations-chat" +import { V2AwsIntegrationsApiGroup } from "./integrations-aws" import { V2GcpIntegrationsApiGroup } from "./integrations-gcp" import { V2PlanetScaleIntegrationsApiGroup } from "./integrations-planetscale" import { V2ErrorIssuesApiGroup } from "./error-issues" @@ -102,6 +103,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2ChatIntegrationsApiGroup) .add(V2PlanetScaleIntegrationsApiGroup) .add(V2GcpIntegrationsApiGroup) + .add(V2AwsIntegrationsApiGroup) .add(V2ErrorIssuesApiGroup) .add(V2AttributeMappingsApiGroup) .add(V2AuditLogApiGroup) diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts index 67b8c0e6ff..344ca33bf6 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -15,6 +15,7 @@ export * from "./error-issues" export * from "./ingest-keys" export * from "./sources" export * from "./integrations-chat" +export * from "./integrations-aws" export * from "./integrations-gcp" export * from "./integrations-planetscale" export * from "./investigations" diff --git a/packages/domain/src/http/v2/integrations-aws.ts b/packages/domain/src/http/v2/integrations-aws.ts new file mode 100644 index 0000000000..2c96807d9d --- /dev/null +++ b/packages/domain/src/http/v2/integrations-aws.ts @@ -0,0 +1,338 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" +import { Schema } from "effect" +import { AwsAccountId, AwsConnectionId, AwsModule } from "../../primitives" +import { + AwsAccountAlreadyConnectedError, + AwsIntegrationUnavailableError, + IntegrationsNotFoundError, + IntegrationsPersistenceError, + IntegrationsValidationError, +} from "../integrations" +import { AuthorizationV2 } from "./auth" +import { wireExample, Timestamp } from "./envelopes" +import { V2InsufficientPermissions } from "./errors" +import { publicErrors } from "./public-error" +import { PublicId, PublicIdPrefixes } from "./public-id" + +// AWS integration. No OAuth and no stored secret: an org registers a connection for an account, an +// administrator deploys the generated CloudFormation stack, and Maple assumes the role it creates +// with an external id Maple minted. Each module is its own policy on the role. Public v2 from the +// start so scripted setups can use a scoped API key. + +/** `awsc_…` public ID ⇄ internal `AwsConnectionId` (raw UUID). */ +export const AwsConnectionPublicId = PublicId(PublicIdPrefixes.awsConnection, AwsConnectionId) + +const connectionExample = { + id: "awsc_YofPTrK9782DWwcnXhpcCw", + object: "aws_connection", + account_id: "123456789012", + role_arn: "arn:aws:iam::123456789012:role/MapleIntegration-018f2b3c4d5e4f708192a3b4", + external_id: "9f1c4e2a7b3d4c6e8a0b1c2d3e4f5a6b", + modules: [], + granted_modules: [], + template_version: 1, + setup_outdated: false, + verified_at: "2026-10-21T12:05:00.000Z", + last_checked_at: "2026-10-21T13:00:00.000Z", + last_check_error: null, + created_at: "2026-10-21T12:00:00.000Z", +} as const + +export const V2AwsConnection = Schema.Struct({ + id: AwsConnectionPublicId, + object: Schema.Literal("aws_connection").annotate({ + description: 'The object type, always `"aws_connection"`.', + }), + account_id: AwsAccountId.annotate({ + description: "The 12-digit AWS account the connection reads.", + }), + role_arn: Schema.String.annotate({ + description: + "The IAM role the setup creates and Maple assumes. Its name is derived from the connection, so it cannot point at another role.", + examples: [connectionExample.role_arn], + }), + external_id: Schema.String.annotate({ + description: + "The external id the role's trust policy requires. Maple mints it; it is not a secret, but it is what keeps another Maple organization from assuming this role.", + examples: [connectionExample.external_id], + }), + modules: Schema.Array(AwsModule).annotate({ + description: "What the connection is meant to let Maple read. The setup grants exactly these.", + examples: [[]], + }), + granted_modules: Schema.NullOr(Schema.Array(AwsModule)).annotate({ + description: + "The modules whose policies the latest check found on the role, or `null` before a check could assume it.", + examples: [[]], + }), + template_version: Schema.NullOr(Schema.Number).annotate({ + description: "The setup version the role is tagged with, or `null` before a check could read it.", + examples: [1], + }), + setup_outdated: Schema.Boolean.annotate({ + description: + "Whether the deployed setup differs from what the connection asks for: an older template, or other modules. Deploy the stack from `setup` again.", + examples: [false], + }), + verified_at: Schema.NullOr(Timestamp).annotate({ + description: "When Maple first assumed the role, or `null` while the setup has not been deployed.", + }), + last_checked_at: Schema.NullOr(Timestamp).annotate({ + description: "When Maple last tried to assume the role. Checked hourly and on `check`.", + }), + last_check_error: Schema.NullOr(Schema.String).annotate({ + description: "Why the latest check failed and what to do about it, or `null`.", + examples: [null], + }), + created_at: Timestamp.annotate({ description: "When the connection was created." }), +}).annotate({ + identifier: "AwsConnection", + title: "AWS connection", + description: "One connected AWS account.", + examples: [wireExample(connectionExample)], +}) +export type V2AwsConnection = Schema.Schema.Type + +export const V2AwsModuleInfo = Schema.Struct({ + module: AwsModule, + title: Schema.String, + summary: Schema.String, + available: Schema.Boolean.annotate({ + description: + "Whether Maple reads anything with this module yet. Unavailable modules cannot be requested.", + }), +}).annotate({ identifier: "AwsModuleInfo", title: "AWS module" }) + +export const V2AwsIntegration = Schema.Struct({ + object: Schema.Literal("aws_integration").annotate({ + description: 'The object type, always `"aws_integration"`.', + }), + available: Schema.Boolean.annotate({ + description: + "Whether this Maple deployment has an AWS identity to assume roles with. When `false`, connections cannot be created.", + examples: [true], + }), + modules: Schema.Array(V2AwsModuleInfo).annotate({ + description: "Every module, in the order the setup lists them.", + }), + connections: Schema.Array(V2AwsConnection).annotate({ + description: "Every connection of the organization, oldest first.", + }), +}).annotate({ + identifier: "AwsIntegration", + title: "AWS integration status", + description: + 'The AWS integration state for the authenticated organization. Note: `connections` is **not** the standard `{ object: "list", data, has_more, next_cursor }` envelope: an organization has a handful of connections and they are returned whole.', + examples: [ + wireExample({ + object: "aws_integration", + available: true, + modules: [], + connections: [connectionExample], + }), + ], +}) +export type V2AwsIntegration = Schema.Schema.Type + +const modulesField = Schema.optionalKey(Schema.Array(AwsModule)).annotate({ + description: "What Maple may read. Defaults to none: the role then only lets Maple confirm the setup.", + examples: [[]], +}) + +export const V2AwsCreateConnectionRequest = Schema.Struct({ + account_id: AwsAccountId.annotate({ description: "The 12-digit AWS account ID." }), + modules: modulesField, +}).annotate({ + identifier: "AwsCreateConnectionRequest", + title: "AWS connection create request", + examples: [wireExample({ account_id: "123456789012" })], +}) +export type V2AwsCreateConnectionRequest = Schema.Schema.Type + +export const V2AwsUpdateConnectionRequest = Schema.Struct({ + modules: modulesField, +}).annotate({ + identifier: "AwsUpdateConnectionRequest", + title: "AWS connection update request", + description: "Omitted fields are unchanged. AWS follows once the stack from `setup` is deployed again.", + examples: [wireExample({ modules: [] })], +}) +export type V2AwsUpdateConnectionRequest = Schema.Schema.Type + +const SCRIPT_EXAMPLE = "set -euo pipefail\n…\naws cloudformation deploy --stack-name maple-integration-…\n" + +export const V2AwsSetup = Schema.Struct({ + object: Schema.Literal("aws_connection.setup").annotate({ + description: 'The object type, always `"aws_connection.setup"`.', + }), + stack_name: Schema.String.annotate({ + description: "The CloudFormation stack the setup deploys.", + examples: ["maple-integration-018f2b3c4d5e4f708192a3b4"], + }), + stack_region: Schema.String.annotate({ + description: + "The region the stack lives in. IAM is global, but a stack is not: create and delete it in this region.", + examples: ["us-east-1"], + }), + setup_script: Schema.String.annotate({ + description: + "Text to paste into AWS CloudShell, signed in to the connection's account as an administrator. It deploys `cloudformation_template` as `stack_name`; run it again after changing `modules`. It carries no secret.", + examples: [SCRIPT_EXAMPLE], + }), + cleanup_script: Schema.String.annotate({ + description: "Text to paste into AWS CloudShell that deletes the stack and everything it created.", + examples: ["aws cloudformation delete-stack --stack-name maple-integration-…\n"], + }), + cloudformation_template: Schema.String.annotate({ + description: + "For a setup in the console: the stack as a CloudFormation template (JSON), to upload under Create stack → With new resources.", + }), + terraform: Schema.String.annotate({ + description: "The same setup as a self-contained Terraform file for the `aws` provider.", + }), +}).annotate({ + identifier: "AwsSetup", + title: "AWS connection setup", + description: + "The ways to set up one AWS connection: the CloudShell script, or the same resources in the console or as code. Maple confirms the setup by assuming the role.", +}) +export type V2AwsSetup = Schema.Schema.Type + +export const V2AwsConnectionDeleteResponse = Schema.Struct({ + id: AwsConnectionPublicId, + object: Schema.Literal("aws_connection").annotate({ + description: 'The object type, always `"aws_connection"`.', + }), + deleted: Schema.Literal(true).annotate({ + description: "Always `true` on a successful delete.", + }), + cleanup_script: Schema.String.annotate({ + description: + "Text to paste into AWS CloudShell that deletes the stack. Maple has no write access to AWS, so an administrator runs it; until then the role exists but no Maple organization holds its external id.", + }), +}).annotate({ + identifier: "AwsConnectionDeleteResponse", + title: "AWS connection delete response", +}) +export type V2AwsConnectionDeleteResponse = Schema.Schema.Type + +const [alreadyConnected, unavailable, validation, connectionNotFound, integrationPersistence] = publicErrors( + AwsAccountAlreadyConnectedError, + AwsIntegrationUnavailableError, + IntegrationsValidationError, + IntegrationsNotFoundError, + IntegrationsPersistenceError, +) + +const connectionParams = { id: AwsConnectionPublicId } + +export class V2AwsIntegrationsApiGroup extends HttpApiGroup.make("awsIntegration") + .add( + HttpApiEndpoint.get("status", "/", { + success: V2AwsIntegration, + error: [integrationPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getAwsIntegration", + summary: "Retrieve AWS integration status", + description: + "Returns the organization's AWS connections with the outcome of their latest check, the modules Maple offers, and whether this deployment can connect AWS at all. Requires the `integrations:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("createConnection", "/connections", { + payload: V2AwsCreateConnectionRequest, + success: V2AwsConnection, + error: [ + V2InsufficientPermissions.schema, + alreadyConnected, + unavailable, + validation, + integrationPersistence, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "createAwsConnection", + summary: "Connect an AWS account", + description: + "Registers a connection for one account and mints its external id. Maple reads nothing until an administrator deploys the stack from `setup`. An account can be connected once per Maple organization. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.patch("updateConnection", "/connections/:id", { + params: connectionParams, + payload: V2AwsUpdateConnectionRequest, + success: V2AwsConnection, + error: [V2InsufficientPermissions.schema, connectionNotFound, validation, integrationPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "updateAwsConnection", + summary: "Change what a connection reads", + description: + "Changes the connection's modules. Maple cannot change AWS itself: deploy the stack from `setup` again afterwards. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.get("setup", "/connections/:id/setup", { + params: connectionParams, + success: V2AwsSetup, + error: [ + V2InsufficientPermissions.schema, + connectionNotFound, + unavailable, + integrationPersistence, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getAwsConnectionSetup", + summary: "Render a connection's setup", + description: + "Returns the CloudShell script, the CloudFormation template and the Terraform file that set up one connection. Requires an org-admin role and the `integrations:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("checkConnection", "/connections/:id/check", { + params: connectionParams, + success: V2AwsConnection, + error: [ + V2InsufficientPermissions.schema, + connectionNotFound, + unavailable, + integrationPersistence, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "checkAwsConnection", + summary: "Check a connection now", + description: + "Assumes the role and reads what the setup granted, instead of waiting for the hourly check. A failed check is reported in `last_check_error`, not as an error response. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("deleteConnection", "/connections/:id", { + params: connectionParams, + success: V2AwsConnectionDeleteResponse, + error: [V2InsufficientPermissions.schema, connectionNotFound, integrationPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "deleteAwsConnection", + summary: "Disconnect an AWS account", + description: + "Deletes the connection and its source, after which Maple no longer assumes the role. Already-ingested data is unaffected. The response carries the script that deletes the stack. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .prefix("/v2/integrations/aws") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "AWS Integration", + description: + "Connect AWS accounts: register a connection, choose what Maple may read, fetch the CloudFormation stack that sets it up, and see whether Maple can assume its role.", + }), + ) {} diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index b178691da8..d917a4c338 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -83,6 +83,7 @@ describe("MapleApiV2 OpenAPI", () => { "DELETE /v2/dashboards/{id}", "DELETE /v2/dashboards/{id}/share", "DELETE /v2/dashboards/{id}/widgets/{widget_id}/share", + "DELETE /v2/integrations/aws/connections/{id}", "DELETE /v2/integrations/chat_connectors/{connector}/identity", "DELETE /v2/integrations/chat_workspaces/{id}", "DELETE /v2/integrations/gcp/connectors/{id}", @@ -129,6 +130,8 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/instrumentation/audit", "GET /v2/instrumentation/recommendations", "GET /v2/instrumentation/signals", + "GET /v2/integrations/aws", + "GET /v2/integrations/aws/connections/{id}/setup", "GET /v2/integrations/chat_connectors", "GET /v2/integrations/chat_workspaces/{id}/destinations", "GET /v2/integrations/gcp", @@ -168,6 +171,7 @@ describe("MapleApiV2 OpenAPI", () => { "PATCH /v2/anomalies/settings", "PATCH /v2/attribute_mappings/{id}", "PATCH /v2/dashboards/{id}", + "PATCH /v2/integrations/aws/connections/{id}", "PATCH /v2/integrations/chat_workspaces/{id}", "PATCH /v2/integrations/gcp/connectors/{id}", "PATCH /v2/scrape_targets/{id}", @@ -198,6 +202,8 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/ingest_keys/public/roll", "POST /v2/instrumentation/recommendations/{id}/dismiss", "POST /v2/instrumentation/recommendations/{id}/reopen", + "POST /v2/integrations/aws/connections", + "POST /v2/integrations/aws/connections/{id}/check", "POST /v2/integrations/chat_connectors/{connector}/identity/link", "POST /v2/integrations/chat_connectors/{connector}/install", "POST /v2/integrations/gcp/connectors", diff --git a/packages/domain/src/http/v2/public-id.ts b/packages/domain/src/http/v2/public-id.ts index 2879313432..6103aaf153 100644 --- a/packages/domain/src/http/v2/public-id.ts +++ b/packages/domain/src/http/v2/public-id.ts @@ -43,6 +43,7 @@ export const PublicIdPrefixes = { chatWorkspace: "chatw", agentFeedback: "afb", gcpConnector: "gcpc", + awsConnection: "awsc", mcpConnection: "mcpc", /** Synthetic identity for logs, which have no native OTel record id. */ log: "log", diff --git a/packages/domain/src/source-health.ts b/packages/domain/src/source-health.ts index bc0d1fc07f..a2176c2d00 100644 --- a/packages/domain/src/source-health.ts +++ b/packages/domain/src/source-health.ts @@ -66,6 +66,8 @@ const POLICIES = { // The inventory refreshes hourly; metrics arrive through the managed scrape. planetscale: { freshness: "poll", staleAfterMs: 3 * 60 * MINUTE }, github: { freshness: "poll", staleAfterMs: 24 * 60 * MINUTE }, + // The connection check runs hourly; three missed checks is no longer a blip. + aws: { freshness: "poll", staleAfterMs: 3 * 60 * MINUTE }, } satisfies Record /** diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 7e77b25dba..655564c4d4 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -118,6 +118,24 @@ export const GcpResourceNumber = Schema.String.check(Schema.isPattern(/^[0-9]{1, ) export type GcpResourceNumber = Schema.Schema.Type +export const AwsConnectionId = MapleUuidId("@maple/AwsConnectionId", "AWS Connection ID") +export type AwsConnectionId = Schema.Schema.Type + +// Twelve digits, as AWS prints it. It is interpolated into role ARNs and setup scripts. +export const AwsAccountId = Schema.String.check(Schema.isPattern(/^[0-9]{12}$/u)).pipe( + Schema.brand("@maple/AwsAccountId"), + Schema.annotate({ identifier: "@maple/AwsAccountId", title: "AWS Account ID" }), +) +export type AwsAccountId = Schema.Schema.Type + +/** + * What an AWS connection lets Maple read. Each module is its own managed policy on the role, so + * turning one on is a stack update, not a reconnect. + */ +export const AWS_MODULES = ["observability", "inventory", "security", "cost"] as const +export const AwsModule = Schema.Literals(AWS_MODULES) +export type AwsModule = Schema.Schema.Type + /** What a Google Cloud connector covers: one project, or everything under a folder or organization. */ export const GcpScopeType = Schema.Literals(["project", "folder", "organization"]) export type GcpScopeType = Schema.Schema.Type @@ -292,6 +310,7 @@ export const PULL_SOURCE_PLATFORMS = [ "railway", "planetscale", "github", + "aws", ] as const export const PushSourcePlatform = Schema.Literals(PUSH_SOURCE_PLATFORMS).annotate({ From 5f1449e74f6bc9731613ce105b1f2b19f68a035d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 21 Oct 2026 14:50:09 +0200 Subject: [PATCH 02/13] chore(aws): regenerate the iOS OpenAPI spec and anticipated error identifiers --- apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json | 4 ++++ .../domain/src/generated/anticipated-error-identifiers.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index decbaa1e31..ec8493639e 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -12224,6 +12224,10 @@ "description": "Connect Google Cloud projects, folders or organizations: register a connector, choose whether it forwards logs and whether Maple may read metrics and resources, fetch the `gcloud` scripts that set it up, and see whether logs are arriving.", "name": "Google Cloud Integration" }, + { + "description": "Connect AWS accounts: register a connection, choose what Maple may read, fetch the CloudFormation stack that sets it up, and see whether Maple can assume its role.", + "name": "AWS Integration" + }, { "description": "Deduplicated errors and alert-backed issues tracked through Maple's triage workflow.", "name": "Error Issues" diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index dcd9eb7788..e65403b6b6 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -21,6 +21,8 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/AlertRuleNotFoundError", "@maple/http/errors/AlertValidationError", "@maple/http/errors/ApiKeyNotFoundError", + "@maple/http/errors/AwsAccountAlreadyConnectedError", + "@maple/http/errors/AwsIntegrationUnavailableError", "@maple/http/errors/BillingConflictError", "@maple/http/errors/BillingForbiddenError", "@maple/http/errors/BillingPaymentRequiredError", From abbbacf544ce48f83fdfbd7800c4e9189effb5a9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 21 Oct 2026 14:56:15 +0200 Subject: [PATCH 03/13] fix(aws): the unavailable card drops the steps it cannot offer --- apps/web/src/page/integrations/view-aws.ts | 32 ++++++++++++---------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/web/src/page/integrations/view-aws.ts b/apps/web/src/page/integrations/view-aws.ts index 83a37b9506..8c58a12332 100644 --- a/apps/web/src/page/integrations/view-aws.ts +++ b/apps/web/src/page/integrations/view-aws.ts @@ -128,24 +128,28 @@ const accountForm = (h: H, model: Model, canCancel: boolean): Html => { ) } +/** Unavailable on this deployment: the card says so, without steps the reader can't take. */ const connectCard = (h: H, model: Model, status: V2AwsIntegration): Html => integrationEmpty(h, [ integrationEmptyFeatures(h, FEATURES), - integrationEmptyCard(h, [ - integrationEmptyMedia(h, BRAND), - integrationEmptyHint(h, [ - "Enter the account to connect. Maple then gives you a stack to deploy from AWS CloudShell, and confirms it by assuming the role.", - ]), + integrationEmptyCard( + h, status.available - ? accountForm(h, model, false) - : h.p( - [h.Class("text-sm text-muted-foreground"), h.Role("status")], - ["AWS can't be connected on this Maple deployment yet."], - ), - integrationEmptyFooter(h, [ - "You need permission to create IAM roles in the account. Each account connects separately.", - ]), - ]), + ? [ + integrationEmptyMedia(h, BRAND), + integrationEmptyHint(h, [ + "Enter the account to connect. Maple then gives you a stack to deploy from AWS CloudShell, and confirms it by assuming the role.", + ]), + accountForm(h, model, false), + integrationEmptyFooter(h, [ + "You need permission to create IAM roles in the account. Each account connects separately.", + ]), + ] + : [ + integrationEmptyMedia(h, BRAND), + integrationEmptyHint(h, ["AWS can't be connected on this Maple deployment yet."]), + ], + ), ]) type Health = Readonly<{ label: string; variant: "ok" | "warn" | "crit" | "muted" }> From d0e7750f379af6ce0276869c6bd8a9fa26231c53 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 21 Oct 2026 15:07:56 +0200 Subject: [PATCH 04/13] fix(aws): a background check only closes its own setup; pending setup is not a failure --- .../src/page/integrations/aws.scene.test.ts | 27 +++++++++++++++++++ apps/web/src/page/integrations/derive.ts | 5 +++- apps/web/src/page/integrations/update-aws.ts | 6 +++-- .../integrations/AwsConnectionService.test.ts | 12 +++++++-- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/web/src/page/integrations/aws.scene.test.ts b/apps/web/src/page/integrations/aws.scene.test.ts index ba819b0bb9..1288de1269 100644 --- a/apps/web/src/page/integrations/aws.scene.test.ts +++ b/apps/web/src/page/integrations/aws.scene.test.ts @@ -10,6 +10,7 @@ import { Message } from "./message" import type { Model } from "./model" import { init, update } from "./update" import { statusKey } from "./update-status" +import { overviewOf } from "./derive" const orgId = Schema.decodeSync(OrgId)("org_acme") const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -161,6 +162,32 @@ describe("aws card", () => { ) }) + test("a background check verifying another connection leaves the open setup alone", () => { + const other = Schema.decodeSync(AwsConnectionId)("028f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8") + const open = pageUpdate( + withStatus(entered(), [connection(), connection({ id: other })]), + Message.ClickedAwsSetup({ id: connectionId }), + ).model + const polling = pageUpdate(open, Message.TickedAwsPending()).model + const verified = connection({ + id: other, + verified_at: Schema.decodeSync(Timestamp)("2026-10-21T12:05:00.000Z"), + }) + const done = pageUpdate( + polling, + Message.CompletedAwsCheck({ id: other, quiet: true, connection: verified, failure: null }), + ) + expect(done.model.awsCard.setupId).toBe(connectionId) + expect(done.outMessage).toBeUndefined() + }) + + test("a connection waiting for its stack reads as setup in progress, not a failure", () => { + const model = withStatus(entered(), [connection()]) + const overview = overviewOf(model.statuses, model.nowMs, "aws") + expect(overview?.kind === "connected" && overview.health).toBe("pending") + expect(overview?.kind === "connected" && overview.issue).toBe("Finish setup") + }) + test("a status read that finds the open setup verified closes it", () => { const waiting = pageUpdate( withStatus(entered(), [connection()]), diff --git a/apps/web/src/page/integrations/derive.ts b/apps/web/src/page/integrations/derive.ts index 4e5eb54400..3f2f8f5873 100644 --- a/apps/web/src/page/integrations/derive.ts +++ b/apps/web/src/page/integrations/derive.ts @@ -261,9 +261,12 @@ const awsOverview = (statuses: Statuses, nowMs: number) => const pending = status.connections.some((connection) => connection.verified_at === null) const issue = failing > 0 ? `${countLabel(failing, "account")} failing` : pending ? "Finish setup" : null + // A stack not deployed yet is setup in progress, not a failure. + const health: Pick = + failing === 0 && pending ? { health: "pending", stateLabel: "Finish setup" } : attentionOr(issue) return { kind: "connected", - ...attentionOr(issue), + ...health, context: status.connections.length === 1 ? (status.connections[0]?.account_id ?? null) : null, stat: countLabel(status.connections.length, "account"), lastSyncLabel: syncedLabel( diff --git a/apps/web/src/page/integrations/update-aws.ts b/apps/web/src/page/integrations/update-aws.ts index bf27017654..a00d34f3d5 100644 --- a/apps/web/src/page/integrations/update-aws.ts +++ b/apps/web/src/page/integrations/update-aws.ts @@ -165,13 +165,15 @@ export const completedCheck = ( } const before = connectionsOf(model).find((entry) => entry.id === id) const justVerified = before?.verified_at === null && connection?.verified_at != null + // Only the open setup's own connection closes it: a background check may verify another. + const isOpen = model.awsCard.setupId === id return chain(settled, [ // A check lands after the page read its clock: "checked" would read as in the future. (next) => ({ model: next, commands: [ReadClock()] }), (next) => requestStatus(next, context, "aws", "Supersede"), (next) => - justVerified - ? chain(withCard(next, { setupId: null }), [ + justVerified && (isOpen || !quiet) + ? chain(withCard(next, isOpen ? { setupId: null } : {}), [ (after) => toast( after, diff --git a/packages/backend/src/services/integrations/AwsConnectionService.test.ts b/packages/backend/src/services/integrations/AwsConnectionService.test.ts index fd0e506408..ad3fe6123e 100644 --- a/packages/backend/src/services/integrations/AwsConnectionService.test.ts +++ b/packages/backend/src/services/integrations/AwsConnectionService.test.ts @@ -118,7 +118,7 @@ describe("AwsConnectionService", () => { }).pipe(Effect.provide(makeLayer(testDb, aws))) }) - it.effect("never lets one connection's stack verify another's", () => { + it.effect("never lets one connection's external id verify another's role", () => { const testDb = createTestDb(trackedDbs) const aws = makeFakeAws() return Effect.gen(function* () { @@ -126,9 +126,17 @@ describe("AwsConnectionService", () => { const mine = yield* service.create(orgId, userId, { accountId: account, modules: [] }) const theirs = yield* service.create(otherOrgId, userId, { accountId: account, modules: [] }) aws.deploy(account, (yield* service.setup(orgId, mine.id)).cloudformationTemplate) + // Their role, deployed exactly as rendered except for trusting my external id: the + // mismatch alone must be what refuses it. + const template = (yield* service.setup(otherOrgId, theirs.id)).cloudformationTemplate + assert.include(template, theirs.externalId) + aws.deploy(account, template.replaceAll(theirs.externalId, mine.externalId)) const checked = yield* later(service.check(otherOrgId, theirs.id)) assert.isNull(checked.verifiedAt) - assert.notStrictEqual(mine.externalId, theirs.externalId) + // The same role trusting their own id verifies: nothing else was wrong with it. + aws.deploy(account, template) + const verified = yield* later(service.check(otherOrgId, theirs.id)) + assert.isNotNull(verified.verifiedAt) }).pipe(Effect.provide(makeLayer(testDb, aws))) }) From 5bba37df848e4975d8bb4848e167025f91202c40 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 21 Oct 2026 15:12:25 +0200 Subject: [PATCH 05/13] fix(aws): the hourly sweep runs batches until nothing is due, never-checked first --- .../integrations/AwsConnectionService.test.ts | 21 ++++++++ .../integrations/AwsConnectionService.ts | 50 ++++++++++++++++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/services/integrations/AwsConnectionService.test.ts b/packages/backend/src/services/integrations/AwsConnectionService.test.ts index ad3fe6123e..be1b3b7c33 100644 --- a/packages/backend/src/services/integrations/AwsConnectionService.test.ts +++ b/packages/backend/src/services/integrations/AwsConnectionService.test.ts @@ -296,4 +296,25 @@ describe("AwsConnectionService", () => { assert.strictEqual(aws.calls.length, calls) }).pipe(Effect.provide(makeLayer(testDb, aws))) }) + + it.effect("one sweep checks every due connection, past a single batch", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + // More than one batch of 100, each in its own account. + yield* Effect.forEach( + Array.from({ length: 101 }, (_, index) => index), + (index) => + service.create(orgId, userId, { + accountId: Schema.decodeUnknownSync(AwsAccountId)(String(100000000000 + index)), + modules: [], + }), + { concurrency: 10 }, + ) + const sweep = yield* service.checkDue() + assert.deepStrictEqual(sweep, { due: 101, checked: 101, failures: 101 }) + assert.deepStrictEqual(yield* service.checkDue(), { due: 0, checked: 0, failures: 0 }) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) }) diff --git a/packages/backend/src/services/integrations/AwsConnectionService.ts b/packages/backend/src/services/integrations/AwsConnectionService.ts index d11f6e2876..bfe4a4a67b 100644 --- a/packages/backend/src/services/integrations/AwsConnectionService.ts +++ b/packages/backend/src/services/integrations/AwsConnectionService.ts @@ -153,8 +153,10 @@ const CHECK_INTERVAL_MS = 55 * 60_000 const CHECK_LEASE_MS = 15 * 60_000 /** A manual check this soon after a good one answers with it. */ const MANUAL_CHECK_INTERVAL_MS = 5_000 -/** Connections one sweep claims, sized so a slow AWS still finishes inside the lane's deadline. */ +/** Connections one batch claims; a sweep runs batches until none is full. */ const SWEEP_LIMIT = 100 +/** A sweep starts no batch past this, inside the lane's 15 minute deadline. */ +const SWEEP_BUDGET_MS = 10 * 60_000 /** * A Worker allows six simultaneous connections, shared by the lane's Postgres pool and the AWS * fetches; each check holds one of each at most. @@ -495,15 +497,16 @@ export class AwsConnectionService extends Context.Service @@ -521,7 +524,12 @@ export class AwsConnectionService extends Context.Service [ + [PG.asBoolean($.lastCheckedAt.isNotNull()), "asc"], + [$.lastCheckedAt, "asc"], + [$.id, "asc"], + ]) .limit(SWEEP_LIMIT), ), ) @@ -546,7 +554,7 @@ export class AwsConnectionService extends Context.Service - runCheck(maple.key, row).pipe( + runCheck(key, row).pipe( Effect.map(({ ok }) => ok), Effect.withSpan("AwsConnectionService.checkDue.connection", { attributes: { orgId: row.orgId, "maple.aws.connection_id": row.id }, @@ -571,6 +579,36 @@ export class AwsConnectionService extends Context.Service => + sweepBatch(key).pipe( + Effect.flatMap((batch) => + Effect.flatMap(Clock.currentTimeMillis, (now) => { + const next = { + due: total.due + batch.due, + checked: total.checked + batch.checked, + failures: total.failures + batch.failures, + } + return batch.due < SWEEP_LIMIT || now - startedAt >= SWEEP_BUDGET_MS + ? Effect.succeed(next) + : sweepFrom(key, startedAt, next) + }), + ), + ) + + const checkDue = Effect.fn("AwsConnectionService.checkDue")(function* () { + if (maple === undefined) return { due: 0, checked: 0, failures: 0 } + const startedAt = yield* Clock.currentTimeMillis + return yield* sweepFrom(maple.key, startedAt, { due: 0, checked: 0, failures: 0 }) + }) + const deleteConnection = Effect.fn("AwsConnectionService.delete")(function* ( orgId: OrgId, connectionId: AwsConnectionId, From fbc715640ea94f3a953880f98fd32255961fa0d3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 21 Oct 2026 15:17:44 +0200 Subject: [PATCH 06/13] fix(aws): a sweep checks its batch in due order, never-checked first --- .../integrations/AwsConnectionService.test.ts | 23 +++++++++++++++++++ .../integrations/AwsConnectionService.ts | 9 +++++++- .../src/services/integrations/aws/fake-aws.ts | 5 ++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/services/integrations/AwsConnectionService.test.ts b/packages/backend/src/services/integrations/AwsConnectionService.test.ts index be1b3b7c33..224d2348b3 100644 --- a/packages/backend/src/services/integrations/AwsConnectionService.test.ts +++ b/packages/backend/src/services/integrations/AwsConnectionService.test.ts @@ -317,4 +317,27 @@ describe("AwsConnectionService", () => { assert.deepStrictEqual(yield* service.checkDue(), { due: 0, checked: 0, failures: 0 }) }).pipe(Effect.provide(makeLayer(testDb, aws))) }) + + it.effect("a sweep checks a never-checked connection ahead of stale ones", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + const accountOf = (digits: string) => Schema.decodeUnknownSync(AwsAccountId)(digits) + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const stale = yield* Effect.forEach(["111111111111", "222222222222"], (digits) => + service.create(orgId, userId, { accountId: accountOf(digits), modules: [] }), + ) + yield* Effect.forEach(stale, (connection) => later(service.check(orgId, connection.id))) + yield* TestClock.adjust("2 hours") + const fresh = yield* service.create(orgId, userId, { + accountId: accountOf("333333333333"), + modules: [], + }) + aws.assumed.length = 0 + yield* service.checkDue() + // Two checks run at a time: the new connection is in the first pair, not behind both stale ones. + assert.include(aws.assumed.slice(0, 2), fresh.roleArn) + assert.lengthOf(aws.assumed, 3) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) }) diff --git a/packages/backend/src/services/integrations/AwsConnectionService.ts b/packages/backend/src/services/integrations/AwsConnectionService.ts index bfe4a4a67b..e12f5cd2d4 100644 --- a/packages/backend/src/services/integrations/AwsConnectionService.ts +++ b/packages/backend/src/services/integrations/AwsConnectionService.ts @@ -551,8 +551,15 @@ export class AwsConnectionService extends Context.Service [row.id, index])) + const ordered = [...claimed].sort( + (a, b) => + (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ) const results = yield* Effect.forEach( - claimed, + ordered, (row) => runCheck(key, row).pipe( Effect.map(({ ok }) => ok), diff --git a/packages/backend/src/services/integrations/aws/fake-aws.ts b/packages/backend/src/services/integrations/aws/fake-aws.ts index 4d84ba591d..6e5e413f5a 100644 --- a/packages/backend/src/services/integrations/aws/fake-aws.ts +++ b/packages/backend/src/services/integrations/aws/fake-aws.ts @@ -100,6 +100,8 @@ export interface FakeAws { readonly client: HttpClient.HttpClient /** Every call, as ``. */ readonly calls: Array + /** The role ARN of every AssumeRole, in order. */ + readonly assumed: Array readonly deploy: (accountId: string, templateJson: string) => void /** Deletes every role of an account, as deleting its stack would. */ readonly deleteStacks: (accountId: string) => void @@ -116,8 +118,10 @@ export const makeFakeAws = ( // Session key id → the role it is a session of. const sessions = new Map() const calls: Array = [] + const assumed: Array = [] const state: FakeAws = { calls, + assumed, failWith: undefined, deploy: (accountId, templateJson) => { const role = deployTemplate(accountId, templateJson) @@ -144,6 +148,7 @@ export const makeFakeAws = ( ) } const roleArn = form.get("RoleArn") ?? "" + assumed.push(roleArn) const role = roles.get(roleArn) if ( role === undefined || From 46672c9b8db10e6923aac4d1933dd9c685fa46ab Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 21 Oct 2026 15:31:06 +0200 Subject: [PATCH 07/13] fix(aws): no check starts past the sweep budget --- .../integrations/AwsConnectionService.test.ts | 27 +++ .../integrations/AwsConnectionService.ts | 23 ++- .../src/services/integrations/aws/fake-aws.ts | 182 ++++++++++-------- 3 files changed, 141 insertions(+), 91 deletions(-) diff --git a/packages/backend/src/services/integrations/AwsConnectionService.test.ts b/packages/backend/src/services/integrations/AwsConnectionService.test.ts index 224d2348b3..b10cd509c4 100644 --- a/packages/backend/src/services/integrations/AwsConnectionService.test.ts +++ b/packages/backend/src/services/integrations/AwsConnectionService.test.ts @@ -340,4 +340,31 @@ describe("AwsConnectionService", () => { assert.lengthOf(aws.assumed, 3) }).pipe(Effect.provide(makeLayer(testDb, aws))) }) + + it.effect("a slow AWS stops a sweep at its budget; the connections it left come due again", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + yield* Effect.forEach( + Array.from({ length: 80 }, (_, index) => index), + (index) => + service.create(orgId, userId, { + accountId: Schema.decodeUnknownSync(AwsAccountId)(String(100000000000 + index)), + modules: [], + }), + { concurrency: 10 }, + ) + // Each check takes 9s, under the SDK's timers: 80 checks two at a time outlast the budget. + aws.beforeEachCall = TestClock.adjust("9 seconds") + const first = yield* service.checkDue() + assert.strictEqual(first.due, 80) + assert.isAbove(first.checked, 0) + assert.isBelow(first.checked, 80) + assert.strictEqual(aws.assumed.length, first.checked) + aws.beforeEachCall = Effect.void + yield* TestClock.adjust("1 hour") + assert.strictEqual((yield* service.checkDue()).checked, 80) + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) }) diff --git a/packages/backend/src/services/integrations/AwsConnectionService.ts b/packages/backend/src/services/integrations/AwsConnectionService.ts index e12f5cd2d4..aad50f733d 100644 --- a/packages/backend/src/services/integrations/AwsConnectionService.ts +++ b/packages/backend/src/services/integrations/AwsConnectionService.ts @@ -506,7 +506,10 @@ export class AwsConnectionService extends Context.Service @@ -561,8 +564,13 @@ export class AwsConnectionService extends Context.Service - runCheck(key, row).pipe( - Effect.map(({ ok }) => ok), + // Past the budget no check starts: a slow AWS cannot carry a batch past the lane's + // deadline, and the rows left stay leased, then come due again. + Effect.flatMap(Clock.currentTimeMillis, (now) => + now >= deadlineAt + ? Effect.succeed(undefined) + : runCheck(key, row).pipe(Effect.map(({ ok }) => ok)), + ).pipe( Effect.withSpan("AwsConnectionService.checkDue.connection", { attributes: { orgId: row.orgId, "maple.aws.connection_id": row.id }, }), @@ -579,11 +587,8 @@ export class AwsConnectionService extends Context.Service !ok).length, - } + const ran = results.filter((ok) => ok !== undefined) + return { due: due.length, checked: ran.length, failures: ran.filter((ok) => !ok).length } }) /** @@ -595,7 +600,7 @@ export class AwsConnectionService extends Context.Service => - sweepBatch(key).pipe( + sweepBatch(key, startedAt + SWEEP_BUDGET_MS).pipe( Effect.flatMap((batch) => Effect.flatMap(Clock.currentTimeMillis, (now) => { const next = { diff --git a/packages/backend/src/services/integrations/aws/fake-aws.ts b/packages/backend/src/services/integrations/aws/fake-aws.ts index 6e5e413f5a..f2296d12fd 100644 --- a/packages/backend/src/services/integrations/aws/fake-aws.ts +++ b/packages/backend/src/services/integrations/aws/fake-aws.ts @@ -105,6 +105,8 @@ export interface FakeAws { readonly deploy: (accountId: string, templateJson: string) => void /** Deletes every role of an account, as deleting its stack would. */ readonly deleteStacks: (accountId: string) => void + /** Runs before every call: a test makes AWS slow with a `TestClock` adjustment. */ + beforeEachCall: Effect.Effect /** Answers every call with this error instead, as AWS does when it throttles or fails. */ failWith: { readonly status: number; readonly code: string } | undefined } @@ -122,6 +124,7 @@ export const makeFakeAws = ( const state: FakeAws = { calls, assumed, + beforeEachCall: Effect.void, failWith: undefined, deploy: (accountId, templateJson) => { const role = deployTemplate(accountId, templateJson) @@ -131,88 +134,103 @@ export const makeFakeAws = ( for (const [arn, role] of roles) if (role.accountId === accountId) roles.delete(arn) }, client: HttpClient.make((request) => - Effect.sync(() => { - const body = request.body._tag === "Uint8Array" ? decoder.decode(request.body.body) : "" - const form = new URLSearchParams(body) - const action = form.get("Action") ?? "" - calls.push(action) - const respond = (response: Response) => HttpClientResponse.fromWeb(request, response) - if (state.failWith !== undefined) { - return respond(stsError(state.failWith.status, state.failWith.code, "fake failure")) - } - const key = signingKey(request.headers["authorization"]) - if (action === "AssumeRole") { - if (key !== mapleKeyId) { - return respond( - stsError(403, "InvalidClientTokenId", "The security token is invalid."), - ) - } - const roleArn = form.get("RoleArn") ?? "" - assumed.push(roleArn) - const role = roles.get(roleArn) - if ( - role === undefined || - role.trustedPrincipalArn !== maplePrincipalArn || - role.externalId !== form.get("ExternalId") - ) { - return respond( - stsError( - 403, - "AccessDenied", - `User is not authorized to perform: sts:AssumeRole on resource: ${roleArn}`, - ), - ) - } - const sessionKey = `ASIA${String(sessions.size).padStart(16, "0")}` - sessions.set(sessionKey, role) - const session = form.get("RoleSessionName") ?? "" - return respond( - xml( - 200, - `${sessionKey}secrettoken2030-01-01T00:00:00ZAROAFAKE:${session}arn:aws:sts::${role.accountId}:assumed-role/${role.roleName}/${session}req`, - ), - ) - } - const role = key === undefined ? undefined : sessions.get(key) - const stillDeployed = - role !== undefined && roles.has(`arn:aws:iam::${role.accountId}:role/${role.roleName}`) - if ( - role === undefined || - !stillDeployed || - request.headers["x-amz-security-token"] !== "token" - ) { - return respond(iamError(403, "InvalidClientTokenId", "The security token is invalid.")) - } - if (!role.canReadSelf || form.get("RoleName") !== role.roleName) { - return respond(iamError(403, "AccessDenied", `not authorized to perform: iam:${action}`)) - } - if (action === "GetRole") { - const tags = role.tags - .map((tag) => `${tag.Key}${tag.Value}`) - .join("") - return respond( - xml( - 200, - `/${role.roleName}AROAFAKEarn:aws:iam::${role.accountId}:role/${role.roleName}2026-10-21T12:00:00Z${tags}req`, - ), - ) - } - if (action === "ListAttachedRolePolicies") { - const policies = role.attachedPolicyNames - .map( - (name) => - `${name}arn:aws:iam::${role.accountId}:policy/${name}`, - ) - .join("") - return respond( - xml( - 200, - `${policies}falsereq`, - ), - ) - } - return respond(iamError(400, "InvalidAction", `unknown action ${action}`)) - }), + Effect.suspend(() => state.beforeEachCall).pipe( + Effect.andThen( + Effect.sync(() => { + const body = + request.body._tag === "Uint8Array" ? decoder.decode(request.body.body) : "" + const form = new URLSearchParams(body) + const action = form.get("Action") ?? "" + calls.push(action) + const respond = (response: Response) => HttpClientResponse.fromWeb(request, response) + if (state.failWith !== undefined) { + return respond( + stsError(state.failWith.status, state.failWith.code, "fake failure"), + ) + } + const key = signingKey(request.headers["authorization"]) + if (action === "AssumeRole") { + if (key !== mapleKeyId) { + return respond( + stsError(403, "InvalidClientTokenId", "The security token is invalid."), + ) + } + const roleArn = form.get("RoleArn") ?? "" + assumed.push(roleArn) + const role = roles.get(roleArn) + if ( + role === undefined || + role.trustedPrincipalArn !== maplePrincipalArn || + role.externalId !== form.get("ExternalId") + ) { + return respond( + stsError( + 403, + "AccessDenied", + `User is not authorized to perform: sts:AssumeRole on resource: ${roleArn}`, + ), + ) + } + const sessionKey = `ASIA${String(sessions.size).padStart(16, "0")}` + sessions.set(sessionKey, role) + const session = form.get("RoleSessionName") ?? "" + return respond( + xml( + 200, + `${sessionKey}secrettoken2030-01-01T00:00:00ZAROAFAKE:${session}arn:aws:sts::${role.accountId}:assumed-role/${role.roleName}/${session}req`, + ), + ) + } + const role = key === undefined ? undefined : sessions.get(key) + const stillDeployed = + role !== undefined && + roles.has(`arn:aws:iam::${role.accountId}:role/${role.roleName}`) + if ( + role === undefined || + !stillDeployed || + request.headers["x-amz-security-token"] !== "token" + ) { + return respond( + iamError(403, "InvalidClientTokenId", "The security token is invalid."), + ) + } + if (!role.canReadSelf || form.get("RoleName") !== role.roleName) { + return respond( + iamError(403, "AccessDenied", `not authorized to perform: iam:${action}`), + ) + } + if (action === "GetRole") { + const tags = role.tags + .map( + (tag) => + `${tag.Key}${tag.Value}`, + ) + .join("") + return respond( + xml( + 200, + `/${role.roleName}AROAFAKEarn:aws:iam::${role.accountId}:role/${role.roleName}2026-10-21T12:00:00Z${tags}req`, + ), + ) + } + if (action === "ListAttachedRolePolicies") { + const policies = role.attachedPolicyNames + .map( + (name) => + `${name}arn:aws:iam::${role.accountId}:policy/${name}`, + ) + .join("") + return respond( + xml( + 200, + `${policies}falsereq`, + ), + ) + } + return respond(iamError(400, "InvalidAction", `unknown action ${action}`)) + }), + ), + ), ), } return state From e8ff331a3cd74cf324780c68f6ba5aed988513c8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 22 Oct 2026 14:02:27 +0200 Subject: [PATCH 08/13] fix(aws): a disconnect closes only its own dialog; document and test PATCH A completed disconnect read the last-clicked connection, so it could close another connection's dialog and setup. The update request now documents that omitted modules stay unchanged, and update is tested in the service and route. --- .../routes/v2/integrations-aws.http.test.ts | 20 ++++++++++++++++ .../src/page/integrations/aws.scene.test.ts | 24 +++++++++++++++++++ apps/web/src/page/integrations/update-aws.ts | 20 +++++++++------- apps/web/src/page/integrations/update.ts | 4 ++-- .../integrations/AwsConnectionService.test.ts | 17 +++++++++++++ .../domain/src/http/v2/integrations-aws.ts | 10 ++++---- 6 files changed, 79 insertions(+), 16 deletions(-) diff --git a/apps/api/src/routes/v2/integrations-aws.http.test.ts b/apps/api/src/routes/v2/integrations-aws.http.test.ts index e8f1ce4b51..277b9fc40d 100644 --- a/apps/api/src/routes/v2/integrations-aws.http.test.ts +++ b/apps/api/src/routes/v2/integrations-aws.http.test.ts @@ -220,6 +220,26 @@ describe("v2 aws integration over HTTP", () => { await harness.dispose() }) + it("lets admins change a connection's modules, and only for their own org's connections", async () => { + const harness = makeHarness() + const admin = await harness.adminKey() + const member = await harness.memberKey() + const created = await harness.request("POST", CONNECTIONS, admin.secret, { + account_id: "123456789012", + }) + const path = `${CONNECTIONS}/${created.body.id}` + + expect((await harness.request("PATCH", path, member.secret, { modules: [] })).status).toBe(403) + const unchanged = await harness.request("PATCH", path, admin.secret, {}) + expect(unchanged.status).toBe(200) + expect(unchanged.body.modules).toEqual([]) + expect((await harness.request("PATCH", path, admin.secret, { modules: ["cost"] })).status).toBe(400) + await harness.request("DELETE", path, admin.secret) + const missing = await harness.request("PATCH", path, admin.secret, { modules: [] }) + expect(missing.status).toBe(404) + await harness.dispose() + }) + it("reports AWS unavailable on a deployment without Maple's AWS identity", async () => { const harness = makeHarness({ awsAvailable: false }) const admin = await harness.adminKey() diff --git a/apps/web/src/page/integrations/aws.scene.test.ts b/apps/web/src/page/integrations/aws.scene.test.ts index 4741e310c2..5e125a9a47 100644 --- a/apps/web/src/page/integrations/aws.scene.test.ts +++ b/apps/web/src/page/integrations/aws.scene.test.ts @@ -235,6 +235,30 @@ describe("aws card", () => { expect(names(dismissed.commands)).toEqual(["WriteAwsRemovedStacks"]) }) + test("a disconnect that lands after another was opened leaves that one alone", () => { + const other = Schema.decodeSync(AwsConnectionId)("028f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8") + const confirming = pageUpdate( + withStatus(entered(), [connection(), connection({ id: other })]), + Message.ClickedAwsDisconnect({ id: connectionId }), + ).model + const sent = pageUpdate(confirming, Message.ConfirmedAwsDisconnect()).model + const reopened = pageUpdate( + pageUpdate(sent, Message.ClickedAwsSetup({ id: other })).model, + Message.ClickedAwsDisconnect({ id: other }), + ).model + const done = pageUpdate( + reopened, + Message.CompletedAwsDisconnect({ + id: connectionId, + removed: { connectionId, accountId: "123456789012", cleanupScript: "aws cloudformation delete-stack" }, + error: null, + }), + ).model + expect(done.awsCard.disconnectId).toBe(other) + expect(done.awsCard.disconnectDialog.isOpen).toBe(true) + expect(done.awsCard.setupId).toBe(other) + }) + test("entering the card reads the stacks this browser kept for the org", () => { expect(names(init({ search: { integration: "aws" } }, context).commands)).toContain( "ReadAwsRemovedStacks", diff --git a/apps/web/src/page/integrations/update-aws.ts b/apps/web/src/page/integrations/update-aws.ts index a00d34f3d5..13296f051f 100644 --- a/apps/web/src/page/integrations/update-aws.ts +++ b/apps/web/src/page/integrations/update-aws.ts @@ -226,25 +226,29 @@ export const confirmedDisconnect = (model: Model): Return => { } } -/** The stack stays in AWS until an administrator deletes it: its cleanup script takes its place. */ +/** + * The stack stays in AWS until an administrator deletes it: its cleanup script takes its place. + * Only this connection's own dialog and setup close: another may have opened meanwhile. + */ export const completedDisconnect = ( model: Model, context: PageContext, + id: AwsConnectionId, removed: AwsRemovedStack | null, error: string | null, ): Return => { const settled = withCard(model, { isDisconnecting: false }) if (removed === null) return toast(settled, "error", "Failed to disconnect AWS", error ?? "") - const wasOpen = settled.awsCard.setupId === settled.awsCard.disconnectId - const stacks = [ - removed, - ...settled.awsCard.removed.filter((stack) => stack.connectionId !== removed.connectionId), - ] + const card = settled.awsCard + const stacks = [removed, ...card.removed.filter((stack) => stack.connectionId !== removed.connectionId)] return chain( // `disconnectId` stays: the closing dialog still names the account. - withCard(settled, { removed: stacks, setupId: wasOpen ? null : settled.awsCard.setupId }), + withCard(settled, { removed: stacks, setupId: card.setupId === id ? null : card.setupId }), [ - (next) => dialogStep(next, Dialog.close(next.awsCard.disconnectDialog)), + (next) => + next.awsCard.disconnectId === id + ? dialogStep(next, Dialog.close(next.awsCard.disconnectDialog)) + : { model: next }, (next) => ({ model: next, commands: [WriteAwsRemoved({ orgId: context.orgId, removed: stacks })], diff --git a/apps/web/src/page/integrations/update.ts b/apps/web/src/page/integrations/update.ts index 1fe8825750..370b5f2918 100644 --- a/apps/web/src/page/integrations/update.ts +++ b/apps/web/src/page/integrations/update.ts @@ -314,8 +314,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedAwsDisconnect: ({ id }) => Aws.clickedDisconnect(model, id), GotAwsDisconnectDialogMessage: ({ message: child }) => Aws.gotDisconnectDialog(model, child), ConfirmedAwsDisconnect: () => Aws.confirmedDisconnect(model), - CompletedAwsDisconnect: ({ removed, error }) => - Aws.completedDisconnect(model, context, removed, error), + CompletedAwsDisconnect: ({ id, removed, error }) => + Aws.completedDisconnect(model, context, id, removed, error), DismissedAwsRemoved: ({ connectionId }) => Aws.dismissedRemoved(model, context, connectionId), CompletedReadAwsRemoved: ({ removed }) => Aws.completedReadRemoved(model, removed), CompletedWriteAwsRemoved: () => ({ model }), diff --git a/packages/backend/src/services/integrations/AwsConnectionService.test.ts b/packages/backend/src/services/integrations/AwsConnectionService.test.ts index b10cd509c4..06ea608d7b 100644 --- a/packages/backend/src/services/integrations/AwsConnectionService.test.ts +++ b/packages/backend/src/services/integrations/AwsConnectionService.test.ts @@ -194,6 +194,23 @@ describe("AwsConnectionService", () => { }).pipe(Effect.provide(makeLayer(testDb, aws))) }) + it.effect("updates a connection's modules only within its own org", () => { + const testDb = createTestDb(trackedDbs) + const aws = makeFakeAws() + return Effect.gen(function* () { + const service = yield* AwsConnectionService + const created = yield* service.create(orgId, userId, { accountId: account, modules: [] }) + const unchanged = yield* service.update(orgId, created.id, {}) + assert.deepStrictEqual(unchanged.modules, []) + const refused = yield* Effect.flip(service.update(orgId, created.id, { modules: ["cost"] })) + assert.strictEqual(refused._tag, "@maple/http/errors/IntegrationsValidationError") + const foreign = yield* Effect.flip(service.update(otherOrgId, created.id, { modules: [] })) + assert.strictEqual(foreign._tag, "@maple/http/errors/IntegrationsNotFoundError") + const unpatched = yield* Effect.flip(service.update(otherOrgId, created.id, {})) + assert.strictEqual(unpatched._tag, "@maple/http/errors/IntegrationsNotFoundError") + }).pipe(Effect.provide(makeLayer(testDb, aws))) + }) + it.effect("is unavailable without Maple's AWS identity", () => { const testDb = createTestDb(trackedDbs) const aws = makeFakeAws() diff --git a/packages/domain/src/http/v2/integrations-aws.ts b/packages/domain/src/http/v2/integrations-aws.ts index 2c96807d9d..b7419c5b9a 100644 --- a/packages/domain/src/http/v2/integrations-aws.ts +++ b/packages/domain/src/http/v2/integrations-aws.ts @@ -134,14 +134,12 @@ export const V2AwsIntegration = Schema.Struct({ }) export type V2AwsIntegration = Schema.Schema.Type -const modulesField = Schema.optionalKey(Schema.Array(AwsModule)).annotate({ - description: "What Maple may read. Defaults to none: the role then only lets Maple confirm the setup.", - examples: [[]], -}) +const modulesField = (description: string) => + Schema.optionalKey(Schema.Array(AwsModule)).annotate({ description, examples: [[]] }) export const V2AwsCreateConnectionRequest = Schema.Struct({ account_id: AwsAccountId.annotate({ description: "The 12-digit AWS account ID." }), - modules: modulesField, + modules: modulesField("What Maple may read. Defaults to none: the role then only lets Maple confirm the setup."), }).annotate({ identifier: "AwsCreateConnectionRequest", title: "AWS connection create request", @@ -150,7 +148,7 @@ export const V2AwsCreateConnectionRequest = Schema.Struct({ export type V2AwsCreateConnectionRequest = Schema.Schema.Type export const V2AwsUpdateConnectionRequest = Schema.Struct({ - modules: modulesField, + modules: modulesField("What Maple may read. Omitted: the connection's modules are unchanged; `[]` removes them all."), }).annotate({ identifier: "AwsUpdateConnectionRequest", title: "AWS connection update request", From 14126ea91ae4c798318405b5bf8e404d767c6159 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 22 Oct 2026 14:15:24 +0200 Subject: [PATCH 09/13] test(web): the in-flight click case starts a poll before it clicks The case clicked a page with no check in flight, so it never reached the flip that makes a background check the user's. --- .../page/sources/detail/detail.scene.test.ts | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/sources/detail/detail.scene.test.ts b/apps/web/src/page/sources/detail/detail.scene.test.ts index 8f383dfb24..a9eb704518 100644 --- a/apps/web/src/page/sources/detail/detail.scene.test.ts +++ b/apps/web/src/page/sources/detail/detail.scene.test.ts @@ -483,11 +483,33 @@ describe("integration focus", () => { }), ) expect(names(polled.commands)).not.toContain("FetchSourceDetailList") - // A click while that poll was in flight made it the user's: its answer re-reads the header. - const clicked = pageUpdate( + // A click while a poll is in flight makes it the user's: its answer re-reads the header. + const withStatus = pageUpdate( model, - Message.GotConnectionMessage({ message: IntegrationMessage.ClickedAwsCheck({ id: connection }) }), + Message.GotConnectionMessage({ + message: IntegrationMessage.CompletedLoadAws({ + key: model.connection?.statuses.aws.key ?? "", + result: Result.succeed({ + object: "aws_integration", + available: true, + modules: [], + connections: [awsConnection(connection)], + }), + }), + }), ).model + const polling = pageUpdate( + withStatus, + Message.GotConnectionMessage({ message: IntegrationMessage.TickedAwsPending() }), + ) + expect(names(polling.commands)).toContain("CheckAwsConnection") + const clicking = pageUpdate( + polling.model, + Message.GotConnectionMessage({ message: IntegrationMessage.ClickedAwsCheck({ id: connection }) }), + ) + // The poll in flight answers for the click: no second call to AWS. + expect(names(clicking.commands)).not.toContain("CheckAwsConnection") + const clicked = clicking.model const answered = pageUpdate( clicked, Message.GotConnectionMessage({ From 434200bbfdfaab8c95e1ee1ae8e741c3a9dc7d63 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 22 Oct 2026 14:16:17 +0200 Subject: [PATCH 10/13] test(aws): the budget sweep allows a check that timed out unassumed Two checks run at once and each fake call jumps the shared clock 9s, so a call can pass its 20s timeout and count as checked without an AssumeRole. --- .../src/services/integrations/AwsConnectionService.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/services/integrations/AwsConnectionService.test.ts b/packages/backend/src/services/integrations/AwsConnectionService.test.ts index 06ea608d7b..3f4ad12217 100644 --- a/packages/backend/src/services/integrations/AwsConnectionService.test.ts +++ b/packages/backend/src/services/integrations/AwsConnectionService.test.ts @@ -378,7 +378,8 @@ describe("AwsConnectionService", () => { assert.strictEqual(first.due, 80) assert.isAbove(first.checked, 0) assert.isBelow(first.checked, 80) - assert.strictEqual(aws.assumed.length, first.checked) + // At most once per check: a call that sees other calls' 9s jumps can time out unassumed. + assert.isAtMost(aws.assumed.length, first.checked) aws.beforeEachCall = Effect.void yield* TestClock.adjust("1 hour") assert.strictEqual((yield* service.checkDue()).checked, 80) From 195fc414ff032833ad52b3b32a5d2cea691c2307 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 22 Oct 2026 18:07:44 +0200 Subject: [PATCH 11/13] fix(web): an AWS stack that needs updating reads as needing attention The hub card, sources picker and header now agree with the connection row. --- .../src/page/integrations/aws.scene.test.ts | 16 +++++++++++++- apps/web/src/page/integrations/derive.ts | 21 ++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/integrations/aws.scene.test.ts b/apps/web/src/page/integrations/aws.scene.test.ts index 5e125a9a47..d087c16838 100644 --- a/apps/web/src/page/integrations/aws.scene.test.ts +++ b/apps/web/src/page/integrations/aws.scene.test.ts @@ -189,6 +189,16 @@ describe("aws card", () => { expect(overview?.kind === "connected" && overview.issue).toBe("Finish setup") }) + test("a verified connection whose stack is outdated needs attention, as its row says", () => { + const verified_at = Schema.decodeSync(Timestamp)("2026-10-21T12:05:00.000Z") + const model = withStatus(entered(), [connection({ verified_at, setup_outdated: true })]) + const overview = overviewOf(model.statuses, model.nowMs, "aws") + expect(overview?.kind === "connected" && overview.health).toBe("attention") + expect(overview?.kind === "connected" && overview.issue).toBe("Update the stack") + const current = withStatus(entered(), [connection({ verified_at })]) + expect(overviewOf(current.statuses, current.nowMs, "aws")).toMatchObject({ issue: null }) + }) + test("a status read that finds the open setup verified closes it", () => { const waiting = pageUpdate( withStatus(entered(), [connection()]), @@ -250,7 +260,11 @@ describe("aws card", () => { reopened, Message.CompletedAwsDisconnect({ id: connectionId, - removed: { connectionId, accountId: "123456789012", cleanupScript: "aws cloudformation delete-stack" }, + removed: { + connectionId, + accountId: "123456789012", + cleanupScript: "aws cloudformation delete-stack", + }, error: null, }), ).model diff --git a/apps/web/src/page/integrations/derive.ts b/apps/web/src/page/integrations/derive.ts index af32e649f6..3e2ba36659 100644 --- a/apps/web/src/page/integrations/derive.ts +++ b/apps/web/src/page/integrations/derive.ts @@ -272,11 +272,23 @@ const awsOverview = (statuses: Statuses, nowMs: number) => (connection) => connection.verified_at !== null && connection.last_check_error !== null, ).length const pending = status.connections.some((connection) => connection.verified_at === null) + // A verified stack older than the connection's modules needs redeploying, as its row says. + const outdated = status.connections.some( + (connection) => connection.verified_at !== null && connection.setup_outdated, + ) const issue = - failing > 0 ? `${countLabel(failing, "account")} failing` : pending ? "Finish setup" : null + failing > 0 + ? `${countLabel(failing, "account")} failing` + : pending + ? "Finish setup" + : outdated + ? "Update the stack" + : null // A stack not deployed yet is setup in progress, not a failure. const health: Pick = - failing === 0 && pending ? { health: "pending", stateLabel: "Finish setup" } : attentionOr(issue) + failing === 0 && pending + ? { health: "pending", stateLabel: "Finish setup" } + : attentionOr(issue) return { kind: "connected", ...health, @@ -574,6 +586,9 @@ export const settingsNavVisibility = ( isClerk: boolean, ): Visibility => { if (!isClerk) return { isClerk, isAdmin: true, canAccessDataPlatform: true } - const hasByoc = AsyncData.getOrElse(AsyncData.map(model.customer.data, hasBringYourOwnCloudAddOn), () => false) + const hasByoc = AsyncData.getOrElse( + AsyncData.map(model.customer.data, hasBringYourOwnCloudAddOn), + () => false, + ) return { isClerk, isAdmin: model.isAdmin, canAccessDataPlatform: model.isAdmin && hasByoc } } From 406f9ecee8c38e2c52a8846f33b7bea8aa665959 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 22 Oct 2026 18:30:20 +0200 Subject: [PATCH 12/13] test(aws): another org's admin cannot see or touch a connection; members cannot check it --- .../routes/v2/integrations-aws.http.test.ts | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/api/src/routes/v2/integrations-aws.http.test.ts b/apps/api/src/routes/v2/integrations-aws.http.test.ts index 277b9fc40d..674dafd023 100644 --- a/apps/api/src/routes/v2/integrations-aws.http.test.ts +++ b/apps/api/src/routes/v2/integrations-aws.http.test.ts @@ -33,6 +33,7 @@ afterEach(() => cleanupTestDbs(createdDbs)) const ORG = Schema.decodeUnknownSync(OrgId)("org_aws_e2e") const USER = Schema.decodeUnknownSync(UserId)("user_aws_e2e") +const OTHER_ORG = Schema.decodeUnknownSync(OrgId)("org_aws_e2e_other") const BASE = "/v2/integrations/aws" const testConfig = (awsAvailable: boolean) => @@ -85,11 +86,11 @@ const makeHarness = (options: { readonly awsAvailable: boolean } = { awsAvailabl const runtime = ManagedRuntime.make(servicesLive) /** Root-role key (API keys resolve as `root` unless their metadata pins roles). */ - const adminKey = (scopes?: ReadonlyArray) => + const adminKey = (scopes?: ReadonlyArray, org: OrgId = ORG) => runtime.runPromise( Effect.gen(function* () { const service = yield* ApiKeysService - return yield* service.create(ORG, USER, { name: "aws-admin", scopes }) + return yield* service.create(org, USER, { name: "aws-admin", scopes }) }), ) @@ -220,7 +221,7 @@ describe("v2 aws integration over HTTP", () => { await harness.dispose() }) - it("lets admins change a connection's modules, and only for their own org's connections", async () => { + it("lets admins change a connection's modules, and 404s once it is gone", async () => { const harness = makeHarness() const admin = await harness.adminKey() const member = await harness.memberKey() @@ -240,6 +241,30 @@ describe("v2 aws integration over HTTP", () => { await harness.dispose() }) + it("hides a connection from another org's admin, and keeps members from checking it", async () => { + const harness = makeHarness() + const admin = await harness.adminKey() + const member = await harness.memberKey() + const outsider = await harness.adminKey(undefined, OTHER_ORG) + const created = await harness.request("POST", CONNECTIONS, admin.secret, { + account_id: "123456789012", + }) + const path = `${CONNECTIONS}/${created.body.id}` + + expect((await harness.request("POST", `${path}/check`, member.secret)).status).toBe(403) + expect((await harness.request("GET", BASE, outsider.secret)).body.connections).toEqual([]) + expect((await harness.request("GET", `${path}/setup`, outsider.secret)).status).toBe(404) + expect((await harness.request("POST", `${path}/check`, outsider.secret)).status).toBe(404) + expect((await harness.request("PATCH", path, outsider.secret, { modules: [] })).status).toBe(404) + expect((await harness.request("DELETE", path, outsider.secret)).status).toBe(404) + // The connection is still there, unchanged, for its own org. + const status = await harness.request("GET", BASE, admin.secret) + expect(status.body.connections.map((connection: { id: string }) => connection.id)).toEqual([ + created.body.id, + ]) + await harness.dispose() + }) + it("reports AWS unavailable on a deployment without Maple's AWS identity", async () => { const harness = makeHarness({ awsAvailable: false }) const admin = await harness.adminKey() From 4ee8570488e5e944150115153af1dc2d25da4ebc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 22 Oct 2026 18:46:27 +0200 Subject: [PATCH 13/13] test(github): pass the initiator in the install admin cases Same one-line fix as #1698: main's #1675 and #1685 crossed, so these cases called completeConnect without the initiator #1675 made required. --- .../vcs/vendor/github/__tests__/GithubConnectService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/services/integrations/vcs/vendor/github/__tests__/GithubConnectService.test.ts b/packages/backend/src/services/integrations/vcs/vendor/github/__tests__/GithubConnectService.test.ts index 092be77215..4bff2afe0b 100644 --- a/packages/backend/src/services/integrations/vcs/vendor/github/__tests__/GithubConnectService.test.ts +++ b/packages/backend/src/services/integrations/vcs/vendor/github/__tests__/GithubConnectService.test.ts @@ -279,7 +279,7 @@ describe("GithubConnectService", () => { const { state } = yield* svc.startConnect(asOrgId("org_test"), asUserId("user_1"), { callbackUrl: "https://tunnel.example/cb", }) - const exit = yield* svc.completeConnect("42", state, TEST_CODE).pipe(Effect.exit) + const exit = yield* svc.completeConnect("42", state, TEST_CODE, INITIATOR).pipe(Effect.exit) assert.deepStrictEqual(roleUrls, [ c.account === "Organization" ? "/user/memberships/orgs/octo" : "/user", ])