Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ target
**/target
apps/api/.data
apps/api/.data/**
apps/ios
apps/web/dist
**/.celld
**/.wrangler
**/.turbo
.env
.env.*
**/.env
Expand Down
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ MAPLE_DB_URL=
# it into a Hyperdrive origin. PR previews bind no database at all. NB the connect-time database is `postgres` (the cluster default),
# NOT the PlanetScale resource name `maple` (that's only for `pscale` commands).
# MAPLE_PG_URL=postgres://user:pass@host.pg.psdb.cloud:5432/postgres?sslmode=verify-full
# celld self-host (no Hyperdrive): logical Postgres URL + host-side WS↔TCP proxy.
# Do not put a Postgres URL in MAPLE_DB_URL (that is the PGlite data dir).
# MAPLE_PG_URL=postgres://maple:maple@127.0.0.1:5499/maple
# MAPLE_PG_WS_PROXY=ws://127.0.0.1:5498

# Base64-encoded 32-byte key (AES-256-GCM) used to encrypt private ingest keys at rest
MAPLE_INGEST_KEY_ENCRYPTION_KEY=
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ mise.local.toml

node_modules
.DS_Store
.tools/
.celld/
dist
dist-ssr
count.txt
Expand Down
31 changes: 31 additions & 0 deletions apps/alerting/wrangler.celld.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
// celld-safe subset of wrangler.jsonc. Forbidden keys (`hyperdrive`, `ai`,
// `ratelimits`, `send_email`, `routes`, `dev`, `workers_dev`) stop `celld
// deploy` / `celld dev`. Secrets overlay via CELLD_VARS_FILE — see
// docs/celld-self-host.md and scripts/celld-dev.sh. Do not commit secrets.
// Own project: never `celld deploy` this onto the maple-api fleet.
"name": "maple-alerting",
"main": "src/worker.ts",
"compatibility_date": "2026-04-08",
"compatibility_flags": ["nodejs_compat"],
"vars": {
"API_V2_RATE_LIMIT_PARTITION": "local",
"MAPLE_AUTH_MODE": "self_hosted",
"MAPLE_DEFAULT_ORG_ID": "default",
"MAPLE_ENVIRONMENT": "development",
"MAPLE_ALERTING_ALLOW_NONPROD": "1",
"MAPLE_INGEST_PUBLIC_URL": "http://127.0.0.1:3474",
"MAPLE_APP_BASE_URL": "http://127.0.0.1:3471",
"CLICKHOUSE_PROVIDER": "clickhouse",
"CLICKHOUSE_URL": "http://127.0.0.1:8123",
"CLICKHOUSE_USER": "maple",
"CLICKHOUSE_DATABASE": "default",
"MAPLE_PG_URL": "postgres://maple:maple@127.0.0.1:5499/maple",
"MAPLE_PG_WS_PROXY": "ws://127.0.0.1:5498",
"TINYBIRD_HOST": "http://127.0.0.1:7181",
"TINYBIRD_TOKEN": "local-placeholder"
},
"triggers": {
"crons": ["* * * * *", "*/5 * * * *", "*/15 * * * *", "0 * * * *"]
}
}
1 change: 1 addition & 0 deletions apps/api/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

# cold-path measurement bundle output
.coldpath-out
.celld/
38 changes: 38 additions & 0 deletions apps/api/src/platform/Crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { assert, describe, it } from "@effect/vitest"
import { Effect } from "effect"
import { decryptAes256Gcm, encryptAes256Gcm, parseBase64Aes256GcmKey } from "./Crypto"

const fail = (message: string) => new Error(message)

