diff --git a/.changeset/database-env-read.md b/.changeset/database-env-read.md new file mode 100644 index 00000000..2f0b2903 --- /dev/null +++ b/.changeset/database-env-read.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/database-client": patch +--- + +Treat unreadable environment variables as unset instead of crashing when Deno runs without --allow-env diff --git a/AGENTS.md b/AGENTS.md index 447f6095..40a30da8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ This is a Bun workspace monorepo with seven packages: - **`@bunny.net/openapi-client`** (`packages/openapi-client/`) — Standalone, type-safe OpenAPI client for bunny.net, generated from OpenAPI specs. Zero CLI dependencies. Publishable to npm. - **`@bunny.net/config`** (`packages/config/`) — Shared `bunny.jsonc` schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. The root `BunnyConfigSchema` has optional `app` (Magic Containers) and `sites` (static sites) blocks; `BunnyAppConfigSchema` narrows it to require `app`. Used by the CLI and potentially other tools. -- **`@bunny.net/database-client`** (`packages/database-client/`) — Standalone SQL client for Bunny Database, for application code rather than the CLI. Speaks hrana-over-HTTP (`POST /v2/pipeline`) using only `fetch`, so it runs unchanged on Edge Scripting (Deno), Bun, and Node. Zero dependencies. **Server-side only, and documented as such:** an auth token is a bearer credential for the whole database and the client sends raw SQL, so it must never reach a browser or other untrusted client. Do not describe it as browser-compatible even though `fetch`-only code would technically run there; the correct pattern is an Edge Script (or `database-rest` behind an auth check) that holds the token and exposes only intended queries. D1-shaped surface: `connect()`, `prepare().bind()`, `.all()`/`.first()`/`.raw()`/`.run()`, plus `batch()` (one transaction, one round trip) and `exec()` (multi-statement script). Deliberately stateless: no baton tracking, no connection pool, no interactive transactions, no cursor streaming. Publishable to npm. +- **`@bunny.net/database-client`** (`packages/database-client/`) — Standalone SQL client for Bunny Database, for application code rather than the CLI. Speaks hrana-over-HTTP (`POST /v2/pipeline`) using only `fetch`, so it runs unchanged on Edge Scripting (Deno), Bun, and Node. Zero dependencies. **Server-side only, and documented as such:** an auth token is a bearer credential for the whole database and the client sends raw SQL, so it must never reach a browser or other untrusted client. Do not describe it as browser-compatible even though `fetch`-only code would technically run there; the correct pattern is an Edge Script (or `database-rest` behind an auth check) that holds the token and exposes only intended queries. Prepared-statement surface: `connect()`, `prepare().bind()`, `.all()`/`.first()`/`.raw()`/`.run()`, plus `batch()` (one transaction, one round trip) and `exec()` (multi-statement script). Deliberately stateless: no baton tracking, no connection pool, no interactive transactions, no cursor streaming. Publishable to npm. - **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`). - **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`): Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm. - **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`, `listFiles`/`deleteFile`/`rename`/`exists`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Blocking `runCommand` accepts `timeout` (rejects with `CommandTimeoutError` carrying partial output), `signal` for cancellation, and `onStdout`/`onStderr` callbacks for live output. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. The handle implements `Symbol.dispose`/`Symbol.asyncDispose` so `using`/`await using` release the SSH connection (without deleting the sandbox). Zero CLI dependencies. diff --git a/packages/database-client/README.md b/packages/database-client/README.md index 6b1ffcf1..0d36f063 100644 --- a/packages/database-client/README.md +++ b/packages/database-client/README.md @@ -1,6 +1,6 @@ # @bunny.net/database-client -A small SQL client for [Bunny Database](https://bunny.net). It uses `fetch` and nothing else, so the same code runs on Bunny Edge Scripting (Deno), Bun, and Node. No dependencies. +A small SQL client for [Bunny Database](https://bunny.net). It has no dependencies and uses `fetch` and nothing else, so the same code runs on Bunny Edge Scripting (Deno), Bun, and Node. > [!WARNING] > **Server-side only. Never ship this to a browser or any other untrusted client.** @@ -67,7 +67,7 @@ Binds positional `?` parameters and returns a new statement. Accepts `null`, `bo Anything else throws instead of being quietly converted, because SQLite has nowhere to put it. `Date` gets its own message suggesting `.toISOString()` or `.getTime()`, since guessing which one you meant would change what ends up in the column. -Integer `number`s past 2^53 are rejected rather than rounded: by the time the client sees one it has already lost precision, so pass a `bigint` for values that large. Bigints must fit SQLite's signed 64-bit range. +Integer `number`s past 2^53 also throw: JavaScript has already lost the precision by the time the client sees the value, so storing it would quietly write the wrong number. Pass a `bigint` for values that large. Bigints must fit SQLite's signed 64-bit range. ### Executing @@ -244,7 +244,7 @@ BunnySDK.net.http.serve(async (request: Request): Promise => { }); ``` -The browser calls your endpoint, and your endpoint decides what SQL runs. If you would rather not hand-write endpoints +The browser calls your endpoint, and your endpoint decides what SQL runs. ### Handling tokens diff --git a/packages/database-client/src/env.test.ts b/packages/database-client/src/env.test.ts new file mode 100644 index 00000000..25cc2366 --- /dev/null +++ b/packages/database-client/src/env.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { readEnv } from "./env.ts"; + +const NAME = "BUNNY_READENV_TEST"; + +type GlobalWithSniffs = { + Deno?: { env?: { get(key: string): string | undefined } }; + process?: unknown; +}; +const g = globalThis as GlobalWithSniffs; + +describe("readEnv", () => { + test("reads from process.env", () => { + process.env[NAME] = "from-process"; + try { + expect(readEnv(NAME)).toBe("from-process"); + } finally { + delete process.env[NAME]; + } + }); + + test("missing and empty string both read as unset", () => { + delete process.env[NAME]; + expect(readEnv(NAME)).toBeUndefined(); + process.env[NAME] = ""; + try { + expect(readEnv(NAME)).toBeUndefined(); + } finally { + delete process.env[NAME]; + } + }); + + test("prefers Deno.env.get when a Deno global is present", () => { + process.env[NAME] = "from-process"; + g.Deno = { env: { get: () => "from-deno" } }; + try { + expect(readEnv(NAME)).toBe("from-deno"); + } finally { + delete g.Deno; + delete process.env[NAME]; + } + }); + + test("an empty Deno value falls through to process.env", () => { + process.env[NAME] = "from-process"; + g.Deno = { env: { get: () => "" } }; + try { + expect(readEnv(NAME)).toBe("from-process"); + } finally { + delete g.Deno; + delete process.env[NAME]; + } + }); + + test("a permission throw from Deno.env.get reads as unset, not a crash", () => { + g.Deno = { + env: { + get: () => { + throw new Error("NotCapable: Requires env access"); + }, + }, + }; + try { + expect(readEnv(NAME)).toBeUndefined(); + } finally { + delete g.Deno; + } + }); + + test("a permission throw from process.env reads as unset, not a crash", () => { + const realProcess = g.process; + g.process = { + env: new Proxy( + {}, + { + get() { + throw new Error("NotCapable: Requires env access"); + }, + }, + ), + }; + try { + expect(readEnv(NAME)).toBeUndefined(); + } finally { + g.process = realProcess; + } + }); +}); diff --git a/packages/database-client/src/env.ts b/packages/database-client/src/env.ts index 9d3c386b..69f7c25a 100644 --- a/packages/database-client/src/env.ts +++ b/packages/database-client/src/env.ts @@ -1,22 +1,16 @@ export const ENV_DATABASE_URL = "BUNNY_DATABASE_URL"; export const ENV_DATABASE_AUTH_TOKEN = "BUNNY_DATABASE_AUTH_TOKEN"; +// Runtime sniff: either global may be absent (wrong runtime) or throw on read (Deno without --allow-env). +const g = globalThis as { + Deno?: { env?: { get(key: string): string | undefined } }; + process?: { env?: Record }; +}; + export function readEnv(name: string): string | undefined { - const deno = ( - globalThis as { Deno?: { env?: { get(key: string): string | undefined } } } - ).Deno; - if (deno?.env?.get) { - try { - const value = deno.env.get(name); - if (value) return value; - } catch { - // No env permission. - // Fall through to process.env, then to the caller's error. - } + try { + return g.Deno?.env?.get(name) || g.process?.env?.[name] || undefined; + } catch { + return undefined; } - - const proc = ( - globalThis as { process?: { env?: Record } } - ).process; - return proc?.env?.[name] || undefined; }