From 4128dc43de665478254aa9ffa9b81b4b636ed1a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 11:30:06 +0000 Subject: [PATCH 1/3] Add v2 public API spec + foundation (Stripe-style) with api_keys pilot Introduces the Maple v2 public API: docs/api-v2.md (conventions spec + resource catalog + rollout phases) and the executable foundation: - packages/domain/src/http/v2: MapleApiV2 HttpApi shell, prefixed public object IDs (key_/dash_/... via a boundary base58 codec, no DB churn), Stripe list envelope ({object:"list",data,has_more,next_cursor}) with uniform limit/cursor pagination, {error:{type,code,message}} error envelope with stable machine codes, snake_case wire fields, ISO-8601 timestamps, and the AuthorizationV2 + V2SchemaErrors middlewares - Scoped API keys: api_keys.scopes jsonb column + migration, scope grammar (:read|write|*), mechanical enforcement derived from method + first /v2 path segment (write implies read; session tokens and legacy null-scope keys bypass) - Pilot resource api_keys at /v2/api_keys (list/create/retrieve/roll/ revoke) as thin adapters over the existing ApiKeysService; mounted alongside v1 with Scalar docs at /v2/docs - Tests: public-ID round-trips, wire-shape/envelope encodes, scope matrix, OpenAPI contract freeze, PGlite scope persistence, and end-to-end HTTP tests over a real router (auth, 401/403/400/404 envelopes, pagination, CRUD) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTnfswT7zw6WhWgkS53WqM --- CLAUDE.md | 1 + apps/api/src/app.ts | 18 +- apps/api/src/routes/api-keys.http.ts | 1 + apps/api/src/routes/v2/api-keys.http.test.ts | 242 + apps/api/src/routes/v2/api-keys.http.ts | 134 + apps/api/src/routes/v2/error-envelope.ts | 20 + .../src/services/ApiAuthorizationV2Layer.ts | 96 + .../services/ApiKeysService.scopes.test.ts | 94 + apps/api/src/services/ApiKeysService.ts | 16 + docs/api-v2.md | 170 + packages/db/drizzle/0013_sour_dagger.sql | 1 + packages/db/drizzle/meta/0013_snapshot.json | 6172 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/api-keys.ts | 2 + packages/domain/package.json | 1 + packages/domain/src/http/api-keys.ts | 4 + packages/domain/src/http/current-tenant.ts | 3 + packages/domain/src/http/v2/api-keys.ts | 89 + packages/domain/src/http/v2/api.ts | 23 + packages/domain/src/http/v2/auth.ts | 74 + packages/domain/src/http/v2/envelopes.ts | 74 + packages/domain/src/http/v2/errors.ts | 93 + packages/domain/src/http/v2/index.ts | 6 + packages/domain/src/http/v2/openapi.test.ts | 35 + packages/domain/src/http/v2/public-id.test.ts | 75 + packages/domain/src/http/v2/public-id.ts | 171 + .../domain/src/http/v2/v2-contract.test.ts | 144 + 27 files changed, 7765 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/routes/v2/api-keys.http.test.ts create mode 100644 apps/api/src/routes/v2/api-keys.http.ts create mode 100644 apps/api/src/routes/v2/error-envelope.ts create mode 100644 apps/api/src/services/ApiAuthorizationV2Layer.ts create mode 100644 apps/api/src/services/ApiKeysService.scopes.test.ts create mode 100644 docs/api-v2.md create mode 100644 packages/db/drizzle/0013_sour_dagger.sql create mode 100644 packages/db/drizzle/meta/0013_snapshot.json create mode 100644 packages/domain/src/http/v2/api-keys.ts create mode 100644 packages/domain/src/http/v2/api.ts create mode 100644 packages/domain/src/http/v2/auth.ts create mode 100644 packages/domain/src/http/v2/envelopes.ts create mode 100644 packages/domain/src/http/v2/errors.ts create mode 100644 packages/domain/src/http/v2/index.ts create mode 100644 packages/domain/src/http/v2/openapi.test.ts create mode 100644 packages/domain/src/http/v2/public-id.test.ts create mode 100644 packages/domain/src/http/v2/public-id.ts create mode 100644 packages/domain/src/http/v2/v2-contract.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 81b39d989..e0fee6786 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,6 +206,7 @@ Use `/Users/maki/Documents/superwall/app` as the reference implementation for Ef End-user and platform documentation lives in `docs/`: +- `docs/api-v2.md` — The v2 public API spec (Stripe-style conventions: prefixed public IDs, list/error envelopes, scoped API keys, `/v2` + `/v2/docs`) and its rollout phases - `docs/sampling-throughput.md` — How Maple handles sampling-aware throughput metrics - `docs/persistence.md` — Database persistence and migration operations - `docs/sst-fork-workflow.md` — Running maple against a local SST fork, syncing with upstream, and opening PRs from fork branches diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 81fb190fb..395f958ff 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,4 +1,5 @@ import { MapleApi } from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" import { Layer } from "effect" import { HttpMiddleware, HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi" @@ -9,6 +10,8 @@ import { HttpAlertsLive } from "./routes/alerts.http" import { HttpAnomaliesLive } from "./routes/anomalies.http" import { HttpErrorsLive } from "./routes/errors.http" import { HttpApiKeysLive } from "./routes/api-keys.http" +import { HttpV2ApiKeysLive } from "./routes/v2/api-keys.http" +import { V2SchemaErrorsLive } from "./routes/v2/error-envelope" import { HttpAuthLive, HttpAuthPublicLive } from "./routes/auth.http" import { HttpChatLive } from "./routes/chat.http" import { HttpDashboardsLive } from "./routes/dashboards.http" @@ -44,6 +47,7 @@ import { NotificationDispatcher } from "./services/NotificationDispatcher" import { ApiKeysService } from "./services/ApiKeysService" import { AuthService } from "./services/AuthService" import { ApiAuthorizationLayer } from "./services/ApiAuthorizationLayer" +import { ApiAuthorizationV2Layer } from "./services/ApiAuthorizationV2Layer" import { CloudflareAnalyticsService } from "./services/CloudflareAnalyticsService" import { CloudflareOAuthService } from "./services/CloudflareOAuthService" import { DashboardPersistenceService } from "./services/DashboardPersistenceService" @@ -91,6 +95,11 @@ const DocsRoute = HttpApiScalar.layerCdn(MapleApi, { path: "/docs", }) +// Public v2 API reference (only v2 groups — the internal v1 surface stays on /docs). +const DocsV2Route = HttpApiScalar.layerCdn(MapleApiV2, { + path: "/v2/docs", +}) + const InfraLive = Env.layer // PlanetScale layer composition: the OAuth grant (token lifecycle) feeds @@ -264,8 +273,14 @@ const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe( ), ) +const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( + Layer.provide(HttpV2ApiKeysLive), + Layer.provide(V2SchemaErrorsLive), +) + export const AllRoutes = Layer.mergeAll( ApiRoutes, + ApiV2Routes, IntegrationsCallbackRouter, OAuthDiscoveryRouter, PlanetScaleWebhookRouter, @@ -276,6 +291,7 @@ export const AllRoutes = Layer.mergeAll( HealthRouter, McpGetFallback, DocsRoute, + DocsV2Route, ).pipe( Layer.provideMerge( HttpRouter.cors({ @@ -289,7 +305,7 @@ export const AllRoutes = Layer.mergeAll( ), ) -export const ApiAuthLive = ApiAuthorizationLayer.pipe( +export const ApiAuthLive = Layer.mergeAll(ApiAuthorizationLayer, ApiAuthorizationV2Layer).pipe( Layer.provideMerge(ApiKeysService.layer), Layer.provideMerge(Env.layer), ) diff --git a/apps/api/src/routes/api-keys.http.ts b/apps/api/src/routes/api-keys.http.ts index 50a93c05a..ac9bad411 100644 --- a/apps/api/src/routes/api-keys.http.ts +++ b/apps/api/src/routes/api-keys.http.ts @@ -29,6 +29,7 @@ export const HttpApiKeysLive = HttpApiBuilder.group(MapleApi, "apiKeys", (handle description: payload.description, expiresInSeconds: payload.expiresInSeconds, kind: payload.kind, + scopes: payload.scopes, createdByEmail, }) }), diff --git a/apps/api/src/routes/v2/api-keys.http.test.ts b/apps/api/src/routes/v2/api-keys.http.test.ts new file mode 100644 index 000000000..523958fb5 --- /dev/null +++ b/apps/api/src/routes/v2/api-keys.http.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it } from "vitest" +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { OrgId, UserId } from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" +import { Env } from "../../lib/Env" +import { cleanupTestDbs, createTestDb, type TestDb } from "../../lib/test-pglite" +import { ApiKeysService } from "../../services/ApiKeysService" +import { AuthService } from "../../services/AuthService" +import { ApiAuthorizationV2Layer } from "../../services/ApiAuthorizationV2Layer" +import { HttpV2ApiKeysLive } from "./api-keys.http" +import { V2SchemaErrorsLive } from "./error-envelope" + +/** + * End-to-end HTTP tests for the v2 pilot: a real router (auth middleware, v2 + * error envelopes, public-ID codecs, list envelope) over an embedded PGlite, + * exercised with fetch Requests exactly as a client would. + */ + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const testConfig = () => + 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", + INTERNAL_SERVICE_TOKEN: "test-internal-token", + }), + ) + +const makeHarness = () => { + const testDb = createTestDb(createdDbs) + const envLive = Env.layer.pipe(Layer.provide(testConfig())) + const servicesLive = Layer.mergeAll(ApiKeysService.layer, AuthService.layer).pipe( + Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer)), + ) + + const routes = HttpApiBuilder.layer(MapleApiV2).pipe( + Layer.provide(HttpV2ApiKeysLive), + Layer.provide(V2SchemaErrorsLive), + Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(servicesLive), + ) + + const { handler, dispose: disposeHandler } = HttpRouter.toWebHandler(routes, { + disableLogger: true, + }) + // Seed runtime for direct service access (bootstrap keys without HTTP). + const runtime = ManagedRuntime.make(servicesLive) + + const request = async ( + method: string, + path: string, + options: { token?: string; body?: unknown } = {}, + ) => { + const response = await handler( + new Request(`http://maple.test${path}`, { + method, + headers: { + ...(options.token !== undefined ? { authorization: `Bearer ${options.token}` } : {}), + ...(options.body !== undefined ? { "content-type": "application/json" } : {}), + }, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + }), + Context.empty() as never, + ) + const text = await response.text() + return { status: response.status, body: text.length > 0 ? JSON.parse(text) : null } + } + + const ORG = Schema.decodeUnknownSync(OrgId)("org_e2e") + const USER = Schema.decodeUnknownSync(UserId)("user_e2e") + + const bootstrapKey = (scopes?: ReadonlyArray) => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + return yield* service.create(ORG, USER, { + name: scopes === undefined ? "root-key" : `scoped:${scopes.join(",")}`, + scopes, + }) + }), + ) + + return { + request, + bootstrapKey, + dispose: async () => { + await disposeHandler() + await runtime.dispose() + }, + } +} + +describe("v2 api_keys over HTTP", () => { + it("returns the list envelope for an authorized key", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey() + + const { status, body } = await harness.request("GET", "/v2/api_keys", { token: key.secret }) + expect(status).toBe(200) + expect(body.object).toBe("list") + expect(body.has_more).toBe(false) + expect(body.next_cursor).toBeNull() + expect(body.data).toHaveLength(1) + expect(body.data[0].object).toBe("api_key") + expect(body.data[0].id.startsWith("key_")).toBe(true) + expect(body.data[0].key_prefix.startsWith("maple_ak_")).toBe(true) + expect(typeof body.data[0].created_at).toBe("string") + await harness.dispose() + }) + + it("rejects missing credentials with a 401 envelope", async () => { + const harness = makeHarness() + const { status, body } = await harness.request("GET", "/v2/api_keys") + expect(status).toBe(401) + expect(body.error.type).toBe("authentication_error") + await harness.dispose() + }) + + it("enforces scopes: read-only key cannot create, and write implies read", async () => { + const harness = makeHarness() + const readOnly = await harness.bootstrapKey(["api_keys:read"]) + + const list = await harness.request("GET", "/v2/api_keys", { token: readOnly.secret }) + expect(list.status).toBe(200) + + const create = await harness.request("POST", "/v2/api_keys", { + token: readOnly.secret, + body: { name: "nope" }, + }) + expect(create.status).toBe(403) + expect(create.body.error).toEqual({ + type: "permission_error", + code: "insufficient_scope", + message: 'This API key does not have the "api_keys:write" scope required for this request.', + }) + + const writeKey = await harness.bootstrapKey(["api_keys:write"]) + const listViaWrite = await harness.request("GET", "/v2/api_keys", { token: writeKey.secret }) + expect(listViaWrite.status).toBe(200) + await harness.dispose() + }) + + it("full CRUD round-trip: create with scopes, retrieve, roll, revoke", async () => { + const harness = makeHarness() + const root = await harness.bootstrapKey() + + const created = await harness.request("POST", "/v2/api_keys", { + token: root.secret, + body: { name: "ci", scopes: ["telemetry:read"], expires_in_seconds: 3600 }, + }) + expect(created.status).toBe(200) + expect(created.body.object).toBe("api_key") + expect(created.body.scopes).toEqual(["telemetry:read"]) + expect(created.body.secret.startsWith("maple_ak_")).toBe(true) + expect(created.body.expires_at).not.toBeNull() + + const id: string = created.body.id + const retrieved = await harness.request("GET", `/v2/api_keys/${id}`, { token: root.secret }) + expect(retrieved.status).toBe(200) + expect(retrieved.body.id).toBe(id) + expect("secret" in retrieved.body).toBe(false) + + const rolled = await harness.request("POST", `/v2/api_keys/${id}/roll`, { token: root.secret }) + expect(rolled.status).toBe(200) + expect(rolled.body.scopes).toEqual(["telemetry:read"]) + expect(rolled.body.id).not.toBe(id) + + const revoked = await harness.request("DELETE", `/v2/api_keys/${rolled.body.id}`, { + token: root.secret, + }) + expect(revoked.status).toBe(200) + expect(revoked.body.revoked).toBe(true) + await harness.dispose() + }) + + it("returns envelope errors for malformed and unknown IDs", async () => { + const harness = makeHarness() + const root = await harness.bootstrapKey() + + const malformed = await harness.request("GET", "/v2/api_keys/not-a-public-id", { + token: root.secret, + }) + expect(malformed.status).toBe(400) + expect(malformed.body.error.type).toBe("invalid_request_error") + expect(malformed.body.error.code).toBe("parameter_invalid") + + // valid key_ encoding of a UUID that doesn't exist + const { encodePublicId } = await import("@maple/domain/http/v2") + const ghost = encodePublicId("key", "0f8fad5b-d9cb-469f-a165-70867728950e") + const missing = await harness.request("GET", `/v2/api_keys/${ghost}`, { token: root.secret }) + expect(missing.status).toBe(404) + expect(missing.body.error.type).toBe("not_found_error") + expect(missing.body.error.code).toBe("resource_missing") + await harness.dispose() + }) + + it("rejects invalid scope strings on create with a 400 envelope", async () => { + const harness = makeHarness() + const root = await harness.bootstrapKey() + + const { status, body } = await harness.request("POST", "/v2/api_keys", { + token: root.secret, + body: { name: "bad", scopes: ["dashboards:admin"] }, + }) + expect(status).toBe(400) + expect(body.error.type).toBe("invalid_request_error") + await harness.dispose() + }) + + it("paginates with limit + cursor", async () => { + const harness = makeHarness() + const root = await harness.bootstrapKey() + await harness.bootstrapKey(["api_keys:read"]) + await harness.bootstrapKey(["api_keys:read"]) + + const first = await harness.request("GET", "/v2/api_keys?limit=2", { token: root.secret }) + expect(first.status).toBe(200) + expect(first.body.data).toHaveLength(2) + expect(first.body.has_more).toBe(true) + + const second = await harness.request( + "GET", + `/v2/api_keys?limit=2&cursor=${encodeURIComponent(first.body.next_cursor)}`, + { token: root.secret }, + ) + expect(second.status).toBe(200) + expect(second.body.data).toHaveLength(1) + expect(second.body.has_more).toBe(false) + await harness.dispose() + }) +}) diff --git a/apps/api/src/routes/v2/api-keys.http.ts b/apps/api/src/routes/v2/api-keys.http.ts new file mode 100644 index 000000000..a44a55b9f --- /dev/null +++ b/apps/api/src/routes/v2/api-keys.http.ts @@ -0,0 +1,134 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi" +import type { ApiKeyCreatedResponse, ApiKeyResponse } from "@maple/domain/http" +import { CurrentTenant } from "@maple/domain/http" +import { + MapleApiV2, + V2ApiKey, + V2ApiKeyWithSecret, + isoTimestamp, + isoTimestampOrNull, + notFound, + paginateArray, + permissionError, + serviceUnavailable, +} from "@maple/domain/http/v2" +import { Effect } from "effect" +import { ApiKeysService } from "../../services/ApiKeysService" +import { AuthService } from "../../services/AuthService" +import { requireAdmin } from "../../lib/auth" + +const adminOnly = (action: string) => () => + permissionError("insufficient_permissions", `Only org admins can ${action} API keys`) + +type ApiKeyFields = Pick< + ApiKeyResponse, + | "id" + | "name" + | "description" + | "keyPrefix" + | "kind" + | "scopes" + | "revoked" + | "revokedAt" + | "lastUsedAt" + | "expiresAt" + | "createdAt" + | "createdBy" + | "createdByEmail" +> + +const toV2ApiKey = (key: ApiKeyFields): V2ApiKey => + new V2ApiKey({ + id: key.id, + object: "api_key", + name: key.name, + description: key.description, + key_prefix: key.keyPrefix, + kind: key.kind, + scopes: key.scopes, + revoked: key.revoked, + revoked_at: isoTimestampOrNull(key.revokedAt), + last_used_at: isoTimestampOrNull(key.lastUsedAt), + expires_at: isoTimestampOrNull(key.expiresAt), + created_at: isoTimestamp(key.createdAt), + created_by: key.createdBy, + created_by_email: key.createdByEmail, + }) + +const toV2ApiKeyWithSecret = (key: ApiKeyCreatedResponse): V2ApiKeyWithSecret => + new V2ApiKeyWithSecret({ ...toV2ApiKey(key), secret: key.secret }) + +/** Service tagged errors → v2 envelope errors. */ +const mapServiceError = (error: { readonly _tag: string; readonly message: string }) => + error._tag === "@maple/http/errors/ApiKeyNotFoundError" + ? notFound(error.message, "id") + : serviceUnavailable(error.message) + +const mapPersistenceError = (error: { readonly message: string }) => serviceUnavailable(error.message) + +export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (handlers) => + Effect.gen(function* () { + const apiKeysService = yield* ApiKeysService + const auth = yield* AuthService + + return handlers + .handle("list", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const response = yield* apiKeysService + .list(tenant.orgId) + .pipe(Effect.mapError(mapPersistenceError)) + const page = paginateArray(response.keys.map(toV2ApiKey), query) + return { object: "list" as const, ...page } + }), + ) + .handle("retrieve", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const key = yield* apiKeysService + .get(tenant.orgId, params.id) + .pipe(Effect.mapError(mapServiceError)) + return toV2ApiKey(key) + }), + ) + .handle("create", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("create")) + const createdByEmail = yield* auth.getUserEmail(tenant.userId) + const created = yield* apiKeysService + .create(tenant.orgId, tenant.userId, { + name: payload.name, + description: payload.description, + expiresInSeconds: payload.expires_in_seconds, + kind: payload.kind, + scopes: payload.scopes, + createdByEmail, + }) + .pipe(Effect.mapError(mapPersistenceError)) + return toV2ApiKeyWithSecret(created) + }), + ) + .handle("roll", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("roll")) + const createdByEmail = yield* auth.getUserEmail(tenant.userId) + const rolled = yield* apiKeysService + .roll(tenant.orgId, tenant.userId, params.id, { createdByEmail }) + .pipe(Effect.mapError(mapServiceError)) + return toV2ApiKeyWithSecret(rolled) + }), + ) + .handle("revoke", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("revoke")) + const revoked = yield* apiKeysService + .revoke(tenant.orgId, params.id) + .pipe(Effect.mapError(mapServiceError)) + return toV2ApiKey(revoked) + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/error-envelope.ts b/apps/api/src/routes/v2/error-envelope.ts new file mode 100644 index 000000000..c74b903c2 --- /dev/null +++ b/apps/api/src/routes/v2/error-envelope.ts @@ -0,0 +1,20 @@ +import { Effect } from "effect" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { invalidRequest, V2SchemaErrors } from "@maple/domain/http/v2" + +/** + * Request-decode failures (params/query/payload) under /v2 are rewritten into + * the v2 error envelope — `{ "error": { "type": "invalid_request_error", + * "code": "parameter_invalid", "message": … } }` — instead of the runtime's + * default empty 400 (see docs/api-v2.md#errors). + */ +export const V2SchemaErrorsLive = HttpApiMiddleware.layerSchemaErrorTransform( + V2SchemaErrors, + (schemaError) => + Effect.fail( + invalidRequest( + "parameter_invalid", + `Invalid request ${schemaError.kind.toLowerCase()}: ${schemaError.cause.message}`, + ), + ), +) diff --git a/apps/api/src/services/ApiAuthorizationV2Layer.ts b/apps/api/src/services/ApiAuthorizationV2Layer.ts new file mode 100644 index 000000000..edc9d808c --- /dev/null +++ b/apps/api/src/services/ApiAuthorizationV2Layer.ts @@ -0,0 +1,96 @@ +import { HttpServerRequest } from "effect/unstable/http" +import { CurrentTenant, RoleName } from "@maple/domain/http" +import { + AuthorizationV2, + authenticationError, + permissionError, + requiredScopeForRequest, + scopeAllows, +} from "@maple/domain/http/v2" +import { Effect, Layer, Option, Schema } from "effect" +import { ApiKeysService } from "./ApiKeysService" +import { makeResolveTenant } from "./AuthService" +import { Env } from "../lib/Env" + +const decodeRoleNameSync = Schema.decodeUnknownSync(RoleName) +const apiKeyDefaultRoles = [decodeRoleNameSync("root")] as const + +const getBearerToken = (headers: Record): string | undefined => { + const header = headers["authorization"] ?? headers["Authorization"] + if (!header) return undefined + const [scheme, token] = header.split(" ") + if (!scheme || !token || scheme.toLowerCase() !== "bearer") return undefined + return token +} + +const requestPath = (url: string): string => { + const queryStart = url.indexOf("?") + return queryStart === -1 ? url : url.slice(0, queryStart) +} + +/** + * v2 flavor of `ApiAuthorizationLayer`: same credential resolution (API key + * first, then Clerk/self-hosted session token), but errors use the v2 + * envelope and restricted API keys are scope-checked mechanically from the + * request (family = first path segment under /v2, GET/HEAD → read else write). + * Session tokens and legacy null-scope keys bypass scope checks. + */ +export const ApiAuthorizationV2Layer = Layer.effect( + AuthorizationV2, + Effect.gen(function* () { + const env = yield* Env + const apiKeys = yield* ApiKeysService + const resolveTenant = makeResolveTenant(env) + + return AuthorizationV2.of({ + bearer: (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + + const token = getBearerToken(request.headers) + const apiKeyResolved = yield* apiKeys.resolveByBearer(token).pipe( + Effect.mapError(() => + authenticationError("api_key_invalid", "API key validation failed"), + ), + ) + + if (Option.isSome(apiKeyResolved)) { + const resolved = apiKeyResolved.value + + const required = requiredScopeForRequest(request.method, requestPath(request.url)) + if (required !== null && !scopeAllows(resolved.scopes, required)) { + return yield* Effect.fail( + permissionError( + "insufficient_scope", + `This API key does not have the "${required.family}:${required.access}" scope required for this request.`, + ), + ) + } + + const tenant = new CurrentTenant.TenantSchema({ + orgId: resolved.orgId, + userId: resolved.userId, + roles: apiKeyDefaultRoles, + authMode: "self_hosted", + ...(resolved.scopes !== null ? { scopes: resolved.scopes } : {}), + }) + return yield* Effect.provideService(httpEffect, CurrentTenant.Context, tenant) + } + + const tenant = yield* resolveTenant(request.headers).pipe( + Effect.mapError((error) => + authenticationError( + "invalid_credentials", + error.message || "Invalid or missing credentials", + ), + ), + ) + return yield* Effect.provideService( + httpEffect, + CurrentTenant.Context, + new CurrentTenant.TenantSchema(tenant), + ) + }), + }) + }), +) diff --git a/apps/api/src/services/ApiKeysService.scopes.test.ts b/apps/api/src/services/ApiKeysService.scopes.test.ts new file mode 100644 index 000000000..c15fc5dbf --- /dev/null +++ b/apps/api/src/services/ApiKeysService.scopes.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { OrgId, UserId } from "@maple/domain/http" +import { ConfigProvider, Effect, Layer, Option, Schema } from "effect" +import { Env } from "../lib/Env" +import { cleanupTestDbs, createTestDb, type TestDb } from "../lib/test-pglite" +import { ApiKeysService } from "./ApiKeysService" + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const testConfig = () => + 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", + INTERNAL_SERVICE_TOKEN: "test-internal-token", + }), + ) + +const makeLayer = () => { + const testDb = createTestDb(createdDbs) + const envLive = Env.layer.pipe(Layer.provide(testConfig())) + return ApiKeysService.layer.pipe(Layer.provide(Layer.mergeAll(envLive, testDb.layer))) +} + +const ORG = Schema.decodeUnknownSync(OrgId)("org_test") +const USER = Schema.decodeUnknownSync(UserId)("user_test") + +describe("ApiKeysService scopes", () => { + it.effect("persists scopes on create and resolves them by key", () => + Effect.gen(function* () { + const service = yield* ApiKeysService + + const created = yield* service.create(ORG, USER, { + name: "restricted", + scopes: ["api_keys:read", "dashboards:write"], + }) + expect(created.scopes).toEqual(["api_keys:read", "dashboards:write"]) + + const resolved = yield* service.resolveByKey(created.secret) + expect(Option.isSome(resolved)).toBe(true) + if (Option.isSome(resolved)) { + expect(resolved.value.scopes).toEqual(["api_keys:read", "dashboards:write"]) + } + + const fetched = yield* service.get(ORG, created.id) + expect(fetched.scopes).toEqual(["api_keys:read", "dashboards:write"]) + }).pipe(Effect.provide(makeLayer())), + ) + + it.effect("legacy keys without scopes resolve with scopes null (full access)", () => + Effect.gen(function* () { + const service = yield* ApiKeysService + + const created = yield* service.create(ORG, USER, { name: "legacy" }) + expect(created.scopes).toBeNull() + + const resolved = yield* service.resolveByKey(created.secret) + expect(Option.isSome(resolved)).toBe(true) + if (Option.isSome(resolved)) { + expect(resolved.value.scopes).toBeNull() + } + }).pipe(Effect.provide(makeLayer())), + ) + + it.effect("roll preserves the original key's scopes", () => + Effect.gen(function* () { + const service = yield* ApiKeysService + + const created = yield* service.create(ORG, USER, { + name: "ci", + scopes: ["telemetry:read"], + }) + const rolled = yield* service.roll(ORG, USER, created.id, {}) + expect(rolled.scopes).toEqual(["telemetry:read"]) + + // old secret is dead, new secret carries the scopes + const oldResolved = yield* service.resolveByKey(created.secret) + expect(Option.isNone(oldResolved)).toBe(true) + const newResolved = yield* service.resolveByKey(rolled.secret) + expect(Option.isSome(newResolved)).toBe(true) + if (Option.isSome(newResolved)) { + expect(newResolved.value.scopes).toEqual(["telemetry:read"]) + } + }).pipe(Effect.provide(makeLayer())), + ) +}) diff --git a/apps/api/src/services/ApiKeysService.ts b/apps/api/src/services/ApiKeysService.ts index e6c1003c1..9820fc07e 100644 --- a/apps/api/src/services/ApiKeysService.ts +++ b/apps/api/src/services/ApiKeysService.ts @@ -22,6 +22,8 @@ interface ResolvedApiKey { readonly userId: UserId readonly keyId: ApiKeyId readonly metadataJson: string | null + /** v2 scope strings; null = legacy full access. */ + readonly scopes: ReadonlyArray | null } const decodeApiKeyIdSync = Schema.decodeUnknownSync(ApiKeyId) @@ -40,6 +42,7 @@ const rowToResponse = (row: typeof apiKeys.$inferSelect): ApiKeyResponse => description: row.description ?? null, keyPrefix: row.keyPrefix, kind: row.kind, + scopes: row.scopes ?? null, revoked: row.revoked, revokedAt: dateToMs(row.revokedAt), lastUsedAt: dateToMs(row.lastUsedAt), @@ -79,6 +82,11 @@ export class ApiKeysService extends Context.Service()("@maple/ap return yield* Effect.fail(new ApiKeyNotFoundError({ keyId, message: "API key not found" })) }) + const get = Effect.fn("ApiKeysService.get")(function* (orgId: OrgId, keyId: ApiKeyId) { + const row = yield* requireById(orgId, keyId) + return rowToResponse(row) + }) + const list = Effect.fn("ApiKeysService.list")(function* (orgId: OrgId) { const rows = yield* database .execute((db) => @@ -103,6 +111,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap description?: string expiresInSeconds?: number kind?: ApiKeyKind + scopes?: ReadonlyArray | null createdByEmail?: string | null }, ) { @@ -113,6 +122,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap const now = yield* Clock.currentTimeMillis const expiresAt = params.expiresInSeconds ? now + params.expiresInSeconds * 1000 : undefined const kind: ApiKeyKind = params.kind ?? "standard" + const scopes = params.scopes == null ? null : [...params.scopes] const createdByEmail = params.createdByEmail ?? null yield* database @@ -125,6 +135,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap keyHash, keyPrefix, kind, + scopes, expiresAt: msToDate(expiresAt), createdAt: new Date(now), createdBy: userId, @@ -139,6 +150,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap description: params.description ?? null, keyPrefix, kind, + scopes, revoked: false, revokedAt: null, lastUsedAt: null, @@ -183,6 +195,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap keyHash, keyPrefix, kind: existing.kind, + scopes: existing.scopes ?? null, expiresAt: null, createdAt: new Date(now), createdBy: userId, @@ -202,6 +215,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap description: existing.description ?? null, keyPrefix, kind: existing.kind, + scopes: existing.scopes ?? null, revoked: false, revokedAt: null, lastUsedAt: null, @@ -251,6 +265,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap keyId: decodeApiKeyIdSync(row.value.id), metadataJson: row.value.metadataJson == null ? null : JSON.stringify(row.value.metadataJson), + scopes: row.value.scopes ?? null, } satisfies ResolvedApiKey) }) @@ -278,6 +293,7 @@ export class ApiKeysService extends Context.Service()("@maple/ap }) return { + get, list, create, roll, diff --git a/docs/api-v2.md b/docs/api-v2.md new file mode 100644 index 000000000..9669f3375 --- /dev/null +++ b/docs/api-v2.md @@ -0,0 +1,170 @@ +# Maple v2 Public API + +The Maple v2 API is the public, documented, stability-committed HTTP surface for everything the dashboard can do. It follows Stripe's API design philosophy — resource-oriented URLs, prefixed object IDs, uniform list/error envelopes, scoped keys — modernized where Stripe's v1 mechanics are legacy (JSON PATCH updates instead of form-encoded POST, ISO-8601 timestamps instead of epoch seconds). + +The **executable contract is the spec**: `MapleApiV2` in `packages/domain/src/http/v2/` (an Effect `HttpApi`). OpenAPI is derived from it automatically and served as an interactive reference at **`/v2/docs`**. This document is the design-guideline layer every v2 contract file must conform to, plus the roadmap for the full surface. + +## Architecture: two tiers + +| Tier | Transport | Consumers | Docs | Stability | +|---|---|---|---|---| +| **Public API** | `MapleApiV2` HttpApi at `/v2/...` | Customers, agents/MCP, the dashboard | `/v2/docs` (OpenAPI/Scalar) | Committed; changes are additive or versioned | +| **Internal RPC** | Effect RPC (`effect/unstable/rpc`) `RpcGroup`s served at `/rpc` | The dashboard only | none (private) | None; changes freely | + +Dashboard-only operations — billing checkout/portal, onboarding state, demo seeding, AI chat apply, digest subscription, AI-triage settings, integration OAuth flows (Slack/Cloudflare/PlanetScale/GitHub), raw warehouse queries, and the error-agent claim/heartbeat/release loop — live in the internal RPC tier. They use the same tenant resolution and org scoping but are **not** HTTP API groups and never appear in the public OpenAPI. Everything else is public API, and the dashboard consumes the same `/v2` endpoints customers do. + +The v1 API (`/api/...`) stays mounted while the dashboard migrates group-by-group; each v1 group is deleted once nothing consumes it. + +## Conventions + +### URLs and methods + +Resources are snake_case plural nouns directly under `/v2`: + +``` +GET /v2/api_keys list +POST /v2/api_keys create +GET /v2/api_keys/{id} retrieve +PATCH /v2/api_keys/{id} update +DELETE /v2/api_keys/{id} delete (or revoke — returns the final object) +POST /v2/api_keys/{id}/roll non-CRUD verbs are sub-resource POSTs +POST /v2/traces/search complex reads are POST .../search +``` + +### Object IDs + +Every v2 object has a prefixed public ID (`key_4CzLmR…`, `dash_…`, `alrt_…`). Public IDs are opaque; internally they are a reversible base58 encoding of the internal ID, computed at the API boundary (`packages/domain/src/http/v2/public-id.ts` — the prefix registry lives there and is the single source of truth). No database migration: rows keep their raw UUIDs / internal strings. + +Prefixes: `key` (API key), `ingk` (ingest key), `dash` (dashboard), `dbv` (dashboard version), `alrt` (alert rule), `dest` (alert destination), `inc` (alert incident), `iss` (error issue), `inv` (investigation), `anom` (anomaly incident), `scrp` (scrape target), `rec` (recommendation), `amap` (attribute mapping); `evt` and `we` are reserved for events/webhooks. + +Exception: Clerk-issued `org_…` / `user_…` IDs are already prefixed public IDs and pass through unchanged. + +A malformed or wrong-prefix ID fails request decoding and returns an `invalid_request_error`. + +### Wire format + +- **snake_case** JSON field names everywhere (`key_prefix`, `created_at`). +- Every resource carries **`object`** (`"api_key"`, `"dashboard"`, `"list"`, …). +- **Timestamps are ISO-8601 UTC strings** (`2026-07-15T12:34:56.000Z`). +- Nullable fields are explicit `null`, not omitted. + +### Lists and pagination + +Every list endpoint accepts `limit` (1–100, default 20) and an opaque `cursor`, and responds with the list envelope: + +```json +{ + "object": "list", + "data": [{ "...": "..." }], + "has_more": true, + "next_cursor": "off_1k" +} +``` + +`next_cursor` is `null` on the last page. Cursors are opaque — clients must not parse them. (Endpoints backed by keyset pagination and endpoints backed by materialized arrays use different cursor payloads; the wire contract is identical.) + +### Errors + +Every error response body is exactly: + +```json +{ + "error": { + "type": "not_found_error", + "code": "resource_missing", + "message": "API key not found", + "param": "id" + } +} +``` + +- `type` is closed: `invalid_request_error` (400), `authentication_error` (401), `permission_error` (403), `not_found_error` (404), `conflict_error` (409), `rate_limit_error` (429), `api_error` (5xx). +- `code` is a stable machine-readable string (`resource_missing`, `insufficient_scope`, `parameter_invalid`, `service_unavailable`, …). Codes are append-only. +- `param` names the offending parameter when applicable; `doc_url` may link to reference docs. +- No internal tags or stack traces ever appear on the wire. + +Implementation: `packages/domain/src/http/v2/errors.ts`; request-decode failures are rewritten into the envelope by the `V2SchemaErrors` middleware (`apps/api/src/routes/v2/error-envelope.ts`, via `HttpApiMiddleware.layerSchemaErrorTransform`). + +### Authentication and scopes + +``` +Authorization: Bearer maple_ak_… +``` + +v2 accepts the same credentials as v1: API keys (`maple_ak_…`) and dashboard session tokens (Clerk or self-hosted JWT). API keys can be **restricted with scopes** at creation: + +- Grammar: `:read`, `:write`, or `*`. The family is the first path segment under `/v2` (`api_keys`, `dashboards`, `alert_rules`, `error_issues`, `traces`, …). +- Enforcement is mechanical: `GET`/`HEAD` requires `:read`, everything else `:write`. `write` implies `read`. +- Keys with no scopes (all pre-v2 keys) have full access. Session tokens are never scope-checked — the dashboard's authorization comes from org roles, like Stripe's own dashboard. +- Failing the check returns `permission_error` / `insufficient_scope`. + +Implementation: `packages/domain/src/http/v2/auth.ts` + `apps/api/src/services/ApiAuthorizationV2Layer.ts`; scopes are stored on `api_keys.scopes` (jsonb). + +### Versioning + +- The `/v2` path prefix is the major version. Breaking changes require `/v3`. +- Within v2, changes are additive (new endpoints, new optional fields, new enum values documented as open sets, new error codes). +- A `Maple-Version: YYYY-MM-DD` header is reserved for future in-v2 evolution; until multiple versions exist, it is accepted and ignored. + +### Idempotency (Phase 4 — reserved) + +Mutating endpoints will accept an `Idempotency-Key` header. Replays within the retention window return the original response. Backed by a Postgres `idempotency_keys` table keyed by `(org_id, key)`. + +### Rate limiting (Phase 4 — reserved) + +Per-key rate limits will return `429` with the error envelope (`type: "rate_limit_error"`) and a `Retry-After` header. + +### Expansion — not supported + +Stripe-style `expand[]` is deliberately omitted: responses embed the small, always-wanted sub-objects directly. May be revisited once real client demand exists. + +## Resource catalog (target surface) + +Implemented in phases; the pilot (`api_keys`) ships first and proves every convention. + +| Resource | Endpoints | Backing v1 group / service | +|---|---|---| +| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `apiKeys` / `ApiKeysService` | +| `ingest_keys` | retrieve, `POST …/public/roll`, `POST …/private/roll` | `ingestKeys` | +| `dashboards` | CRUD + `versions` (list/retrieve/restore) + `templates` (list/instantiate) + perses import | `dashboards` | +| `alert_rules` | CRUD + `test` + `preview` + `checks` | `alerts` | +| `alert_destinations` | CRUD + `test` | `alerts` | +| `alert_incidents` | list/retrieve | `alerts` | +| `error_issues` | list/retrieve + `events`, `incidents`, `comments`, `transitions`, `assignee`, `severity` | `errors` | +| `investigations` | list/retrieve/create/status | `investigations` | +| `anomalies` | incidents list/retrieve/resolve/link-issue + settings | `anomalies` | +| `recommendations` | list + dismiss/reopen | `recommendationIssues` | +| `scrape_targets` | CRUD + `probe` + `checks` | `scrapeTargets` | +| `attribute_mappings` | CRUD | `ingestAttributeMappings` | +| `session_replays` | list/retrieve + events/transcript/for-trace | `sessionReplays` | +| `organization` | retrieve/update settings (incl. ClickHouse BYOC), delete | `organizations`, `orgClickHouseSettings` | +| `traces` | `POST /v2/traces/search`, `GET /v2/traces/{trace_id}`, `GET /v2/traces/{trace_id}/spans/{span_id}` | `queryEngine`, `observability` | +| `logs` | `POST /v2/logs/search`, `GET /v2/logs/{id}` | `queryEngine` | +| `metrics` | `GET /v2/metrics`, `POST /v2/metrics/timeseries` | `queryEngine` | +| `services` | `GET /v2/services`, `GET /v2/services/{name}`, `GET /v2/service_map` | `queryEngine` | +| `query` | `POST /v2/query` — query-builder execution; raw SQL org-gated | `queryEngine` | + +The long tail of ~40 query-engine RPC endpoints (facets, infra hosts/pods/nodes/workloads, Cloudflare/PlanetScale infra) starts in the internal RPC tier and is promoted into `/v2` individually as shapes stabilize. + +Not in v2: org membership and invitations (delegated to Clerk; revisit if/when a members API is needed). + +### ElectricSQL `txid` constraint + +The dashboard reconciles optimistic writes against ElectricSQL synced shapes using the Postgres `txid` returned by mutation responses (dashboards, alert rules/destinations, error issues). v2 mutation responses for those resources **must keep the `txid` field**. It is documented as an internal reconciliation token; API consumers should ignore it. + +## Rollout phases + +- **Phase 0 (this change)** — conventions doc, v2 primitives (`public-id`, `envelopes`, `errors`, `auth`), scoped API keys (schema + service + enforcement), `MapleApiV2` shell mounted at `/v2` with Scalar docs at `/v2/docs`, pilot resource `api_keys` end-to-end with tests. +- **Phase 1 — core resources**: dashboards, alerts, error issues, scrape targets, ingest keys, attribute mappings, investigations, anomalies, recommendations, organization, session replays. Thin handler adapters over existing services; `txid` preserved. +- **Phase 2 — telemetry reads**: traces/logs/metrics/services/service_map/query over `QueryEngineService`. +- **Phase 3 — internal RPC tier + dashboard migration**: `RpcGroup` contracts in `packages/domain/src/rpc/` served at `/rpc`; dashboard gets a `MapleApiV2AtomClient` (same wiring as `apps/web/src/lib/services/common/atom-client.ts`, pointed at `MapleApiV2`) plus an `RpcClient`; migrate group-by-group, deleting v1 groups as they empty. (Note: the billing-scoped 401 retry in `atom-client.ts` must follow billing to its RPC home.) +- **Phase 4 — hardening**: `Idempotency-Key`, per-key rate limiting (Workers rate-limit binding), `Maple-Version` header enforcement. +- **Phase 5 — events & webhooks**: `evt_` event objects, `GET /v2/events`, `/v2/webhook_endpoints` CRUD, HMAC-signed deliveries (`Maple-Signature`) via an outbox drained by the alerting worker. + +## Adding a v2 resource (checklist) + +1. Contract in `packages/domain/src/http/v2/.ts`: snake_case `Schema.Class` wire models with `object` literal and `Timestamp` fields; public IDs via `PublicId(prefix, InternalId)` (register the prefix in `public-id.ts`); lists use `ListQuery` + `ListOf`; errors from `v2/errors.ts` only; group `.prefix("/v2/")` + `.middleware(AuthorizationV2)` + `.middleware(V2SchemaErrors)`. +2. Add the group to `MapleApiV2` in `v2/api.ts` and export from `v2/index.ts`. +3. Handlers in `apps/api/src/routes/v2/.http.ts`: thin adapters over the existing service — map camelCase/epoch-ms service responses to the wire model, map service tagged errors to envelope errors. Register the layer in `ApiV2Routes` (`apps/api/src/app.ts`). +4. Tests: wire-shape encode (snake_case, public ID, envelope), error mapping, and a PGlite service test if the service changed. +5. Confirm the resource renders correctly at `/v2/docs`. diff --git a/packages/db/drizzle/0013_sour_dagger.sql b/packages/db/drizzle/0013_sour_dagger.sql new file mode 100644 index 000000000..669a12572 --- /dev/null +++ b/packages/db/drizzle/0013_sour_dagger.sql @@ -0,0 +1 @@ +ALTER TABLE "api_keys" ADD COLUMN "scopes" jsonb; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0013_snapshot.json b/packages/db/drizzle/meta/0013_snapshot.json new file mode 100644 index 000000000..bb58dd4e4 --- /dev/null +++ b/packages/db/drizzle/meta/0013_snapshot.json @@ -0,0 +1,6172 @@ +{ + "id": "0edb883f-4a57-4ced-9565-d8e5cd2304b7", + "prevId": "8baf3939-c56d-4338-b7ee-43205d8877b7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_triage_runs": { + "name": "ai_triage_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_kind": { + "name": "incident_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "context_json": { + "name": "context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_triage_runs_incident_idx": { + "name": "ai_triage_runs_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_triage_runs_org_issue_idx": { + "name": "ai_triage_runs_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_triage_runs_org_created_idx": { + "name": "ai_triage_runs_org_created_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_triage_settings": { + "name": "ai_triage_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "max_runs_per_day": { + "name": "max_runs_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_delivery_events": { + "name": "alert_delivery_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_key": { + "name": "delivery_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_message": { + "name": "provider_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_delivery_events_org_idx": { + "name": "alert_delivery_events_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_org_incident_idx": { + "name": "alert_delivery_events_org_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_due_idx": { + "name": "alert_delivery_events_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_claim_idx": { + "name": "alert_delivery_events_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_delivery_attempt_idx": { + "name": "alert_delivery_events_delivery_attempt_idx", + "columns": [ + { + "expression": "delivery_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_destinations": { + "name": "alert_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_tag": { + "name": "secret_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_destinations_org_idx": { + "name": "alert_destinations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_destinations_org_enabled_idx": { + "name": "alert_destinations_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_destinations_org_name_idx": { + "name": "alert_destinations_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_incidents": { + "name": "alert_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_key": { + "name": "incident_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_name": { + "name": "rule_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comparator": { + "name": "comparator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_upper": { + "name": "threshold_upper", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_observed_value": { + "name": "last_observed_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_delivered_event_type": { + "name": "last_delivered_event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_notified_at": { + "name": "last_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_issue_id": { + "name": "error_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_incidents_org_idx": { + "name": "alert_incidents_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_status_idx": { + "name": "alert_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_rule_idx": { + "name": "alert_incidents_org_rule_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_issue_idx": { + "name": "alert_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "error_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_incident_key_idx": { + "name": "alert_incidents_incident_key_idx", + "columns": [ + { + "expression": "incident_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rule_states": { + "name": "alert_rule_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'__total__'" + }, + "consecutive_breaches": { + "name": "consecutive_breaches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_healthy": { + "name": "consecutive_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rule_states_org_idx": { + "name": "alert_rule_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "alert_rule_states_org_id_rule_id_group_key_pk": { + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "columns": [ + "org_id", + "rule_id", + "group_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rules": { + "name": "alert_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notification_template_json": { + "name": "notification_template_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_names_json": { + "name": "service_names_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exclude_service_names_json": { + "name": "exclude_service_names_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tags_json": { + "name": "tags_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comparator": { + "name": "comparator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_upper": { + "name": "threshold_upper", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "window_minutes": { + "name": "window_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "minimum_sample_count": { + "name": "minimum_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_breaches_required": { + "name": "consecutive_breaches_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "consecutive_healthy_required": { + "name": "consecutive_healthy_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "renotify_interval_minutes": { + "name": "renotify_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "metric_name": { + "name": "metric_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metric_type": { + "name": "metric_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metric_aggregation": { + "name": "metric_aggregation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "apdex_threshold_ms": { + "name": "apdex_threshold_ms", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "query_builder_draft_json": { + "name": "query_builder_draft_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "raw_query_sql": { + "name": "raw_query_sql", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_by": { + "name": "group_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_ids_json": { + "name": "destination_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "query_spec_json": { + "name": "query_spec_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reducer": { + "name": "reducer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sample_count_strategy": { + "name": "sample_count_strategy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "no_data_behavior": { + "name": "no_data_behavior", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_scheduled_at": { + "name": "last_scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rules_org_idx": { + "name": "alert_rules_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_rules_org_enabled_idx": { + "name": "alert_rules_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_rules_org_name_idx": { + "name": "alert_rules_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_detector_settings": { + "name": "anomaly_detector_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sensitivity": { + "name": "sensitivity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "muted_signals_json": { + "name": "muted_signals_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_detector_states": { + "name": "anomaly_detector_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_env": { + "name": "deployment_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_breaches": { + "name": "consecutive_breaches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_healthy": { + "name": "consecutive_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "baseline_median": { + "name": "baseline_median", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "open_incident_id": { + "name": "open_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_resolved_at": { + "name": "last_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_incident_id": { + "name": "last_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anomaly_detector_states_org_idx": { + "name": "anomaly_detector_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_detector_states_open_incident_idx": { + "name": "anomaly_detector_states_open_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "open_incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_detector_states_evaluated_idx": { + "name": "anomaly_detector_states_evaluated_idx", + "columns": [ + { + "expression": "last_evaluated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "anomaly_detector_states_org_id_detector_key_pk": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_incidents": { + "name": "anomaly_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_env": { + "name": "deployment_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_issue_id": { + "name": "error_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opened_value": { + "name": "opened_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "baseline_median": { + "name": "baseline_median", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "baseline_sigma": { + "name": "baseline_sigma", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_value": { + "name": "threshold_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_observed_value": { + "name": "last_observed_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolve_reason": { + "name": "resolve_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triage_status": { + "name": "triage_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprints_json": { + "name": "fingerprints_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reopen_count": { + "name": "reopen_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reopened_at": { + "name": "last_reopened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anomaly_incidents_org_status_idx": { + "name": "anomaly_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_triggered_idx": { + "name": "anomaly_incidents_org_triggered_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_triggered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_detector_idx": { + "name": "anomaly_incidents_org_detector_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detector_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_issue_idx": { + "name": "anomaly_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "error_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_email": { + "name": "created_by_email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_org_id_idx": { + "name": "api_keys_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_analytics_state": { + "name": "cloudflare_analytics_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "watermark_at": { + "name": "watermark_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_at": { + "name": "backfill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings_fetched_at": { + "name": "settings_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quantiles_available": { + "name": "quantiles_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "live_scripts_json": { + "name": "live_scripts_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cf_analytics_state_org_dataset_zone_idx": { + "name": "cf_analytics_state_org_dataset_zone_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dataset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cf_analytics_state_org_idx": { + "name": "cf_analytics_state_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_logpush_connectors": { + "name": "cloudflare_logpush_connectors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'http_requests'" + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_tag": { + "name": "secret_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cloudflare_logpush_connectors_org_idx": { + "name": "cloudflare_logpush_connectors_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_logpush_connectors_org_enabled_idx": { + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_logpush_connectors_secret_hash_unique": { + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_versions": { + "name": "dashboard_versions", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "change_kind": { + "name": "change_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_version_id": { + "name": "source_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "dashboard_versions_org_dashboard_idx": { + "name": "dashboard_versions_org_dashboard_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_versions_org_dashboard_version_unq": { + "name": "dashboard_versions_org_dashboard_version_unq", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dashboard_versions_org_id_id_pk": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "dashboards_org_updated_idx": { + "name": "dashboards_org_updated_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboards_org_name_idx": { + "name": "dashboards_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dashboards_org_id_id_pk": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.digest_subscriptions": { + "name": "digest_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "digest_subscriptions_org_user_idx": { + "name": "digest_subscriptions_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "digest_subscriptions_org_enabled_idx": { + "name": "digest_subscriptions_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.actors": { + "name": "actors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities_json": { + "name": "capabilities_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "actors_org_user_idx": { + "name": "actors_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "actors_org_agent_name_idx": { + "name": "actors_org_agent_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "actors_org_type_idx": { + "name": "actors_org_type_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_incidents": { + "name": "error_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_incidents_org_issue_idx": { + "name": "error_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_incidents_org_status_idx": { + "name": "error_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_events": { + "name": "error_issue_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_state": { + "name": "from_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_state": { + "name": "to_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_events_issue_idx": { + "name": "error_issue_events_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_events_actor_idx": { + "name": "error_issue_events_actor_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_events_type_idx": { + "name": "error_issue_events_type_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_states": { + "name": "error_issue_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_observed_occurrence_at": { + "name": "last_observed_occurrence_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "open_incident_id": { + "name": "open_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_states_org_idx": { + "name": "error_issue_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "error_issue_states_org_id_issue_id_pk": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issues": { + "name": "error_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'error'" + }, + "source_ref_json": { + "name": "source_ref_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_type": { + "name": "exception_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_message": { + "name": "exception_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_label": { + "name": "error_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "top_frame": { + "name": "top_frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_state": { + "name": "workflow_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'triage'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity_source": { + "name": "severity_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_actor_id": { + "name": "assigned_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_holder_actor_id": { + "name": "lease_holder_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by_actor_id": { + "name": "resolved_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snooze_until": { + "name": "snooze_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issues_org_fp_idx": { + "name": "error_issues_org_fp_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_workflow_idx": { + "name": "error_issues_org_workflow_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_severity_idx": { + "name": "error_issues_org_severity_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_last_seen_idx": { + "name": "error_issues_org_last_seen_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_assignee_idx": { + "name": "error_issues_org_assignee_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assigned_actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_lease_expiry_idx": { + "name": "error_issues_lease_expiry_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_notification_policies": { + "name": "error_notification_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "destination_ids_json": { + "name": "destination_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "notify_on_first_seen": { + "name": "notify_on_first_seen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_regression": { + "name": "notify_on_regression", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_resolve": { + "name": "notify_on_resolve", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_transition_in_review": { + "name": "notify_on_transition_in_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_transition_done": { + "name": "notify_on_transition_done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_claim": { + "name": "notify_on_claim", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "min_occurrence_count": { + "name": "min_occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'warning'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_escalation_policies": { + "name": "issue_escalation_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rules_json": { + "name": "rules_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_escalations": { + "name": "issue_escalations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "issue_escalations_dedupe_idx": { + "name": "issue_escalations_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_escalations_due_idx": { + "name": "issue_escalations_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_escalations_org_issue_idx": { + "name": "issue_escalations_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigations": { + "name": "investigations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'investigating'" + }, + "seeded_by": { + "name": "seeded_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "subject_json": { + "name": "subject_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "incident_kind": { + "name": "incident_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_json": { + "name": "report_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "diagnosed_at": { + "name": "diagnosed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "investigations_incident_idx": { + "name": "investigations_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"investigations\".\"incident_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_created_idx": { + "name": "investigations_org_created_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_issue_idx": { + "name": "investigations_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_status_idx": { + "name": "investigations_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_states": { + "name": "oauth_auth_states", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "return_to": { + "name": "return_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_auth_states_expires_idx": { + "name": "oauth_auth_states_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_connections": { + "name": "oauth_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_email": { + "name": "external_user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_account_name": { + "name": "external_account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_iv": { + "name": "access_token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_tag": { + "name": "access_token_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_iv": { + "name": "refresh_token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_tag": { + "name": "refresh_token_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_connections_org_provider_idx": { + "name": "oauth_connections_org_provider_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_connections_org_idx": { + "name": "oauth_connections_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_onboarding_state": { + "name": "org_onboarding_state", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_data_requested": { + "name": "demo_data_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "checklist_dismissed_at": { + "name": "checklist_dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_data_received_at": { + "name": "first_data_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "welcome_email_sent_at": { + "name": "welcome_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "connect_nudge_email_sent_at": { + "name": "connect_nudge_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stalled_email_sent_at": { + "name": "stalled_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activation_email_sent_at": { + "name": "activation_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_attribute_mappings": { + "name": "org_ingest_attribute_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_context": { + "name": "source_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_ingest_attribute_mappings_org_idx": { + "name": "org_ingest_attribute_mappings_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_recommendation_issues": { + "name": "org_recommendation_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recommendation_key": { + "name": "recommendation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_key": { + "name": "canonical_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "org_recommendation_issues_org_idx": { + "name": "org_recommendation_issues_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_recommendation_issues_org_key_idx": { + "name": "org_recommendation_issues_org_key_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recommendation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_keys": { + "name": "org_ingest_keys", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_hash": { + "name": "public_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_ciphertext": { + "name": "private_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_tag": { + "name": "private_key_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_hash": { + "name": "private_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_rotated_at": { + "name": "public_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "private_rotated_at": { + "name": "private_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "org_ingest_keys_public_key_unique": { + "name": "org_ingest_keys_public_key_unique", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_ingest_keys_public_key_hash_unique": { + "name": "org_ingest_keys_public_key_hash_unique", + "columns": [ + { + "expression": "public_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_ingest_keys_private_key_hash_unique": { + "name": "org_ingest_keys_private_key_hash_unique", + "columns": [ + { + "expression": "private_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_ingest_keys_org_id_pk": { + "name": "org_ingest_keys_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_sampling_policies": { + "name": "org_ingest_sampling_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trace_sample_ratio": { + "name": "trace_sample_ratio", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "always_keep_error_spans": { + "name": "always_keep_error_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "always_keep_slow_spans_ms": { + "name": "always_keep_slow_spans_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_clickhouse_settings": { + "name": "org_clickhouse_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_url": { + "name": "ch_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_user": { + "name": "ch_user", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_password_ciphertext": { + "name": "ch_password_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_password_iv": { + "name": "ch_password_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_password_tag": { + "name": "ch_password_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_database": { + "name": "ch_database", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_clickhouse_settings_org_id_pk": { + "name": "org_clickhouse_settings_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_clickhouse_schema_apply_runs": { + "name": "org_clickhouse_schema_apply_runs", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_migration": { + "name": "current_migration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steps_total": { + "name": "steps_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steps_done": { + "name": "steps_done", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "applied_versions": { + "name": "applied_versions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skipped": { + "name": "skipped", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_clickhouse_schema_apply_runs_org_id_pk": { + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_connections": { + "name": "planetscale_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ps_organization": { + "name": "ps_organization", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scrape_target_id": { + "name": "scrape_target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_ciphertext": { + "name": "webhook_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_iv": { + "name": "webhook_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_tag": { + "name": "webhook_secret_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_permissions_json": { + "name": "detected_permissions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_inventory_at": { + "name": "last_inventory_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_inventory_error": { + "name": "last_inventory_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_connections_org_idx": { + "name": "planetscale_connections_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_databases": { + "name": "planetscale_databases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mysql'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branches_json": { + "name": "branches_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_databases_org_db_idx": { + "name": "planetscale_databases_org_db_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "database_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planetscale_databases_org_idx": { + "name": "planetscale_databases_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_poll_state": { + "name": "planetscale_poll_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "watermark_at": { + "name": "watermark_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_poll_state_org_dataset_db_idx": { + "name": "planetscale_poll_state_org_dataset_db_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dataset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "database_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planetscale_poll_state_org_idx": { + "name": "planetscale_poll_state_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_target_checks": { + "name": "scrape_target_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "scrape_target_checks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_target_key": { + "name": "sub_target_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "samples_scraped": { + "name": "samples_scraped", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "samples_post_relabel": { + "name": "samples_post_relabel", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scrape_target_checks_target_checked_idx": { + "name": "scrape_target_checks_target_checked_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scrape_target_checks_target_id_scrape_targets_id_fk": { + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "tableFrom": "scrape_target_checks", + "tableTo": "scrape_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_targets": { + "name": "scrape_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prometheus'" + }, + "discovery_config_json": { + "name": "discovery_config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scrape_interval_seconds": { + "name": "scrape_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "labels_json": { + "name": "labels_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_ciphertext": { + "name": "auth_credentials_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_iv": { + "name": "auth_credentials_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_tag": { + "name": "auth_credentials_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_scrape_at": { + "name": "last_scrape_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_scrape_error": { + "name": "last_scrape_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scrape_targets_org_idx": { + "name": "scrape_targets_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scrape_targets_org_enabled_idx": { + "name": "scrape_targets_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_commits": { + "name": "vcs_commits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authored_at": { + "name": "authored_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "committed_at": { + "name": "committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_commits_repo_sha_idx": { + "name": "vcs_commits_repo_sha_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_commits_org_sha_idx": { + "name": "vcs_commits_org_sha_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_installations": { + "name": "vcs_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_installation_id": { + "name": "external_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_installations_provider_external_idx": { + "name": "vcs_installations_provider_external_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_installations_org_idx": { + "name": "vcs_installations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_repositories": { + "name": "vcs_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "tracked_branch": { + "name": "tracked_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_repositories_org_repo_idx": { + "name": "vcs_repositories_org_repo_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repositories_org_idx": { + "name": "vcs_repositories_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repositories_installation_idx": { + "name": "vcs_repositories_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_repository_branches": { + "name": "vcs_repository_branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_repository_branches_repo_name_idx": { + "name": "vcs_repository_branches_repo_name_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repository_branches_org_idx": { + "name": "vcs_repository_branches_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 416b8dfc1..9faf563d8 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1783775467529, "tag": "0012_cute_veda", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1784112584278, + "tag": "0013_sour_dagger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/api-keys.ts b/packages/db/src/schema/api-keys.ts index c8ef44a7f..bb2e72213 100644 --- a/packages/db/src/schema/api-keys.ts +++ b/packages/db/src/schema/api-keys.ts @@ -14,6 +14,8 @@ export const apiKeys = pgTable( lastUsedAt: timestamp("last_used_at", { withTimezone: true, mode: "date" }), expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }), metadataJson: jsonb("metadata_json").$type(), + // v2 scope strings (":read"/":write"/"*"); null = legacy full access. + scopes: jsonb("scopes").$type(), kind: text("kind", { enum: ["standard", "mcp"] }).notNull().default("standard"), createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull(), createdBy: text("created_by").notNull(), diff --git a/packages/domain/package.json b/packages/domain/package.json index 72f47f1cb..bbfd01ee2 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -7,6 +7,7 @@ "./anticipated-errors": "./src/anticipated-errors.ts", "./billing": "./src/billing.ts", "./http": "./src/http/index.ts", + "./http/v2": "./src/http/v2/index.ts", "./internal-rpc": "./src/internal-rpc.ts", "./primitives": "./src/primitives.ts", "./query-engine": "./src/query-engine.ts", diff --git a/packages/domain/src/http/api-keys.ts b/packages/domain/src/http/api-keys.ts index 0a134e44c..d655e74b0 100644 --- a/packages/domain/src/http/api-keys.ts +++ b/packages/domain/src/http/api-keys.ts @@ -12,6 +12,8 @@ export class ApiKeyResponse extends Schema.Class("ApiKeyResponse description: Schema.NullOr(Schema.String), keyPrefix: Schema.String, kind: ApiKeyKind, + // v2 scope strings; null = legacy full access. + scopes: Schema.NullOr(Schema.Array(Schema.String)), revoked: Schema.Boolean, revokedAt: Schema.NullOr(Schema.Number), lastUsedAt: Schema.NullOr(Schema.Number), @@ -27,6 +29,7 @@ export class ApiKeyCreatedResponse extends Schema.Class(" description: Schema.NullOr(Schema.String), keyPrefix: Schema.String, kind: ApiKeyKind, + scopes: Schema.NullOr(Schema.Array(Schema.String)), revoked: Schema.Boolean, revokedAt: Schema.NullOr(Schema.Number), lastUsedAt: Schema.NullOr(Schema.Number), @@ -46,6 +49,7 @@ export class CreateApiKeyRequest extends Schema.Class("Crea description: Schema.optional(Schema.String), expiresInSeconds: Schema.optional(Schema.Number), kind: Schema.optional(ApiKeyKind), + scopes: Schema.optional(Schema.Array(Schema.String)), }) {} export class ApiKeyPersistenceError extends Schema.TaggedErrorClass()( diff --git a/packages/domain/src/http/current-tenant.ts b/packages/domain/src/http/current-tenant.ts index 092054c4f..a1f857ac3 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -15,6 +15,9 @@ export class TenantSchema extends Schema.Class("TenantSchema")({ userId: UserId, roles: Schema.Array(RoleName), authMode: AuthMode, + // Present only for API-key auth with restricted scopes (v2). Undefined for + // session tokens and legacy full-access keys — those bypass scope checks. + scopes: Schema.optional(Schema.Array(Schema.String)), }) {} export class Context extends EffectContext.Service()( diff --git a/packages/domain/src/http/v2/api-keys.ts b/packages/domain/src/http/v2/api-keys.ts new file mode 100644 index 000000000..9b13959f7 --- /dev/null +++ b/packages/domain/src/http/v2/api-keys.ts @@ -0,0 +1,89 @@ +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { ApiKeyId, UserId } from "../../primitives" +import { ApiKeyKind } from "../api-keys" +import { AuthorizationV2, V2SchemaErrors, V2Scope } from "./auth" +import { ListOf, ListQuery, Timestamp } from "./envelopes" +import { + V2InvalidRequestError, + V2NotFoundError, + V2PermissionError, + V2ServiceUnavailableError, +} from "./errors" +import { PublicId, PublicIdPrefixes } from "./public-id" + +/** `key_…` public ID ⇄ internal `ApiKeyId` (raw UUID). */ +export const ApiKeyPublicId = PublicId(PublicIdPrefixes.apiKey, ApiKeyId) + +export class V2ApiKey extends Schema.Class("V2ApiKey")({ + id: ApiKeyPublicId, + object: Schema.Literal("api_key"), + name: Schema.String, + description: Schema.NullOr(Schema.String), + key_prefix: Schema.String, + kind: ApiKeyKind, + scopes: Schema.NullOr(Schema.Array(V2Scope)), + revoked: Schema.Boolean, + revoked_at: Schema.NullOr(Timestamp), + last_used_at: Schema.NullOr(Timestamp), + expires_at: Schema.NullOr(Timestamp), + created_at: Timestamp, + created_by: UserId, + created_by_email: Schema.NullOr(Schema.String), +}) {} + +/** Returned only by create/roll — the one time the secret is visible. */ +export class V2ApiKeyWithSecret extends Schema.Class("V2ApiKeyWithSecret")({ + ...V2ApiKey.fields, + secret: Schema.String, +}) {} + +export class V2ApiKeyCreateParams extends Schema.Class("V2ApiKeyCreateParams")({ + name: Schema.String.check(Schema.isMinLength(1)), + description: Schema.optionalKey(Schema.String), + expires_in_seconds: Schema.optionalKey(Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0))), + kind: Schema.optionalKey(ApiKeyKind), + scopes: Schema.optionalKey(Schema.Array(V2Scope)), +}) {} + +const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const + +export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") + .add( + HttpApiEndpoint.get("list", "/", { + query: ListQuery, + success: ListOf(V2ApiKey), + error: [...commonErrors], + }), + ) + .add( + HttpApiEndpoint.post("create", "/", { + payload: V2ApiKeyCreateParams, + success: V2ApiKeyWithSecret, + error: [...commonErrors, V2PermissionError], + }), + ) + .add( + HttpApiEndpoint.get("retrieve", "/:id", { + params: { id: ApiKeyPublicId }, + success: V2ApiKey, + error: [...commonErrors, V2NotFoundError], + }), + ) + .add( + HttpApiEndpoint.post("roll", "/:id/roll", { + params: { id: ApiKeyPublicId }, + success: V2ApiKeyWithSecret, + error: [...commonErrors, V2PermissionError, V2NotFoundError], + }), + ) + .add( + HttpApiEndpoint.delete("revoke", "/:id", { + params: { id: ApiKeyPublicId }, + success: V2ApiKey, + error: [...commonErrors, V2PermissionError, V2NotFoundError], + }), + ) + .prefix("/v2/api_keys") + .middleware(AuthorizationV2) + .middleware(V2SchemaErrors) {} diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts new file mode 100644 index 000000000..bdd33c83f --- /dev/null +++ b/packages/domain/src/http/v2/api.ts @@ -0,0 +1,23 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { V2ApiKeysApiGroup } from "./api-keys" + +/** + * The Maple v2 public API (see docs/api-v2.md). + * + * Stripe-style conventions: `/v2/` nouns, prefixed public IDs, + * `{object:"list",data,has_more,next_cursor}` list envelopes, the + * `{error:{type,code,message}}` error envelope, snake_case wire fields, + * ISO-8601 timestamps, and scoped API keys. + * + * Mounted alongside the internal v1 `MapleApi`; groups are added here as they + * are promoted to the public surface. Dashboard-only operations move to the + * internal Effect RPC tier instead — they never appear in this API. + */ +export class MapleApiV2 extends HttpApi.make("MapleApiV2").add(V2ApiKeysApiGroup).annotateMerge( + OpenApi.annotations({ + title: "Maple API", + version: "2.0.0", + description: + "The Maple public API. Resource-oriented REST endpoints with prefixed object IDs, cursor pagination, and scoped API keys. See docs/api-v2.md for conventions.", + }), +) {} diff --git a/packages/domain/src/http/v2/auth.ts b/packages/domain/src/http/v2/auth.ts new file mode 100644 index 000000000..e1a08850a --- /dev/null +++ b/packages/domain/src/http/v2/auth.ts @@ -0,0 +1,74 @@ +import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { Context } from "../current-tenant" +import { V2AuthenticationError, V2InvalidRequestError, V2PermissionError } from "./errors" + +/** + * v2 bearer authorization. Same credential resolution as v1 (`maple_ak_…` API + * key, else Clerk/self-hosted session token) but errors use the v2 envelope + * and API keys are subject to scope enforcement (see docs/api-v2.md#scopes). + * + * Note: the error option must stay a *list* of classes (not `Schema.Union`) so + * each error keeps its own `httpApiStatus` when responses are encoded. + */ +export class AuthorizationV2 extends HttpApiMiddleware.Service< + AuthorizationV2, + { + provides: Context + } +>()("AuthorizationV2", { + error: [V2AuthenticationError, V2PermissionError], + security: { + bearer: HttpApiSecurity.bearer, + }, +}) {} + +/** + * Rewrites request-decode failures (params/query/payload schema errors) into + * the v2 `invalid_request_error` envelope. Implemented in apps/api via + * `HttpApiMiddleware.layerSchemaErrorTransform`; every v2 group must attach it. + */ +export class V2SchemaErrors extends HttpApiMiddleware.Service()("V2SchemaErrors", { + error: V2InvalidRequestError, +}) {} + +/** Scope string grammar: `:read`, `:write`, or `*`. */ +export const V2Scope = Schema.String.check( + Schema.isPattern(/^([a-z][a-z0-9_]*:(read|write)|\*)$/, { + description: 'scope like "dashboards:read", "alert_rules:write", or "*"', + }), +) +export type V2Scope = Schema.Schema.Type + +export interface RequiredScope { + /** First path segment under /v2, e.g. "api_keys". */ + readonly family: string + readonly access: "read" | "write" +} + +/** + * Mechanical scope derivation: the resource family is the first path segment + * after `/v2/`, and the access level follows the HTTP method (GET/HEAD → read, + * everything else → write). Returns null for non-/v2 paths. + */ +export const requiredScopeForRequest = (method: string, path: string): RequiredScope | null => { + const match = /^\/v2\/([a-z][a-z0-9_]*)(?:\/|$)/.exec(path) + if (match === null) return null + const access = method === "GET" || method === "HEAD" ? "read" : "write" + return { family: match[1]!, access } +} + +/** + * Scope check for API-key tenants. `write` implies `read` (Stripe semantics). + * A key with no scopes recorded (legacy key) has full access; session-token + * tenants are never scope-checked (they carry no scopes). + */ +export const scopeAllows = ( + scopes: ReadonlyArray | null | undefined, + required: RequiredScope, +): boolean => { + if (scopes == null) return true + if (scopes.includes("*")) return true + if (scopes.includes(`${required.family}:write`)) return true + return required.access === "read" && scopes.includes(`${required.family}:read`) +} diff --git a/packages/domain/src/http/v2/envelopes.ts b/packages/domain/src/http/v2/envelopes.ts new file mode 100644 index 000000000..3691d14b6 --- /dev/null +++ b/packages/domain/src/http/v2/envelopes.ts @@ -0,0 +1,74 @@ +import { Schema } from "effect" + +/** + * Shared v2 wire-format primitives (see docs/api-v2.md). + * + * v2 responses use snake_case field names, ISO-8601 UTC timestamps, an + * `object` type field on every resource, and the Stripe list envelope + * `{ object: "list", data, has_more, next_cursor }` on every list endpoint. + */ + +/** ISO-8601 UTC timestamp on the v2 wire (e.g. `2026-07-15T12:34:56.000Z`). */ +export const Timestamp = Schema.String.annotate({ + title: "Timestamp", + description: "ISO-8601 UTC timestamp", +}) + +/** Convert service-layer epoch-ms to the v2 wire timestamp. */ +export const isoTimestamp = (epochMs: number): string => new Date(epochMs).toISOString() + +export const isoTimestampOrNull = (epochMs: number | null | undefined): string | null => + epochMs == null ? null : isoTimestamp(epochMs) + +export const LIST_LIMIT_DEFAULT = 20 +export const LIST_LIMIT_MAX = 100 + +/** Standard pagination query params for every v2 list endpoint. */ +export const ListQuery = Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isBetween({ minimum: 1, maximum: LIST_LIMIT_MAX }), + ), + ), + cursor: Schema.optional(Schema.String), +}) +export type ListQuery = Schema.Schema.Type + +/** Stripe-style list envelope: `{ object: "list", data, has_more, next_cursor }`. */ +export const ListOf = (item: S) => + Schema.Struct({ + object: Schema.Literal("list"), + data: Schema.Array(item), + has_more: Schema.Boolean, + next_cursor: Schema.NullOr(Schema.String), + }) + +/** + * Opaque offset cursor for lists whose backing service returns full arrays. + * Endpoints backed by native keyset pagination use their own cursor payloads — + * the wire contract (`cursor` in, `next_cursor` out) is identical either way. + */ +export const encodeOffsetCursor = (offset: number): string => `off_${offset.toString(36)}` + +export const decodeOffsetCursor = (cursor: string): number | null => { + if (!cursor.startsWith("off_")) return null + const offset = Number.parseInt(cursor.slice(4), 36) + return Number.isInteger(offset) && offset >= 0 ? offset : null +} + +/** Paginate an already-materialized array into the list envelope. */ +export const paginateArray = ( + items: ReadonlyArray, + query: { readonly limit?: number | undefined; readonly cursor?: string | undefined }, +): { data: ReadonlyArray; has_more: boolean; next_cursor: string | null } => { + const limit = query.limit ?? LIST_LIMIT_DEFAULT + const offset = query.cursor === undefined ? 0 : (decodeOffsetCursor(query.cursor) ?? 0) + const data = items.slice(offset, offset + limit) + const hasMore = offset + limit < items.length + return { + data, + has_more: hasMore, + next_cursor: hasMore ? encodeOffsetCursor(offset + limit) : null, + } +} diff --git a/packages/domain/src/http/v2/errors.ts b/packages/domain/src/http/v2/errors.ts new file mode 100644 index 000000000..72d02bd82 --- /dev/null +++ b/packages/domain/src/http/v2/errors.ts @@ -0,0 +1,93 @@ +import { Schema } from "effect" + +/** + * v2 error envelope (see docs/api-v2.md): every error response body is + * `{ "error": { "type", "code", "message", "param"?, "doc_url"? } }` with a + * closed set of `type`s and stable machine-readable `code`s. + * + * These are `Schema.ErrorClass`es (not Tagged) so the wire body carries no + * internal `_tag` — exactly the envelope, nothing else. + */ + +export const V2ErrorType = Schema.Literals([ + "invalid_request_error", + "authentication_error", + "permission_error", + "not_found_error", + "conflict_error", + "rate_limit_error", + "api_error", +]) +export type V2ErrorType = Schema.Schema.Type + +const errorBody = (type: T) => + Schema.Struct({ + type: Schema.Literal(type), + code: Schema.String, + message: Schema.String, + param: Schema.optionalKey(Schema.String), + doc_url: Schema.optionalKey(Schema.String), + }) + +export class V2InvalidRequestError extends Schema.ErrorClass( + "@maple/http/v2/InvalidRequestError", +)({ error: errorBody("invalid_request_error") }, { httpApiStatus: 400 }) {} + +export class V2AuthenticationError extends Schema.ErrorClass( + "@maple/http/v2/AuthenticationError", +)({ error: errorBody("authentication_error") }, { httpApiStatus: 401 }) {} + +export class V2PermissionError extends Schema.ErrorClass( + "@maple/http/v2/PermissionError", +)({ error: errorBody("permission_error") }, { httpApiStatus: 403 }) {} + +export class V2NotFoundError extends Schema.ErrorClass("@maple/http/v2/NotFoundError")( + { error: errorBody("not_found_error") }, + { httpApiStatus: 404 }, +) {} + +export class V2ConflictError extends Schema.ErrorClass("@maple/http/v2/ConflictError")( + { error: errorBody("conflict_error") }, + { httpApiStatus: 409 }, +) {} + +export class V2RateLimitError extends Schema.ErrorClass("@maple/http/v2/RateLimitError")( + { error: errorBody("rate_limit_error") }, + { httpApiStatus: 429 }, +) {} + +export class V2ApiError extends Schema.ErrorClass("@maple/http/v2/ApiError")( + { error: errorBody("api_error") }, + { httpApiStatus: 500 }, +) {} + +/** `api_error` flavor for upstream/persistence unavailability (503). */ +export class V2ServiceUnavailableError extends Schema.ErrorClass( + "@maple/http/v2/ServiceUnavailableError", +)({ error: errorBody("api_error") }, { httpApiStatus: 503 }) {} + +// Constructors — keep handler adapters one-liners. + +export const invalidRequest = (code: string, message: string, param?: string) => + new V2InvalidRequestError({ error: { type: "invalid_request_error", code, message, ...(param !== undefined ? { param } : {}) } }) + +export const authenticationError = (code: string, message: string) => + new V2AuthenticationError({ error: { type: "authentication_error", code, message } }) + +export const permissionError = (code: string, message: string) => + new V2PermissionError({ error: { type: "permission_error", code, message } }) + +/** `resource_missing` matches Stripe's code for a bad object ID. */ +export const notFound = (message: string, param?: string) => + new V2NotFoundError({ + error: { type: "not_found_error", code: "resource_missing", message, ...(param !== undefined ? { param } : {}) }, + }) + +export const conflict = (code: string, message: string) => + new V2ConflictError({ error: { type: "conflict_error", code, message } }) + +export const apiError = (message: string) => + new V2ApiError({ error: { type: "api_error", code: "internal_error", message } }) + +export const serviceUnavailable = (message: string) => + new V2ServiceUnavailableError({ error: { type: "api_error", code: "service_unavailable", message } }) diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts new file mode 100644 index 000000000..416a956a0 --- /dev/null +++ b/packages/domain/src/http/v2/index.ts @@ -0,0 +1,6 @@ +export * from "./api" +export * from "./api-keys" +export * from "./auth" +export * from "./envelopes" +export * from "./errors" +export * from "./public-id" diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts new file mode 100644 index 000000000..ce9e673d9 --- /dev/null +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" +import { OpenApi } from "effect/unstable/httpapi" +import { MapleApiV2 } from "./api" + +/** + * Contract freeze: the public v2 OpenAPI surface (paths + methods) is asserted + * explicitly so an accidental route change fails CI. Additions require + * updating this list — which is the point. + */ +describe("MapleApiV2 OpenAPI", () => { + const spec = OpenApi.fromApi(MapleApiV2) + + it("derives with v2 metadata", () => { + expect(spec.info.title).toBe("Maple API") + expect(spec.info.version).toBe("2.0.0") + }) + + it("exposes exactly the committed v2 paths", () => { + const surface = Object.entries(spec.paths ?? {}) + .flatMap(([path, item]) => + Object.keys(item ?? {}) + .filter((key) => ["get", "post", "put", "patch", "delete"].includes(key)) + .map((method) => `${method.toUpperCase()} ${path}`), + ) + .sort() + + expect(surface).toEqual([ + "DELETE /v2/api_keys/{id}", + "GET /v2/api_keys", + "GET /v2/api_keys/{id}", + "POST /v2/api_keys", + "POST /v2/api_keys/{id}/roll", + ]) + }) +}) diff --git a/packages/domain/src/http/v2/public-id.test.ts b/packages/domain/src/http/v2/public-id.test.ts new file mode 100644 index 000000000..bacdd3876 --- /dev/null +++ b/packages/domain/src/http/v2/public-id.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest" +import { Schema } from "effect" +import { ApiKeyId, DashboardId } from "../../primitives" +import { decodePublicId, encodePublicId, PublicId, PublicIdPrefixes } from "./public-id" + +const UUID = "0f8fad5b-d9cb-469f-a165-70867728950e" + +describe("encodePublicId / decodePublicId", () => { + it("round-trips a UUID internal ID", () => { + const publicId = encodePublicId("key", UUID) + expect(publicId.startsWith("key_")).toBe(true) + expect(decodePublicId("key", publicId)).toBe(UUID) + }) + + it("round-trips a free-form string internal ID", () => { + const internal = "my-dashboard-2024" + const publicId = encodePublicId("dash", internal) + expect(publicId.startsWith("dash_")).toBe(true) + expect(decodePublicId("dash", publicId)).toBe(internal) + }) + + it("round-trips unicode free-form IDs", () => { + const internal = "ダッシュ boards/π" + expect(decodePublicId("dash", encodePublicId("dash", internal))).toBe(internal) + }) + + it("is deterministic", () => { + expect(encodePublicId("key", UUID)).toBe(encodePublicId("key", UUID)) + }) + + it("normalizes UUID case on round-trip", () => { + expect(decodePublicId("key", encodePublicId("key", UUID.toUpperCase()))).toBe(UUID) + }) + + it("rejects a wrong prefix", () => { + const publicId = encodePublicId("key", UUID) + expect(decodePublicId("dash", publicId)).toBeNull() + }) + + it("rejects garbage bodies", () => { + expect(decodePublicId("key", "key_")).toBeNull() + expect(decodePublicId("key", "key_0OIl")).toBeNull() // chars outside base58 alphabet + expect(decodePublicId("key", "key_zzzz")).toBeNull() // undecodable payload + expect(decodePublicId("key", "not-a-public-id")).toBeNull() + }) +}) + +describe("PublicId schema codec", () => { + const KeyId = PublicId(PublicIdPrefixes.apiKey, ApiKeyId) + const decode = Schema.decodeUnknownSync(KeyId) + const encode = Schema.encodeSync(KeyId) + + it("decodes a wire public ID to the internal branded ID", () => { + const wire = encodePublicId("key", UUID) + expect(decode(wire)).toBe(UUID) + }) + + it("encodes an internal ID to the wire public ID", () => { + const internal = Schema.decodeUnknownSync(ApiKeyId)(UUID) + expect(encode(internal)).toBe(encodePublicId("key", UUID)) + }) + + it("fails decode on a malformed wire ID", () => { + expect(() => decode("key_not!base58")).toThrow() + expect(() => decode("dash_abc")).toThrow() + }) + + it("supports free-form internal ID schemas", () => { + const DashId = PublicId(PublicIdPrefixes.dashboard, DashboardId) + const internal = Schema.decodeUnknownSync(DashboardId)("service-overview") + const wire = Schema.encodeSync(DashId)(internal) + expect(wire.startsWith("dash_")).toBe(true) + expect(Schema.decodeUnknownSync(DashId)(wire)).toBe("service-overview") + }) +}) diff --git a/packages/domain/src/http/v2/public-id.ts b/packages/domain/src/http/v2/public-id.ts new file mode 100644 index 000000000..115b4d911 --- /dev/null +++ b/packages/domain/src/http/v2/public-id.ts @@ -0,0 +1,171 @@ +import { Effect, Option, Schema, SchemaGetter, SchemaIssue } from "effect" + +/** + * Stripe-style prefixed public object IDs for the v2 API. + * + * Wire format: `_` (e.g. `key_4CzLmR1pTx…`). The body is + * a base58 encoding of the *internal* ID with a 1-byte mode header: + * + * - mode 0x01 — the internal ID is a UUID; the body is its 16 raw bytes + * - mode 0x02 — the internal ID is a free-form string; the body is its UTF-8 bytes + * + * The codec lives entirely at the API boundary: handlers and services keep + * using internal IDs (raw UUIDs / branded strings), and no DB migration is + * needed. Encoding is deterministic and reversible. + * + * Clerk-issued IDs (`org_…`, `user_…`) are already prefixed public IDs and are + * passed through as-is — they must NOT be wrapped with this codec. + */ + +/** Registry of v2 public-ID prefixes — single source of truth. */ +export const PublicIdPrefixes = { + apiKey: "key", + dashboard: "dash", + dashboardVersion: "dbv", + alertRule: "alrt", + alertDestination: "dest", + alertIncident: "inc", + errorIssue: "iss", + investigation: "inv", + anomalyIncident: "anom", + scrapeTarget: "scrp", + recommendation: "rec", + ingestKey: "ingk", + attributeMapping: "amap", + /** Reserved for the future events/webhooks system. */ + event: "evt", + webhookEndpoint: "we", +} as const + +export type PublicIdPrefix = (typeof PublicIdPrefixes)[keyof typeof PublicIdPrefixes] + +// Bitcoin base58 alphabet (no 0/O/I/l). +const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +const ALPHABET_MAP = new Map([...ALPHABET].map((char, index) => [char, index])) + +const base58Encode = (bytes: Uint8Array): string => { + let zeros = 0 + while (zeros < bytes.length && bytes[zeros] === 0) zeros++ + + const digits: number[] = [] + for (let i = zeros; i < bytes.length; i++) { + let carry = bytes[i]! + for (let j = 0; j < digits.length; j++) { + carry += digits[j]! << 8 + digits[j] = carry % 58 + carry = (carry / 58) | 0 + } + while (carry > 0) { + digits.push(carry % 58) + carry = (carry / 58) | 0 + } + } + + let out = "1".repeat(zeros) + for (let i = digits.length - 1; i >= 0; i--) out += ALPHABET[digits[i]!] + return out +} + +const base58Decode = (input: string): Uint8Array | null => { + if (input.length === 0) return new Uint8Array(0) + + let zeros = 0 + while (zeros < input.length && input[zeros] === "1") zeros++ + + const bytes: number[] = [] + for (let i = zeros; i < input.length; i++) { + const value = ALPHABET_MAP.get(input[i]!) + if (value === undefined) return null + let carry = value + for (let j = 0; j < bytes.length; j++) { + carry += bytes[j]! * 58 + bytes[j] = carry & 0xff + carry >>= 8 + } + while (carry > 0) { + bytes.push(carry & 0xff) + carry >>= 8 + } + } + + const out = new Uint8Array(zeros + bytes.length) + for (let i = 0; i < bytes.length; i++) out[zeros + i] = bytes[bytes.length - 1 - i]! + return out +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +const MODE_UUID = 0x01 +const MODE_UTF8 = 0x02 + +const uuidToBytes = (uuid: string): Uint8Array => { + const hex = uuid.replaceAll("-", "") + const bytes = new Uint8Array(16) + for (let i = 0; i < 16; i++) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + return bytes +} + +const bytesToUuid = (bytes: Uint8Array): string => { + let hex = "" + for (const byte of bytes) hex += byte.toString(16).padStart(2, "0") + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +/** Encode an internal ID (raw UUID or free-form string) as a `_…` public ID. */ +export const encodePublicId = (prefix: string, internalId: string): string => { + const isUuid = UUID_RE.test(internalId) + const idBytes = isUuid ? uuidToBytes(internalId.toLowerCase()) : new TextEncoder().encode(internalId) + const bytes = new Uint8Array(1 + idBytes.length) + bytes[0] = isUuid ? MODE_UUID : MODE_UTF8 + bytes.set(idBytes, 1) + return `${prefix}_${base58Encode(bytes)}` +} + +/** Decode a `_…` public ID back to its internal ID. Returns null on any mismatch. */ +export const decodePublicId = (prefix: string, publicId: string): string | null => { + if (!publicId.startsWith(`${prefix}_`)) return null + const body = publicId.slice(prefix.length + 1) + if (body.length === 0) return null + + const bytes = base58Decode(body) + if (bytes === null || bytes.length < 2) return null + + const mode = bytes[0]! + const idBytes = bytes.subarray(1) + if (mode === MODE_UUID) { + if (idBytes.length !== 16) return null + return bytesToUuid(idBytes) + } + if (mode === MODE_UTF8) { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(idBytes) + } catch { + return null + } + } + return null +} + +/** + * Schema codec: wire `_…` public ID ⇄ internal branded ID schema. + * + * Decoding a malformed or wrong-prefix ID fails schema decode, which the v2 + * error middleware surfaces as an `invalid_request_error`. + */ +export const PublicId = >(prefix: string, internal: S) => + Schema.String.pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transformOrFail((publicId: string) => { + const internalId = decodePublicId(prefix, publicId) + return internalId === null + ? Effect.fail( + new SchemaIssue.InvalidValue(Option.some(publicId), { + message: `Invalid ID: expected an ID with prefix "${prefix}_"`, + }), + ) + : Effect.succeed(internalId) + }), + encode: SchemaGetter.transform((internalId: string) => encodePublicId(prefix, internalId)), + }), + Schema.decodeTo(internal), + ).annotate({ title: `Public ID (${prefix}_…)` }) diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts new file mode 100644 index 000000000..8285bf86a --- /dev/null +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest" +import { Schema } from "effect" +import { V2ApiKey } from "./api-keys" +import { requiredScopeForRequest, scopeAllows, V2Scope } from "./auth" +import { decodeOffsetCursor, encodeOffsetCursor, isoTimestamp, paginateArray } from "./envelopes" +import { notFound, permissionError, V2NotFoundError } from "./errors" +import { encodePublicId } from "./public-id" + +const UUID = "0f8fad5b-d9cb-469f-a165-70867728950e" + +describe("V2ApiKey wire format", () => { + it("encodes snake_case fields, an object type, and a key_ public ID", () => { + const key = Schema.decodeUnknownSync(V2ApiKey)({ + id: encodePublicId("key", UUID), + object: "api_key", + name: "ci", + description: null, + key_prefix: "maple_ak_abc...", + kind: "standard", + scopes: ["dashboards:read"], + revoked: false, + revoked_at: null, + last_used_at: null, + expires_at: null, + created_at: "2026-07-15T00:00:00.000Z", + created_by: "user_123", + created_by_email: null, + }) + expect(key.id).toBe(UUID) // decoded to the internal ID + + const wire = Schema.encodeSync(V2ApiKey)(key) + expect(wire.id).toBe(encodePublicId("key", UUID)) + expect(wire.object).toBe("api_key") + expect(wire.key_prefix).toBe("maple_ak_abc...") + expect(wire.created_at).toBe("2026-07-15T00:00:00.000Z") + expect("keyPrefix" in wire).toBe(false) + }) +}) + +describe("v2 error envelope", () => { + it("encodes exactly the Stripe envelope with no _tag", () => { + const error = notFound("No such api_key", "id") + const wire = Schema.encodeSync(V2NotFoundError)(error) as Record + expect(wire).toEqual({ + error: { + type: "not_found_error", + code: "resource_missing", + message: "No such api_key", + param: "id", + }, + }) + expect("_tag" in wire).toBe(false) + }) + + it("omits param when not provided", () => { + const wire = Schema.encodeSync(V2NotFoundError)(notFound("gone")) as { + error: Record + } + expect("param" in wire.error).toBe(false) + }) + + it("permissionError has type permission_error", () => { + expect(permissionError("insufficient_scope", "nope").error.type).toBe("permission_error") + }) +}) + +describe("scopes", () => { + const check = Schema.decodeUnknownSync(V2Scope) + + it("accepts valid scope strings and rejects invalid ones", () => { + expect(check("dashboards:read")).toBe("dashboards:read") + expect(check("alert_rules:write")).toBe("alert_rules:write") + expect(check("*")).toBe("*") + expect(() => check("dashboards")).toThrow() + expect(() => check("dashboards:admin")).toThrow() + expect(() => check("Dashboards:read")).toThrow() + }) + + it("derives the required scope from method + path", () => { + expect(requiredScopeForRequest("GET", "/v2/api_keys")).toEqual({ + family: "api_keys", + access: "read", + }) + expect(requiredScopeForRequest("GET", "/v2/api_keys/key_abc")).toEqual({ + family: "api_keys", + access: "read", + }) + expect(requiredScopeForRequest("POST", "/v2/api_keys/key_abc/roll")).toEqual({ + family: "api_keys", + access: "write", + }) + expect(requiredScopeForRequest("DELETE", "/v2/api_keys/key_abc")).toEqual({ + family: "api_keys", + access: "write", + }) + expect(requiredScopeForRequest("GET", "/api/api-keys")).toBeNull() + }) + + it("enforces the scope matrix", () => { + const required = { family: "api_keys", access: "read" } as const + const write = { family: "api_keys", access: "write" } as const + + expect(scopeAllows(null, write)).toBe(true) // legacy full-access key + expect(scopeAllows(undefined, write)).toBe(true) // session token + expect(scopeAllows(["*"], write)).toBe(true) + expect(scopeAllows(["api_keys:read"], required)).toBe(true) + expect(scopeAllows(["api_keys:read"], write)).toBe(false) + expect(scopeAllows(["api_keys:write"], write)).toBe(true) + expect(scopeAllows(["api_keys:write"], required)).toBe(true) // write implies read + expect(scopeAllows(["dashboards:write"], required)).toBe(false) + expect(scopeAllows([], required)).toBe(false) + }) +}) + +describe("list pagination", () => { + const items = Array.from({ length: 45 }, (_, index) => index) + + it("paginates with default limit and opaque cursors", () => { + const first = paginateArray(items, {}) + expect(first.data).toHaveLength(20) + expect(first.has_more).toBe(true) + expect(first.next_cursor).not.toBeNull() + + const second = paginateArray(items, { cursor: first.next_cursor! }) + expect(second.data[0]).toBe(20) + + const third = paginateArray(items, { cursor: second.next_cursor!, limit: 20 }) + expect(third.data).toHaveLength(5) + expect(third.has_more).toBe(false) + expect(third.next_cursor).toBeNull() + }) + + it("cursor round-trips and rejects garbage", () => { + expect(decodeOffsetCursor(encodeOffsetCursor(1234))).toBe(1234) + expect(decodeOffsetCursor("garbage")).toBeNull() + expect(decodeOffsetCursor("off_-1")).toBeNull() + }) +}) + +describe("timestamps", () => { + it("formats epoch-ms as ISO-8601 UTC", () => { + expect(isoTimestamp(0)).toBe("1970-01-01T00:00:00.000Z") + }) +}) From bdca47a9bde550c31c29218e8620771c273bd588 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 11:37:29 +0000 Subject: [PATCH 2/3] Use Schema.optionalKey for scopes in CreateApiKeyRequest JSON-decoded HTTP payload schemas use optionalKey per repo convention (review finding on #211). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTnfswT7zw6WhWgkS53WqM --- packages/domain/src/http/api-keys.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/domain/src/http/api-keys.ts b/packages/domain/src/http/api-keys.ts index d655e74b0..efcaa1254 100644 --- a/packages/domain/src/http/api-keys.ts +++ b/packages/domain/src/http/api-keys.ts @@ -49,7 +49,7 @@ export class CreateApiKeyRequest extends Schema.Class("Crea description: Schema.optional(Schema.String), expiresInSeconds: Schema.optional(Schema.Number), kind: Schema.optional(ApiKeyKind), - scopes: Schema.optional(Schema.Array(Schema.String)), + scopes: Schema.optionalKey(Schema.Array(Schema.String)), }) {} export class ApiKeyPersistenceError extends Schema.TaggedErrorClass()( From eab4b33b16f658f535f2521d66983d260ff66b47 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 16 Jul 2026 01:03:07 +0200 Subject: [PATCH 3/3] docs(api-v2): enrich OpenAPI metadata for the api_keys pilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the first v2 public endpoint the reference standard: full OpenAPI titles, descriptions, examples, operationIds, and info-block metadata so /v2/docs renders completely. - api-keys: per-field descriptions + examples, component titles/descriptions with full wire-form example objects, endpoint summaries/descriptions/ operationIds (listApiKeys, createApiKey, …), and an "API Keys" group tag. Clean component names (ApiKey, ApiKeyWithSecret, ApiKeyCreateParams). - api: info summary, servers (api.maple.dev), richer description, plus info.contact and top-level externalDocs via the api-level transform. - auth: bearer scheme description + bearerFormat; Scope title/examples. - envelopes/errors: shared list + error-envelope docs (descriptions, examples) and clean error component names — inherited by all v2 groups. - public-id: describe/example/format on the encoded base string so every prefixed id renders docs. Wire schemas move from Schema.Class to annotated Schema.Struct + type: Effect's OpenAPI generation drops component-level title/description/examples for a Class but renders them for a Struct. Handlers return plain wire objects (the HTTP layer still validates on encode). openapi.test.ts freezes the enriched surface (operationIds, tag prose, clean component names, decodable examples, security, contact/servers). Co-Authored-By: Claude Opus 4.8 --- apps/api/src/routes/v2/api-keys.http.ts | 42 ++-- packages/domain/src/http/v2/api-keys.ts | 244 +++++++++++++++++--- packages/domain/src/http/v2/api.ts | 35 ++- packages/domain/src/http/v2/auth.ts | 20 +- packages/domain/src/http/v2/envelopes.ts | 45 +++- packages/domain/src/http/v2/errors.ts | 157 +++++++++++-- packages/domain/src/http/v2/openapi.test.ts | 120 +++++++++- packages/domain/src/http/v2/public-id.ts | 11 +- 8 files changed, 587 insertions(+), 87 deletions(-) diff --git a/apps/api/src/routes/v2/api-keys.http.ts b/apps/api/src/routes/v2/api-keys.http.ts index a44a55b9f..9456e3d22 100644 --- a/apps/api/src/routes/v2/api-keys.http.ts +++ b/apps/api/src/routes/v2/api-keys.http.ts @@ -3,8 +3,6 @@ import type { ApiKeyCreatedResponse, ApiKeyResponse } from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" import { MapleApiV2, - V2ApiKey, - V2ApiKeyWithSecret, isoTimestamp, isoTimestampOrNull, notFound, @@ -12,6 +10,7 @@ import { permissionError, serviceUnavailable, } from "@maple/domain/http/v2" +import type { V2ApiKey, V2ApiKeyWithSecret } from "@maple/domain/http/v2" import { Effect } from "effect" import { ApiKeysService } from "../../services/ApiKeysService" import { AuthService } from "../../services/AuthService" @@ -37,26 +36,27 @@ type ApiKeyFields = Pick< | "createdByEmail" > -const toV2ApiKey = (key: ApiKeyFields): V2ApiKey => - new V2ApiKey({ - id: key.id, - object: "api_key", - name: key.name, - description: key.description, - key_prefix: key.keyPrefix, - kind: key.kind, - scopes: key.scopes, - revoked: key.revoked, - revoked_at: isoTimestampOrNull(key.revokedAt), - last_used_at: isoTimestampOrNull(key.lastUsedAt), - expires_at: isoTimestampOrNull(key.expiresAt), - created_at: isoTimestamp(key.createdAt), - created_by: key.createdBy, - created_by_email: key.createdByEmail, - }) +const toV2ApiKey = (key: ApiKeyFields): V2ApiKey => ({ + id: key.id, + object: "api_key", + name: key.name, + description: key.description, + key_prefix: key.keyPrefix, + kind: key.kind, + scopes: key.scopes, + revoked: key.revoked, + revoked_at: isoTimestampOrNull(key.revokedAt), + last_used_at: isoTimestampOrNull(key.lastUsedAt), + expires_at: isoTimestampOrNull(key.expiresAt), + created_at: isoTimestamp(key.createdAt), + created_by: key.createdBy, + created_by_email: key.createdByEmail, +}) -const toV2ApiKeyWithSecret = (key: ApiKeyCreatedResponse): V2ApiKeyWithSecret => - new V2ApiKeyWithSecret({ ...toV2ApiKey(key), secret: key.secret }) +const toV2ApiKeyWithSecret = (key: ApiKeyCreatedResponse): V2ApiKeyWithSecret => ({ + ...toV2ApiKey(key), + secret: key.secret, +}) /** Service tagged errors → v2 envelope errors. */ const mapServiceError = (error: { readonly _tag: string; readonly message: string }) => diff --git a/packages/domain/src/http/v2/api-keys.ts b/packages/domain/src/http/v2/api-keys.ts index 9b13959f7..9b3e636d8 100644 --- a/packages/domain/src/http/v2/api-keys.ts +++ b/packages/domain/src/http/v2/api-keys.ts @@ -1,4 +1,4 @@ -import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { ApiKeyId, UserId } from "../../primitives" import { ApiKeyKind } from "../api-keys" @@ -12,78 +12,256 @@ import { } from "./errors" import { PublicId, PublicIdPrefixes } from "./public-id" +/** + * Author OpenAPI `examples` in wire (encoded) shape. Effect types the `examples` + * annotation against a schema's decoded `Type`, but an HttpApi response renders + * the *encoded* schema — so a realistic example must use wire values (`id` + * as `key_…`, not the internal UUID). This adapter bridges the wire object to + * the annotation's `Type` slot; each example is verified to be a decodable wire + * payload in `openapi.test.ts`. + */ +const wireExample = (example: object): A => example as A + /** `key_…` public ID ⇄ internal `ApiKeyId` (raw UUID). */ export const ApiKeyPublicId = PublicId(PublicIdPrefixes.apiKey, ApiKeyId) -export class V2ApiKey extends Schema.Class("V2ApiKey")({ +/** Realistic wire example reused for `ApiKey` and (with `secret`) `ApiKeyWithSecret`. */ +const apiKeyExample = { + id: "key_aXwpxqBkqtYwtBtmsGbR41", + object: "api_key", + name: "ci-pipeline", + description: "Publishes deploys from the CI pipeline", + key_prefix: "maple_ak_9f2c", + kind: "standard", + scopes: ["dashboards:read", "alert_rules:write"], + revoked: false, + revoked_at: null, + last_used_at: "2026-07-15T09:12:00.000Z", + expires_at: null, + created_at: "2026-07-01T12:00:00.000Z", + created_by: "user_2Nk8mXqPfR3yZ1aB4cD5eF6g", + created_by_email: "ci@acme.com", +} as const + +// v2 wire schemas are annotated `Schema.Struct`s (not `Schema.Class`). Effect's +// OpenAPI generation renders component-level `title`/`description`/`examples` +// from a Struct's annotations, but drops them for a `Schema.Class`; a Struct is +// also the schema consumers want (handlers return plain wire objects — the HTTP +// layer validates on encode). Field-level annotations render either way. +export const V2ApiKey = Schema.Struct({ id: ApiKeyPublicId, - object: Schema.Literal("api_key"), - name: Schema.String, - description: Schema.NullOr(Schema.String), - key_prefix: Schema.String, - kind: ApiKeyKind, - scopes: Schema.NullOr(Schema.Array(V2Scope)), - revoked: Schema.Boolean, - revoked_at: Schema.NullOr(Timestamp), - last_used_at: Schema.NullOr(Timestamp), - expires_at: Schema.NullOr(Timestamp), - created_at: Timestamp, - created_by: UserId, - created_by_email: Schema.NullOr(Schema.String), -}) {} + object: Schema.Literal("api_key").annotate({ + description: 'The object type — always `"api_key"`.', + examples: ["api_key"], + }), + name: Schema.String.annotate({ + description: "Human-readable label for the key, shown in the dashboard.", + examples: ["ci-pipeline"], + }), + description: Schema.NullOr(Schema.String).annotate({ + description: "Optional longer description of what the key is for, or `null`.", + examples: ["Publishes deploys from the CI pipeline"], + }), + key_prefix: Schema.String.annotate({ + description: + "The non-secret leading portion of the key, safe to display. The full secret is returned only when the key is created or rolled.", + examples: ["maple_ak_9f2c"], + }), + kind: ApiKeyKind.annotate({ + description: "Key type: `standard` for HTTP API access, `mcp` for the Model Context Protocol server.", + examples: ["standard"], + }), + scopes: Schema.NullOr(Schema.Array(V2Scope)).annotate({ + description: "The scopes granted to the key, or `null` for a legacy key with full access.", + examples: [["dashboards:read", "alert_rules:write"]], + }), + revoked: Schema.Boolean.annotate({ + description: "Whether the key has been revoked. Revoked keys can no longer authenticate.", + examples: [false], + }), + revoked_at: Schema.NullOr(Timestamp).annotate({ + description: "When the key was revoked, or `null` if it is still active.", + }), + last_used_at: Schema.NullOr(Timestamp).annotate({ + description: "When the key was last used to authenticate a request, or `null` if never used.", + }), + expires_at: Schema.NullOr(Timestamp).annotate({ + description: "When the key expires, or `null` if it never expires.", + }), + created_at: Timestamp.annotate({ + description: "When the key was created.", + }), + created_by: UserId.annotate({ + description: "Maple user ID of the key's creator (e.g. `user_…`).", + }), + created_by_email: Schema.NullOr(Schema.String).annotate({ + description: "Email of the user who created the key, when known.", + examples: ["ci@acme.com"], + }), +}).annotate({ + identifier: "ApiKey", + title: "API Key", + description: + "A credential for programmatic access to the Maple API, scoped to one organization. The secret is shown only once (at creation or roll); afterward only its `key_prefix` is stored and returned.", + examples: [wireExample(apiKeyExample)], +}) +export type V2ApiKey = Schema.Schema.Type /** Returned only by create/roll — the one time the secret is visible. */ -export class V2ApiKeyWithSecret extends Schema.Class("V2ApiKeyWithSecret")({ +export const V2ApiKeyWithSecret = Schema.Struct({ ...V2ApiKey.fields, - secret: Schema.String, -}) {} + secret: Schema.String.annotate({ + description: + "The full API key secret. **Returned only once**, in the create and roll responses — it cannot be retrieved later. Store it securely.", + examples: ["maple_ak_9f2cB1x8Kt7pQ2wR5vN0sL3dJ6hM4gY9"], + }), +}).annotate({ + identifier: "ApiKeyWithSecret", + title: "API Key (with secret)", + description: + "An API key including its one-time `secret`. Returned only by the create and roll endpoints; every other endpoint returns the `ApiKey` object without the secret.", + examples: [ + wireExample({ + ...apiKeyExample, + secret: "maple_ak_9f2cB1x8Kt7pQ2wR5vN0sL3dJ6hM4gY9", + }), + ], +}) +export type V2ApiKeyWithSecret = Schema.Schema.Type -export class V2ApiKeyCreateParams extends Schema.Class("V2ApiKeyCreateParams")({ - name: Schema.String.check(Schema.isMinLength(1)), - description: Schema.optionalKey(Schema.String), - expires_in_seconds: Schema.optionalKey(Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0))), - kind: Schema.optionalKey(ApiKeyKind), - scopes: Schema.optionalKey(Schema.Array(V2Scope)), -}) {} +export const V2ApiKeyCreateParams = Schema.Struct({ + name: Schema.String.check(Schema.isMinLength(1)).annotate({ + description: "Human-readable label for the key. Required, non-empty.", + examples: ["ci-pipeline"], + }), + description: Schema.optionalKey( + Schema.String.annotate({ + description: "Optional longer description of what the key is for.", + examples: ["Publishes deploys from the CI pipeline"], + }), + ), + expires_in_seconds: Schema.optionalKey( + Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)).annotate({ + description: "Optional lifetime in seconds. Omit for a key that never expires.", + examples: [7_776_000], + }), + ), + kind: Schema.optionalKey( + ApiKeyKind.annotate({ + description: "Key type. Defaults to `standard`.", + examples: ["standard"], + }), + ), + scopes: Schema.optionalKey( + Schema.Array(V2Scope).annotate({ + description: + "Scopes to restrict the key to. Omit for a key with full access to the organization.", + examples: [["dashboards:read", "alert_rules:write"]], + }), + ), +}).annotate({ + identifier: "ApiKeyCreateParams", + title: "API Key create parameters", + description: "Request body for creating an API key.", + examples: [ + wireExample({ + name: "ci-pipeline", + description: "Publishes deploys from the CI pipeline", + expires_in_seconds: 7_776_000, + kind: "standard", + scopes: ["dashboards:read", "alert_rules:write"], + }), + ], +}) +export type V2ApiKeyCreateParams = Schema.Schema.Type const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const +/** List response: a named, cursor-paginated page of API keys. */ +const ApiKeyList = ListOf(V2ApiKey).annotate({ + identifier: "ApiKeyList", + title: "API key list", + description: "A cursor-paginated page of API keys.", +}) + export class V2ApiKeysApiGroup extends HttpApiGroup.make("apiKeys") .add( HttpApiEndpoint.get("list", "/", { query: ListQuery, - success: ListOf(V2ApiKey), + success: ApiKeyList, error: [...commonErrors], - }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "listApiKeys", + summary: "List API keys", + description: + "Returns your organization's API keys, most recently created first. Cursor-paginated. Requires the `api_keys:read` scope.", + }), + ), ) .add( HttpApiEndpoint.post("create", "/", { payload: V2ApiKeyCreateParams, success: V2ApiKeyWithSecret, error: [...commonErrors, V2PermissionError], - }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "createApiKey", + summary: "Create an API key", + description: + "Creates an API key and returns it **with its one-time `secret`** — store the secret securely, it cannot be retrieved later. Requires an org-admin role and the `api_keys:write` scope.", + }), + ), ) .add( HttpApiEndpoint.get("retrieve", "/:id", { params: { id: ApiKeyPublicId }, success: V2ApiKey, error: [...commonErrors, V2NotFoundError], - }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "getApiKey", + summary: "Retrieve an API key", + description: + "Returns a single API key by its `key_…` ID (without the secret). Requires the `api_keys:read` scope.", + }), + ), ) .add( HttpApiEndpoint.post("roll", "/:id/roll", { params: { id: ApiKeyPublicId }, success: V2ApiKeyWithSecret, error: [...commonErrors, V2PermissionError, V2NotFoundError], - }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "rollApiKey", + summary: "Roll an API key", + description: + "Invalidates the key's current secret and issues a new one for the same key, returning it **with the new one-time `secret`**. Requires an org-admin role and the `api_keys:write` scope.", + }), + ), ) .add( HttpApiEndpoint.delete("revoke", "/:id", { params: { id: ApiKeyPublicId }, success: V2ApiKey, error: [...commonErrors, V2PermissionError, V2NotFoundError], - }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "revokeApiKey", + summary: "Revoke an API key", + description: + "Permanently revokes an API key and returns the final object. A revoked key can no longer authenticate. Requires an org-admin role and the `api_keys:write` scope.", + }), + ), ) .prefix("/v2/api_keys") .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) {} + .middleware(V2SchemaErrors) + .annotateMerge( + OpenApi.annotations({ + title: "API Keys", + description: + "Programmatic credentials for the Maple API. Create scoped keys, list and retrieve them, roll their secrets, and revoke them. Creating, rolling, and revoking keys is admin-only; a key's `secret` is returned only when it is created or rolled.", + }), + ) {} diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index bdd33c83f..c2cba26b3 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -17,7 +17,38 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2").add(V2ApiKeysApiGroup OpenApi.annotations({ title: "Maple API", version: "2.0.0", - description: - "The Maple public API. Resource-oriented REST endpoints with prefixed object IDs, cursor pagination, and scoped API keys. See docs/api-v2.md for conventions.", + summary: "The public, stability-committed HTTP API for the Maple observability platform.", + description: [ + "The Maple public API is a resource-oriented REST interface for everything the dashboard can do.", + "It follows Stripe's design philosophy, modernized where useful:", + "", + "- **Resources** are plural nouns under `/v2` (`/v2/api_keys`). Non-CRUD verbs are sub-resource POSTs (`/v2/api_keys/{id}/roll`).", + "- **Object IDs** are opaque, prefixed strings (`key_…`, `dash_…`) — reversible encodings of internal IDs.", + "- **Wire format** is snake_case JSON with an `object` type field on every resource and ISO-8601 UTC timestamps.", + "- **Lists** use cursor pagination and a uniform `{ object: \"list\", data, has_more, next_cursor }` envelope.", + "- **Errors** use a uniform `{ error: { type, code, message } }` envelope with a closed set of `type`s and stable `code`s.", + "- **Auth** is a Bearer API key (`maple_ak_…`) or dashboard session token; keys can be restricted with scopes.", + "", + "See `docs/api-v2.md` for the full conventions.", + ].join("\n"), + servers: [{ url: "https://api.maple.dev", description: "Production" }], + // `info.contact` and top-level `externalDocs` have no dedicated annotation + // key (they are not in `OpenAPISpecInfo`), so inject them via the api-level + // spec transform, which receives the whole generated document. + transform: (spec) => ({ + ...spec, + info: { + ...spec.info, + contact: { + name: "Maple Support", + url: "https://maple.dev", + email: "support@maple.dev", + }, + }, + externalDocs: { + url: "https://api.maple.dev/v2/docs", + description: "Interactive Maple API reference", + }, + }), }), ) {} diff --git a/packages/domain/src/http/v2/auth.ts b/packages/domain/src/http/v2/auth.ts index e1a08850a..3574ff501 100644 --- a/packages/domain/src/http/v2/auth.ts +++ b/packages/domain/src/http/v2/auth.ts @@ -1,4 +1,4 @@ -import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +import { HttpApiMiddleware, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi" import { Schema } from "effect" import { Context } from "../current-tenant" import { V2AuthenticationError, V2InvalidRequestError, V2PermissionError } from "./errors" @@ -19,7 +19,15 @@ export class AuthorizationV2 extends HttpApiMiddleware.Service< >()("AuthorizationV2", { error: [V2AuthenticationError, V2PermissionError], security: { - bearer: HttpApiSecurity.bearer, + bearer: HttpApiSecurity.bearer.pipe( + HttpApiSecurity.annotateMerge( + OpenApi.annotations({ + description: + "Authenticate every request with a Bearer token: `Authorization: Bearer `. Accepts a Maple API key (`maple_ak_…`) or a dashboard session token (Clerk / self-hosted JWT). API keys may be restricted with scopes — see the `Scope` schema.", + format: "maple_ak_… API key or session JWT", + }), + ), + ), }, }) {} @@ -37,7 +45,13 @@ export const V2Scope = Schema.String.check( Schema.isPattern(/^([a-z][a-z0-9_]*:(read|write)|\*)$/, { description: 'scope like "dashboards:read", "alert_rules:write", or "*"', }), -) +).annotate({ + identifier: "Scope", + title: "Scope", + description: + "Permission grant on a restricted API key. Grammar: `:read`, `:write`, or `*` (all). The family is the first path segment under `/v2` (e.g. `api_keys`, `dashboards`, `alert_rules`). `write` implies `read`; a key with no scopes has full access.", + examples: ["api_keys:read", "dashboards:write", "*"], +}) export type V2Scope = Schema.Schema.Type export interface RequiredScope { diff --git a/packages/domain/src/http/v2/envelopes.ts b/packages/domain/src/http/v2/envelopes.ts index 3691d14b6..52c908cb0 100644 --- a/packages/domain/src/http/v2/envelopes.ts +++ b/packages/domain/src/http/v2/envelopes.ts @@ -11,7 +11,9 @@ import { Schema } from "effect" /** ISO-8601 UTC timestamp on the v2 wire (e.g. `2026-07-15T12:34:56.000Z`). */ export const Timestamp = Schema.String.annotate({ title: "Timestamp", - description: "ISO-8601 UTC timestamp", + description: "ISO-8601 UTC timestamp, e.g. `2026-07-15T12:34:56.000Z`.", + examples: ["2026-07-15T12:34:56.000Z"], + format: "date-time", }) /** Convert service-layer epoch-ms to the v2 wire timestamp. */ @@ -29,19 +31,48 @@ export const ListQuery = Schema.Struct({ Schema.NumberFromString.check( Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: LIST_LIMIT_MAX }), - ), + ).annotate({ + title: "Limit", + description: `Maximum number of objects to return, between 1 and ${LIST_LIMIT_MAX}. Defaults to ${LIST_LIMIT_DEFAULT}.`, + examples: [LIST_LIMIT_DEFAULT], + }), ), - cursor: Schema.optional(Schema.String), + cursor: Schema.optional( + Schema.String.annotate({ + title: "Cursor", + description: + "Opaque pagination cursor. Pass the `next_cursor` from a previous response to fetch the following page. Omit for the first page.", + examples: ["off_1k"], + }), + ), +}).annotate({ + identifier: "ListQuery", + title: "List query", + description: "Cursor-pagination query parameters shared by every v2 list endpoint.", }) export type ListQuery = Schema.Schema.Type /** Stripe-style list envelope: `{ object: "list", data, has_more, next_cursor }`. */ export const ListOf = (item: S) => Schema.Struct({ - object: Schema.Literal("list"), - data: Schema.Array(item), - has_more: Schema.Boolean, - next_cursor: Schema.NullOr(Schema.String), + object: Schema.Literal("list").annotate({ + description: 'Always `"list"` for a list response.', + examples: ["list"], + }), + data: Schema.Array(item).annotate({ + description: "The page of objects, newest first.", + }), + has_more: Schema.Boolean.annotate({ + description: "Whether more objects exist after this page. When `true`, use `next_cursor` to fetch them.", + examples: [true], + }), + next_cursor: Schema.NullOr(Schema.String).annotate({ + description: "Cursor for the next page, or `null` on the last page. Pass it back as the `cursor` query param.", + examples: ["off_1k"], + }), + }).annotate({ + title: "List", + description: "Cursor-paginated list envelope wrapping a page of objects.", }) /** diff --git a/packages/domain/src/http/v2/errors.ts b/packages/domain/src/http/v2/errors.ts index 72d02bd82..36c2d78e8 100644 --- a/packages/domain/src/http/v2/errors.ts +++ b/packages/domain/src/http/v2/errors.ts @@ -20,51 +20,172 @@ export const V2ErrorType = Schema.Literals([ ]) export type V2ErrorType = Schema.Schema.Type -const errorBody = (type: T) => +interface ErrorExample { + readonly code: string + readonly message: string + readonly param?: string +} + +const errorBody = (type: T, example: ErrorExample) => Schema.Struct({ - type: Schema.Literal(type), - code: Schema.String, - message: Schema.String, - param: Schema.optionalKey(Schema.String), - doc_url: Schema.optionalKey(Schema.String), + type: Schema.Literal(type).annotate({ + description: + "Error category — a closed enum (`invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`, `conflict_error`, `rate_limit_error`, `api_error`). Branch on `code` for specifics.", + }), + code: Schema.String.annotate({ + description: "Stable, machine-readable error code. Codes are append-only; branch on this.", + examples: [example.code], + }), + message: Schema.String.annotate({ + description: "Human-readable explanation of what went wrong. For humans, not for programmatic branching.", + examples: [example.message], + }), + param: Schema.optionalKey( + Schema.String.annotate({ + description: "The request parameter that caused the error, when applicable.", + ...(example.param !== undefined ? { examples: [example.param] } : {}), + }), + ), + doc_url: Schema.optionalKey( + Schema.String.annotate({ + description: "Link to reference documentation for this error, when available.", + examples: ["https://api.maple.dev/v2/docs#errors"], + }), + ), }) export class V2InvalidRequestError extends Schema.ErrorClass( "@maple/http/v2/InvalidRequestError", -)({ error: errorBody("invalid_request_error") }, { httpApiStatus: 400 }) {} +)( + { + error: errorBody("invalid_request_error", { + code: "parameter_invalid", + message: "Invalid request query: limit must be between 1 and 100.", + param: "limit", + }), + }, + { + httpApiStatus: 400, + identifier: "InvalidRequestError", + title: "Invalid request error", + description: + "The request was malformed — a parameter is missing, of the wrong type, or out of range. HTTP 400.", + }, +) {} export class V2AuthenticationError extends Schema.ErrorClass( "@maple/http/v2/AuthenticationError", -)({ error: errorBody("authentication_error") }, { httpApiStatus: 401 }) {} +)( + { + error: errorBody("authentication_error", { + code: "invalid_credentials", + message: "Invalid or missing credentials.", + }), + }, + { + httpApiStatus: 401, + identifier: "AuthenticationError", + title: "Authentication error", + description: "The Bearer token is missing, malformed, or invalid. HTTP 401.", + }, +) {} export class V2PermissionError extends Schema.ErrorClass( "@maple/http/v2/PermissionError", -)({ error: errorBody("permission_error") }, { httpApiStatus: 403 }) {} +)( + { + error: errorBody("permission_error", { + code: "insufficient_scope", + message: 'This API key does not have the "api_keys:write" scope required for this request.', + }), + }, + { + httpApiStatus: 403, + identifier: "PermissionError", + title: "Permission error", + description: + "The credentials are valid but lack the required scope or org role for this operation. HTTP 403.", + }, +) {} export class V2NotFoundError extends Schema.ErrorClass("@maple/http/v2/NotFoundError")( - { error: errorBody("not_found_error") }, - { httpApiStatus: 404 }, + { + error: errorBody("not_found_error", { + code: "resource_missing", + message: "No such api_key.", + param: "id", + }), + }, + { + httpApiStatus: 404, + identifier: "NotFoundError", + title: "Not found error", + description: "No object exists for the given ID. HTTP 404.", + }, ) {} export class V2ConflictError extends Schema.ErrorClass("@maple/http/v2/ConflictError")( - { error: errorBody("conflict_error") }, - { httpApiStatus: 409 }, + { + error: errorBody("conflict_error", { + code: "resource_conflict", + message: "The object was modified concurrently; retry the request.", + }), + }, + { + httpApiStatus: 409, + identifier: "ConflictError", + title: "Conflict error", + description: "The request conflicts with the current state of the object. HTTP 409.", + }, ) {} export class V2RateLimitError extends Schema.ErrorClass("@maple/http/v2/RateLimitError")( - { error: errorBody("rate_limit_error") }, - { httpApiStatus: 429 }, + { + error: errorBody("rate_limit_error", { + code: "rate_limited", + message: "Too many requests; slow down and retry after the interval in the Retry-After header.", + }), + }, + { + httpApiStatus: 429, + identifier: "RateLimitError", + title: "Rate limit error", + description: "Too many requests in a given window. Back off and retry. HTTP 429.", + }, ) {} export class V2ApiError extends Schema.ErrorClass("@maple/http/v2/ApiError")( - { error: errorBody("api_error") }, - { httpApiStatus: 500 }, + { + error: errorBody("api_error", { + code: "internal_error", + message: "An unexpected error occurred on our end.", + }), + }, + { + httpApiStatus: 500, + identifier: "ApiError", + title: "API error", + description: "An unexpected server-side error. Safe to retry with backoff. HTTP 500.", + }, ) {} /** `api_error` flavor for upstream/persistence unavailability (503). */ export class V2ServiceUnavailableError extends Schema.ErrorClass( "@maple/http/v2/ServiceUnavailableError", -)({ error: errorBody("api_error") }, { httpApiStatus: 503 }) {} +)( + { + error: errorBody("api_error", { + code: "service_unavailable", + message: "The service is temporarily unavailable; retry after a short delay.", + }), + }, + { + httpApiStatus: 503, + identifier: "ServiceUnavailableError", + title: "Service unavailable error", + description: "A dependency (persistence or upstream) was temporarily unavailable. Retry with backoff. HTTP 503.", + }, +) {} // Constructors — keep handler adapters one-liners. diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index ce9e673d9..58da45a22 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -1,15 +1,31 @@ import { describe, expect, it } from "vitest" +import { Schema } from "effect" import { OpenApi } from "effect/unstable/httpapi" import { MapleApiV2 } from "./api" +import { V2ApiKey, V2ApiKeyCreateParams, V2ApiKeyWithSecret } from "./api-keys" /** * Contract freeze: the public v2 OpenAPI surface (paths + methods) is asserted * explicitly so an accidental route change fails CI. Additions require * updating this list — which is the point. + * + * Beyond the route surface, we also freeze the *documentation quality* of the + * pilot `api_keys` resource: operation summaries/descriptions/ids, tag prose, + * clean component names, schema-level titles/descriptions/examples, and the + * security scheme. This makes the first endpoint the reference standard and + * fails CI if a future edit strips the metadata. */ -describe("MapleApiV2 OpenAPI", () => { - const spec = OpenApi.fromApi(MapleApiV2) +const spec = OpenApi.fromApi(MapleApiV2) + +// The generated document carries fields (info.contact, top-level externalDocs, +// security bearerFormat, schema examples) beyond the pruned `OpenAPISpec` type, +// so read the dynamic bits through an untyped view. +const doc = spec as unknown as Record +const schemas = doc.components.schemas as Record +const operation = (method: string, path: string): Record => + (spec.paths as Record)[path][method] +describe("MapleApiV2 OpenAPI", () => { it("derives with v2 metadata", () => { expect(spec.info.title).toBe("Maple API") expect(spec.info.version).toBe("2.0.0") @@ -32,4 +48,104 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/api_keys/{id}/roll", ]) }) + + it("populates the info block: summary, description, contact, servers, external docs", () => { + expect(doc.info.summary).toEqual(expect.any(String)) + expect(doc.info.description).toContain("resource-oriented") + expect(doc.info.contact).toEqual({ + name: "Maple Support", + url: "https://maple.dev", + email: "support@maple.dev", + }) + expect(doc.servers).toEqual([{ url: "https://api.maple.dev", description: "Production" }]) + expect(doc.externalDocs?.url).toBe("https://api.maple.dev/v2/docs") + }) + + it("names the group tag and gives it a description", () => { + const tag = (spec.tags ?? []).find((t) => t.name === "API Keys") + expect(tag).toBeDefined() + expect(tag?.description).toEqual(expect.stringContaining("Programmatic credentials")) + }) + + it("gives every operation a stable operationId, summary, and description", () => { + const expected: ReadonlyArray = [ + ["get", "/v2/api_keys", "listApiKeys"], + ["post", "/v2/api_keys", "createApiKey"], + ["get", "/v2/api_keys/{id}", "getApiKey"], + ["post", "/v2/api_keys/{id}/roll", "rollApiKey"], + ["delete", "/v2/api_keys/{id}", "revokeApiKey"], + ] + for (const [method, path, operationId] of expected) { + const op = operation(method, path) + expect(op.operationId).toBe(operationId) + expect(op.summary).toEqual(expect.any(String)) + expect(op.summary.length).toBeGreaterThan(0) + expect(op.description.length).toBeGreaterThan(20) + expect(op.tags).toEqual(["API Keys"]) + expect(op.security).toEqual([{ bearer: [] }]) + } + }) + + it("uses clean, unprefixed component schema names", () => { + const names = Object.keys(schemas) + expect(names).toEqual(expect.arrayContaining(["ApiKey", "ApiKeyWithSecret", "ApiKeyCreateParams", "ApiKeyList", "Scope"])) + expect(names).toEqual( + expect.arrayContaining([ + "InvalidRequestError", + "AuthenticationError", + "PermissionError", + "NotFoundError", + "ServiceUnavailableError", + ]), + ) + // No internal / v2-prefixed / namespaced identifiers leaked into the public spec. + expect(names.some((n) => n.startsWith("V2") || n.includes("@maple") || n.includes("/"))).toBe(false) + }) + + it("documents the ApiKey schema with a title, description, and a decodable example", () => { + const apiKey = schemas["ApiKey"] + expect(apiKey.title).toBe("API Key") + expect(apiKey.description.length).toBeGreaterThan(20) + expect(apiKey.examples).toHaveLength(1) + // The example is authored in wire shape — it must decode through the schema. + const decoded = Schema.decodeUnknownSync(V2ApiKey)(apiKey.examples[0]) + expect(apiKey.examples[0].id).toMatch(/^key_/) + expect(decoded.object).toBe("api_key") + + // Field-level docs render too. + expect(apiKey.properties.name.description).toEqual(expect.any(String)) + expect(apiKey.properties.name.examples).toEqual(["ci-pipeline"]) + expect(apiKey.properties.key_prefix.description).toContain("secret") + }) + + it("documents ApiKeyWithSecret and ApiKeyCreateParams with decodable examples", () => { + const withSecret = schemas["ApiKeyWithSecret"] + expect(withSecret.title).toBe("API Key (with secret)") + expect(withSecret.properties.secret.description).toContain("once") + expect(() => Schema.decodeUnknownSync(V2ApiKeyWithSecret)(withSecret.examples[0])).not.toThrow() + + const createParams = schemas["ApiKeyCreateParams"] + expect(createParams.examples).toHaveLength(1) + expect(() => Schema.decodeUnknownSync(V2ApiKeyCreateParams)(createParams.examples[0])).not.toThrow() + }) + + it("documents the public-ID and Scope primitives with examples", () => { + expect(schemas["_maple_ApiKeyId"].description).toContain("public object ID") + expect(schemas["_maple_ApiKeyId"].examples?.[0]).toMatch(/^key_/) + expect(schemas["Scope"].allOf?.[0]?.examples).toEqual(expect.arrayContaining(["*"])) + }) + + it("documents the bearer security scheme with a description and bearer format", () => { + const bearer = doc.components.securitySchemes.bearer + expect(bearer.type).toBe("http") + expect(bearer.scheme).toBe("Bearer") + expect(bearer.description).toContain("maple_ak_") + expect(bearer.bearerFormat.length).toBeGreaterThan(0) + }) + + it("documents error responses with a stable code example", () => { + const notFound = schemas["NotFoundError"] + expect(notFound.properties.error.properties.code.examples).toEqual(["resource_missing"]) + expect(notFound.properties.error.properties.message.description).toEqual(expect.any(String)) + }) }) diff --git a/packages/domain/src/http/v2/public-id.ts b/packages/domain/src/http/v2/public-id.ts index 115b4d911..a2979deca 100644 --- a/packages/domain/src/http/v2/public-id.ts +++ b/packages/domain/src/http/v2/public-id.ts @@ -153,7 +153,16 @@ export const decodePublicId = (prefix: string, publicId: string): string | null * error middleware surfaces as an `invalid_request_error`. */ export const PublicId = >(prefix: string, internal: S) => - Schema.String.pipe( + // Annotate the *encoded* base string: the OpenAPI schema renders the wire + // form, and annotations on the transformation node above are dropped in the + // encoded projection — so `description`/`examples`/`format` must live here to + // surface in `/v2/docs`. Metadata only; decoding/encoding is unchanged. + Schema.String.annotate({ + title: "Public ID", + description: `Opaque, prefixed public object ID (e.g. \`${prefix}_4CzLmR8pTqVn6yWjZ2xK\`). A reversible base58 encoding of the internal ID — treat it as an opaque string.`, + examples: [`${prefix}_4CzLmR8pTqVn6yWjZ2xK`], + format: "maple.public_id", + }).pipe( Schema.decodeTo(Schema.String, { decode: SchemaGetter.transformOrFail((publicId: string) => { const internalId = decodePublicId(prefix, publicId)