describe("AES-256-GCM via Web Crypto", () => {
it.effect("round-trips plaintext without AAD", () =>
Effect.gen(function* () {
const key = yield* parseBase64Aes256GcmKey(Buffer.alloc(32, 7).toString("base64"), fail)
const encrypted = yield* encryptAes256Gcm("maple_sk_test", key, fail)
const plaintext = yield* decryptAes256Gcm(encrypted, key, fail)
assert.strictEqual(plaintext, "maple_sk_test")
assert.isTrue(encrypted.iv.length > 0)
assert.isTrue(encrypted.tag.length > 0)
}),
)

it.effect("round-trips with AAD and rejects a mismatched AAD", () =>
Effect.gen(function* () {
const key = yield* parseBase64Aes256GcmKey(Buffer.alloc(32, 9).toString("base64"), fail)
const aad = Buffer.from("org:default")
const encrypted = yield* encryptAes256Gcm("secret", key, fail, aad)
const plaintext = yield* decryptAes256Gcm(encrypted, key, fail, aad)
assert.strictEqual(plaintext, "secret")

const exit = yield* Effect.exit(decryptAes256Gcm(encrypted, key, fail, Buffer.from("org:other")))
assert.isTrue(exit._tag === "Failure")
}),
)

it.effect("rejects a non-32-byte encryption key", () =>
Effect.gen(function* () {
const exit = yield* Effect.exit(parseBase64Aes256GcmKey(Buffer.alloc(16, 1).toString("base64"), fail))
assert.isTrue(exit._tag === "Failure")
}),
)
})
82 changes: 55 additions & 27 deletions apps/api/src/platform/Crypto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
import { Effect } from "effect"

