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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ 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. celld v0.4.1+ dials it
# over cloudflare:sockets. Do not put a Postgres URL in MAPLE_DB_URL (PGlite dir).
# MAPLE_PG_URL=postgres://maple:maple@127.0.0.1:5499/maple

# 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
30 changes: 30 additions & 0 deletions apps/alerting/wrangler.celld.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
// 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",
"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"),
})
3 changes: 2 additions & 1 deletion apps/api/src/platform/pg-connection-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ 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 = wrapMaplePgClient(open.sql, { onQuery: hooks.collect })
return await fn(db)
}, extraAttributes),
)
}),
Expand Down
54 changes: 54 additions & 0 deletions apps/api/src/platform/pg-connection-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,58 @@ describe("mapleDbConnectionFromEnv", () => {
it("reports an absent database for an empty env", () => {
expect(mapleDbConnectionFromEnv({})).toStrictEqual(Option.none())
})

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

expect(connection).toStrictEqual(
Option.some({
connectionString: "postgres://maple:s3cret@127.0.0.1:5499/maple",
attributes: {
"db.namespace": "maple",
"server.address": "127.0.0.1",
"server.port": 5499,
},
}),
)
expect(JSON.stringify(Option.getOrThrow(connection).attributes)).not.toContain("s3cret")
})

it("ignores MAPLE_PG_URL when a Hyperdrive binding is present", () => {
const connection = mapleDbConnectionFromEnv({
[MAPLE_DB_BINDING]: hyperdriveBinding,
MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple",
})

expect(connection).toStrictEqual(
Option.some({
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 connection = mapleDbConnectionFromEnv({
[MAPLE_DB_BINDING]: "postgres://maple:maple@127.0.0.1:5499/maple",
MAPLE_PG_URL: "postgres://maple:maple@127.0.0.1:5499/maple",
})

expect(Option.getOrThrow(connection).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 absent when MAPLE_PG_URL is %s",
(pgUrl) => {
expect(mapleDbConnectionFromEnv({ MAPLE_PG_URL: pgUrl })).toStrictEqual(Option.none())
},
)
})
83 changes: 74 additions & 9 deletions apps/api/src/platform/pg-connection-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,87 @@
* is checked) and turned into what the Postgres layers need. Keep Workers on
* Hyperdrive: request-scoped sockets make direct PSBouncer connections pay a
* handshake per execute (measured 679ms + 158ms versus Hyperdrive's 11ms + 14ms).
*
* celld has no Hyperdrive binding. `MAPLE_PG_URL` synthesizes the same
* `{ connectionString, host, port, database }` shape and postgres.js dials it
* over TCP (`cloudflare:sockets` on celld). A string `MAPLE_DB` is still
* absent on purpose — that binding is an object or it is absent.
*/
import { readMapleDbBinding } from "@maple/infra/cloudflare"
import { Layer, Option } from "effect"
import { type DatabaseConnection, MapleDbConnection } from "./bindings"

/** The port's value for one Worker env record: `None` on a stage without a database. */
export const mapleDbConnectionFromEnv = (env: Record<string, unknown>): Option.Option<DatabaseConnection> =>
Option.map(readMapleDbBinding(env), (binding) => ({
connectionString: binding.connectionString,
export const MAPLE_PG_URL_VAR = "MAPLE_PG_URL"

const readNonEmptyString = (value: unknown): string | undefined => {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}

const parsePostgresUrl = (
raw: string,
):
| {
readonly connectionString: string
readonly host: string
readonly port: number
readonly database: string
}
| undefined => {
let url: URL
try {
url = new URL(raw)
} catch {
return undefined
}
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return undefined
const host = url.hostname
if (host.length === 0) return undefined
const port = url.port.length === 0 ? 5432 : Number(url.port)
if (!Number.isFinite(port) || port <= 0) return undefined
const path = decodeURIComponent(url.pathname.replace(/^\/+/, ""))
const database = path.split("/")[0] ?? ""
return {
connectionString: raw,
host,
port,
database: database.length > 0 ? database : "postgres",
}
}

const connectionFromPostgresUrl = (raw: string): Option.Option<DatabaseConnection> => {
const parsed = parsePostgresUrl(raw)
if (parsed === undefined) return Option.none()
return Option.some({
connectionString: parsed.connectionString,
attributes: {
// The read path normalizes Hyperdrive's opaque host/database to its sentinel node.
"db.namespace": binding.database,
"server.address": binding.host,
"server.port": binding.port,
"db.namespace": parsed.database,
"server.address": parsed.host,
"server.port": parsed.port,
},
}))
})
}

/** The port's value for one Worker env record: `None` on a stage without a database. */
export const mapleDbConnectionFromEnv = (env: Record<string, unknown>): Option.Option<DatabaseConnection> => {
const binding = readMapleDbBinding(env)
if (Option.isSome(binding)) {
return Option.some({
connectionString: binding.value.connectionString,
attributes: {
// The read path normalizes Hyperdrive's opaque host/database to its sentinel node.
"db.namespace": binding.value.database,
"server.address": binding.value.host,
"server.port": binding.value.port,
},
})
}

const pgUrl = readNonEmptyString(env[MAPLE_PG_URL_VAR])
if (pgUrl !== undefined) return connectionFromPostgresUrl(pgUrl)
return Option.none()
}

/** The port over an env record in hand — a Worker's, a Durable Object's, a Workflow run's, a cron fire's. */
export const mapleDbConnectionLayer = (env: Record<string, unknown>): Layer.Layer<MapleDbConnection> =>
Expand Down
Loading