Skip to content
Open
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
30 changes: 30 additions & 0 deletions apps/cli/src/core/cloudflare-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest"
import { isCloudflareAccessResponse, shouldUseCloudflareAccess } from "./cloudflare-access"

describe("shouldUseCloudflareAccess", () => {
it("uses cloudflared only for non-loopback HTTPS without explicit Access credentials", () => {
expect(shouldUseCloudflareAccess("https://maple.example.com", {})).toBe(true)
expect(shouldUseCloudflareAccess("http://maple.example.com", {})).toBe(false)
expect(shouldUseCloudflareAccess("https://127.0.0.1:4318", {})).toBe(false)
expect(shouldUseCloudflareAccess("https://localhost:4318", {})).toBe(false)
expect(shouldUseCloudflareAccess("https://maple.example.com", { "CF-Access-Client-Id": "id" })).toBe(
false,
)
expect(shouldUseCloudflareAccess("https://maple.example.com", { "CF-ACCESS-TOKEN": "token" })).toBe(
false,
)
})
})

describe("isCloudflareAccessResponse", () => {
it("recognizes denied responses and Access redirects", () => {
expect(isCloudflareAccessResponse(401, null)).toBe(true)
expect(isCloudflareAccessResponse(403, null)).toBe(true)
expect(
isCloudflareAccessResponse(302, "https://example.cloudflareaccess.com/cdn-cgi/access/login"),
).toBe(true)
expect(isCloudflareAccessResponse(303, "/cdn-cgi/access/login")).toBe(true)
expect(isCloudflareAccessResponse(302, "https://example.com/login")).toBe(false)
expect(isCloudflareAccessResponse(500, "/cdn-cgi/access/login")).toBe(false)
})
})
77 changes: 77 additions & 0 deletions apps/cli/src/core/cloudflare-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Effect, Schema } from "effect"

const hasHeader = (headers: Readonly<Record<string, string>>, name: string): boolean =>
Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase())

const isLoopbackHost = (hostname: string): boolean => {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "")
return (
host === "localhost" ||
host.endsWith(".localhost") ||
host === "::1" ||
/^127(?:\.\d{1,3}){3}$/.test(host)
)
}

export const shouldUseCloudflareAccess = (
baseUrl: string,
headers: Readonly<Record<string, string>>,
): boolean => {
let url: URL
try {
url = new URL(baseUrl)
} catch {
return false
}
return (
url.protocol === "https:" &&
!isLoopbackHost(url.hostname) &&
!hasHeader(headers, "cf-access-token") &&
!hasHeader(headers, "cf-access-client-id")
)
}

export const isCloudflareAccessResponse = (status: number, location: string | null): boolean => {
if (status === 401 || status === 403) return true
if (status < 300 || status >= 400 || location === null) return false
try {
const url = new URL(location, "https://maple.invalid")
return url.hostname.endsWith(".cloudflareaccess.com") || url.pathname.startsWith("/cdn-cgi/access/")
} catch {
return false
}
}

export class CloudflareAccessError extends Schema.TaggedError<CloudflareAccessError>()(
"@maple/cli/CloudflareAccessError",
{ message: Schema.String },
) {}

export const cloudflareAccessError = (baseUrl: string): CloudflareAccessError => {
const origin = new URL(baseUrl).origin
return new CloudflareAccessError({
message: `Cloudflare Access denied the local server request. Run \`cloudflared access login ${origin}\`, or set MAPLE_LOCAL_HEADERS with CF-Access-Client-Id and CF-Access-Client-Secret.`,
})
}

export const withCloudflareAccessToken = (
baseUrl: string,
headers: Readonly<Record<string, string>>,
): Effect.Effect<Record<string, string>> => {
const configured = { ...headers }
if (!shouldUseCloudflareAccess(baseUrl, configured)) return Effect.succeed(configured)
const origin = new URL(baseUrl).origin
return Effect.tryPromise({
try: async () => {
const process = Bun.spawn(["cloudflared", "access", "token", `-app=${origin}`], {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
})
const token = (await new Response(process.stdout).text()).trim()
if ((await process.exited) !== 0 || !token) return configured
return { ...configured, "cf-access-token": token }
},
catch: () => configured,
}).pipe(Effect.orElseSucceed(() => configured))
}
16 changes: 16 additions & 0 deletions apps/cli/src/core/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest"
import { parseLocalHeaders } from "./config"

