Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b67357d
feat(cost): Cloudflare as a cost source, read with a pasted API token
Makisuo Nov 12, 2026
b0a391b
feat(cost): add, replace and remove a Cloudflare source on the Cost page
Makisuo Nov 12, 2026
9d9cdb6
fix(cost): review pass on the Cloudflare source
Makisuo Nov 12, 2026
ad73595
test(cost): a refused cost credential's reason reaches the page, a fa…
Makisuo Nov 12, 2026
c584c57
test(cost): the Cloudflare pull over a window that reaches past the s…
Makisuo Nov 12, 2026
a22ad1f
fix(cost): a Cloudflare answer must say it succeeded and hold all it …
Makisuo Nov 12, 2026
2bce555
fix(cost): place Cloudflare subscription fees for clamped, undated an…
Makisuo Nov 12, 2026
b33b7ac
fix(cost): name each source row's actions by its provider
Makisuo Nov 12, 2026
38ac663
Merge remote-tracking branch 'origin/main' into cost/cloudflare-source
Makisuo Nov 12, 2026
3feecf7
Merge remote-tracking branch 'origin/main' into cost/cloudflare-source
Makisuo Nov 13, 2026
25bbfad
feat(cost): Cloudflare marks, account label and drill-down on the rew…
Makisuo Nov 13, 2026
45a21ef
Merge remote-tracking branch 'origin/main' into cost/cloudflare-source
Makisuo Nov 13, 2026
04118f5
feat(cost): refuse a Cloudflare token that can read more than billing
Makisuo Nov 13, 2026
9dcf1d8
feat(cost): the Cloudflare token link opens a billing-only token
Makisuo Nov 13, 2026
02e07d5
docs(cost): how the Cloudflare token is kept least privileged, and th…
Makisuo Nov 13, 2026
239f6eb
fix(cost): the Cloudflare token help says what the check does, not th…
Makisuo Nov 13, 2026
4bbb9b4
perf(cost): drop the Workers scripts list from the Cloudflare token p…
Makisuo Nov 13, 2026
100ecba
Merge remote-tracking branch 'origin/main' into cost/cloudflare-source
Makisuo Nov 13, 2026
6914656
docs(cost): a Cloudflare source is named by its account's name
Makisuo Nov 13, 2026
0543abc
refactor(cost): drop a provider helper the merge left without a caller
Makisuo Nov 13, 2026
a972546
fix(cost): text that is not shaped like a Cloudflare token is refused…
Makisuo Nov 13, 2026
14eb216
Merge remote-tracking branch 'origin/main' into cost/cloudflare-source
Makisuo Nov 13, 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
50 changes: 50 additions & 0 deletions apps/api/src/routes/internal/cloudflare-cost.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { HttpApiBuilder } from "effect/http-api"
import {
CostConnectionForbiddenError,
CostConnectionRemoved,
CostConnectionSaved,
CurrentTenant,
MapleInternalApi,
} from "@maple/domain/http"
import { Effect } from "effect"
import * as Policy from "@maple/domain/policy"
import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService"
import { CloudflareCostConnectionService } from "@maple/backend/services/cost/CloudflareCostConnectionService"

// A cost source is a connection like any other: the role that manages integrations manages it.
const requireIntegrationsManager = (roles: CurrentTenant.TenantSchema["roles"]) =>
Policy.check(
roles,
"integrations:manage",
(denial) => new CostConnectionForbiddenError({ message: denial.message }),
)

