diff --git a/agents/db/src/collection.ts b/agents/db/src/collection.ts index c342087..060c8b4 100644 --- a/agents/db/src/collection.ts +++ b/agents/db/src/collection.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/cloudflare'; +import { getAgentByName, type AgentNamespace } from 'agents'; import { count, eq } from 'drizzle-orm'; import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; import { z } from 'zod'; @@ -1305,8 +1306,13 @@ export class DbCollection extends LiveShard { } private async parentStub(projectId: string) { - const namespace = this.env.DbAgent as unknown as DurableObjectNamespace; - return namespace.get(namespace.idFromName(projectId)); + const namespace = this.env.DbAgent as unknown as AgentNamespace; + // getAgentByName, never a raw namespace stub: plain RPC bypasses the + // SDK's fetch-path initialization, so a cold parent would serve the + // config pull before onStart's migrations and every first REST touch + // of a fresh project would 503 ("no such table: collections"). + // getAgentByName synchronizes on onStart before returning the stub. + return getAgentByName(namespace, projectId); } protected getVerifier(): ProjectJwtVerifier { diff --git a/agents/db/src/gateway.ts b/agents/db/src/gateway.ts index cbcec78..6ad321b 100644 --- a/agents/db/src/gateway.ts +++ b/agents/db/src/gateway.ts @@ -1,4 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; +import { getAgentByName, type AgentNamespace } from 'agents'; import { count, eq, and } from 'drizzle-orm'; import { drizzle, type DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; @@ -388,9 +389,12 @@ export class DbGateway extends DurableObject { // ------------------------------------------------------------------------- // Parent plumbing (routing, origins, socket-pressure reports) - private parentStub() { - const namespace = this.env.DbAgent as unknown as DurableObjectNamespace; - return namespace.get(namespace.idFromName(this.projectId)); + private async parentStub() { + const namespace = this.env.DbAgent as unknown as AgentNamespace; + // getAgentByName, never a raw namespace stub - see the twin comment in + // collection.ts: a raw RPC can reach a cold parent before onStart's + // migrations and turn every first touch into a 503. + return getAgentByName(namespace, this.projectId); } private routingTtl(): number { @@ -403,7 +407,7 @@ export class DbGateway extends DurableObject { if (cached && cached.expires > now) return cached; let entry: ShardRoutingEntry = { kind: null, auto: false, expires: now + this.routingTtl() }; try { - const routing = await this.parentStub().getShardRouting(name); + const routing = await (await this.parentStub()).getShardRouting(name); if (routing) { entry = { kind: routing.kind, @@ -433,7 +437,7 @@ export class DbGateway extends DurableObject { if (this.origins && this.origins.expires > now) return this.origins.allowed; let allowed: string[] = []; try { - allowed = await this.parentStub().getAllowedOrigins(); + allowed = await (await this.parentStub()).getAllowedOrigins(); } catch { // Fail open to the environment allowlist only - a broken parent // link must not take realtime down for same-origin clients. @@ -457,7 +461,7 @@ export class DbGateway extends DurableObject { return; } try { - await this.parentStub().reportGatewaySockets(this.gatewayId, this.region, sockets); + await (await this.parentStub()).reportGatewaySockets(this.gatewayId, this.region, sockets); this.lastReportedSockets = sockets; } catch { this.lastReportedSockets = null; diff --git a/agents/db/src/table.ts b/agents/db/src/table.ts index 4abf6e3..aca32f8 100644 --- a/agents/db/src/table.ts +++ b/agents/db/src/table.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/cloudflare'; +import { getAgentByName, type AgentNamespace } from 'agents'; import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; import { z } from 'zod'; import migrations from './migrations'; @@ -1600,8 +1601,11 @@ export class DbTable extends LiveShard { } private async parentStub(projectId: string) { - const namespace = this.env.DbAgent as unknown as DurableObjectNamespace; - return namespace.get(namespace.idFromName(projectId)); + const namespace = this.env.DbAgent as unknown as AgentNamespace; + // getAgentByName, never a raw namespace stub - see the twin comment in + // collection.ts: a raw RPC can reach a cold parent before onStart's + // migrations and turn every first touch into a 503. + return getAgentByName(namespace, projectId); } protected getVerifier(): ProjectJwtVerifier { diff --git a/e2e/demo-project.api.spec.ts b/e2e/demo-project.api.spec.ts index 4922304..d5c81d4 100644 --- a/e2e/demo-project.api.spec.ts +++ b/e2e/demo-project.api.spec.ts @@ -85,6 +85,27 @@ test.describe('demo project', () => { expect(settings.ok(), await settings.text()).toBeTruthy(); }); + test('the first REST touch of a brand-new project auto-creates, never 503s', async ({ + request + }) => { + // Regression: the collection's lazy config pull used a raw namespace + // stub, which bypasses the SDK's fetch-path initialization - so a parent + // DbAgent that had never been woken over HTTP had no schema yet, the + // pull died on "no such table: collections", and EVERY first REST touch + // of a fresh project answered a persistent 503. Every other test here + // provisions through the admin surface first (which wakes the parent + // over HTTP), which is exactly why this path went uncovered. A unique + // id per run keeps this a genuine first touch. + const fresh = `demo-${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}`; + const write = await request.post(dbDocumentsPath(fresh, 'posts'), { + data: { data: { title: 'first touch' } } + }); + // Default collection access is 'auth', so the correct answer to an + // anonymous write is an auth refusal - which proves the config pull and + // the auto-create both ran. The bug's signature is a 503. + expect(write.status(), await write.text()).toBe(401); + }); + test('serves the demo database flow without an operator', async ({ request }) => { // The Database snippets must run for the anonymous visitor too: the demo // bypass covers the admin surface (creating the collection), and the diff --git a/e2e/landing.ui.spec.ts b/e2e/landing.ui.spec.ts index 12625b1..8c22e94 100644 --- a/e2e/landing.ui.spec.ts +++ b/e2e/landing.ui.spec.ts @@ -32,6 +32,26 @@ test.describe('landing page (frontend)', () => { await expect(page.getByText('AuthAgent · DO SQLite')).toBeVisible(); }); + test('the install section offers the CLI command and the agent prompt', async ({ + page, + request + }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'Or let your agent do it' })).toBeVisible(); + await expect(page.getByTestId('install-command')).toContainText( + 'npx @frostbase-dev/cli init my-backend' + ); + await expect(page.getByTestId('agent-prompt')).toContainText( + 'Read frostbase.dev/agents.md and set up Frostbase in this project' + ); + + // The prompt is only honest if the file it points at is actually served. + const response = await request.get('/agents.md'); + expect(response.status()).toBe(200); + expect(await response.text()).toContain('# Set up Frostbase'); + }); + test('the pricing story and comparison table state the trade-offs', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: /Our price/ })).toBeVisible(); diff --git a/src/routes/(marketing)/+page.svelte b/src/routes/(marketing)/+page.svelte index c024d97..1f5c140 100644 --- a/src/routes/(marketing)/+page.svelte +++ b/src/routes/(marketing)/+page.svelte @@ -19,6 +19,7 @@ X, ArrowRight, Check, + Copy, ChevronDown, Database, GitFork, @@ -58,6 +59,26 @@ 'Analytics Engine' ]; + // The install section's two one-liners: the CLI command and its + // agent-shaped twin, which points at /agents.md (static/agents.md) - the + // setup instructions written for coding agents, throwaway demo path + // included. Copy state is per box so one click never flips the other icon. + const installCommand = 'npx @frostbase-dev/cli init my-backend'; + const agentPrompt = 'Read frostbase.dev/agents.md and set up Frostbase in this project'; + const agentNames = ['Claude Code', 'Codex', 'Gemini CLI', 'OpenCode', 'Cursor']; + let copiedInstall = $state<'cli' | 'agent' | null>(null); + let copyInstallTimer: ReturnType | undefined; + async function copyInstall(kind: 'cli' | 'agent', text: string) { + try { + await navigator.clipboard.writeText(text); + copiedInstall = kind; + clearTimeout(copyInstallTimer); + copyInstallTimer = setTimeout(() => (copiedInstall = null), 1500); + } catch { + // clipboard unavailable - the text stays selectable + } + } + // The comparison matrix. Cells are a rating plus a short receipt - never a // bare checkmark. Competitor cells stay qualitative on purpose: exact // prices live once, with dated sources, on /pricing. @@ -765,6 +786,88 @@ + +
+
+
+ Get started +