describe("parseLocalHeaders", () => {
it("parses comma-separated headers, trimming entries and splitting values on the first equals", () => {
expect(parseLocalHeaders(" X-Test=1,Authorization=Bearer=a=b, Empty= , ,NoEquals")).toEqual({
"X-Test": "1",
Authorization: "Bearer=a=b",
Empty: "",
})
})

it("returns no headers when the environment variable is absent", () => {
expect(parseLocalHeaders(undefined)).toEqual({})
})
})
17 changes: 17 additions & 0 deletions apps/cli/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ import * as path from "node:path"
import { defaultLocalUrl } from "../lib/local-address"
import { deleteNativeCredential, readNativeCredential, writeNativeCredential } from "./credential-store"

export const parseLocalHeaders = (raw: string | undefined): Record<string, string> => {
const headers: Record<string, string> = {}
for (const entry of raw?.split(",") ?? []) {
const trimmed = entry.trim()
if (!trimmed) continue
const separator = trimmed.indexOf("=")
if (separator < 0) continue
const key = trimmed.slice(0, separator).trim()
if (!key) continue
headers[key] = trimmed.slice(separator + 1).trim()
}
return headers
}

/**
* On-disk CLI config, stored at `~/.maple/config.json` (mode 0600). The same
* `~/.maple` directory holds the local binary's data dir and the extracted
Expand Down Expand Up @@ -79,6 +93,8 @@ export interface MapleConfigValues {
readonly envTokenOverride: boolean
/** Local binary base URL (env `MAPLE_LOCAL_URL`, else the default). */
readonly localUrl: string
/** Extra headers for local-mode requests (env `MAPLE_LOCAL_HEADERS`). */
readonly localHeaders: Readonly<Record<string, string>>
readonly defaultMode: Option.Option<"local" | "remote">
/** API URL to use for `maple login` when none is passed. */
readonly defaultApiUrl: string
Expand Down Expand Up @@ -134,6 +150,7 @@ export class MapleConfig extends Context.Service<MapleConfig, MapleConfigValues>
tokenSource,
envTokenOverride: envToken !== undefined,
localUrl: env.MAPLE_LOCAL_URL ?? defaultLocalUrl(env.MAPLE_LOCAL_BIND_HOST),
localHeaders: parseLocalHeaders(env.MAPLE_LOCAL_HEADERS),
defaultMode: Option.fromNullishOr(stored.defaultMode),
defaultApiUrl: env.MAPLE_API_URL ?? DEFAULT_API_URL,
lastUpdateCheck: Option.fromNullishOr(stored.lastUpdateCheck),
Expand Down
21 changes: 19 additions & 2 deletions apps/cli/src/core/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ const stubFetch = (handler: (url: string, init?: RequestInit) => Response) => {
}