export const HttpCloudflareCostLive = HttpApiBuilder.group(MapleInternalApi, "cloudflareCost", (handlers) =>
Effect.gen(function* () {
const connections = yield* CloudflareCostConnectionService

return handlers
.handle("set", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
const saved = yield* connections.set(tenant.orgId, tenant.userId, payload.apiToken)
yield* recordHttpAudit("cost_source_key.updated", {
metadata: { provider: "cloudflare", accountId: saved.accountId },
})
return new CostConnectionSaved(saved)
}).pipe(Effect.withSpan("HttpCloudflareCost.set")),
)
.handle("remove", () =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
yield* connections.remove(tenant.orgId)
yield* recordHttpAudit("cost_source_key.deleted", {
metadata: { provider: "cloudflare" },
})
return new CostConnectionRemoved({ removed: true })
}).pipe(Effect.withSpan("HttpCloudflareCost.remove")),
)
}),
)
2 changes: 2 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { HttpAiSessionsInternalLive } from "@/routes/internal/ai-sessions.http"
import { HttpMcpInternalLive } from "@/routes/internal/mcp.http"
import { HttpUsersInternalLive } from "@/routes/internal/users.http"
import { HttpAiTriageLive } from "@/routes/internal/ai-triage.http"
import { HttpCloudflareCostLive } from "@/routes/internal/cloudflare-cost.http"
import { HttpGithubCostLive } from "@/routes/internal/github-cost.http"
import { HttpOpenRouterCostLive } from "@/routes/internal/openrouter-cost.http"
import { HttpOpenRouterKeyLive } from "@/routes/internal/openrouter-key.http"
Expand Down Expand Up @@ -169,6 +170,7 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe(
HttpAuditTrailLive,
HttpDependencyScanLive,
HttpDigestLive,
HttpCloudflareCostLive,
HttpGithubCostLive,
HttpOpenRouterCostLive,
HttpOpenRouterKeyLive,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/runtime/service-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { SupabaseConnectionService } from "@maple/backend/services/integrations/
import { SupabaseInsightsService } from "@maple/backend/services/integrations/SupabaseInsightsService"
import { SupabaseLogsService } from "@maple/backend/services/integrations/SupabaseLogsService"
import { SupabaseOAuthService } from "@maple/backend/services/auth/SupabaseOAuthService"
import { CloudflareCostConnectionService } from "@maple/backend/services/cost/CloudflareCostConnectionService"
import { GithubCostSourceService } from "@maple/backend/services/cost/GithubCostSourceService"
import { OpenRouterCostConnectionService } from "@maple/backend/services/cost/OpenRouterCostConnectionService"
import { VercelCostSourceService } from "@maple/backend/services/cost/VercelCostSourceService"
Expand Down Expand Up @@ -191,6 +192,7 @@ export const HttpServicesLive = Layer.mergeAll(
AwsIngestService.layer,
OrgOpenRouterKeyService.layer,
OpenRouterCostConnectionService.layer,
CloudflareCostConnectionService.layer,
GithubCostSourceService.layer,
VercelCostSourceService.layer,
ServiceMapWorldService.layer,
Expand Down
33 changes: 29 additions & 4 deletions apps/web/src/lab/cost-fixture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("cost lab fixture", () => {
.rows,
(row) => row.key,
),
).toEqual(["aws", "vercel", "github"])
).toEqual(["aws", "vercel", "cloudflare", "github"])

// Filtered to OpenRouter there is nothing left: AWS cost must not show under that filter.
expect(labTotals(openRouterOnly).cost).toBeGreaterThan(0)
Expand All @@ -44,10 +44,30 @@ describe("cost lab fixture", () => {
).toEqual([])
})

test("Cloudflare is added waiting for its first sync, and a removed one takes its cost with it", () => {
const cloudflare = (added: boolean | null, from = LAB_SOURCES.synced) => {
const changes = { openRouter: null, cloudflare: added, github: {}, vercel: null }
return {
sources: Arr.map(
Arr.filter(
labSourcesWith(from, changes).sources,
(source) => source.provider === "cloudflare",
),
(source) => source.status,
),
costRemoved: Arr.contains(labRemovedProviders(changes), "cloudflare"),
}
}
expect(cloudflare(null)).toEqual({ sources: ["ok"], costRemoved: false })
expect(cloudflare(false)).toEqual({ sources: [], costRemoved: true })
expect(cloudflare(true, LAB_SOURCES.none)).toEqual({ sources: ["pending"], costRemoved: false })
})

