Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d881a41
docs(onboarding): gRPC works on port 443, add a Ruby and Rails guide
Makisuo Nov 7, 2026
abf371b
fix(onboarding): setup snippets match the docs, the browser card gets…
Makisuo Nov 7, 2026
22d63b3
fix(onboarding): members set up with the public ingest key instead of…
Makisuo Nov 7, 2026
05e4b72
fix(onboarding): the Ruby card links the new Ruby and Rails guide
Makisuo Nov 7, 2026
9f2a387
Merge branch 'fix/onboarding-snippets' into fix/onboarding-member-key
Makisuo Nov 7, 2026
f94cd83
Merge remote-tracking branch 'origin/docs-onboarding-grpc-ruby' into …
Makisuo Nov 7, 2026
e2f6876
Merge branch 'fix/onboarding-snippets' into fix/onboarding-member-key
Makisuo Nov 7, 2026
4b946db
fix(onboarding): .NET and Python send over HTTP, Rust crates pinned, …
Makisuo Nov 7, 2026
635c80c
Merge branch 'fix/onboarding-snippets' into fix/onboarding-member-key
Makisuo Nov 7, 2026
63bffd1
fix(onboarding): only a permission refusal falls back to the public key
Makisuo Nov 7, 2026
201ec7e
fix(onboarding): the Node note does not point at a guide below the code
Makisuo Nov 7, 2026
6aa158b
Merge branch 'fix/onboarding-snippets' into fix/onboarding-member-key
Makisuo Nov 7, 2026
101545c
Merge branch 'fix/onboarding-snippets' into fix/onboarding-member-key
Makisuo Nov 7, 2026
b41be8e
chore(web): regenerate the kit index for the setup key helpers
Makisuo Nov 7, 2026
1eea0df
fix(onboarding): a failed key read sends members to someone who can s…
Makisuo Nov 7, 2026
0bb4f33
Merge remote-tracking branch 'origin/main' into docs-merge
Makisuo Nov 7, 2026
bca8144
Merge branch 'docs-merge' into fix/onboarding-snippets
Makisuo Nov 7, 2026
555e5a7
Merge branch 'fix/onboarding-snippets' into fix/onboarding-member-key
Makisuo Nov 7, 2026
bb14a76
Merge remote-tracking branch 'origin/main' into fix/onboarding-member…
Makisuo Nov 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/api/src/routes/v2/config-resources.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
17 changes: 16 additions & 1 deletion apps/api/src/routes/v2/ingest-keys.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand Down
76 changes: 68 additions & 8 deletions apps/web/src/data/ingest-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -16,12 +16,46 @@ const keys = {
private_rotated_at: "2026-11-01T00:00:00Z",
}

const run = (roles: ReadonlyArray<string>) =>
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 = <A, E>(
roles: ReadonlyArray<string>,
read: Effect.Effect<A, E, SessionTenant | MapleApiV2Client>,
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, {
Expand All @@ -38,13 +72,13 @@ const run = (roles: ReadonlyArray<string>) =>
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")
Expand All @@ -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")
})
})
28 changes: 27 additions & 1 deletion apps/web/src/data/ingest-keys.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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,
})),
),
)
})
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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" } },
}
}

Expand Down Expand Up @@ -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 }),
),
)
})
})
15 changes: 7 additions & 8 deletions apps/web/src/page/onboarding/quick-start-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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", {}),
])

Expand All @@ -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) },
Expand All @@ -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()),
),
})
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/page/onboarding/quick-start-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/page/overview/setup-checklist-command.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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()),
),
})
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/page/overview/setup-checklist-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { textLink } from "@maple/views/ui/text-link"

type H = HtmlBuilder<Message>

/** `IngestKeysUnavailableNote`: members cannot read the org's ingest keys. */
/** `IngestKeysUnavailableNote`: neither key could be read. */
const keysUnavailable = (h: H): Html =>
h.p(
[
Expand All @@ -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,
{
Expand Down Expand Up @@ -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 }),
Expand Down
Loading
Loading