diff --git a/apps/api/src/routes/v2/config-resources.http.test.ts b/apps/api/src/routes/v2/config-resources.http.test.ts index 2d61759465..e0ca47ecec 100644 --- a/apps/api/src/routes/v2/config-resources.http.test.ts +++ b/apps/api/src/routes/v2/config-resources.http.test.ts @@ -512,6 +512,25 @@ describe("v2 ingest_keys over HTTP", () => { await harness.dispose() }) + it("lets a member read the public key alone, and only engineers and admins both", async () => { + const harness = makeHarness() + const admin = await harness.bootstrapKey() + const member = await harness.bootstrapMemberKey() + + const both = await harness.request("GET", "/v2/ingest_keys", { token: admin.secret }) + const publicOnly = await harness.request("GET", "/v2/ingest_keys/public", { token: member.secret }) + expect(publicOnly.status).toBe(200) + expect(publicOnly.body).toEqual({ + object: "public_ingest_key", + public_key: both.body.public_key, + public_rotated_at: both.body.public_rotated_at, + }) + + const denied = await harness.request("GET", "/v2/ingest_keys", { token: member.secret }) + expect(denied.status).toBe(403) + await harness.dispose() + }) + it("enforces the ingest_keys scope family", async () => { const harness = makeHarness() const scoped = await harness.bootstrapKey(["dashboards:read"]) diff --git a/apps/api/src/routes/v2/ingest-keys.http.ts b/apps/api/src/routes/v2/ingest-keys.http.ts index 19722ee698..25260926f4 100644 --- a/apps/api/src/routes/v2/ingest-keys.http.ts +++ b/apps/api/src/routes/v2/ingest-keys.http.ts @@ -2,7 +2,7 @@ import { HttpApiBuilder } from "effect/http-api" import type { IngestKeysResponse } from "@maple/domain/http" import { CurrentTenant } from "@maple/domain/http" import { MapleApiV2, V2InsufficientPermissions } from "@maple/domain/http/v2" -import type { V2IngestKeys } from "@maple/domain/http/v2" +import type { V2IngestKeys, V2PublicIngestKey } from "@maple/domain/http/v2" import { Effect } from "effect" import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" import { OrgIngestKeysService } from "@maple/backend/services/org/OrgIngestKeysService" @@ -32,6 +32,21 @@ export const HttpV2IngestKeysLive = HttpApiBuilder.group(MapleApiV2, "ingestKeys return toV2IngestKeys(keys) }), ) + .handle("retrievePublic", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* Policy.check(tenant.roles, "ingest_keys:view_public", (denial) => + V2InsufficientPermissions.make(denial.message), + ) + const keys = yield* ingestKeys.getOrCreate(tenant.orgId, tenant.userId) + + return { + object: "public_ingest_key", + public_key: keys.publicKey, + public_rotated_at: keys.publicRotatedAt, + } satisfies V2PublicIngestKey + }), + ) .handle("rollPublic", () => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 256e4c53e5..4980e62033 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -14273,7 +14273,7 @@ "name": "Alert Maintenance Windows" }, { - "description": "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. All operations are admin-only.", + "description": "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. Any member may read the public key; reading both keys takes an engineer or admin, and rolling one takes an admin.", "name": "Ingest Keys" }, { diff --git a/apps/web/src/data/ingest-keys.test.ts b/apps/web/src/data/ingest-keys.test.ts index 71a77568c3..1590904f6b 100644 --- a/apps/web/src/data/ingest-keys.test.ts +++ b/apps/web/src/data/ingest-keys.test.ts @@ -1,11 +1,11 @@ import { CurrentTenant } from "@maple/domain/http" import { MapleApiV2 } from "@maple/domain/http/v2" -import { Effect, Layer, Predicate, Ref, Schema } from "effect" -import { HttpClient, HttpClientResponse } from "effect/http" +import { Effect, Layer, Predicate, Ref, Schema, String as Str } from "effect" +import { HttpClient, type HttpClientRequest, HttpClientResponse } from "effect/http" import { HttpApiClient } from "effect/http-api" import { describe, expect, test } from "vitest" import { MapleApiV2Client } from "../platform/api-clients" -import { retrieveIngestKeys } from "./ingest-keys" +import { retrieveIngestKeys, retrieveSetupKeys } from "./ingest-keys" import { SessionTenant } from "./session-tenant" const keys = { @@ -16,12 +16,46 @@ const keys = { private_rotated_at: "2026-11-01T00:00:00Z", } -const run = (roles: ReadonlyArray) => +const publicKey = { + object: "public_ingest_key", + public_key: "maple_pk_test", + public_rotated_at: "2026-11-01T00:00:00Z", +} + +const denied = { + error: { + _tag: "@maple/http/v2/InsufficientPermissionsError", + type: "permission_error", + code: "insufficient_permissions", + title: "Permission required", + message: "Only org admins and engineers can view ingest keys.", + retryable: false, + recovery: "request_access", + }, +} + +/** What `GET /v2/ingest_keys` answers; the public read always succeeds. */ +type BothKeysAnswer = "keys" | "denied" | "outage" + +const answer = (request: HttpClientRequest.HttpClientRequest, both: BothKeysAnswer): Response => + Str.endsWith("/public")(request.url) + ? Response.json(publicKey) + : both === "keys" + ? Response.json(keys) + : both === "denied" + ? Response.json(denied, { status: 403 }) + : new Response("upstream down", { status: 503 }) + +const run = ( + roles: ReadonlyArray, + read: Effect.Effect, + both: BothKeysAnswer = "keys", +) => Effect.gen(function* () { const sent = yield* Ref.make(0) const http = HttpClient.make((request) => Ref.update(sent, (count) => count + 1).pipe( - Effect.as(HttpClientResponse.fromWeb(request, Response.json(keys))), + Effect.as(HttpClientResponse.fromWeb(request, answer(request, both))), ), ) const tenant = Layer.succeed(SessionTenant, { @@ -38,13 +72,13 @@ const run = (roles: ReadonlyArray) => MapleApiV2Client, HttpApiClient.make(MapleApiV2, { baseUrl: "https://api.test" }), ).pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, http))) - const result = yield* Effect.result(retrieveIngestKeys).pipe(Effect.provide([tenant, client])) + const result = yield* Effect.result(read).pipe(Effect.provide([tenant, client])) return { result, requests: yield* Ref.get(sent) } }).pipe(Effect.runPromise) describe("reading the ingest keys", () => { test("a member below engineer gets a permission failure without a request", async () => { - const { result, requests } = await run(["org:member"]) + const { result, requests } = await run(["org:member"], retrieveIngestKeys) expect(requests).toBe(0) expect(result._tag).toBe("Failure") if (result._tag === "Failure") @@ -54,8 +88,34 @@ describe("reading the ingest keys", () => { }) test("an engineer reads them from the API", async () => { - const { result, requests } = await run(["org:engineer"]) + const { result, requests } = await run(["org:engineer"], retrieveIngestKeys) expect(requests).toBe(1) expect(result._tag).toBe("Success") }) + + test("setup screens give a member the public key and an engineer both", async () => { + const member = await run(["org:member"], retrieveSetupKeys) + expect(member.requests).toBe(1) + expect(member.result).toEqual( + expect.objectContaining({ success: { privateKey: null, publicKey: "maple_pk_test" } }), + ) + const engineer = await run(["org:engineer"], retrieveSetupKeys) + expect(engineer.result).toEqual( + expect.objectContaining({ success: { privateKey: "maple_sk_test", publicKey: "maple_pk_test" } }), + ) + }) + + test("a refusal from the API itself also falls back to the public key", async () => { + const { result, requests } = await run(["org:engineer"], retrieveSetupKeys, "denied") + expect(requests).toBe(2) + expect(result).toEqual( + expect.objectContaining({ success: { privateKey: null, publicKey: "maple_pk_test" } }), + ) + }) + + test("a key store outage stays a failure, so an admin is never handed the public key instead", async () => { + const { result, requests } = await run(["org:admin"], retrieveSetupKeys, "outage") + expect(requests).toBe(1) + expect(result._tag).toBe("Failure") + }) }) diff --git a/apps/web/src/data/ingest-keys.ts b/apps/web/src/data/ingest-keys.ts index f5031775a8..dd533425b3 100644 --- a/apps/web/src/data/ingest-keys.ts +++ b/apps/web/src/data/ingest-keys.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { V2InsufficientPermissions } from "@maple/domain/http/v2" -import { Effect, Option } from "effect" +import { Effect, Option, Predicate, Schema } from "effect" import { MapleApiV2Client } from "../platform/api-clients" import { SessionTenant } from "./session-tenant" @@ -15,3 +15,29 @@ export const retrieveIngestKeys = Effect.gen(function* () { return yield* V2InsufficientPermissions.make(Policy.denialFor("ingest_keys:view").message) return yield* (yield* MapleApiV2Client).ingestKeys.retrieve() }) + +const DeniedEnvelope = Schema.Struct({ + error: Schema.Struct({ code: Schema.Literal(V2InsufficientPermissions.code) }), +}) + +/** The refusal as the API answers it (the decoded envelope) or as the session check makes it (the class). */ +const isPermissionDenied = (error: unknown): boolean => + Predicate.isTagged(error, V2InsufficientPermissions.tag) || Schema.is(DeniedEnvelope)(error) + +/** The keys a setup screen shows: `privateKey` is null for a member, who reads the public one only. */ +export type SetupKeyPair = Readonly<{ privateKey: string | null; publicKey: string }> + +/** Both keys for an engineer and up; the public key alone for anyone else (it sends from servers too). */ +export const retrieveSetupKeys = Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* retrieveIngestKeys.pipe( + Effect.map((keys): SetupKeyPair => ({ privateKey: keys.private_key, publicKey: keys.public_key })), + // Only a refusal falls back: a key store outage must not hand an admin the public key. + Effect.catchIf(isPermissionDenied, () => + Effect.map(client.ingestKeys.retrievePublic(), (key): SetupKeyPair => ({ + privateKey: null, + publicKey: key.public_key, + })), + ), + ) +}) diff --git a/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts b/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts index 2a305b7821..75bb907c2f 100644 --- a/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts +++ b/apps/web/src/page/onboarding/quick-start-agent-prompt.scene.test.ts @@ -1,5 +1,5 @@ import type { Html, HtmlBuilder } from "foldkit/html" -import { click, Command, expect as expectView, given, Mount, role, scene } from "foldkit/scene" +import { click, Command, expect as expectView, given, Mount, role, scene, text } from "foldkit/scene" import { describe, test } from "vitest" import { agentSetupPrompt } from "../../components/ingest/onboard-skill" import { ingestUrl } from "../../lib/services/common/ingest-url" @@ -17,15 +17,12 @@ const state: QuickStartState = { stack: { languages: ["nodejs"], platforms: [] }, } -const atConnect = (): Model => { +const atConnect = (privateKey: string | null = "maple_sk_test"): Model => { const model = init("maple-onboarding-v6-org_1", false, false, "/", "org_1").model return { ...model, state, - connect: { - ...model.connect, - keys: { _tag: "Ready", privateKey: "maple_sk_test", publicKey: "maple_pk_test" }, - }, + connect: { ...model.connect, keys: { _tag: "Ready", privateKey, publicKey: "maple_pk_test" } }, } } @@ -65,4 +62,25 @@ describe("build with your agent", () => { ), ) }) + + test("a member, who reads only the public key, gets it in the prompt and is told whom to ask", () => { + scene( + { update, view }, + given(atConnect(null)), + indicatorMeasured(), + expectView(text(/^This is your org's public key\./)).toExist(), + click(role("button", { name: "Build with your agent" })), + Command.expectExact( + CopyButton.WriteClipboard({ + text: agentSetupPrompt(ingestUrl, "maple_pk_test"), + generation: 1, + }), + ), + Command.resolve(CopyButton.WriteClipboard, CopyButton.Message.SucceededCopy({ generation: 1 })), + Command.resolve( + CopyButton.WaitCopyStatus, + CopyButton.Message.ElapsedCopyStatus({ generation: 1 }), + ), + ) + }) }) diff --git a/apps/web/src/page/onboarding/quick-start-connect.ts b/apps/web/src/page/onboarding/quick-start-connect.ts index e9fba4f9a7..0ea4fab89e 100644 --- a/apps/web/src/page/onboarding/quick-start-connect.ts +++ b/apps/web/src/page/onboarding/quick-start-connect.ts @@ -5,8 +5,8 @@ import { baseKey } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import { backoffPollTicks, type Presence } from "../../data/poll" import { IntegrationStatuses } from "../../data/integration-statuses" +import { retrieveSetupKeys } from "../../data/ingest-keys" import { STACK_PLATFORM_IDS, type StackPlatform } from "../../lib/onboarding-stack" -import { MapleApiV2Client } from "../../platform/api-clients" import type { Resources } from "../../platform/resources" import * as CopyButton from "@maple/views/ui/copy-button" import * as Tabs from "@maple/views/ui/tabs" @@ -27,8 +27,9 @@ const isStatusProvider = (platform: StackPlatform): platform is StatusProvider = const Keys = Schema.Union([ Schema.TaggedStruct("Pending", {}), - Schema.TaggedStruct("Ready", { privateKey: Schema.String, publicKey: Schema.String }), - /** The read failed (not an admin, or the key store is down): the prompt shows a placeholder. */ + /** `privateKey` is null for a member: they read the public key only, which sends from servers too. */ + Schema.TaggedStruct("Ready", { privateKey: Schema.NullOr(Schema.String), publicKey: Schema.String }), + /** Neither key could be read (the key store is down): the snippets show a placeholder. */ Schema.TaggedStruct("Failed", {}), ]) @@ -45,7 +46,7 @@ export const Connect = Schema.Struct({ export type Connect = typeof Connect.Type export const ConnectMessage = defineMessageUnion({ - SucceededLoadKeys: { privateKey: Schema.String, publicKey: Schema.String }, + SucceededLoadKeys: { privateKey: Schema.NullOr(Schema.String), publicKey: Schema.String }, FailedLoadKeys: {}, LoadedPresence: { result: Schema.Result(SignalPresences, LoadFailure) }, LoadedConnected: { connected: Schema.Record(Schema.String, Schema.Boolean) }, @@ -72,10 +73,8 @@ export const initConnect = (): Connect => ({ const FetchConnectKeys = Command.define("FetchOnboardingConnectKeys", { messages: [ConnectMessage.SucceededLoadKeys, ConnectMessage.FailedLoadKeys], - execute: Effect.flatMap(MapleApiV2Client, (client) => client.ingestKeys.retrieve()).pipe( - Effect.map((keys) => - ConnectMessage.SucceededLoadKeys({ privateKey: keys.private_key, publicKey: keys.public_key }), - ), + execute: retrieveSetupKeys.pipe( + Effect.map((keys) => ConnectMessage.SucceededLoadKeys(keys)), Effect.orElseSucceed(() => ConnectMessage.FailedLoadKeys()), ), }) diff --git a/apps/web/src/page/onboarding/quick-start-view.ts b/apps/web/src/page/onboarding/quick-start-view.ts index e6e936334b..5a561c2be6 100644 --- a/apps/web/src/page/onboarding/quick-start-view.ts +++ b/apps/web/src/page/onboarding/quick-start-view.ts @@ -685,10 +685,16 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => ...(connect.keys._tag === "Failed" ? [ typography(h, { size: "xs", tone: "muted", as: "p" }, [ - "Couldn't read your ingest key. An admin can copy it from Settings, Ingestion.", + "Couldn't read your ingest key. Try again in a minute, or ask an engineer or admin to copy it from Settings, Ingestion.", ]), ] - : []), + : connect.keys._tag === "Ready" && connect.keys.privateKey === null + ? [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "This is your org's public key. It works from servers too. For production servers, an engineer or admin can give you the private key from Settings, Ingestion.", + ]), + ] + : []), ]), stepFooter( h, diff --git a/apps/web/src/page/overview/setup-checklist-command.ts b/apps/web/src/page/overview/setup-checklist-command.ts index 599005f85f..33d331fb06 100644 --- a/apps/web/src/page/overview/setup-checklist-command.ts +++ b/apps/web/src/page/overview/setup-checklist-command.ts @@ -1,4 +1,4 @@ -import { retrieveIngestKeys } from "../../data/ingest-keys" +import { retrieveSetupKeys } from "../../data/ingest-keys" import { getServiceOverview } from "@maple/web-data/warehouse/services" import { Effect, Schema, Array as Arr } from "effect" import { Command } from "foldkit" @@ -22,13 +22,11 @@ export const SaveSetupState = Command.define("SaveSetupChecklistState", { Effect.as(writeQuickStartState(storageKey, state), Message.CompletedSaveState()), }) -/** `retainedQueryV2("ingestKeys", "retrieve", {})`. */ +/** Both ingest keys, or the public one alone for a member. */ export const FetchSetupKeys = Command.define("FetchSetupChecklistKeys", { messages: [Message.SucceededLoadKeys, Message.FailedLoadKeys], - execute: retrieveIngestKeys.pipe( - Effect.map((keys) => - Message.SucceededLoadKeys({ publicKey: keys.public_key, privateKey: keys.private_key }), - ), + execute: retrieveSetupKeys.pipe( + Effect.map((keys) => Message.SucceededLoadKeys(keys)), Effect.orElseSucceed(() => Message.FailedLoadKeys()), ), }) diff --git a/apps/web/src/page/overview/setup-checklist-view.ts b/apps/web/src/page/overview/setup-checklist-view.ts index efecf4f4d6..a129524c1b 100644 --- a/apps/web/src/page/overview/setup-checklist-view.ts +++ b/apps/web/src/page/overview/setup-checklist-view.ts @@ -22,7 +22,7 @@ import { textLink } from "@maple/views/ui/text-link" type H = HtmlBuilder -/** `IngestKeysUnavailableNote`: members cannot read the org's ingest keys. */ +/** `IngestKeysUnavailableNote`: neither key could be read. */ const keysUnavailable = (h: H): Html => h.p( [ @@ -31,7 +31,7 @@ const keysUnavailable = (h: H): Html => ), ], [ - "Ask an org admin for your ingest keys, or open ", + "Couldn't read your ingest keys. Try again in a minute, or ask an engineer or admin to copy one from ", textLink( h, { @@ -70,8 +70,9 @@ const credentials = (h: H, model: Model): Html => ) : copyableField(h, { id: "setup-private-key", - label: "Private ingest key", - value: model.keys.privateKey, + label: + model.keys.privateKey === null ? "Public ingest key" : "Private ingest key", + value: model.keys.privateKey ?? model.keys.publicKey, copy: model.copies.privateKey, toCopyMessage: (message) => Message.GotCopyMessage({ field: "privateKey", message }), diff --git a/apps/web/src/page/overview/setup-checklist.scene.test.ts b/apps/web/src/page/overview/setup-checklist.scene.test.ts index c21cbd1361..60bb68f605 100644 --- a/apps/web/src/page/overview/setup-checklist.scene.test.ts +++ b/apps/web/src/page/overview/setup-checklist.scene.test.ts @@ -118,6 +118,22 @@ describe("overview setup checklist", () => { ) }) + test("a member's card labels the key it shows as the public one", () => { + const waiting = withTracePresence(withServices(entered(true).read.model, []), "absent") + const memberKeys = pageUpdate( + waiting, + setup(SetupMessage.SucceededLoadKeys({ publicKey: "maple_pk_member", privateKey: null })), + ).model + scene( + { update: pageUpdate, view }, + given(memberKeys), + ...pageMounts, + ...tabMounts, + expectView(text("Public ingest key")).toExist(), + expectView(text("Private ingest key")).not.toExist(), + ) + }) + test("an org whose traces are older than the last hour is not asked to connect, nor polled", () => { const quiet = withServices(entered(true).read.model, []) const seenBefore = withTracePresence(quiet, "present") diff --git a/apps/web/src/page/overview/setup-checklist.ts b/apps/web/src/page/overview/setup-checklist.ts index 279f283514..ed55a87f3f 100644 --- a/apps/web/src/page/overview/setup-checklist.ts +++ b/apps/web/src/page/overview/setup-checklist.ts @@ -10,11 +10,12 @@ import { QuickStartState } from "../onboarding/quick-start-state" // The setup checklist: Clerk mode's // "Connect your app" card over `useQuickStart` and `useIngestConnection`. -/** `readIngestKeys`: both keys from one admin-only read. */ +/** Both keys for an engineer and up, the public one alone for a member (`retrieveSetupKeys`). */ export const Keys = Schema.Union([ // Not "Loading": the keys are read only while the card shows, so this can hold for the whole visit. Schema.TaggedStruct("Pending", {}), - Schema.TaggedStruct("Ready", { publicKey: Schema.String, privateKey: Schema.String }), + /** `privateKey` is null for a member, who reads the public key only. */ + Schema.TaggedStruct("Ready", { publicKey: Schema.String, privateKey: Schema.NullOr(Schema.String) }), Schema.TaggedStruct("Failed", {}), ]) export type Keys = typeof Keys.Type @@ -55,7 +56,7 @@ export type Model = typeof Model.Type export const Message = defineMessageUnion({ ReadState: { state: QuickStartState }, - SucceededLoadKeys: { publicKey: Schema.String, privateKey: Schema.String }, + SucceededLoadKeys: { publicKey: Schema.String, privateKey: Schema.NullOr(Schema.String) }, FailedLoadKeys: {}, SucceededLoadOverview: { window: TimeWindow, services: Schema.Array(ServiceRow) }, FailedLoadOverview: { window: TimeWindow, failure: LoadFailure }, diff --git a/docs/api-v2.md b/docs/api-v2.md index d590b27fd1..672cceb8a8 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -161,7 +161,7 @@ Implemented in phases; the pilot (`api_keys`) ships first and proves every conve | Resource | Endpoints | Backing service | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `ApiKeysService` | -| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | +| `ingest_keys` ✅ | retrieve, `GET …/public`, `POST …/public/roll`, `POST …/private/roll` | `OrgIngestKeysService` | | `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/preview/instantiate) + Perses import | `dashboards` | | `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `AlertsService` | | `alerts/destinations` ✅ | CRUD + `test` | `AlertsService` | diff --git a/packages/domain/src/http/v2/ingest-keys.ts b/packages/domain/src/http/v2/ingest-keys.ts index 1b5a975364..1a8cd65292 100644 --- a/packages/domain/src/http/v2/ingest-keys.ts +++ b/packages/domain/src/http/v2/ingest-keys.ts @@ -47,6 +47,28 @@ export const V2IngestKeys = Schema.Struct({ }) export type V2IngestKeys = Schema.Schema.Type +export const V2PublicIngestKey = Schema.Struct({ + object: Schema.Literal("public_ingest_key").annotate({ + description: 'The object type, always `"public_ingest_key"`.', + examples: ["public_ingest_key"], + }), + public_key: V2IngestKeys.fields.public_key, + public_rotated_at: V2IngestKeys.fields.public_rotated_at, +}).annotate({ + identifier: "PublicIngestKey", + title: "Public Ingest Key", + description: + "Your organization's public ingest key alone. Every member may read it: it is made to ship in browser and mobile code, and it can only send telemetry.", + examples: [ + wireExample({ + object: "public_ingest_key", + public_key: ingestKeysExample.public_key, + public_rotated_at: ingestKeysExample.public_rotated_at, + }), + ], +}) +export type V2PublicIngestKey = Schema.Schema.Type + const ingestKeyErrors = publicErrors(IngestKeyPersistenceError, IngestKeyEncryptionError) export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") @@ -63,6 +85,19 @@ export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") }), ), ) + .add( + HttpApiEndpoint.get("retrievePublic", "/public", { + success: V2PublicIngestKey, + error: [V2InsufficientPermissions.schema, ...ingestKeyErrors], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getPublicIngestKey", + summary: "Retrieve the public ingest key", + description: + "Returns your organization's public ingest key, creating the keys on first access. Any member may call it. Requires the `ingest_keys:read` scope.", + }), + ), + ) .add( HttpApiEndpoint.post("rollPublic", "/public/roll", { success: V2IngestKeys, @@ -95,6 +130,6 @@ export class V2IngestKeysApiGroup extends HttpApiGroup.make("ingestKeys") OpenApi.annotations({ title: "Ingest Keys", description: - "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. All operations are admin-only.", + "Telemetry ingest credentials for your organization: a public key for client-side senders and a private key for server-side senders. Retrieve them or roll either key. Any member may read the public key; reading both keys takes an engineer or admin, and rolling one takes an admin.", }), ) {} diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 9978313693..7e4f78df98 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -156,6 +156,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/error_issues/service_counts", "GET /v2/error_issues/{id}", "GET /v2/ingest_keys", + "GET /v2/ingest_keys/public", "GET /v2/ingest_rules", "GET /v2/ingest_rules/stats", "GET /v2/ingest_rules/{id}", diff --git a/packages/domain/src/policy/permissions.ts b/packages/domain/src/policy/permissions.ts index 89be267629..8fc76219ab 100644 --- a/packages/domain/src/policy/permissions.ts +++ b/packages/domain/src/policy/permissions.ts @@ -8,6 +8,9 @@ import type { Role } from "./roles" * Not to be confused with `@maple/domain/permission`, which decides what an agent may run. */ export const permissions = { + // The public ingest key ships in browser bundles, so every member may read it to set up an app. + "ingest_keys:view_public": { min: "member", action: "view the public ingest key" }, + // Technical setup: engineers and above. "integrations:manage": { min: "engineer", action: "manage integrations" }, "code_review:configure": { min: "engineer", action: "change code review settings" },