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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/ios/Config/Secrets.example.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ CLERK_PUBLISHABLE_KEY = pk_test_your_key_here

// Maple's own telemetry — the PUBLIC ingest key, `maple_pk_…`. Leave it unset
// and the SDK stays quiet: no spans, no recordings, one log line at launch.
// Take the value from https:/$()/app.maple.dev/developer?tab=ingestion.
// Take the value from https:/$()/app.maple.dev/settings?tab=ingestion.
// MAPLE_INGEST_KEY = maple_pk_your_key_here

// Point telemetry at a local gateway or staging instead of production. Same
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/common/segmented-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,21 @@ export function MultiSegmentedSelect<T extends string>({
onChange,
size = "default",
className,
disabled,
"aria-label": ariaLabel,
}: {
options: ReadonlyArray<SegmentedOption<T>>
value: readonly T[]
onChange: (value: T[]) => void
size?: Size
className?: string
disabled?: boolean
"aria-label"?: string
}) {
return (
<ToggleGroup
multiple
disabled={disabled}
connected={false}
value={value}
onValueChange={(next) => onChange(next as T[])}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/dashboard/setup-checklist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ function SetupChecklistCard() {
<div className="overflow-hidden">
<CardContent className="border-t border-primary/20 p-5 space-y-5">
<GuidedSetup
apiKey={connection.apiKey}
apiKey={connection.privateKey}
apiKeyStatus={connection.apiKeyStatus}
showCredentials
/>
<SendTestEventStrip apiKey={connection.apiKey} onTestSent={connection.refresh} />
<SendTestEventStrip apiKey={connection.publicKey} onTestSent={connection.refresh} />
</CardContent>
</div>
</div>
Expand Down
12 changes: 5 additions & 7 deletions apps/web/src/components/ingest/guided-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const ROLE_DEFAULT_FRAMEWORK = {
} satisfies Record<OnboardingRole, FrameworkId>

interface GuidedSetupProps {
/** Public ingest key, interpolated into the instrument snippet. */
/** Private ingest key: every snippet configures a server-side sender. */
apiKey: string
apiKeyStatus?: IngestKeyStatus
/** Render the endpoint + key credentials column beside the snippet tabs. */
Expand Down Expand Up @@ -170,6 +170,7 @@ export function ConnectInstructions({
variant = "boxed",
}: {
framework: FrameworkId
/** Private ingest key: every snippet configures a server-side sender. */
apiKey: string
apiKeyStatus?: IngestKeyStatus
showCredentials?: boolean
Expand Down Expand Up @@ -216,10 +217,7 @@ export function ConnectInstructions({
/>
</TabsContent>

<TabsContent
value="claude-code"
className={cn("overflow-auto mt-0 space-y-2", contentPadding)}
>
<TabsContent value="claude-code" className={cn("overflow-auto mt-0 space-y-2", contentPadding)}>
<p className="text-xs text-muted-foreground">
Install the Maple skills, then run the prompt in Claude Code, Codex, or Cursor. The{" "}
<InlineCode>maple-onboard</InlineCode> skill walks every service in the repo, installs
Expand All @@ -246,9 +244,9 @@ export function ConnectInstructions({
{apiKeyStatus === "failed" ? (
<IngestKeysUnavailableNote />
) : apiKeyStatus === "loading" || !apiKey ? (
<CopyableFieldSkeleton label="API key" />
<CopyableFieldSkeleton label="Private ingest key" />
) : (
<CopyableField value={apiKey} label="API key" masked />
<CopyableField value={apiKey} label="Private ingest key" masked />
)}
</div>
{tabs}
Expand Down
34 changes: 33 additions & 1 deletion apps/web/src/components/ingest/use-ingest-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { assert, describe, it } from "@effect/vitest"
import { Effect } from "effect"
import { TestClock } from "effect/testing"
import { FetchHttpClient } from "effect/http"
import { sendTestEventEffect } from "./use-ingest-connection"
import * as AsyncResult from "effect/reactivity/AsyncResult"
import { Cause } from "effect"
import { readIngestKeys, sendTestEventEffect } from "./use-ingest-connection"

interface RecordedRequest {
readonly url: string
Expand Down Expand Up @@ -70,3 +72,33 @@ describe("sendTestEventEffect", () => {
}),
)
})

describe("readIngestKeys", () => {
const keys = {
object: "ingest_keys" as const,
public_key: "maple_pk_public",
private_key: "maple_sk_private",
public_rotated_at: "2026-10-01T00:00:00.000Z",
private_rotated_at: "2026-10-01T00:00:00.000Z",
}

it("hands the server snippets the private key and the browser test event the public one", () => {
assert.deepStrictEqual(readIngestKeys(AsyncResult.success(keys)), {
publicKey: "maple_pk_public",
privateKey: "maple_sk_private",
status: "ready",
})
})

it("exposes neither key when the admin-only read is denied", () => {
assert.deepStrictEqual(readIngestKeys(AsyncResult.failure(Cause.fail("forbidden"))), {
publicKey: "",
privateKey: "",
status: "failed",
})
})

it("reports loading before the read settles", () => {
assert.strictEqual(readIngestKeys(AsyncResult.initial(true)).status, "loading")
})
})
36 changes: 24 additions & 12 deletions apps/web/src/components/ingest/use-ingest-connection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Clock, Effect, Schema } from "effect"
import type * as AsyncResult from "effect/reactivity/AsyncResult"
import type { V2IngestKeys } from "@maple/domain/http/v2"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"
import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom"
import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client"
Expand All @@ -18,9 +20,11 @@ export interface IngestConnection {
firstRealService?: string
/** Spans per minute across real services, averaged over the 1h window. */
spansPerMinute: number
/** The org's public ingest key (empty string until loaded / when denied). */
apiKey: string
/** `failed` when the key could not be read (e.g. members without access to org keys). */
/** Public key, for browser senders such as the test event (empty until loaded / when denied). */
publicKey: string
/** Private key, for the server-side SDK snippets (empty until loaded / when denied). */
privateKey: string
/** `failed` when the keys could not be read (members cannot read org ingest keys). */
apiKeyStatus: IngestKeyStatus
/** Force an immediate re-poll of the service overview. */
refresh: () => void
Expand All @@ -31,22 +35,29 @@ interface UseIngestConnectionOptions {
poll?: boolean
}

/** Both keys come from one admin-only read, so a member sees neither rather than just the public one. */
export function readIngestKeys(result: AsyncResult.AsyncResult<V2IngestKeys, unknown>): {
publicKey: string
privateKey: string
status: IngestKeyStatus
} {
if (result._tag === "Success") {
return { publicKey: result.value.public_key, privateKey: result.value.private_key, status: "ready" }
}
return { publicKey: "", privateKey: "", status: result._tag === "Failure" ? "failed" : "loading" }
}

/**
* Live ingest-connection state, shared across the Connect popover, ingestion
* settings, and the dashboard setup checklist. Polls the service overview and
* filters out the seeded `demo-` services so the signal reflects the user's own
* telemetry. Reuses the cached `ingestKeys` query for the public key.
* telemetry. Reuses the cached `ingestKeys` query for both keys.
*/
export function useIngestConnection({ poll = true }: UseIngestConnectionOptions = {}): IngestConnection {
const { startTime, endTime } = useEffectiveTimeRange(undefined, undefined, "1h")

const keysResult = useAtomValue(retainedQueryV2("ingestKeys", "retrieve", {}))
const apiKey = Result.isSuccess(keysResult) ? keysResult.value.public_key : ""
const apiKeyStatus: IngestKeyStatus = Result.isSuccess(keysResult)
? "ready"
: Result.isFailure(keysResult)
? "failed"
: "loading"
const keys = readIngestKeys(keysResult)

const overviewAtom = getServiceOverviewResultAtom({ data: { startTime, endTime } })
const overviewResult = useAtomValue(overviewAtom)
Expand All @@ -68,8 +79,9 @@ export function useIngestConnection({ poll = true }: UseIngestConnectionOptions
serviceCount: realServices.length,
firstRealService,
spansPerMinute,
apiKey,
apiKeyStatus,
publicKey: keys.publicKey,
privateKey: keys.privateKey,
apiKeyStatus: keys.status,
refresh,
}
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/quick-start/sdk-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface SdkSnippet {
instrument: string
}

// Every snippet configures a server-side sender, so `{{API_KEY}}` is the private ingest key.
export const sdkSnippets: SdkSnippet[] = [
{
language: "nextjs",
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/components/settings/ai-provider-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function AiProviderSection() {

const [draft, setDraft] = useState("")
const [confirmRemove, setConfirmRemove] = useState(false)
const [confirmAddOn, setConfirmAddOn] = useState(false)

const [saveKey, isSaving] = useAsyncAction(async (apiKey: string) => {
const exit = await setMutation({
Expand Down Expand Up @@ -172,13 +173,25 @@ export function AiProviderSection() {
label={`Add-on, ${ADD_ON_PRICE}`}
description="Billed with your plan. Cancel any time from billing, and Maple's own key takes over again."
control={
<Button disabled={isAttaching} onClick={() => addOn()}>
{isAttaching ? "Opening checkout..." : "Add to plan"}
<Button disabled={isAttaching} onClick={() => setConfirmAddOn(true)}>
Add to plan
</Button>
}
/>
)}

{/* With a card on file the attach charges at once and no checkout page follows. */}
<ConfirmDialog
open={confirmAddOn}
onOpenChange={setConfirmAddOn}
tone="default"
title={`Add the OpenRouter add-on for ${ADD_ON_PRICE}?`}
description="If your organization has a payment method on file, the add-on is attached right away and billed with your plan. Otherwise you continue to checkout."
confirmLabel="Add to plan"
pending={isAttaching}
onConfirm={() => addOn()}
/>

<ConfirmDialog
open={confirmRemove}
onOpenChange={setConfirmRemove}
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/components/settings/api-key-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { UserId } from "@maple/domain/primitives"
import { Schema } from "effect"
import { describe, expect, it } from "vitest"
import { apiKeyRowPermissions } from "./api-key-permissions"

const ME = Schema.decodeSync(UserId)("user_me")
const OTHER = Schema.decodeSync(UserId)("user_other")
const live = { revoked: false, created_by: ME }

describe("apiKeyRowPermissions", () => {
it("lets an admin roll and revoke any live key", () => {
expect(
apiKeyRowPermissions(
{ ...live, kind: "standard", created_by: OTHER },
{ isAdmin: true, userId: ME },
),
).toEqual({ canRoll: true, canRevoke: true })
})

it("lets a member revoke only their own MCP key, and never roll", () => {
const member = { isAdmin: false, userId: ME }
expect(apiKeyRowPermissions({ ...live, kind: "mcp" }, member)).toEqual({
canRoll: false,
canRevoke: true,
})
expect(apiKeyRowPermissions({ ...live, kind: "mcp", created_by: OTHER }, member)).toEqual({
canRoll: false,
canRevoke: false,
})
expect(apiKeyRowPermissions({ ...live, kind: "standard" }, member)).toEqual({
canRoll: false,
canRevoke: false,
})
})

it("offers nothing on a revoked key or before the session names the viewer", () => {
expect(
apiKeyRowPermissions({ ...live, kind: "mcp", revoked: true }, { isAdmin: true, userId: ME }),
).toEqual({
canRoll: false,
canRevoke: false,
})
expect(
apiKeyRowPermissions({ ...live, kind: "mcp" }, { isAdmin: false, userId: null }).canRevoke,
).toBe(false)
})
})
14 changes: 14 additions & 0 deletions apps/web/src/components/settings/api-key-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { V2ApiKey } from "@maple/domain/http/v2"

/**
* Mirrors the v2 api-keys routes: rolling is admin-only, and revoking is admin-only except
* that a member may revoke an MCP key they created, since members can mint those.
*/
export function apiKeyRowPermissions(
key: Pick<V2ApiKey, "kind" | "created_by" | "revoked">,
viewer: { readonly isAdmin: boolean; readonly userId: string | null },
): { readonly canRoll: boolean; readonly canRevoke: boolean } {
if (key.revoked) return { canRoll: false, canRevoke: false }
const ownMcpKey = key.kind === "mcp" && viewer.userId !== null && key.created_by === viewer.userId
return { canRoll: viewer.isAdmin, canRevoke: viewer.isAdmin || ownMcpKey }
}
Loading
Loading