const stubLocalServer = (rows: ReadonlyArray<Record<string, unknown>>) => {
const requests: Array<{ url: string; sql: string }> = []
const requests: Array<{ url: string; sql: string; headers: Headers }> = []
stubFetch((url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { sql?: string }
requests.push({ url, sql: body.sql ?? "" })
requests.push({ url, sql: body.sql ?? "", headers: new Headers(init?.headers) })
return new Response(JSON.stringify(rows), {
status: 200,
headers: { "content-type": "application/json" },
Expand Down Expand Up @@ -56,6 +56,23 @@ describe("makeLocalWarehouseExecutorApi", () => {
}),
)

it.effect("attaches configured headers to local query requests", () =>
Effect.gen(function* () {
const requests = stubLocalServer([])
const shape = makeLocalWarehouseExecutorApi("http://127.0.0.1:4318", { "X-Test": "1" })
yield* shape.compiledQuery(
unsafeCompiledQuery({
reason: "test-fixture",
note: "Synthetic SQL asserting request headers.",
tenantScope: "org",
sql: "SELECT 1 FROM traces WHERE OrgId = 'local'",
}),
)

expect(requests[0]!.headers.get("x-test")).toBe("1")
}),
)

it.effect("keeps the executor's OrgId scoping guard for trusted SQL", () =>
Effect.gen(function* () {
stubLocalServer([])
Expand Down
26 changes: 21 additions & 5 deletions apps/cli/src/core/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { Effect, Schema } from "effect"
import { OrgId, UserId } from "@maple/domain/http"
import { makeWarehouseExecutor, type WarehouseSqlClient } from "@maple/query-engine/execution"
import type { WarehouseExecutorApi } from "@maple/query-engine/observability"
import { executeLocalQuery } from "@maple/query-engine/local"
import { executeLocalQuery, LocalQueryHttpError } from "@maple/query-engine/local"
import { cloudflareAccessError, isCloudflareAccessResponse } from "./cloudflare-access"
import { debugLog } from "../lib/debug"

// Local mode is single-tenant: the local binary writes every row under this
Expand All @@ -17,11 +18,23 @@ const LOCAL_TENANT = { orgId: LOCAL_ORG_ID, userId: LOCAL_USER_ID, authMode: "lo
// timing the round-trip and (under --debug) printing the SQL + elapsed ms to
// stderr. The `finally` logs even on failure so a failing query still shows
// its SQL.
const localChdbClient = (baseUrl: string): WarehouseSqlClient => ({
const localChdbClient = (baseUrl: string, headers: Readonly<Record<string, string>>): WarehouseSqlClient => ({
sql: async (sql) => {
const started = performance.now()
try {
return { data: await executeLocalQuery<Record<string, unknown>>(sql, baseUrl) }
return {
data: await executeLocalQuery<Record<string, unknown>>(sql, baseUrl, undefined, {
...headers,
}).catch((error: unknown) => {
if (
error instanceof LocalQueryHttpError &&
isCloudflareAccessResponse(error.status, error.location)
) {
throw cloudflareAccessError(baseUrl)
}
throw error
}),
}
} finally {
debugLog(`local query · ${Math.round(performance.now() - started)}ms`, sql)
}
Expand All @@ -45,9 +58,12 @@ const localChdbClient = (baseUrl: string): WarehouseSqlClient => ({
* depends on a `WarehouseExecutor` — work unchanged against local mode, with
* the same `warehouse.backend="chdb"` span contract as the cloud.
*/
export const makeLocalWarehouseExecutorApi = (baseUrl: string): WarehouseExecutorApi =>
export const makeLocalWarehouseExecutorApi = (
baseUrl: string,
headers: Readonly<Record<string, string>> = {},
): WarehouseExecutorApi =>
makeWarehouseExecutor({
createClient: () => localChdbClient(baseUrl),
createClient: () => localChdbClient(baseUrl, headers),
resolveRoute: () =>
Effect.succeed({
source: "managed" as const,
Expand Down
12 changes: 10 additions & 2 deletions apps/cli/src/core/mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ class ModeError extends Schema.TaggedError<ModeError>()("@maple/cli/ModeError",
}) {}

type ResolvedMode =
| { readonly _tag: "local"; readonly baseUrl: string }
| {
readonly _tag: "local"
readonly baseUrl: string
readonly headers?: Readonly<Record<string, string>>
}
| {
readonly _tag: "remote"
readonly apiUrl: string
Expand Down Expand Up @@ -65,7 +69,11 @@ export class Mode extends Context.Service<Mode, ModeApi>()("@maple/cli/Mode", {
}),
)
const remote = Option.getOrUndefined(remoteConfig)
const local = (): ResolvedMode => ({ _tag: "local", baseUrl: config.localUrl })
const local = (): ResolvedMode => ({
_tag: "local",
baseUrl: config.localUrl,
headers: config.localHeaders,
})

const resolve: Effect.Effect<ResolvedMode, ModeError> = Effect.gen(function* () {
const forceRemote = hasFlag("--remote")
Expand Down
5 changes: 4 additions & 1 deletion apps/cli/src/core/warehouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { WarehouseConfigError } from "@maple/domain/http/warehouse-errors"
import type { WarehouseQueryName } from "@maple/domain/warehouse-queries"
import { Mode } from "./mode"
import { makeLocalWarehouseExecutorApi } from "./executor"
import { withCloudflareAccessToken } from "./cloudflare-access"

/**
* Provides `WarehouseExecutor` whose concrete backend (local chDB vs remote
Expand All @@ -28,7 +29,9 @@ export const WarehouseExecutorFromMode = Layer.effect(
mode.resolve.pipe(
Effect.flatMap((m) =>
m._tag === "local"
? Effect.succeed(makeLocalWarehouseExecutorApi(m.baseUrl))
? withCloudflareAccessToken(m.baseUrl, m.headers ?? {}).pipe(
Effect.map((headers) => makeLocalWarehouseExecutorApi(m.baseUrl, headers)),
)
: // Remote mode never reaches the executor: `operations.ts`
// dispatches to the v2 client before asking for one. Anything
// that lands here is an operation that forgot to branch, so
Expand Down
21 changes: 21 additions & 0 deletions apps/landing/src/content/docs/local-mode/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,26 @@ Pin the default backend so commands stop auto-detecting, or restore auto-detect.
2. `defaultMode` pinned via `maple use`.
3. Auto-detect — a configured token implies remote; otherwise a quick `GET /health` probe of the local server implies local. If neither is available, the CLI prints an actionable error.

### Remote local server behind Cloudflare Access

Authenticate `cloudflared` once, then point local mode at the protected origin:

```bash
cloudflared access login https://maple.example.com
MAPLE_LOCAL_URL=https://maple.example.com maple --local services
```

For a non-loopback HTTPS URL, Maple obtains the cached identity token with
`cloudflared access token -app=<origin>` and sends it with each local query.
For service-token or other explicit credentials, provide comma-separated
headers instead:

```bash
MAPLE_LOCAL_URL=https://maple.example.com \
MAPLE_LOCAL_HEADERS='CF-Access-Client-Id=<id>,CF-Access-Client-Secret=<secret>' \
maple --local services
```

## Server endpoints

`maple start` binds `127.0.0.1` by default. `--host` or
Expand Down Expand Up @@ -456,6 +476,7 @@ OTLP bodies may be protobuf (default) or JSON, optionally gzip-encoded. The `/lo
| `MAPLE_LOCAL_BIND_HOST` | `127.0.0.1` | Server bind host and default same-machine CLI target; wildcards map to loopback |
| `MAPLE_LOCAL_ADVERTISE_HOST` | connection-safe bind host | Host printed for clients and the bundled UI |
| `MAPLE_LOCAL_URL` | derived bind host + `4318` | Explicit base URL override for CLI query and mode detection |
| `MAPLE_LOCAL_HEADERS` | | Comma-separated `Key=Value` headers attached to every local-mode query request |
| `MAPLE_LOCAL_UI_URL` | `https://local.maple.dev` | Exact separately hosted UI origin linked by `maple start` and allowed by CORS |
| `MAPLE_LIBCHDB` | _(auto)_ | Explicit path to `libchdb`. Otherwise resolved beside the binary (Homebrew keeps it in the same `libexec` dir), then `~/.maple/bin/libchdb.{so,dylib}` |
| `MAPLE_API_URL` | `https://api.maple.dev` | Remote API base URL |
Expand Down
19 changes: 17 additions & 2 deletions packages/query-engine/src/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,36 @@
* that accepts the connection but hangs surfaces as an error
* instead of pending forever. Heavy list queries pass nothing.
*/
export class LocalQueryHttpError extends Error {

Check failure on line 23 in packages/query-engine/src/local.ts

View workflow job for this annotation

GitHub Actions / TypeScript (effect-lint)

effecttsgo(extends-native-error)

packages/query-engine/src/local.ts:23:14: This class extends the native `Error` type directly. Untagged native errors lose distinction in the Effect failure channel.
readonly name = "LocalQueryHttpError"

constructor(
readonly status: number,
readonly statusText: string,
readonly location: string | null,
readonly detail: string,
) {
super(`Local query failed (${status} ${statusText})${detail ? `: ${detail}` : ""}`)
}
}

export async function executeLocalQuery<T = Record<string, unknown>>(
sql: string,
baseUrl = "",
signal?: AbortSignal,
headers: Record<string, string> = {},
): Promise<T[]> {
const res = await fetch(`${baseUrl}/local/query`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify({ sql }),
signal,
redirect: "manual",
})

if (!res.ok) {
const detail = await res.text().catch(() => "")
throw new Error(`Local query failed (${res.status} ${res.statusText})${detail ? `: ${detail}` : ""}`)
throw new LocalQueryHttpError(res.status, res.statusText, res.headers.get("location"), detail)
}

const json = (await res.json()) as unknown
Expand Down
Loading