test("a GitHub organization is added and removed on its own, and the other keeps its source and cost", () => {
const github = (changes: Readonly<Record<string, boolean>>) => {
const sources = labSourcesWith(LAB_SOURCES.synced, {
openRouter: null,
cloudflare: null,
github: changes,
vercel: null,
})
Expand All @@ -61,7 +81,12 @@ describe("cost lab fixture", () => {
(installation) => `${installation.accountLogin} ${installation.state}`,
),
costRemoved: Arr.contains(
labRemovedProviders({ openRouter: null, github: changes, vercel: null }),
labRemovedProviders({
openRouter: null,
cloudflare: null,
github: changes,
vercel: null,
}),
"github",
),
}
Expand Down Expand Up @@ -100,13 +125,13 @@ describe("cost lab fixture", () => {
labBreakdown({ ...window, without: ["vercel"], groupBy: "provider", sortBy: "cost" }).rows,
(row) => row.key,
),
).toEqual(["aws", "openrouter", "github"])
).toEqual(["aws", "openrouter", "cloudflare", "github"])
expect(labTotals({ days: 30, filters: { provider: ["vercel"] }, without: ["vercel"] }).cost).toBe(0)
})

test("a Vercel source added again after its removal is a new one that waits for its first sync", () => {
const after = (vercel: boolean | null) => {
const changes = { openRouter: null, github: {}, vercel }
const changes = { openRouter: null, cloudflare: null, github: {}, vercel }
const sources = labSourcesWith(LAB_SOURCES.synced, changes)
return {
vercel: Arr.map(
Expand Down
62 changes: 52 additions & 10 deletions apps/web/src/lab/cost-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ const line = (
drift,
})

/** A saved OpenRouter key, as OpenRouter masks it. */
export const LAB_OPENROUTER_KEY = "sk-or-v1-4f2...c81"
/** The OpenRouter account the lab's source reads: a personal account's user id. */
const LAB_OPENROUTER_ACCOUNT = "user_2labExampleAccount0000000000"

Expand Down Expand Up @@ -113,6 +111,27 @@ const productLine = (
drift,
})

/** The Cloudflare account the lab's source reads: rows carry its id, the page shows its name. */
const LAB_CLOUDFLARE_ACCOUNT = { id: "1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d", name: "Example Account" }

const cloudflareLine = (
product: string,
category: string,
metric: string,
daily: number,
drift: number,
): Line => ({
cloud_service: product,
provider: "cloudflare",
service_category: category,
region: "global",
usage_type: metric,
resource: "",
account: LAB_CLOUDFLARE_ACCOUNT.id,
daily,
drift,
})

/** `daily` is the charge per day today; `drift` how much it grew or shrank over the last 30 days. */
const LINES: ReadonlyArray<Line> = [
line("Example Compute", "Compute", "north-1", "north-1:instance-hours", 61.2, 0.18),
Expand Down Expand Up @@ -140,6 +159,10 @@ const LINES: ReadonlyArray<Line> = [
githubLine("Actions", "Actions storage", "example-site", 0.7, 0.05),
githubLine("Copilot", "Copilot Business", "", 6.3, 0.0),
githubLine("Packages", "Packages storage", "example-api", 0.4, -0.1),
cloudflareLine("Workers", "Compute", "Workers Requests", 9.4, 0.25),
cloudflareLine("Durable Objects", "Compute", "Durable Objects Compute Duration", 5.1, 0.12),
cloudflareLine("R2", "Storage", "R2 Storage", 3.6, 0.05),
cloudflareLine("D1", "Databases", "D1 Rows Read", 1.2, -0.08),
productLine("Example Function Duration", "Compute", "iad1", "function-duration", "example-web", 9.4, 0.2),
productLine(
"Example Function Duration",
Expand Down Expand Up @@ -363,6 +386,18 @@ const vercelSource = source({
requestsThisMonth: 0,
})

/** The Cloudflare source: history back to the eleventh month before this one, and no billed requests. */
const cloudflareSource = source({
id: "0190f2b3-0000-7000-8000-00000000c064",
provider: "cloudflare",
connectionRef: "0190f2b3-0000-7000-8000-00000000a064",
accountId: LAB_CLOUDFLARE_ACCOUNT.id,
accountName: LAB_CLOUDFLARE_ACCOUNT.name,
backfillDate: "2025-12-01",
lastSyncedAt: LAB_NOW_MS - 2 * 3_600_000,
requestsThisMonth: 0,
})

/** A source that was just added: it waits for its first sync. */
const justAdded = (entry: SourceWire): SourceWire => ({
...entry,
Expand Down Expand Up @@ -390,11 +425,12 @@ export const labGithubLogin = (installationId: string): string =>
)

/**
* What the lab's reader did since the state loaded. `openRouter` and `vercel`: added (true),
* removed (false) or neither (null). `github`: an installation's id to whether it is a source now.
* What the lab's reader did since the state loaded. `openRouter`, `cloudflare` and `vercel`: added
* (true), removed (false) or neither (null). `github`: an installation's id to whether it is a source now.
*/
export type LabSourceChanges = Readonly<{
openRouter: boolean | null
cloudflare: boolean | null
github: Readonly<Record<string, boolean>>
vercel: boolean | null
}>
Expand All @@ -408,6 +444,7 @@ export const labRemovedProviders = (changes: LabSourceChanges): ReadonlyArray<Co
CostProvider.literals,
(provider) =>
(provider === "openrouter" && changes.openRouter === false) ||
(provider === "cloudflare" && changes.cloudflare === false) ||
(provider === "github" && Rec.has(changes.github, LAB_GITHUB_INSTALLATION)) ||
// A team added again is a new source: it has no cost until a sync the lab does not model.
(provider === "vercel" && changes.vercel !== null),
Expand All @@ -424,15 +461,18 @@ export const labSourcesWith = (
const kept = Arr.filter(Schema.encodeSync(CostSourcesResponse)(sources).sources, (entry) =>
entry.provider === "openrouter"
? changes.openRouter !== false
: entry.provider === "vercel"
? changes.vercel === null
: entry.provider !== "github" || !Rec.has(changes.github, entry.connectionRef),
: entry.provider === "cloudflare"
? changes.cloudflare !== false
: entry.provider === "vercel"
? changes.vercel === null
: entry.provider !== "github" || !Rec.has(changes.github, entry.connectionRef),
)
const hasOpenRouter = Arr.some(kept, (entry) => entry.provider === "openrouter")
const has = (provider: CostProvider) => Arr.some(kept, (entry) => entry.provider === provider)
return decodeSources({
sources: [
...kept,
...(changes.openRouter === true && !hasOpenRouter ? [justAdded(openRouterSource)] : []),
...(changes.openRouter === true && !has("openrouter") ? [justAdded(openRouterSource)] : []),
...(changes.cloudflare === true && !has("cloudflare") ? [justAdded(cloudflareSource)] : []),
...(changes.vercel === true ? [justAdded(vercelSource)] : []),
...Arr.map(
Arr.filter(LAB_GITHUB_ORGS, (org) => changes.github[org.installationId] === true),
Expand Down Expand Up @@ -484,7 +524,9 @@ export const labGithubInstallations = (sources: CostSourcesResponse): GithubCost
}

export const LAB_SOURCES = {
synced: decodeSources({ sources: [source({}), openRouterSource, githubSource, vercelSource] }),
synced: decodeSources({
sources: [source({}), openRouterSource, githubSource, vercelSource, cloudflareSource],
}),
none: decodeSources({ sources: [] }),
pending: decodeSources({
sources: [
Expand Down
Loading
Loading