A backend in one command.

+

+ Scaffold a Worker with auth installed, add the database, deploy to your own Cloudflare + account. +

+
+ +
+ $ {installCommand} + +
+ +
+

Or let your agent do it

+

Paste this into any coding agent.

+
+ +
+
+ {#each agentNames as name (name)} + {name} + + {/each} + <your agent> +
+
+ > {agentPrompt} + +
+
+

+ + agents.md + includes a zero-account path: the agent builds on a throwaway demo project and hands you the + live dashboard. +

+
+
+
diff --git a/static/agents.md b/static/agents.md new file mode 100644 index 0000000..d4e8c2b --- /dev/null +++ b/static/agents.md @@ -0,0 +1,185 @@ +# Set up Frostbase + +You are a coding agent setting up Frostbase for a user. Frostbase is an +open-source Firebase alternative that runs on Cloudflare: auth (Better Auth), +a realtime document database plus typed SQL tables, object storage, and remote +config. Every project gets its own Durable Objects, so tenants are isolated by +architecture. + +Pick ONE path, then run the Verify step for it. + +- **Path A — throwaway demo.** No account, no deploy, ready in seconds. + Best for prototypes, hackathons, and trying the API. +- **Path B — the user's own Cloudflare account.** A real, self-hosted backend. + Best for production. +- **Path C — a managed console project.** The user already has an account on + frostbase.dev or a self-hosted console. + +## Path A — throwaway demo (no account) + +Mint a project id: `demo-` + 12 random lowercase hex characters, generated +with a CSPRNG — the id is the only credential, so it must be unguessable. + +```bash +node -e "console.log('demo-'+require('crypto').randomBytes(6).toString('hex'))" +``` + +The project exists the moment you touch it — there is no create call. Your +base URLs (replace ``): + +``` +Auth https://frostbase.dev/api/projects//auth +Database https://frostbase.dev/api/projects//db +Storage https://frostbase.dev/agents/storage-agent/ +OpenAPI https://frostbase.dev/api/projects//openapi.json +Console https://frostbase.dev/dashboard/ +``` + +Fetch the OpenAPI document first — it is public and describes every endpoint +for this exact project. Point the user at the Console URL: it is a full +dashboard for their demo project and updates live as your code writes data. + +Demo limits, tell the user these: data is capped, erased after ~30 days, and +reachable by anyone who knows the id. Never put real user data in a demo +project; graduate to Path B or C instead. + +### Verify (Path A) + +Collections default to authenticated access, so mint a project token, create +the collection explicitly, then write — anonymous sign-in counts as signing +in: + +```bash +BASE=https://frostbase.dev/api/projects/ +# 1. Anonymous session; the bearer arrives in the set-auth-token header +SESSION=$(curl -si -X POST $BASE/auth/sign-in/anonymous -H 'content-type: application/json' -d '{}' \ + | grep -i '^set-auth-token:' | cut -d' ' -f2 | tr -d '\r') +# 2. Trade it for a project JWT +JWT=$(curl -s $BASE/auth/token -H "authorization: Bearer $SESSION" | sed 's/.*"token":"\([^"]*\)".*/\1/') +# 3. Create the collection (defaults: readAccess/writeAccess "auth") +curl -s -X PUT $BASE/db/admin/collections/posts -H 'content-type: application/json' -d '{}' +# 4. Write a document +curl -s -X POST $BASE/db/collections/posts/documents \ + -H 'content-type: application/json' -H "authorization: Bearer $JWT" \ + -d '{"data":{"title":"hello from an agent","votes":1}}' +``` + +Step 4 answers `201` with the document, now visible in the Console URL above. +Create every collection explicitly, as in step 3 — writes also auto-create on +first touch, but explicit creation is deterministic and is where access modes +are set. If a write ever answers `503 {"error":"collection is unavailable"}`, +create the collection explicitly and retry the write. + +## Path B — the user's own Cloudflare account + +Requires Node 20+ and a Cloudflare account (the free tier is enough). +`npx wrangler login` opens a browser — have the user run it, or let them set +`CLOUDFLARE_API_TOKEN`. Ask before deploying; it creates resources on their +account. + +New backend: + +```bash +npm install -g @frostbase-dev/cli +frostbase init my-backend # scaffolds a Worker, installs the auth agent +cd my-backend +frostbase add db # optional: documents, SQL tables, live queries +frostbase add storage # optional: object storage on R2 +npx wrangler login # first time only +frostbase deploy # prints the deployed URL +``` + +Existing Worker project: run `frostbase add ` inside it instead of +`init`. It npm-installs the package, merges the agent's config fragment into +`wrangler.jsonc`, wires the entrypoint, and reruns `wrangler types`. It is +idempotent and never overwrites values the user set. `wrangler.toml` projects +are declined — convert to `wrangler.jsonc` first. + +The deployed Worker serves every project id under these bases — pick one id +(for example the app's name) and use it everywhere; the first request +provisions it: + +``` +Auth https://.workers.dev/agents/auth-agent//api/auth +Database https://.workers.dev/agents/db-agent/ +Storage https://.workers.dev/agents/storage-agent/ +``` + +No secrets are required: each project generates its own signing key. Sign-in +works immediately — the deployment trusts its own origin. Only add +`TRUSTED_ORIGINS` (in `wrangler.jsonc` vars) for *extra* origins, like a +separate domain serving the UI. + +### Verify (Path B) + +```bash +curl -s https://.workers.dev/agents/auth-agent//config +``` + +JSON config comes back. Operator routes (`/admin/*`, `/overview`) answer 404 +on this Worker by design — they are reachable from server code through the +Durable Object binding, never over public HTTP. Do not set +`EXPOSE_OPERATOR_API` on a Worker with a public hostname. + +## Path C — a managed console project + +```bash +npm install -g @frostbase-dev/cli +frostbase login https://frostbase.dev # browser approval; or a self-hosted console URL +frostbase init # bare init: pick or create a project, writes frostbase.json +frostbase key create # server-side service key, shown once +``` + +Base URLs are the console's, same shapes as Path A with the real project id. +Service keys (`fsb_…`) are admin-grade and server-only: the API refuses any +request carrying an `Origin` header, so a key leaked into frontend code fails +at the desk, not in production. Put the key in an env var +(`FROSTBASE_SERVICE_KEY`), never in the bundle or the repo. + +## Integrate the app + +Auth — the stock Better Auth client works as-is: + +```ts +import { createAuthClient } from 'better-auth/client'; // or /react, /svelte +const auth = createAuthClient({ baseURL: '' }); +await auth.signUp.email({ name, email, password }); +``` + +Database — documents, typed tables, and live queries: + +```ts +import { createDbClient } from '@frostbase-dev/db/client'; +const db = createDbClient({ + baseUrl: '', + // New collections default to authenticated access, so wire getToken from + // day one. Same-origin or trusted-origin browser apps ride the session + // cookie; elsewhere add the Better Auth session bearer to this fetch. + getToken: async () => (await (await fetch('/token')).json()).token +}); +const posts = db.collection('posts'); +await posts.create({ title: 'hello', votes: 1 }); +posts.subscribe({ orderBy: [{ field: 'votes', direction: 'desc' }], limit: 25 }, + { onSnapshot: (docs) => render(docs) }); +``` + +Storage — `createStorageClient` from `@frostbase-dev/storage/client`, same +`baseUrl`/`getToken` pattern; `upload()` handles multipart above 100 MB. + +Everything is also plain HTTP — no SDK is required. The per-project OpenAPI +document is the complete, machine-readable contract; prefer it over guessing +routes. + +## Rules + +- Ask the user before deploying, logging in, or creating anything on their + Cloudflare account. +- Never hand-edit `worker-configuration.d.ts`; rerun `npx wrangler types`. +- Rerunning `frostbase add` is safe; running it with no argument lists the + installable agents (`auth`, `db`, `storage`). +- Pin an agent version with `FROSTBASE_DB_SPEC=@frostbase-dev/db@x.y.z + frostbase add db` (same pattern for auth and storage). +- Demo projects are throwaway: unguessable-id access, capped, erased in ~30 + days. Say so in your handoff to the user. + +Source: https://github.com/frostbase-dev/frostbase · Site: https://frostbase.dev diff --git a/static/llms.txt b/static/llms.txt index f73b205..6c18cd0 100644 --- a/static/llms.txt +++ b/static/llms.txt @@ -7,6 +7,7 @@ Frostbase is agent-first: every backend primitive ships as a versioned npm packa ## Site - [Home](https://frostbase.dev/): what it is, how it compares to Firebase and Supabase, architecture, and roadmap. +- [agents.md](https://frostbase.dev/agents.md): setup instructions written for coding agents — throwaway demo projects, self-hosted deploys, and managed projects. - [Pricing](https://frostbase.dev/pricing): the same workload priced on Cloudflare, Firebase, and Supabase, with dated sources. - [Live demo](https://frostbase.dev/dashboard): an ephemeral throwaway project, no sign-up.