export interface EncryptedValue {
Expand All @@ -7,6 +6,25 @@ export interface EncryptedValue {
readonly tag: string
}

const AES_GCM_TAG_BYTES = 16

const toBytes = (value: Buffer | Uint8Array): Uint8Array =>
new Uint8Array(value.buffer, value.byteOffset, value.byteLength)

const toBase64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64")

const fromBase64 = (raw: string): Uint8Array => new Uint8Array(Buffer.from(raw, "base64"))

const importAesGcmKey = (encryptionKey: Buffer, usage: KeyUsage) =>
crypto.subtle.importKey("raw", toBytes(encryptionKey), { name: "AES-GCM" }, false, [usage])

const aesGcmParams = (iv: BufferSource, aad: Buffer | undefined): AesGcmParams => ({
name: "AES-GCM",
iv,
tagLength: AES_GCM_TAG_BYTES * 8,
...(aad !== undefined ? { additionalData: toBytes(aad) } : {}),
})

export const parseBase64Aes256GcmKey = <E>(raw: string, onError: (message: string) => E) =>
Effect.try({
try: () => {
Expand All @@ -32,24 +50,39 @@ export const parseBase64Aes256GcmKey = <E>(raw: string, onError: (message: strin
* the original (AAD-free) format — existing ciphertexts written without one must
* keep decrypting, so callers may only start passing an `aad` for columns with
* no live rows.
*
* Implemented with Web Crypto (`crypto.subtle`) rather than `node:crypto`
* `createCipheriv`. celld/workerd's nodejs_compat HMAC works; AES-GCM through
* `createCipheriv` does not, which 500'd `GET /v2/ingest_keys` on self-host.
* The stored `{ciphertext,iv,tag}` layout is unchanged, so Cloud-written rows
* still decrypt.
*/
export const encryptAes256Gcm = <E>(
plaintext: string,
encryptionKey: Buffer,
onError: (message: string) => E,
aad?: Buffer,
) =>
Effect.try({
try: () => {
const iv = randomBytes(12)
const cipher = createCipheriv("aes-256-gcm", encryptionKey, iv)
if (aad !== undefined) cipher.setAAD(aad)
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()])

Effect.tryPromise({
try: async () => {
const iv = crypto.getRandomValues(new Uint8Array(12))
const key = await importAesGcmKey(encryptionKey, "encrypt")
const bundled = new Uint8Array(
await crypto.subtle.encrypt(
aesGcmParams(iv, aad),
key,
new TextEncoder().encode(plaintext),
),
)
if (bundled.byteLength < AES_GCM_TAG_BYTES) {
throw new Error("AES-GCM encrypt returned a truncated payload")
}
const tag = bundled.subarray(bundled.byteLength - AES_GCM_TAG_BYTES)
const ciphertext = bundled.subarray(0, bundled.byteLength - AES_GCM_TAG_BYTES)
return {
ciphertext: ciphertext.toString("base64"),
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
ciphertext: toBase64(ciphertext),
iv: toBase64(iv),
tag: toBase64(tag),
} satisfies EncryptedValue
},
catch: (error) => onError(error instanceof Error ? error.message : "Encryption failed"),
Expand All @@ -62,22 +95,17 @@ export const decryptAes256Gcm = <E>(
onError: (message: string) => E,
aad?: Buffer,
) =>
Effect.try({
try: () => {
const decipher = createDecipheriv(
"aes-256-gcm",
encryptionKey,
Buffer.from(encrypted.iv, "base64"),
)
if (aad !== undefined) decipher.setAAD(aad)
decipher.setAuthTag(Buffer.from(encrypted.tag, "base64"))

const plaintext = Buffer.concat([
decipher.update(Buffer.from(encrypted.ciphertext, "base64")),
decipher.final(),
])

return plaintext.toString("utf8")
Effect.tryPromise({
try: async () => {
const iv = fromBase64(encrypted.iv)
const ciphertext = fromBase64(encrypted.ciphertext)
const tag = fromBase64(encrypted.tag)
const bundled = new Uint8Array(ciphertext.byteLength + tag.byteLength)
bundled.set(ciphertext, 0)
bundled.set(tag, ciphertext.byteLength)
const key = await importAesGcmKey(encryptionKey, "decrypt")
const plaintext = await crypto.subtle.decrypt(aesGcmParams(iv, aad), key, bundled)
return new TextDecoder().decode(plaintext)
},
catch: () => onError("Decryption failed"),
})
7 changes: 6 additions & 1 deletion apps/api/src/platform/DatabasePgLive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ const makePgDatabase = Effect.gen(function* () {
execute: <T>(fn: (db: DatabaseClient) => Promise<T>) =>
Effect.flatMap(PgConnectionScope, (scope) =>
scope === undefined
? executeOnFreshPgClient(source.connectionString, fn, source.attributes)
? executeOnFreshPgClient(
source.connectionString,
fn,
source.attributes,
source.wsProxyUrl,
)
: scope.run(fn),
),
} satisfies DatabaseApi)
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/platform/pg-connection-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,23 @@ describe("PgConnectionScope", () => {
}),
)

it.effect("forwards the websocket proxy URL to the socket factory", () =>
Effect.gen(function* () {
const rec = recorder()
const scope = makePgConnectionScope(
"postgres://unused",
undefined,
{ openSocket: rec.openSocket },
"ws://127.0.0.1:5498",
)

yield* scope.run(noop)

assert.strictEqual(rec.lastOptions()?.wsProxyUrl, "ws://127.0.0.1:5498")
yield* Effect.promise(() => scope.close())
}),
)

it.effect("closes twice without opening a second connection", () =>
Effect.gen(function* () {
const rec = recorder()
Expand Down
12 changes: 9 additions & 3 deletions apps/api/src/platform/pg-connection-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,12 @@ export const makePgConnectionScope = (
connectionString: string,
extraAttributes?: Record<string, unknown>,
seams?: PgConnectionScopeSeams,
wsProxyUrl?: string,
): PgConnectionScopeApi => {
const options: MaplePgSocketOptions = {
maxConnections: MAX_CONNECTIONS,
connectTimeoutSeconds: CONNECT_TIMEOUT_SECONDS,
...(wsProxyUrl === undefined ? undefined : { wsProxyUrl }),
}
const create =
seams?.openSocket ?? ((opts: MaplePgSocketOptions) => createMaplePgSocket(connectionString, opts))
Expand Down Expand Up @@ -170,7 +172,10 @@ export const makePgConnectionScope = (
// Wrapped per call so each call's statements land in its own span.
// One shared wrapper would cross-attribute `db.query.text` between
// concurrent calls; the wrapper is cheap (relational config only).
return await fn(wrapMaplePgClient(open.sql, { onQuery: hooks.collect }))
const db =
open.wrapClient?.({ onQuery: hooks.collect }) ??
wrapMaplePgClient(open.sql, { onQuery: hooks.collect })
return await fn(db)
}, extraAttributes),
)
}),
Expand Down Expand Up @@ -213,9 +218,10 @@ export const executeOnFreshPgClient = <T>(
connectionString: string,
fn: (db: DatabaseClient) => Promise<T>,
extraAttributes?: Record<string, unknown>,
wsProxyUrl?: string,
): Effect.Effect<T, DatabaseError> =>
Effect.suspend(() => {
const scope = makePgConnectionScope(connectionString, extraAttributes)
const scope = makePgConnectionScope(connectionString, extraAttributes, undefined, wsProxyUrl)
// Never let a socket-teardown error shadow the real DB error from fn(db).
return scope.run(fn).pipe(Effect.ensuring(Effect.promise(() => scope.close())))
})
Expand Down Expand Up @@ -251,7 +257,7 @@ export const withPgConnectionScope = <A, E, R>(
if (source._tag === "Unavailable") return yield* program

return yield* withPgConnectionScopeOf(
makePgConnectionScope(source.connectionString, source.attributes),
makePgConnectionScope(source.connectionString, source.attributes, undefined, source.wsProxyUrl),
program,
)
})
Expand Down
77 changes: 77 additions & 0 deletions apps/api/src/platform/pg-connection-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,81 @@ describe("resolveDbConnectionSource", () => {

expect(source._tag).toBe("Unavailable")
})

it("synthesizes a connection from MAPLE_PG_URL without leaking credentials", () => {
const source = resolveDbConnectionSource({
MAPLE_PG_URL: "postgres://maple:s3cret@127.0.0.1:5499/maple",
})

expect(source).toStrictEqual({
_tag: "Available",
connectionString: "postgres://maple:s3cret@127.0.0.1:5499/maple",
attributes: {
"db.namespace": "maple",
"server.address": "127.0.0.1",
"server.port": 5499,
},
})
const serialized = JSON.stringify(source._tag === "Available" ? source.attributes : {})
expect(serialized).not.toContain("s3cret")
})

it("attaches MAPLE_PG_WS_PROXY only on the MAPLE_PG_URL path", () => {
const source = resolveDbConnectionSource({
MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple",
MAPLE_PG_WS_PROXY: "ws://127.0.0.1:5498",
})

expect(source._tag).toBe("Available")
expect(source._tag === "Available" && source.wsProxyUrl).toBe("ws://127.0.0.1:5498")
})

it("ignores MAPLE_PG_WS_PROXY that is not a websocket URL", () => {
const source = resolveDbConnectionSource({
MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple",
MAPLE_PG_WS_PROXY: "http://127.0.0.1:5498",
})

expect(source._tag).toBe("Available")
expect(source._tag === "Available" && source.wsProxyUrl).toBeUndefined()
})

it("ignores MAPLE_PG_URL when a Hyperdrive binding is present", () => {
const source = resolveDbConnectionSource({
[HYPERDRIVE_BINDING]: hyperdriveBinding,
MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple",
MAPLE_PG_WS_PROXY: "ws://127.0.0.1:5498",
})

expect(source).toStrictEqual({
_tag: "Available",
connectionString: hyperdriveBinding.connectionString,
attributes: {
"db.namespace": hyperdriveBinding.database,
"server.address": hyperdriveBinding.host,
"server.port": hyperdriveBinding.port,
},
})
})

it("falls through to MAPLE_PG_URL when MAPLE_DB is a string rather than a Hyperdrive object", () => {
const source = resolveDbConnectionSource({
[HYPERDRIVE_BINDING]: "postgres://maple:maple@127.0.0.1:5499/maple",
MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple",
})

expect(source._tag).toBe("Available")
expect(source._tag === "Available" && source.connectionString).toBe(
"postgres://maple:maple@127.0.0.1:5499/maple",
)
})

it.each(["not-a-url", "http://127.0.0.1:5499/maple", "postgres://"])(
"reports unavailable when MAPLE_PG_URL is %s",
(pgUrl) => {
const source = resolveDbConnectionSource({ MAPLE_PG_URL: pgUrl })

expect(source._tag).toBe("Unavailable")
},
)
})
Loading